From 5deed977f319e9fad15d10738a40b9e565c5e8a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:24:42 +0200 Subject: [PATCH 1/2] Bump softprops/action-gh-release from 2 to 3 in the actions-deps group (#43) Bumps the actions-deps group with 1 update: [softprops/action-gh-release](https://github.com/softprops/action-gh-release). Updates `softprops/action-gh-release` from 2 to 3 - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d7d7f3..c00125f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,7 @@ jobs: # Create a GitHub release on push to main only if: | github.ref == 'refs/heads/main' && github.event_name == 'push' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: name: v${{ steps.nbgv.outputs.SemVer2 }} tag_name: v${{ steps.nbgv.outputs.SemVer2 }} From c88191edd3f200330c3133b53fbf7ffbd574a30a Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 10 May 2026 19:00:03 +0200 Subject: [PATCH 2/2] Full Linux support (#42) * fix debug disposal * Handle null `TextureData` during `GuiDialog` initialization. * Engine support for finding files on linux systems the way the game would do it wiht abstractions layers like Wine or Valve Proton * Add support for the `--useDefaultBaseline` option and improve baseline selection logic * Remove obsolete filesystem abstraction utilities for directory and file globbing. * Introduce `PetroglyphFileSystem` abstraction for cross-platform filesystem operations. * Refactor `PetroglyphXmlFileParserBase`: remove redundant `IServiceProvider` field, optimize path handling with OS-specific logic. * Add `PG.StarWarsGame.Engine.FileSystem` project to solution * Update dependencies and add reference to `PG.StarWarsGame.Engine.FileSystem` project * Refactor: Replace generic filesystem abstraction with `PetroglyphFileSystem` for consistent, cross-platform path handling across the engine. * Refactor: Simplify `NormalizePath` logic and remove unused return value * Refactor: Use `ValueStringBuilder` in `PathStartsWithDataDirectory` for performance and normalize input paths * Refactor: Replace direct filename handling with `NormalizeFileName` for consistency and re-enable particle name mismatch validation * Refactor path normalization to use PathNormalizer Replaces custom path normalization logic in PetroglyphFileSystem with PathNormalizer from AnakinRaW.CommonUtilities. Moves LinuxDirectorySeparatorNormalizeOptions to a static readonly field. Cleans up unused usings and retains legacy code as comments for reference. * Rename StringExtensions and add Enum.Parse extension Renamed StringExtensions to Extensions. Added a generic Enum.Parse extension method for NETFRAMEWORK builds to simplify enum parsing from strings. * Refactor AudioFileVerifier: move FriendlyName, remove Verify Moved FriendlyName override to class body and removed the Verify method implementation from AudioFileVerifier. * Add comprehensive unit tests for `PG.StarWarsGame.Engine.FileSystem` and include test project in solution * Remove unused test case for volume file names and update path handling in `FileExists` test * Optimize case-insensitive file existence check in `PetroglyphFileSystem` and add additional test cases * Update README with detailed mod verification examples for Windows and Linux * Add Linux-specific build target to release workflow * Fix mod paths in Linux instructions * revert to old impl * Add Linux-specific tests for case-insensitive file existence handling in `PetroglyphFileSystem` * Update README: include `--engine` parameter in examples and add Linux baseline creation example * update dependencies * Refactor file path handling in GetFileInfoFromMasterMeg Check filePath length before allocating ValueStringBuilder to improve efficiency. Update warning log to use filePath.ToString() for overlong paths. Reorganize normalization logic for clarity and ensure proper resource disposal. * rename method to express intent * formatting * Update path normalization for Windows-like behavior on Linux Replaced LinuxDirectorySeparatorNormalizeOptions with PGFileSystemDirectorySeparatorNormalizeOptions to ensure consistent Windows-like path handling on Linux. Updated class documentation to clarify behavior. Refactored IsDirectorySeparator to use defined separator constants for improved clarity. * documentation * Refactor path handling to use platform-specific separators ModPaths and AdditionalFallbackPath are now single string properties using the platform-specific path separator, instead of IList. SettingsBuilder splits and normalizes these paths accordingly. Help texts are updated, and unit tests are added to verify correct path parsing and normalization using a mock file system. * fix reporting of missing texutres * add new algorithm to evaluate * Handle "none" GUI textures and unify repo lookups Refactored texture existence checks by introducing IsNone and GuiSpecialTextureExists helpers. Special cases for "none" textures now avoid false warnings and are consistently cached. Centralized repository lookup logic for Scanlines, ButtonMiddle, and FrameBackground types. Improved error messages by clarifying origin labeling and adjusting error context. * update sub * update verifiers * update sub * update deps * fix false positive for "none" textures * exceptions stack traces from a verifystep now are now shown instead of the unusefull pipeline trace * add an alternate wine-style file lookup algorithm * more consistent path handling * fix and test dot segements handling * update and fix wine file lookup * add linux fs path caching * spacing * search animations based on relative path instead of absolute path. * fix potential memory leaks because of undisposed VSB * implement file system strategies to find files in a case-insensitive manner * add design spec for live virtual file-exists strategy Co-Authored-By: Claude Opus 4.7 (1M context) * window stategy is default engine for virtual stategy on windows * fix model corrupted findings are incorrectly reported if the the engine had crc collision and loaded a different file. * add implementation plan for live virtual file-exists strategy Co-Authored-By: Claude Opus 4.7 (1M context) * remove design spec and implementation plan for live virtual file-exists strategy Co-Authored-By: Claude Opus 4.7 (1M context) * rename to baseDirectory * conditional using * add some test gaps * update baseline --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 5 +- Directory.Build.props | 2 +- ModVerify.slnx | 2 + README.md | 43 +- modules/ModdingToolBase | 2 +- .../App/ModVerifyApplicationAction.cs | 31 + src/ModVerify.CliApp/App/VerifyAction.cs | 3 +- src/ModVerify.CliApp/ModVerify.CliApp.csproj | 18 +- src/ModVerify.CliApp/ModVerifyConstants.cs | 1 + .../Reporting/BaselineSelector.cs | 18 +- .../Resources/Baselines/baseline-foc.json | 2044 +++++++++-------- .../CommandLine/BaseModVerifyOptions.cs | 12 +- .../Settings/CommandLine/VerifyVerbOption.cs | 12 +- .../Settings/ModVerifyAppSettings.cs | 1 + .../Settings/SettingsBuilder.cs | 36 +- src/ModVerify/GameVerifierService.cs | 13 +- src/ModVerify/ModVerify.csproj | 4 +- .../Reporting/VerificationCompletionStatus.cs | 1 + src/ModVerify/Reporting/VerificationResult.cs | 2 + .../Verifiers/Commons/AudioFileVerifier.cs | 4 +- .../Verifiers/Commons/SingleModelVerifier.cs | 148 +- .../GuiDialogs/GuiDialogsVerifier.cs | 37 +- src/ModVerify/Verifiers/VerifierErrorCodes.cs | 2 + .../FileExistsStrategyTestBase.cs | 247 ++ .../TrackingFileExistsStrategy.cs | 30 + .../VirtualDirectoryTests.cs | 20 + .../VirtualFileExistsStrategyTests.cs | 143 ++ .../WindowsFileExistsStrategyTests.cs | 20 + .../WineFileExistsStrategyTests.cs | 9 + .../PetroglyphFileSystemTests.CombineJoin.cs | 119 + .../IO/PetroglyphFileSystemTests.Exist.cs | 31 + .../IO/PetroglyphFileSystemTests.Names.cs | 84 + .../IO/PetroglyphFileSystemTests.Normalize.cs | 94 + .../IO/PetroglyphFileSystemTests.PathEqual.cs | 26 + .../PetroglyphFileSystemTests.UseStrategy.cs | 117 + .../IO/PetroglyphFileSystemTests.cs | 85 + ...StarWarsGame.Engine.FileSystem.Test.csproj | 48 + .../PathAssert.cs | 18 + .../TestBaseWithPGFileSystem.cs | 40 + .../Utilities/LowLevelPathTests.cs | 69 + .../Utilities/ValueStringBuilderTests.cs | 371 +++ .../AssemblyAttributes.cs | 4 + .../FileExistStrategies/FileExistsStrategy.cs | 14 + .../FileExistStrategies/VirtualDirectory.cs | 20 + .../VirtualFileExistsStrategy.cs | 162 ++ .../WindowsFileExistsStrategy.cs | 57 + .../WineFileExistsStrategy.cs | 168 ++ .../IO/PetroglyphFileSystem.CombineJoin.cs | 63 + .../IO/PetroglyphFileSystem.Exist.cs | 111 + .../IO/PetroglyphFileSystem.Names.cs | 221 ++ .../IO/PetroglyphFileSystem.Normalize.cs | 28 + .../IO/PetroglyphFileSystem.PathEqual.cs | 51 + .../IO/PetroglyphFileSystem.Strategies.cs | 76 + .../IO/PetroglyphFileSystem.cs | 135 ++ .../PG.StarWarsGame.Engine.FileSystem.csproj | 25 + .../Utilities/LowLevelPath.cs | 51 + .../Utilities/ValueStringBuilder.cs | 24 +- .../CommandBarGameManager_Initialization.cs | 4 +- .../PG.StarWarsGame.Engine/GameManagerBase.cs | 8 +- ...ameObjectTypeGameManager.Initialization.cs | 4 +- .../GuiDialog/GuiDialogGameManager.cs | 54 +- .../GuiDialogGameManager_Initialization.cs | 5 +- .../IO/IGameRepository.cs | 7 +- .../IO/MultiPassRepository.cs | 67 +- .../IO/Repositories/EffectsRepository.cs | 6 +- .../IO/Repositories/FocGameRepository.cs | 14 +- .../IO/Repositories/GameRepository.Files.cs | 191 +- .../IO/Repositories/GameRepository.cs | 86 +- .../IO/Repositories/ModelRepository.cs | 11 +- .../IO/Repositories/TextureRepository.cs | 3 +- .../Utilities/DirectoryInfoGlobbingWrapper.cs | 106 - .../IO/Utilities/FileInfoGlobbingWrapper.cs | 35 - .../IO/Utilities/MatcherExtensions.cs | 78 - .../IO/Utilities/PathExtensions.cs | 30 - .../Localization/GameLanguageManager.cs | 30 +- .../PG.StarWarsGame.Engine.csproj | 14 +- .../Rendering/PGRender.cs | 77 +- .../Xml/PetroglyphStarWarsGameXmlParser.cs | 32 +- .../PG.StarWarsGame.Files.ChunkFiles.csproj | 2 +- .../PG.StarWarsGame.Files.XML.csproj | 2 +- .../Base/PetroglyphXmlFileParserBase.cs | 24 +- .../ModVerify.CliApp.Test.csproj | 10 +- .../ModVerifyOptionsParserTest.cs | 70 + .../SettingsBuilderTest.cs | 110 + .../{StringExtensions.cs => Extensions.cs} | 12 +- 85 files changed, 4628 insertions(+), 1686 deletions(-) create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/TrackingFileExistsStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualDirectoryTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/TestBaseWithPGFileSystem.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/LowLevelPathTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WindowsFileExistsStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WineFileExistsStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/LowLevelPath.cs rename src/PetroglyphTools/{PG.StarWarsGame.Engine => PG.StarWarsGame.Engine.FileSystem}/Utilities/ValueStringBuilder.cs (95%) delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs create mode 100644 test/ModVerify.CliApp.Test/SettingsBuilderTest.cs rename test/ModVerify.CliApp.Test/Utilities/{StringExtensions.cs => Extensions.cs} (54%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c00125f..a45a516 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,8 @@ jobs: - name: Create Net Core Release # use publish for .NET Core run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --output ./releases/net10.0 /p:DebugType=None /p:DebugSymbols=false + - name: Create Linux Release + run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --runtime linux-x64 --self-contained true --output ./releases/linux-x64 /p:DebugType=None /p:DebugSymbols=false - name: Upload a Build Artifact uses: actions/upload-artifact@v7 with: @@ -117,4 +119,5 @@ jobs: generate_release_notes: true files: | ./releases/net481/ModVerify.exe - ./releases/ModVerify-Net10.zip \ No newline at end of file + ./releases/ModVerify-Net10.zip + ./releases/linux-x64/ModVerify \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 3e2f119..9637821 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,7 +33,7 @@ - + all 3.9.50 diff --git a/ModVerify.slnx b/ModVerify.slnx index 3527ff4..917a44f 100644 --- a/ModVerify.slnx +++ b/ModVerify.slnx @@ -17,6 +17,8 @@ + + diff --git a/README.md b/README.md index d3aa24c..2725d72 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,42 @@ In general ModVerify has two operation mods. 1. `verify` Verifying a game or mod 2. `createBaseline` Creating a baseline for a game or mod, that can be used for further verifications in order to verify you did not add more errors to your mods. -### 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: +### Examples -```bash +#### Example 1: Auto-detection with a custom baseline +Analyzes a specific mod, uses the FoC baseline and writes the output into a dedicated directory: + +**Windows:** +```bat .\ModVerify.exe verify --path "C:\My Games\FoC\Mods\MyMod" --outDir "C:\My Games\FoC\Mods\MyMod\verifyResults" --baseline ./focBaseline.json ``` +**Linux:** +```bash +./ModVerify verify \ + --path "/home/user/games/FoC/Mods/MyMod" \ + --outDir "/home/user/games/FoC/Mods/MyMod/verifyResults" \ + --baseline ./focBaseline.json +``` + +#### Example 2: Manual mod setup with sub-mods, EaW fallback and default baseline +Uses manual mod setup, including sub-mods and the EaW fallback game, and uses the default embedded baseline: + +**Windows:** +```bat +.\ModVerify.exe verify --mods "C:\My Games\FoC\Mods\MySubMod;C:\My Games\FoC\Mods\MyMod" --game "C:\My Games\FoC" --fallbackGame "C:\My Games\EaW" --engine FOC --useDefaultBaseline +``` + +**Linux:** +```bash +./ModVerify verify \ + --mods "/home/user/games/FoC/Mods/MySubMod:/home/user/games/FoC/Mods/MyMod" \ + --game "/home/user/games/FoC" \ + --fallbackGame "/home/user/games/EaW" \ + --engine FOC \ + --useDefaultBaseline +``` + --- ## Available Checks @@ -116,6 +145,14 @@ The following verifiers are currently implemented: If you want to create your own baseline use the `createBaseline` option. ### Example + +**Windows** ```bash ModVerify.exe createBaseline --outFile myBaseline.json --path "C:\My Games\FoC\Mods\MyMod" ``` +**Linux** +```bash +./ModVerify createBaseline \ + --outFile myBaseline.json \ + --path "C:\My Games\FoC\Mods\MyMod" +``` diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase index da072f4..3901d1a 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit da072f43e6b85aab35b43d11f6b36eab61bdcfa6 +Subproject commit 3901d1a899b8830ef691c06684b023a85f290b84 diff --git a/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs b/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs index 113e633..4fd10dc 100644 --- a/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs +++ b/src/ModVerify.CliApp/App/ModVerifyApplicationAction.cs @@ -1,5 +1,6 @@ using System; using System.IO.Abstractions; +using System.Linq; using System.Threading.Tasks; using AET.ModVerify.App.GameFinder; using AET.ModVerify.App.Reporting; @@ -9,6 +10,7 @@ using AET.ModVerify.Reporting.Baseline; using AET.ModVerify.Reporting.Suppressions; using AnakinRaW.ApplicationBase; +using AnakinRaW.CommonUtilities.SimplePipeline; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -74,9 +76,34 @@ public async Task ExecuteAsync() var verificationResult = await VerifyTargetAsync(verificationTarget) .ConfigureAwait(false); + Console.WriteLine(); + + switch (verificationResult.Status) + { + case VerificationCompletionStatus.Cancelled: + return ModVerifyConstants.VerifyCancelled; + case VerificationCompletionStatus.Failed: + return ReportVerificationFailure(verificationResult.Exception!); + } + return await ProcessResult(verificationResult); } + private int ReportVerificationFailure(Exception verificationException) + { + var exceptionToReport = verificationException switch + { + AggregateException aggregate => aggregate.InnerExceptions.FirstOrDefault() ?? aggregate, + StepFailureException stepFailure => stepFailure.FailedSteps.First().Error!, + _ => verificationException + }; + + ConsoleUtilities.WriteApplicationFatalError(_appEnvironment.ApplicationName, exceptionToReport); + Logger?.LogError(exceptionToReport, exceptionToReport.Message); + + return exceptionToReport.HResult; + } + protected abstract Task ProcessResult(VerificationResult result); protected abstract VerificationBaseline GetBaseline(VerificationTarget verificationTarget); @@ -112,6 +139,10 @@ private async Task VerifyTargetAsync(VerificationTarget veri case VerificationCompletionStatus.Cancelled: Logger?.LogWarning(ModVerifyConstants.ConsoleEventId, "Verification was cancelled."); break; + case VerificationCompletionStatus.Failed: + progressReporter.ReportError("Verification failed!", + $"An unexpected error occurred while verifying '{verificationTarget.Name}'."); + break; case VerificationCompletionStatus.Completed: default: Logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "Verification completed successfully."); diff --git a/src/ModVerify.CliApp/App/VerifyAction.cs b/src/ModVerify.CliApp/App/VerifyAction.cs index e17d7bc..deeb053 100644 --- a/src/ModVerify.CliApp/App/VerifyAction.cs +++ b/src/ModVerify.CliApp/App/VerifyAction.cs @@ -61,7 +61,8 @@ protected override VerificationBaseline GetBaseline(VerificationTarget verificat { Console.WriteLine(); ModVerifyConsoleUtilities.WriteBaselineInfo(baseline, baselinePath); - Logger?.LogDebug("Using baseline {Baseline} from location '{Path}'", baseline.ToString(), baselinePath); + Logger?.LogDebug("Using baseline {Baseline} from location '{Path}'", + baseline.ToString(), baselinePath ?? "Embedded"); Console.WriteLine(); } return baseline; diff --git a/src/ModVerify.CliApp/ModVerify.CliApp.csproj b/src/ModVerify.CliApp/ModVerify.CliApp.csproj index f7b7ff8..b1c9747 100644 --- a/src/ModVerify.CliApp/ModVerify.CliApp.csproj +++ b/src/ModVerify.CliApp/ModVerify.CliApp.csproj @@ -37,11 +37,11 @@ - - - - - + + + + + @@ -51,7 +51,7 @@ - + all @@ -62,15 +62,15 @@ - + compile runtime; build; native; contentfiles; analyzers; buildtransitive - + compile runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/ModVerify.CliApp/ModVerifyConstants.cs b/src/ModVerify.CliApp/ModVerifyConstants.cs index 041a4a5..4b603b5 100644 --- a/src/ModVerify.CliApp/ModVerifyConstants.cs +++ b/src/ModVerify.CliApp/ModVerifyConstants.cs @@ -12,6 +12,7 @@ internal static class ModVerifyConstants public const int Success = 0; public const int CompletedWithFindings = 1; public const int ErrorBadArguments = 0xA0; + public const int VerifyCancelled = -1; public static readonly EventId ConsoleEventId = new(ConsoleEventIdValue, "LogToConsole"); } \ No newline at end of file diff --git a/src/ModVerify.CliApp/Reporting/BaselineSelector.cs b/src/ModVerify.CliApp/Reporting/BaselineSelector.cs index efcaae5..4fff812 100644 --- a/src/ModVerify.CliApp/Reporting/BaselineSelector.cs +++ b/src/ModVerify.CliApp/Reporting/BaselineSelector.cs @@ -42,7 +42,7 @@ public VerificationBaseline SelectBaseline(VerificationTarget verificationTarget } } - if (!settings.ReportSettings.SearchBaselineLocally) + if (settings.ReportSettings is { SearchBaselineLocally: false, UseDefaultBaseline: false }) { _logger?.LogDebug(ModVerifyConstants.ConsoleEventId, "No baseline path specified and local search is not enabled. Using empty baseline."); @@ -134,7 +134,7 @@ internal static VerificationBaseline LoadEmbeddedBaseline(GameEngineType engineT private VerificationBaseline FindBaselineNonInteractive(VerificationTarget target, out string? usedPath) { if (_baselineFactory.TryFindBaselineInDirectory( - target.Location.TargetPath, + target.Location.TargetPath, b => IsBaselineCompatible(b, target), out var baseline, out usedPath)) @@ -144,6 +144,20 @@ private VerificationBaseline FindBaselineNonInteractive(VerificationTarget targe } _logger?.LogTrace("No baseline file found in taget path '{TargetPath}'.", target.Location.TargetPath); usedPath = null; + if (settings.ReportSettings.UseDefaultBaseline) + { + try + { + var defaultBaseline = LoadEmbeddedBaseline(target.Engine); + _logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "Automatically applying default embedded baseline for engine '{Engine}'.", target.Engine); + return defaultBaseline; + } + catch (InvalidBaselineException) + { + throw new InvalidOperationException( + "Invalid baseline packed along ModVerify App. Please reach out to the creators. Thanks!"); + } + } return VerificationBaseline.Empty; } diff --git a/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json b/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json index 84525c1..fb8cb64 100644 --- a/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json +++ b/src/ModVerify.CliApp/Resources/Baselines/baseline-foc.json @@ -8,16 +8,6 @@ }, "minSeverity": "Information", "errors": [ - { - "id": "XML08", - "severity": "Information", - "asset": "DATA\\XML\\UNITS_SPACE_UNDERWORLD_INTERCEPTOR4.XML", - "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_SPACE_UNDERWORLD_INTERCEPTOR4.XML #0\u0027", - "context": [ - "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", - "File: DATA\\XML\\UNITS_SPACE_UNDERWORLD_INTERCEPTOR4.XML" - ] - }, { "id": "XML04", "severity": "Warning", @@ -29,14 +19,25 @@ "Unit_TIE_Fighter_Fire" ] }, + { + "id": "XML10", + "severity": "Information", + "asset": "Disabled_Darken", + "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8569\u0027", + "context": [ + "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", + "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", + "b_fast_forward" + ] + }, { "id": "XML08", "severity": "Information", - "asset": "DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML", - "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML #0\u0027", + "asset": "DATA\\XML\\UNITS_SPACE_UNDERWORLD_INTERCEPTOR4.XML", + "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_SPACE_UNDERWORLD_INTERCEPTOR4.XML #0\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", - "File: DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML" + "File: DATA\\XML\\UNITS_SPACE_UNDERWORLD_INTERCEPTOR4.XML" ] }, { @@ -50,15 +51,25 @@ "bm_text_steal" ] }, + { + "id": "XML08", + "severity": "Information", + "asset": "DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML", + "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML #0\u0027", + "context": [ + "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", + "File: DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML" + ] + }, { "id": "XML10", "severity": "Information", "asset": "Disabled_Darken", - "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8569\u0027", + "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8550\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "b_fast_forward" + "b_play_pause" ] }, { @@ -72,14 +83,13 @@ ] }, { - "id": "XML10", + "id": "XML08", "severity": "Information", - "asset": "Disabled_Darken", - "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8608\u0027", + "asset": "DATA\\XML\\SPACEPROPS_UNDERWORLD.XML", + "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\SPACEPROPS_UNDERWORLD.XML #0\u0027", "context": [ - "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", - "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "b_fast_forward_t" + "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", + "File: DATA\\XML\\SPACEPROPS_UNDERWORLD.XML" ] }, { @@ -93,24 +103,25 @@ ] }, { - "id": "XML08", + "id": "XML10", "severity": "Information", - "asset": "DATA\\XML\\SPACEPROPS_UNDERWORLD.XML", - "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\SPACEPROPS_UNDERWORLD.XML #0\u0027", + "asset": "Disabled_Darken", + "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8608\u0027", "context": [ - "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", - "File: DATA\\XML\\SPACEPROPS_UNDERWORLD.XML" + "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", + "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", + "b_fast_forward_t" ] }, { "id": "XML10", "severity": "Information", - "asset": "Disabled_Darken", - "message": "The node \u0027Disabled_Darken\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8550\u0027", + "asset": "Mega_Texture_Name", + "message": "The node \u0027Mega_Texture_Name\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "b_play_pause" + "i_main_commandbar" ] }, { @@ -127,29 +138,18 @@ { "id": "XML08", "severity": "Information", - "asset": "DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML", - "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML #0\u0027", + "asset": "DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML", + "message": "XML header is not the first entry of the XML file. File=\u0027DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML #0\u0027", "context": [ "Parser: PG.StarWarsGame.Engine.Xml.Parsers.GameObjectFileParser", - "File: DATA\\XML\\UNITS_LAND_REBEL_GALLOFREE_HTT.XML" - ] - }, - { - "id": "XML10", - "severity": "Information", - "asset": "Mega_Texture_Name", - "message": "The node \u0027Mega_Texture_Name\u0027 is not supported. File=\u0027DATA\\XML\\COMMANDBARCOMPONENTS.XML #8\u0027", - "context": [ - "Parser: PG.StarWarsGame.Engine.Xml.Parsers.CommandBarComponentParser", - "File: DATA\\XML\\COMMANDBARCOMPONENTS.XML", - "i_main_commandbar" + "File: DATA\\XML\\UNITS_SPACE_EMPIRE_TIE_DEFENDER.XML" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0312_ENG.WAV", - "message": "Audio file \u0027U000_LEI0312_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0315_ENG.WAV", + "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Attack_Leia" ] @@ -157,35 +157,35 @@ { "id": "FILE00", "severity": "Error", - "asset": "AMB_DES_CLEAR_LOOP_1.WAV", - "message": "Audio file \u0027AMB_DES_CLEAR_LOOP_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0202_ENG.WAV", + "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", "context": [ - "Weather_Ambient_Clear_Sandstorm_Loop" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0205_ENG.WAV", - "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0110_ENG.WAV", + "message": "Audio file \u0027U000_LEI0110_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0211_ENG.WAV", - "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0203_ENG.WAV", + "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0305_ENG.WAV", - "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0301_ENG.WAV", + "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Attack_Leia" ] @@ -193,46 +193,46 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0209_ENG.WAV", - "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0306_ENG.WAV", + "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0112_ENG.WAV", - "message": "Audio file \u0027U000_LEI0112_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0602_ENG.WAV", + "message": "Audio file \u0027U000_LEI0602_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Increase_Production_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0502_ENG.WAV", - "message": "Audio file \u0027U000_LEI0502_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0104_ENG.WAV", + "message": "Audio file \u0027U000_LEI0104_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Remove_Corruption_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0115_ENG.WAV", - "message": "Audio file \u0027U000_LEI0115_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0207_ENG.WAV", + "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0313_ENG.WAV", - "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", + "asset": "U000_ARC3106_ENG.WAV", + "message": "Audio file \u0027U000_ARC3106_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Complete_Troops_Arc_Hammer" ] }, { @@ -247,26 +247,26 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0101_ENG.WAV", - "message": "Audio file \u0027U000_LEI0101_ENG.WAV\u0027 could not be found.", + "asset": "U000_DEF3006_ENG.WAV", + "message": "Audio file \u0027U000_DEF3006_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Corrupt_Sabateur" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0105_ENG.WAV", - "message": "Audio file \u0027U000_LEI0105_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0211_ENG.WAV", + "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0504_ENG.WAV", - "message": "Audio file \u0027U000_LEI0504_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0501_ENG.WAV", + "message": "Audio file \u0027U000_LEI0501_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Remove_Corruption_Leia" ] @@ -277,97 +277,97 @@ "asset": "U000_LEI0207_ENG.WAV", "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0404_ENG.WAV", - "message": "Audio file \u0027U000_LEI0404_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0215_ENG.WAV", + "message": "Audio file \u0027U000_LEI0215_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0603_ENG.WAV", - "message": "Audio file \u0027U000_LEI0603_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0203_ENG.WAV", + "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Increase_Production_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0311_ENG.WAV", - "message": "Audio file \u0027U000_LEI0311_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0402_ENG.WAV", + "message": "Audio file \u0027U000_LEI0402_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0108_ENG.WAV", - "message": "Audio file \u0027U000_LEI0108_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0401_ENG.WAV", + "message": "Audio file \u0027U000_LEI0401_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0206_ENG.WAV", - "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0215_ENG.WAV", + "message": "Audio file \u0027U000_LEI0215_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0308_ENG.WAV", - "message": "Audio file \u0027U000_LEI0308_ENG.WAV\u0027 could not be found.", + "asset": "AMB_DES_CLEAR_LOOP_1.WAV", + "message": "Audio file \u0027AMB_DES_CLEAR_LOOP_1.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Weather_Ambient_Clear_Sandstorm_Loop" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0313_ENG.WAV", - "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0202_ENG.WAV", + "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_ARC3104_ENG.WAV", - "message": "Audio file \u0027U000_ARC3104_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0115_ENG.WAV", + "message": "Audio file \u0027U000_LEI0115_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Produce_Troops_Arc_Hammer" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_3.WAV", - "message": "Audio file \u0027FS_BEETLE_3.WAV\u0027 could not be found.", + "asset": "U000_LEI0303_ENG.WAV", + "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_TMC0212_ENG.WAV", - "message": "Audio file \u0027U000_TMC0212_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0604_ENG.WAV", + "message": "Audio file \u0027U000_LEI0604_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Tie_Mauler" + "Unit_Increase_Production_Leia" ] }, { @@ -382,107 +382,98 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0213_ENG.WAV", - "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", + "asset": "U000_TMC0212_ENG.WAV", + "message": "Audio file \u0027U000_TMC0212_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Move_Tie_Mauler" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0215_ENG.WAV", - "message": "Audio file \u0027U000_LEI0215_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0308_ENG.WAV", + "message": "Audio file \u0027U000_LEI0308_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0309_ENG.WAV", - "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_3.WAV", + "message": "Audio file \u0027FS_BEETLE_3.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0315_ENG.WAV", - "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_4.WAV", + "message": "Audio file \u0027FS_BEETLE_4.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0114_ENG.WAV", - "message": "Audio file \u0027U000_LEI0114_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0208_ENG.WAV", + "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0206_ENG.WAV", - "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0101_ENG.WAV", + "message": "Audio file \u0027U000_LEI0101_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0307_ENG.WAV", - "message": "Audio file \u0027U000_LEI0307_ENG.WAV\u0027 could not be found.", - "context": [ - "Unit_Attack_Leia" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "C000_DST0102_ENG.WAV", - "message": "Audio file \u0027C000_DST0102_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_2.WAV", + "message": "Audio file \u0027FS_BEETLE_2.WAV\u0027 could not be found.", "context": [ - "EHD_Death_Star_Activate" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0202_ENG.WAV", - "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0109_ENG.WAV", + "message": "Audio file \u0027U000_LEI0109_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0209_ENG.WAV", - "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0113_ENG.WAV", + "message": "Audio file \u0027U000_LEI0113_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0211_ENG.WAV", - "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0403_ENG.WAV", + "message": "Audio file \u0027U000_LEI0403_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0201_ENG.WAV", - "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0206_ENG.WAV", + "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Fleet_Move_Leia" ] @@ -490,44 +481,44 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0109_ENG.WAV", - "message": "Audio file \u0027U000_LEI0109_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0209_ENG.WAV", + "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_MCF1601_ENG.WAV", - "message": "Audio file \u0027U000_MCF1601_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0312_ENG.WAV", + "message": "Audio file \u0027U000_LEI0312_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_StarDest_MC30_Frigate" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_1.WAV", - "message": "Audio file \u0027FS_BEETLE_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0304_ENG.WAV", + "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0304_ENG.WAV", - "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", + "asset": "U000_TMC0212_ENG.WAV", + "message": "Audio file \u0027U000_TMC0212_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Assist_Move_Tie_Mauler" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0306_ENG.WAV", - "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0315_ENG.WAV", + "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Attack_Leia" ] @@ -538,68 +529,68 @@ "asset": "U000_LEI0308_ENG.WAV", "message": "Audio file \u0027U000_LEI0308_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0304_ENG.WAV", - "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0303_ENG.WAV", + "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0303_ENG.WAV", - "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0111_ENG.WAV", + "message": "Audio file \u0027U000_LEI0111_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0212_ENG.WAV", - "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", + "asset": "EGL_STAR_VIPER_SPINNING_1.WAV", + "message": "Audio file \u0027EGL_STAR_VIPER_SPINNING_1.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Star_Viper_Spinning_By" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0202_ENG.WAV", - "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0201_ENG.WAV", + "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0203_ENG.WAV", - "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", + "asset": "FS_BEETLE_1.WAV", + "message": "Audio file \u0027FS_BEETLE_1.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "SFX_Anim_Beetle_Footsteps" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_DEF3106_ENG.WAV", - "message": "Audio file \u0027U000_DEF3106_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0313_ENG.WAV", + "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Weaken_Sabateur" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0314_ENG.WAV", - "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0305_ENG.WAV", + "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Group_Attack_Leia" ] @@ -607,181 +598,181 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_ARC3105_ENG.WAV", - "message": "Audio file \u0027U000_ARC3105_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0307_ENG.WAV", + "message": "Audio file \u0027U000_LEI0307_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Complete_Troops_Arc_Hammer" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0503_ENG.WAV", - "message": "Audio file \u0027U000_LEI0503_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0313_ENG.WAV", + "message": "Audio file \u0027U000_LEI0313_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Remove_Corruption_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0315_ENG.WAV", - "message": "Audio file \u0027U000_LEI0315_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0108_ENG.WAV", + "message": "Audio file \u0027U000_LEI0108_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0206_ENG.WAV", - "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0503_ENG.WAV", + "message": "Audio file \u0027U000_LEI0503_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Remove_Corruption_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_2.WAV", - "message": "Audio file \u0027FS_BEETLE_2.WAV\u0027 could not be found.", + "asset": "U000_LEI0205_ENG.WAV", + "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0201_ENG.WAV", - "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0204_ENG.WAV", + "message": "Audio file \u0027U000_LEI0204_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0403_ENG.WAV", - "message": "Audio file \u0027U000_LEI0403_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0205_ENG.WAV", + "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0110_ENG.WAV", - "message": "Audio file \u0027U000_LEI0110_ENG.WAV\u0027 could not be found.", + "asset": "U000_DEF3106_ENG.WAV", + "message": "Audio file \u0027U000_DEF3106_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Weaken_Sabateur" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0301_ENG.WAV", - "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", + "asset": "U000_MAL0503_ENG.WAV", + "message": "Audio file \u0027U000_MAL0503_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Assist_Move_Missile_Launcher" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0602_ENG.WAV", - "message": "Audio file \u0027U000_LEI0602_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0114_ENG.WAV", + "message": "Audio file \u0027U000_LEI0114_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Increase_Production_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0306_ENG.WAV", - "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0504_ENG.WAV", + "message": "Audio file \u0027U000_LEI0504_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Remove_Corruption_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0204_ENG.WAV", - "message": "Audio file \u0027U000_LEI0204_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0211_ENG.WAV", + "message": "Audio file \u0027U000_LEI0211_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0205_ENG.WAV", - "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0502_ENG.WAV", + "message": "Audio file \u0027U000_LEI0502_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Remove_Corruption_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "FS_BEETLE_4.WAV", - "message": "Audio file \u0027FS_BEETLE_4.WAV\u0027 could not be found.", + "asset": "U000_ARC3104_ENG.WAV", + "message": "Audio file \u0027U000_ARC3104_ENG.WAV\u0027 could not be found.", "context": [ - "SFX_Anim_Beetle_Footsteps" + "Unit_Produce_Troops_Arc_Hammer" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0204_ENG.WAV", - "message": "Audio file \u0027U000_LEI0204_ENG.WAV\u0027 could not be found.", + "asset": "TESTUNITMOVE_ENG.WAV", + "message": "Audio file \u0027TESTUNITMOVE_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Move_Gneneric_Test" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0208_ENG.WAV", - "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0202_ENG.WAV", + "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0213_ENG.WAV", - "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0309_ENG.WAV", + "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0102_ENG.WAV", - "message": "Audio file \u0027U000_LEI0102_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0210_ENG.WAV", + "message": "Audio file \u0027U000_LEI0210_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0203_ENG.WAV", - "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", + "asset": "AMB_URB_CLEAR_LOOP_1.WAV", + "message": "Audio file \u0027AMB_URB_CLEAR_LOOP_1.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Weather_Ambient_Clear_Urban_Loop" ] }, { "id": "FILE00", "severity": "Error", - "asset": "EGL_STAR_VIPER_SPINNING_1.WAV", - "message": "Audio file \u0027EGL_STAR_VIPER_SPINNING_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0304_ENG.WAV", + "message": "Audio file \u0027U000_LEI0304_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Star_Viper_Spinning_By" + "Unit_Group_Attack_Leia" ] }, { @@ -796,26 +787,26 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_ARC3106_ENG.WAV", - "message": "Audio file \u0027U000_ARC3106_ENG.WAV\u0027 could not be found.", + "asset": "C000_DST0102_ENG.WAV", + "message": "Audio file \u0027C000_DST0102_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Complete_Troops_Arc_Hammer" + "EHD_Death_Star_Activate" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0307_ENG.WAV", - "message": "Audio file \u0027U000_LEI0307_ENG.WAV\u0027 could not be found.", + "asset": "U000_MCF1601_ENG.WAV", + "message": "Audio file \u0027U000_MCF1601_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_StarDest_MC30_Frigate" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0207_ENG.WAV", - "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0212_ENG.WAV", + "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Move_Leia" ] @@ -823,98 +814,98 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0314_ENG.WAV", - "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0205_ENG.WAV", + "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0106_ENG.WAV", - "message": "Audio file \u0027U000_LEI0106_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0601_ENG.WAV", + "message": "Audio file \u0027U000_LEI0601_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Increase_Production_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0604_ENG.WAV", - "message": "Audio file \u0027U000_LEI0604_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0404_ENG.WAV", + "message": "Audio file \u0027U000_LEI0404_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Increase_Production_Leia" + "Unit_Guard_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0210_ENG.WAV", - "message": "Audio file \u0027U000_LEI0210_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0314_ENG.WAV", + "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "TESTUNITMOVE_ENG.WAV", - "message": "Audio file \u0027TESTUNITMOVE_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0208_ENG.WAV", + "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Gneneric_Test" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0309_ENG.WAV", - "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0103_ENG.WAV", + "message": "Audio file \u0027U000_LEI0103_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0202_ENG.WAV", - "message": "Audio file \u0027U000_LEI0202_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0107_ENG.WAV", + "message": "Audio file \u0027U000_LEI0107_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0205_ENG.WAV", - "message": "Audio file \u0027U000_LEI0205_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0203_ENG.WAV", + "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "AMB_URB_CLEAR_LOOP_1.WAV", - "message": "Audio file \u0027AMB_URB_CLEAR_LOOP_1.WAV\u0027 could not be found.", + "asset": "U000_LEI0603_ENG.WAV", + "message": "Audio file \u0027U000_LEI0603_ENG.WAV\u0027 could not be found.", "context": [ - "Weather_Ambient_Clear_Urban_Loop" + "Unit_Increase_Production_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0402_ENG.WAV", - "message": "Audio file \u0027U000_LEI0402_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0206_ENG.WAV", + "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0103_ENG.WAV", - "message": "Audio file \u0027U000_LEI0103_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0112_ENG.WAV", + "message": "Audio file \u0027U000_LEI0112_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Select_Leia" ] @@ -922,64 +913,55 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0601_ENG.WAV", - "message": "Audio file \u0027U000_LEI0601_ENG.WAV\u0027 could not be found.", - "context": [ - "Unit_Increase_Production_Leia" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "U000_LEI0111_ENG.WAV", - "message": "Audio file \u0027U000_LEI0111_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0209_ENG.WAV", + "message": "Audio file \u0027U000_LEI0209_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0501_ENG.WAV", - "message": "Audio file \u0027U000_LEI0501_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0307_ENG.WAV", + "message": "Audio file \u0027U000_LEI0307_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Remove_Corruption_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0207_ENG.WAV", - "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0201_ENG.WAV", + "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Fleet_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0203_ENG.WAV", - "message": "Audio file \u0027U000_LEI0203_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0311_ENG.WAV", + "message": "Audio file \u0027U000_LEI0311_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0401_ENG.WAV", - "message": "Audio file \u0027U000_LEI0401_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0301_ENG.WAV", + "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Guard_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0107_ENG.WAV", - "message": "Audio file \u0027U000_LEI0107_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0213_ENG.WAV", + "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Move_Leia" ] }, { @@ -988,104 +970,104 @@ "asset": "U000_LEI0210_ENG.WAV", "message": "Audio file \u0027U000_LEI0210_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0303_ENG.WAV", - "message": "Audio file \u0027U000_LEI0303_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0309_ENG.WAV", + "message": "Audio file \u0027U000_LEI0309_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_MAL0503_ENG.WAV", - "message": "Audio file \u0027U000_MAL0503_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0207_ENG.WAV", + "message": "Audio file \u0027U000_LEI0207_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Assist_Move_Missile_Launcher" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0208_ENG.WAV", - "message": "Audio file \u0027U000_LEI0208_ENG.WAV\u0027 could not be found.", + "asset": "U000_ARC3105_ENG.WAV", + "message": "Audio file \u0027U000_ARC3105_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Fleet_Move_Leia" + "Unit_Complete_Troops_Arc_Hammer" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0113_ENG.WAV", - "message": "Audio file \u0027U000_LEI0113_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0212_ENG.WAV", + "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Select_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0305_ENG.WAV", - "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0102_ENG.WAV", + "message": "Audio file \u0027U000_LEI0102_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_TMC0212_ENG.WAV", - "message": "Audio file \u0027U000_TMC0212_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0306_ENG.WAV", + "message": "Audio file \u0027U000_LEI0306_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Assist_Move_Tie_Mauler" + "Unit_Group_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_DEF3006_ENG.WAV", - "message": "Audio file \u0027U000_DEF3006_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0206_ENG.WAV", + "message": "Audio file \u0027U000_LEI0206_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Corrupt_Sabateur" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0201_ENG.WAV", - "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0204_ENG.WAV", + "message": "Audio file \u0027U000_LEI0204_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Move_Leia" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0215_ENG.WAV", - "message": "Audio file \u0027U000_LEI0215_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0314_ENG.WAV", + "message": "Audio file \u0027U000_LEI0314_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0301_ENG.WAV", - "message": "Audio file \u0027U000_LEI0301_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0105_ENG.WAV", + "message": "Audio file \u0027U000_LEI0105_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Group_Attack_Leia" + "Unit_Select_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0104_ENG.WAV", - "message": "Audio file \u0027U000_LEI0104_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0106_ENG.WAV", + "message": "Audio file \u0027U000_LEI0106_ENG.WAV\u0027 could not be found.", "context": [ "Unit_Select_Leia" ] @@ -1093,144 +1075,137 @@ { "id": "FILE00", "severity": "Error", - "asset": "U000_LEI0212_ENG.WAV", - "message": "Audio file \u0027U000_LEI0212_ENG.WAV\u0027 could not be found.", + "asset": "U000_LEI0213_ENG.WAV", + "message": "Audio file \u0027U000_LEI0213_ENG.WAV\u0027 could not be found.", "context": [ - "Unit_Move_Leia" + "Unit_Group_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "underworld_logo_rollover.tga", - "message": "Could not find GUI texture \u0027underworld_logo_rollover.tga\u0027 at location \u0027MegaTexture\u0027.", + "asset": "U000_LEI0201_ENG.WAV", + "message": "Audio file \u0027U000_LEI0201_ENG.WAV\u0027 could not be found.", "context": [ - "IDC_PLAY_FACTION_A_BUTTON_BIG", - "MegaTexture" + "Unit_Move_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "underworld_logo_off.tga", - "message": "Could not find GUI texture \u0027underworld_logo_off.tga\u0027 at location \u0027MegaTexture\u0027.", + "asset": "U000_LEI0305_ENG.WAV", + "message": "Audio file \u0027U000_LEI0305_ENG.WAV\u0027 could not be found.", "context": [ - "IDC_PLAY_FACTION_A_BUTTON_BIG", - "MegaTexture" + "Unit_Attack_Leia" ] }, { "id": "FILE00", "severity": "Error", - "asset": "underworld_logo_selected.tga", - "message": "Could not find GUI texture \u0027underworld_logo_selected.tga\u0027 at location \u0027MegaTexture\u0027.", + "asset": "underworld_logo_rollover.tga", + "message": "Could not find GUI texture \u0027underworld_logo_rollover.tga\u0027 of type \u0027ButtonMiddleMouseOver\u0027 at origin \u0027MegaTexture\u0027 for component \u0027IDC_PLAY_FACTION_A_BUTTON_BIG\u0027.", "context": [ - "IDC_PLAY_FACTION_A_BUTTON_BIG", - "MegaTexture" + "IDC_PLAY_FACTION_A_BUTTON_BIG" ] }, { "id": "FILE00", "severity": "Error", "asset": "i_dialogue_button_large_middle_off.tga", - "message": "Could not find GUI texture \u0027i_dialogue_button_large_middle_off.tga\u0027 at location \u0027Repository\u0027.", + "message": "Could not find GUI texture \u0027i_dialogue_button_large_middle_off.tga\u0027 of type \u0027ButtonMiddleDisabled\u0027 at origin \u0027Repository\u0027 for component \u0027IDC_PLAY_FACTION_B_BUTTON_BIG\u0027.", "context": [ - "IDC_PLAY_FACTION_B_BUTTON_BIG", - "Repository" + "IDC_PLAY_FACTION_B_BUTTON_BIG" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_SITH_ARCH.ALO", - "message": "Unable to find Alamo file \u0027w_sith_arch.alo\u0027", + "asset": "underworld_logo_off.tga", + "message": "Could not find GUI texture \u0027underworld_logo_off.tga\u0027 of type \u0027ButtonMiddle\u0027 at origin \u0027MegaTexture\u0027 for component \u0027IDC_PLAY_FACTION_A_BUTTON_BIG\u0027.", "context": [ - "Cin_sith_arch", - "Tag: Land_Model_Name" + "IDC_PLAY_FACTION_A_BUTTON_BIG" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_ALLSHADERS.ALO", - "message": "Unable to find Alamo file \u0027W_AllShaders.ALO\u0027", + "asset": "underworld_logo_selected.tga", + "message": "Could not find GUI texture \u0027underworld_logo_selected.tga\u0027 of type \u0027ButtonMiddlePressed\u0027 at origin \u0027MegaTexture\u0027 for component \u0027IDC_PLAY_FACTION_A_BUTTON_BIG\u0027.", "context": [ - "Prop_AllShaders", - "Tag: Land_Model_Name" + "IDC_PLAY_FACTION_A_BUTTON_BIG" ] }, { "id": "FILE00", "severity": "Error", - "asset": "NB_YsalamiriTree_B.tga", - "message": "Could not find texture \u0027NB_YsalamiriTree_B.tga\u0027 for context: [Ysalamiri_Tree--\u003ETag: Land_Model_Name--\u003ENB_YSALAMIRI_TREE.ALO--\u003ENB_YSALAMIRI_TREE.ALO].", + "asset": "CIN_SHUTTLE_TYDERIUM.ALO", + "message": "Unable to find Alamo file \u0027Cin_Shuttle_Tyderium.alo\u0027", "context": [ - "Ysalamiri_Tree", - "Tag: Land_Model_Name", - "NB_YSALAMIRI_TREE.ALO" + "Intro2_Shuttle_Tyderium", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_NAVYROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_NavyRow.alo\u0027", + "asset": "Default.fx", + "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027UV_SKIPRAY.ALO\u0027.", "context": [ - "Cin_Rebel_NavyRow", - "Tag: Land_Model_Name" + "Skipray_Bombing_Run", + "Tag: Land_Model_Name", + "UV_SKIPRAY.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EI_VADER.ALO", - "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_IG88.ALO\u0027", "context": [ - "Cin_Vader_Shot_6-9", - "Tag: Land_Model_Name" + "IG-88", + "Tag: Land_Model_Name", + "UI_IG88.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_MEDIUM.ALO\u0027", + "asset": "CIN_EV_STARDESTROYER_WARP.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_Stardestroyer_Warp.alo\u0027", "context": [ - "Stars_Medium", - "Tag: Space_Model_Name", - "W_STARS_MEDIUM.ALO" + "Star_Destroyer_Warp", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", + "asset": "CIN_RBEL_GREYGROUP.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_GreyGroup.alo\u0027", "context": [ - "Lambda_Shuttle_150X6-9", + "Cin_Rebel_GreyGroup", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Default.fx", - "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027UV_SKIPRAY.ALO\u0027.", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027RI_KYLEKATARN.ALO\u0027", "context": [ - "Skipray_Bombing_Run", + "Kyle_Katarn", "Tag: Land_Model_Name", - "UV_SKIPRAY.ALO" + "RI_KYLEKATARN.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_TE_Rock_f_02_b.tga", - "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [Vengeance_Frigate--\u003ETag: Space_Model_Name--\u003EUV_VENGEANCE.ALO--\u003EUV_VENGEANCE.ALO].", + "asset": "CIN_DSTAR_TURRETLASERS.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_TurretLasers.alo\u0027", "context": [ - "Vengeance_Frigate", - "Tag: Space_Model_Name", - "UV_VENGEANCE.ALO" + "TurretLasers_DStar_Xplode", + "Tag: Space_Model_Name" ] }, { @@ -1246,43 +1221,65 @@ { "id": "FILE00", "severity": "Error", - "asset": "W_KAMINO_REFLECT.ALO", - "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", + "asset": "CIN_BRIDGE.ALO", + "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", "context": [ - "Prop_Kamino_Reflection_01", - "Tag: Land_Model_Name" + "Imperial_Bridge", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "P_mptl-2a_Die", - "message": "Proxy particle \u0027P_mptl-2a_Die\u0027 not found for model \u0027RV_MPTL-2A.ALO\u0027", - "context": [ - "MPTL", + "asset": "CIN_P_PROTON_TORPEDO.ALO", + "message": "Unable to find Alamo file \u0027CIN_p_proton_torpedo.alo\u0027", + "context": [ + "Cin_Proj_Ground_Proton_Torpedo", + "Tag: Land_Model_Name" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "p_hp_archammer-damage", + "message": "Proxy particle \u0027p_hp_archammer-damage\u0027 not found for model \u0027EV_ARCHAMMER.ALO\u0027", + "context": [ + "Arc_Hammer", + "Tag: Space_Model_Name", + "EV_ARCHAMMER.ALO" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "p_cold_tiny01", + "message": "Proxy particle \u0027p_cold_tiny01\u0027 not found for model \u0027NB_SCH.ALO\u0027", + "context": [ + "Arctic_Civilian_Spawn_House", "Tag: Land_Model_Name", - "RV_MPTL-2A.ALO" + "NB_SCH.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_NAVYTROOPER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_NavyTrooper_Row.alo\u0027", + "asset": "Cin_Reb_CelebHall_Wall_B.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO].", "context": [ - "Cin_NavyTrooper_Row", - "Tag: Land_Model_Name" + "Cin_sith_console", + "Tag: Land_Model_Name", + "W_SITH_CONSOLE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_particle_master", - "message": "Could not find texture \u0027p_particle_master\u0027 for context: [Test_Particle--\u003ETag: Land_Model_Name--\u003EP_DIRT_EMITTER_TEST1.ALO].", + "asset": "P_heat_small01", + "message": "Proxy particle \u0027P_heat_small01\u0027 not found for model \u0027NB_VCH.ALO\u0027", "context": [ - "Test_Particle", + "Volcanic_Civilian_Spawn_House_Independent_AI", "Tag: Land_Model_Name", - "P_DIRT_EMITTER_TEST1.ALO" + "NB_VCH.ALO" ] }, { @@ -1299,7 +1296,7 @@ "id": "FILE00", "severity": "Error", "asset": "Cin_Reb_CelebHall_Wall.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO--\u003EW_SITH_CONSOLE.ALO].", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO].", "context": [ "Cin_sith_console", "Tag: Land_Model_Name", @@ -1309,39 +1306,38 @@ { "id": "FILE00", "severity": "Error", - "asset": "CIN_REB_CELEBHALL.ALO", - "message": "Unable to find Alamo file \u0027CIN_Reb_CelebHall.alo\u0027", + "asset": "CIN_DSTAR_DISH_CLOSE.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_Dish_close.alo\u0027", "context": [ - "REb_CelebHall", - "Tag: Land_Model_Name" + "Death_Star_Dish_Close", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_PLANET_VOLCANIC.ALO", - "message": "Unable to find Alamo file \u0027w_planet_volcanic.alo\u0027", + "asset": "CIN_BRIDGE.ALO", + "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", "context": [ - "Volcanic_Backdrop_Large", - "Tag: Space_Model_Name" + "UM05_PROP_BRIDGE", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "lookat", - "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE_UC.ALO\u0027", + "asset": "CIN_REB_CELEBCHARACTERS.ALO", + "message": "Unable to find Alamo file \u0027CIN_REb_CelebCharacters.alo\u0027", "context": [ - "Eclipse_Super_Star_Destroyer", - "Tag: Space_Model_Name", - "UV_ECLIPSE_UC.ALO" + "REb_CelebCharacters", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", "asset": "W_TE_Rock_f_02_b.tga", - "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [F9TZ_Cloaking_Transport--\u003ETag: Land_Model_Name--\u003EUV_F9TZTRANSPORT.ALO--\u003EUV_F9TZTRANSPORT.ALO].", + "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [F9TZ_Cloaking_Transport--\u003ETag: Land_Model_Name--\u003EUV_F9TZTRANSPORT.ALO].", "context": [ "F9TZ_Cloaking_Transport", "Tag: Land_Model_Name", @@ -1351,421 +1347,406 @@ { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_HIGH.ALO\u0027", + "asset": "p_ewok_drag_dirt", + "message": "Proxy particle \u0027p_ewok_drag_dirt\u0027 not found for model \u0027UI_EWOK_HANDLER.ALO\u0027", "context": [ - "Stars_High", - "Tag: Space_Model_Name", - "W_STARS_HIGH.ALO" + "Ewok_Handler", + "Tag: Land_Model_Name", + "UI_EWOK_HANDLER.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_smoke_small_thin2", - "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027RB_HYPERVELOCITYGUN.ALO\u0027", + "asset": "p_explosion_small_delay00", + "message": "Proxy particle \u0027p_explosion_small_delay00\u0027 not found for model \u0027EB_COMMANDCENTER.ALO\u0027", "context": [ - "Ground_Empire_Hypervelocity_Gun", + "Imperial_Command_Center", "Tag: Land_Model_Name", - "RB_HYPERVELOCITYGUN.ALO" + "EB_COMMANDCENTER.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_steam_small", - "message": "Proxy particle \u0027p_steam_small\u0027 not found for model \u0027RB_HEAVYVEHICLEFACTORY.ALO\u0027", + "asset": "UB_girder_B.tga", + "message": "Could not find texture \u0027UB_girder_B.tga\u0027 for context: [Underworld_Ysalamiri_Cage--\u003ETag: Land_Model_Name--\u003EUV_MDU_CAGE.ALO].", "context": [ - "R_Ground_Heavy_Vehicle_Factory", + "Underworld_Ysalamiri_Cage", "Tag: Land_Model_Name", - "RB_HEAVYVEHICLEFACTORY.ALO" + "UV_MDU_CAGE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_PLANET_ALDERAAN_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_Planet_Alderaan_High.alo\u0027", + "asset": "NB_YsalamiriTree_B.tga", + "message": "Could not find texture \u0027NB_YsalamiriTree_B.tga\u0027 for context: [Underworld_Ysalamiri_Cage--\u003ETag: Land_Model_Name--\u003EUV_MDU_CAGE.ALO].", "context": [ - "Alderaan_Backdrop_Large 6x", - "Tag: Space_Model_Name" + "Underworld_Ysalamiri_Cage", + "Tag: Land_Model_Name", + "UV_MDU_CAGE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_ssd_debris", - "message": "Proxy particle \u0027p_ssd_debris\u0027 not found for model \u0027UV_ECLIPSE_UC_DC.ALO\u0027", + "asset": "p_bomb_spin", + "message": "Proxy particle \u0027p_bomb_spin\u0027 not found for model \u0027W_THERMAL_DETONATOR_EMPIRE.ALO\u0027", "context": [ - "Eclipse_Super_Star_Destroyer_Death_Clone", - "Tag: Space_Model_Name", - "UV_ECLIPSE_UC_DC.ALO" + "TIE_Bomber_Bombing_Run_Bomb", + "Tag: Land_Model_Name", + "W_THERMAL_DETONATOR_EMPIRE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO--\u003EW_SITH_LEFTHALL.ALO].", + "asset": "CIN_OFFICER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Officer_Row.alo\u0027", "context": [ - "Cin_sith_lefthall", - "Tag: Land_Model_Name", - "W_SITH_LEFTHALL.ALO" + "Cin_Officer_Row", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_LOW.ALO\u0027", + "asset": "W_TE_Rock_f_02_b.tga", + "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [Vengeance_Frigate--\u003ETag: Space_Model_Name--\u003EUV_VENGEANCE.ALO].", "context": [ - "Stars_Low", + "Vengeance_Frigate", "Tag: Space_Model_Name", - "W_STARS_LOW.ALO" + "UV_VENGEANCE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_LAMBDA_MOUTH.ALO", - "message": "Unable to find Alamo file \u0027CIN_Lambda_Mouth.alo\u0027", + "asset": "CIN_RBEL_GREY.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_grey.alo\u0027", "context": [ - "Cin_Lambda_Mouth", + "Cin_Rebel_Grey", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_BRIDGE.ALO", - "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", + "asset": "Cin_Reb_CelebHall_Wall.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO].", "context": [ - "UM05_PROP_BRIDGE", - "Tag: Land_Model_Name" + "Cin_w_tile", + "Tag: Land_Model_Name", + "W_TILE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_LAMBDA_HEAD.ALO", - "message": "Unable to find Alamo file \u0027CIN_Lambda_Head.alo\u0027", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_01_STATION_D.ALO\u0027", "context": [ - "Cin_Lambda_Head", - "Tag: Land_Model_Name" + "Underworld_Star_Base_1_Death_Clone", + "Tag: Space_Model_Name", + "UB_01_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_LEVERPANEL.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_LeverPanel.alo\u0027", + "asset": "CIN_LAMBDA_HEAD.ALO", + "message": "Unable to find Alamo file \u0027CIN_Lambda_Head.alo\u0027", "context": [ - "Death_Star_LeverPanel", - "Tag: Space_Model_Name" + "Cin_Lambda_Head", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_PROBE_DROID.ALO", - "message": "Unable to find Alamo file \u0027CIN_Probe_Droid.alo\u0027", + "asset": "p_smoke_small_thin2", + "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027NB_MONCAL_BUILDING.ALO\u0027", "context": [ - "Empire_Droid", - "Tag: Land_Model_Name" + "MonCalamari_Spawn_House", + "Tag: Land_Model_Name", + "NB_MONCAL_BUILDING.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EI_PALPATINE.ALO", - "message": "Unable to find Alamo file \u0027Cin_EI_Palpatine.alo\u0027", + "asset": "p_ssd_debris", + "message": "Proxy particle \u0027p_ssd_debris\u0027 not found for model \u0027UV_ECLIPSE_UC_DC.ALO\u0027", "context": [ - "Cin_Emperor_Shot_6-9", - "Tag: Land_Model_Name" + "Eclipse_Super_Star_Destroyer_Death_Clone", + "Tag: Space_Model_Name", + "UV_ECLIPSE_UC_DC.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_IG88.ALO\u0027", + "asset": "p_smoke_small_thin2", + "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027RB_HYPERVELOCITYGUN.ALO\u0027", "context": [ - "IG-88", + "Ground_Empire_Hypervelocity_Gun", "Tag: Land_Model_Name", - "UI_IG88.ALO" + "RB_HYPERVELOCITYGUN.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_VOL_STEAM01.ALO", - "message": "Unable to find Alamo file \u0027W_Vol_Steam01.ALO\u0027", + "asset": "CIN_EV_TIEADVANCED.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_TieAdvanced.alo\u0027", "context": [ - "Prop_Vol_Steam01", - "Tag: Land_Model_Name" + "Fin_Vader_TIE", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_PLANET_HOTH_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_Planet_Hoth_High.alo\u0027", + "asset": "pe_bwing_yellow", + "message": "Proxy particle \u0027pe_bwing_yellow\u0027 not found for model \u0027RV_BWING.ALO\u0027", "context": [ - "Hoth_Backdrop_Large 6x", - "Tag: Space_Model_Name" + "B-Wing", + "Tag: Space_Model_Name", + "RV_BWING.ALO" ] }, { - "id": "FILE00", - "severity": "Error", - "asset": "CIN_DEATHSTAR_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", + "id": "FILE03", + "severity": "Information", + "asset": "MOV_EMPIRE_INTRO_SHUTTLE.ALO", + "message": "Possible file CRC32 collision: \u0027MOV_Empire_Intro_Shuttle.ALO\u0027 was requested but \u0027U000_EMP0212_ENG.WAV\u0027 was found by the engine.", "context": [ - "Death_Star_Whole_small", - "Tag: Space_Model_Name" + "Shuttle_Tyderium_Lua_Cinematic", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EV_STARDESTROYER_WARP.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_Stardestroyer_Warp.alo\u0027", + "asset": "CIN_NAVYTROOPER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_NavyTrooper_Row.alo\u0027", "context": [ - "Star_Destroyer_Warp", - "Tag: Space_Model_Name" + "Cin_NavyTrooper_Row", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_DISH_CLOSE.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_Dish_close.alo\u0027", + "asset": "Default.fx", + "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027UV_CRUSADERCLASSCORVETTE.ALO\u0027.", "context": [ - "Death_Star_Dish_Close", - "Tag: Space_Model_Name" + "Crusader_Gunship", + "Tag: Space_Model_Name", + "UV_CRUSADERCLASSCORVETTE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO--\u003EW_TILE.ALO].", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027EI_MARAJADE.ALO\u0027", "context": [ - "Cin_w_tile", + "Mara_Jade", "Tag: Land_Model_Name", - "W_TILE.ALO" + "EI_MARAJADE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_OFFICER.ALO", - "message": "Unable to find Alamo file \u0027Cin_Officer.alo\u0027", + "asset": "CIN_LAMBDA_MOUTH.ALO", + "message": "Unable to find Alamo file \u0027CIN_Lambda_Mouth.alo\u0027", "context": [ - "FIN_Officer", - "Tag: Space_Model_Name" + "Cin_Lambda_Mouth", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", - "severity": "Error", - "asset": "CIN_P_PROTON_TORPEDO.ALO", - "message": "Unable to find Alamo file \u0027CIN_p_proton_torpedo.alo\u0027", + "severity": "Warning", + "asset": "i_button_general_dodonna.tga", + "message": "Could not find icon \u0027i_button_general_dodonna.tga\u0027 for game object type \u0027General_Dodonna\u0027.", "context": [ - "Cin_Proj_Ground_Proton_Torpedo", - "Tag: Land_Model_Name" + "General_Dodonna" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_smoke_small_thin4", - "message": "Proxy particle \u0027p_smoke_small_thin4\u0027 not found for model \u0027NB_PRISON.ALO\u0027", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_MEDIUM.ALO\u0027", "context": [ - "Imperial_Prison_Facility", - "Tag: Land_Model_Name", - "NB_PRISON.ALO" - ] - }, - { - "id": "FILE00", - "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_03_STATION_D.ALO\u0027", - "context": [ - "Underworld_Star_Base_3_Death_Clone", + "Stars_Medium", "Tag: Space_Model_Name", - "UB_03_STATION_D.ALO" + "W_STARS_MEDIUM.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall_B.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_console--\u003ETag: Land_Model_Name--\u003EW_SITH_CONSOLE.ALO--\u003EW_SITH_CONSOLE.ALO].", + "asset": "Cin_Reb_CelebHall_Wall.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO].", "context": [ - "Cin_sith_console", + "Cin_sith_lefthall", "Tag: Land_Model_Name", - "W_SITH_CONSOLE.ALO" + "W_SITH_LEFTHALL.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027RI_KYLEKATARN.ALO\u0027", + "asset": "W_DROID_STEAM.ALO", + "message": "Unable to find Alamo file \u0027W_droid_steam.alo\u0027", "context": [ - "Kyle_Katarn", - "Tag: Land_Model_Name", - "RI_KYLEKATARN.ALO" + "Prop_Droid_Steam", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_TE_Rock_f_02_b.tga", - "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [TIE_Phantom--\u003ETag: Space_Model_Name--\u003EEV_TIE_PHANTOM.ALO--\u003EEV_TIE_PHANTOM.ALO].", + "asset": "CIN_REB_CELEBHALL.ALO", + "message": "Unable to find Alamo file \u0027CIN_Reb_CelebHall.alo\u0027", "context": [ - "TIE_Phantom", - "Tag: Space_Model_Name", - "EV_TIE_PHANTOM.ALO" + "REb_CelebHall", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EV_TIEADVANCED.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_TieAdvanced.alo\u0027", + "asset": "CIN_EI_PALPATINE.ALO", + "message": "Unable to find Alamo file \u0027Cin_EI_Palpatine.alo\u0027", "context": [ - "Fin_Vader_TIE", - "Tag: Space_Model_Name" + "Cin_Emperor_Shot_6-9", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027EI_MARAJADE.ALO\u0027", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_05_STATION_D.ALO\u0027", "context": [ - "Mara_Jade", - "Tag: Land_Model_Name", - "EI_MARAJADE.ALO" + "Underworld_Star_Base_5_Death_Clone", + "Tag: Space_Model_Name", + "UB_05_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_explosion_smoke_small_thin5", - "message": "Proxy particle \u0027p_explosion_smoke_small_thin5\u0027 not found for model \u0027NB_NOGHRI_HUT.ALO\u0027", + "asset": "CIN_TROOPER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Trooper_Row.alo\u0027", "context": [ - "Noghri_Spawn_House", - "Tag: Land_Model_Name", - "NB_NOGHRI_HUT.ALO" + "Cin_Trooper_Row", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall_B.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO--\u003EW_SITH_LEFTHALL.ALO].", + "asset": "CIN_DEATHSTAR_WALL.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_Wall.alo\u0027", "context": [ - "Cin_sith_lefthall", - "Tag: Land_Model_Name", - "W_SITH_LEFTHALL.ALO" + "Death_Star_Hangar_Outside", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_WALL.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_Wall.alo\u0027", + "asset": "p_prison_light", + "message": "Proxy particle \u0027p_prison_light\u0027 not found for model \u0027NB_PRISON.ALO\u0027", "context": [ - "Death_Star_Hangar_Outside", - "Tag: Space_Model_Name" + "Imperial_Prison_Facility", + "Tag: Land_Model_Name", + "NB_PRISON.ALO" ] }, { "id": "FILE00", - "severity": "Error", - "asset": "W_DROID_STEAM.ALO", - "message": "Unable to find Alamo file \u0027W_droid_steam.alo\u0027", + "severity": "Warning", + "asset": "i_button_ni_nightsister_ranger.tga", + "message": "Could not find icon \u0027i_button_ni_nightsister_ranger.tga\u0027 for game object type \u0027Dathomir_Night_Sister\u0027.", "context": [ - "Prop_Droid_Steam", - "Tag: Land_Model_Name" + "Dathomir_Night_Sister" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_prison_light", - "message": "Proxy particle \u0027p_prison_light\u0027 not found for model \u0027NB_PRISON.ALO\u0027", + "asset": "CIN_DEATHSTAR_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "Imperial_Prison_Facility", - "Tag: Land_Model_Name", - "NB_PRISON.ALO" + "Death_Star_Whole_Vsmall", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_SHUTTLE_TYDERIUM.ALO", - "message": "Unable to find Alamo file \u0027Cin_Shuttle_Tyderium.alo\u0027", + "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", "context": [ - "Intro2_Shuttle_Tyderium", - "Tag: Space_Model_Name" + "Lambda_Shuttle_150X6-9", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_Reb_CelebHall_Wall_B.tga", - "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO--\u003EW_TILE.ALO].", + "asset": "CIN_FIRE_MEDIUM.ALO", + "message": "Unable to find Alamo file \u0027CIN_Fire_Medium.alo\u0027", "context": [ - "Cin_w_tile", - "Tag: Land_Model_Name", - "W_TILE.ALO" + "Fin_Fire_Medium", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "lookat", - "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE.ALO\u0027", + "asset": "W_VOLCANO_ROCK02.ALO", + "message": "Unable to find Alamo file \u0027W_Volcano_Rock02.ALO\u0027", "context": [ - "Eclipse_Prop", - "Tag: Space_Model_Name", - "UV_ECLIPSE.ALO" + "Prop_Volcano_RockForm03", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "UB_girder_B.tga", - "message": "Could not find texture \u0027UB_girder_B.tga\u0027 for context: [Underworld_Ysalamiri_Cage--\u003ETag: Land_Model_Name--\u003EUV_MDU_CAGE.ALO--\u003EUV_MDU_CAGE.ALO].", + "asset": "CIN_EI_VADER.ALO", + "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", "context": [ - "Underworld_Ysalamiri_Cage", - "Tag: Land_Model_Name", - "UV_MDU_CAGE.ALO" + "Cin_Vader", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_smoke_small_thin2", - "message": "Proxy particle \u0027p_smoke_small_thin2\u0027 not found for model \u0027NB_MONCAL_BUILDING.ALO\u0027", + "asset": "W_VOL_STEAM01.ALO", + "message": "Unable to find Alamo file \u0027W_Vol_Steam01.ALO\u0027", "context": [ - "MonCalamari_Spawn_House", - "Tag: Land_Model_Name", - "NB_MONCAL_BUILDING.ALO" + "Prop_Vol_Steam01", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RV_XWINGPROP.ALO", - "message": "Unable to find Alamo file \u0027Cin_rv_XWingProp.alo\u0027", + "asset": "CIN_DEATHSTAR_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "Grounded_Xwing", + "UM05_PROP_DSTAR", "Tag: Land_Model_Name" ] }, @@ -1775,39 +1756,40 @@ "asset": "CIN_RV_XWINGPROP.ALO", "message": "Unable to find Alamo file \u0027Cin_rv_XWingProp.alo\u0027", "context": [ - "Cin_X-WingProp", + "Grounded_Xwing", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_SWAMPGASEMIT.ALO", - "message": "Unable to find Alamo file \u0027W_SwampGasEmit.ALO\u0027", + "asset": "Default.fx", + "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_MDU_SENSORNODE.ALO\u0027.", "context": [ - "Prop_SwampGasEmitter", - "Tag: Land_Model_Name" + "Empire_Offensive_Sensor_Node", + "Tag: Land_Model_Name", + "EV_MDU_SENSORNODE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_GREY.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_grey.alo\u0027", + "asset": "p_particle_master", + "message": "Could not find texture \u0027p_particle_master\u0027 for context: [Test_Particle--\u003ETag: Land_Model_Name--\u003EP_DIRT_EMITTER_TEST1.ALO].", "context": [ - "Cin_Rebel_Grey", - "Tag: Land_Model_Name" + "Test_Particle", + "Tag: Land_Model_Name", + "P_DIRT_EMITTER_TEST1.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Cin_DeathStar.tga", - "message": "Could not find texture \u0027Cin_DeathStar.tga\u0027 for context: [Test_Base_Hector--\u003ETag: Land_Model_Name--\u003EALTTEST.ALO--\u003EALTTEST.ALO].", + "asset": "CIN_BIKER_ROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Biker_Row.alo\u0027", "context": [ - "Test_Base_Hector", - "Tag: Land_Model_Name", - "ALTTEST.ALO" + "Cin_Biker_Row", + "Tag: Land_Model_Name" ] }, { @@ -1816,132 +1798,131 @@ "asset": "CIN_DEATHSTAR_HIGH.ALO", "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "Death_Star_Whole_Vsmall", + "Death_Star_Whole_small", "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE_LUA.ALO\u0027", + "asset": "CIN_FIRE_HUGE.ALO", + "message": "Unable to find Alamo file \u0027CIN_Fire_Huge.alo\u0027", "context": [ - "Stars_Lua_Cinematic", - "Tag: Land_Model_Name", - "W_STARS_CINE_LUA.ALO" + "Fin_Fire_Huge", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_BRIDGE.ALO", - "message": "Unable to find Alamo file \u0027Cin_bridge.alo\u0027", + "asset": "CIN_RBEL_NAVYROW.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_NavyRow.alo\u0027", "context": [ - "Imperial_Bridge", - "Tag: Space_Model_Name" + "Cin_Rebel_NavyRow", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_01_STATION_D.ALO\u0027", + "asset": "lookat", + "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE.ALO\u0027", "context": [ - "Underworld_Star_Base_1_Death_Clone", + "Eclipse_Prop", "Tag: Space_Model_Name", - "UB_01_STATION_D.ALO" + "UV_ECLIPSE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_EI_VADER.ALO", - "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", + "asset": "CIN_PLANET_HOTH_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_Planet_Hoth_High.alo\u0027", "context": [ - "Cin_Vader", - "Tag: Land_Model_Name" + "Hoth_Backdrop_Large 6x", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", + "asset": "W_TE_Rock_f_02_b.tga", + "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [TIE_Phantom--\u003ETag: Space_Model_Name--\u003EEV_TIE_PHANTOM.ALO].", "context": [ - "UM05_PROP_DSTAR", - "Tag: Land_Model_Name" + "TIE_Phantom", + "Tag: Space_Model_Name", + "EV_TIE_PHANTOM.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_04_STATION_D.ALO\u0027", + "asset": "P_SPLASH_WAKE_LAVA.ALO", + "message": "Unable to find Alamo file \u0027p_splash_wake_lava.alo\u0027", "context": [ - "Underworld_Star_Base_4_Death_Clone", - "Tag: Space_Model_Name", - "UB_04_STATION_D.ALO" + "Splash_Wake_Lava", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "NB_YsalamiriTree_B.tga", - "message": "Could not find texture \u0027NB_YsalamiriTree_B.tga\u0027 for context: [Underworld_Ysalamiri_Cage--\u003ETag: Land_Model_Name--\u003EUV_MDU_CAGE.ALO--\u003EUV_MDU_CAGE.ALO].", + "asset": "CIN_PLANET_ALDERAAN_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_Planet_Alderaan_High.alo\u0027", "context": [ - "Underworld_Ysalamiri_Cage", - "Tag: Land_Model_Name", - "UV_MDU_CAGE.ALO" + "Alderaan_Backdrop_Large 6x", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_TROOPER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Trooper_Row.alo\u0027", + "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", + "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", "context": [ - "Cin_Trooper_Row", + "Lambda_Shuttle_150", "Tag: Land_Model_Name" ] }, { "id": "FILE00", - "severity": "Warning", - "asset": "i_button_general_dodonna.tga", - "message": "Could not find icon \u0027i_button_general_dodonna.tga\u0027 for game object type \u0027General_Dodonna\u0027.", + "severity": "Error", + "asset": "W_PLANET_VOLCANIC.ALO", + "message": "Unable to find Alamo file \u0027w_planet_volcanic.alo\u0027", "context": [ - "General_Dodonna" + "Volcanic_Backdrop_Large", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_ewok_drag_dirt", - "message": "Proxy particle \u0027p_ewok_drag_dirt\u0027 not found for model \u0027UI_EWOK_HANDLER.ALO\u0027", + "asset": "CIN_EI_VADER.ALO", + "message": "Unable to find Alamo file \u0027Cin_EI_Vader.alo\u0027", "context": [ - "Ewok_Handler", - "Tag: Land_Model_Name", - "UI_EWOK_HANDLER.ALO" + "Cin_Vader_Shot_6-9", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", - "severity": "Warning", - "asset": "i_button_ni_nightsister_ranger.tga", - "message": "Could not find icon \u0027i_button_ni_nightsister_ranger.tga\u0027 for game object type \u0027Dathomir_Night_Sister\u0027.", + "severity": "Error", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_02_STATION_D.ALO\u0027", "context": [ - "Dathomir_Night_Sister" + "Underworld_Star_Base_2_Death_Clone", + "Tag: Space_Model_Name", + "UB_02_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_bomb_spin", - "message": "Proxy particle \u0027p_bomb_spin\u0027 not found for model \u0027W_THERMAL_DETONATOR_EMPIRE.ALO\u0027", + "asset": "CINE_EV_STARDESTROYER.ALO", + "message": "Unable to find Alamo file \u0027CINE_EV_StarDestroyer.ALO\u0027", "context": [ - "TIE_Bomber_Bombing_Run_Bomb", - "Tag: Land_Model_Name", - "W_THERMAL_DETONATOR_EMPIRE.ALO" + "CIN_Star_Destroyer3X", + "Tag: Space_Model_Name" ] }, { @@ -1958,611 +1939,636 @@ { "id": "FILE00", "severity": "Error", - "asset": "CIN_EV_LAMBDASHUTTLE_150.ALO", - "message": "Unable to find Alamo file \u0027Cin_EV_lambdaShuttle_150.alo\u0027", + "asset": "CIN_CORUSCANT.ALO", + "message": "Unable to find Alamo file \u0027Cin_Coruscant.alo\u0027", "context": [ - "Lambda_Shuttle_150", - "Tag: Land_Model_Name" + "Corusant_Backdrop_Large 6x", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "pe_bwing_yellow", - "message": "Proxy particle \u0027pe_bwing_yellow\u0027 not found for model \u0027RV_BWING.ALO\u0027", + "asset": "Cin_Reb_CelebHall_Wall_B.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_sith_lefthall--\u003ETag: Land_Model_Name--\u003EW_SITH_LEFTHALL.ALO].", "context": [ - "B-Wing", + "Cin_sith_lefthall", + "Tag: Land_Model_Name", + "W_SITH_LEFTHALL.ALO" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_03_STATION_D.ALO\u0027", + "context": [ + "Underworld_Star_Base_3_Death_Clone", "Tag: Space_Model_Name", - "RV_BWING.ALO" + "UB_03_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_desert_ground_dust", - "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_SABOTEUR.ALO\u0027", + "asset": "Default.fx", + "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_TIE_LANCET.ALO\u0027.", "context": [ - "Underworld_Saboteur", + "Lancet_Air_Artillery", "Tag: Land_Model_Name", - "UI_SABOTEUR.ALO" + "EV_TIE_LANCET.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "P_heat_small01", - "message": "Proxy particle \u0027P_heat_small01\u0027 not found for model \u0027NB_VCH.ALO\u0027", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE_LUA.ALO\u0027", "context": [ - "Volcanic_Civilian_Spawn_House_Independent_AI", + "Stars_Lua_Cinematic", "Tag: Land_Model_Name", - "NB_VCH.ALO" + "W_STARS_CINE_LUA.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_SOLDIER.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier.alo\u0027", + "asset": "W_SWAMPGASEMIT.ALO", + "message": "Unable to find Alamo file \u0027W_SwampGasEmit.ALO\u0027", "context": [ - "Cin_Rebel_soldier", + "Prop_SwampGasEmitter", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_BUSH_SWMP00.ALO", - "message": "Unable to find Alamo file \u0027W_Bush_Swmp00.ALO\u0027", + "asset": "p_smoke_small_thin4", + "message": "Proxy particle \u0027p_smoke_small_thin4\u0027 not found for model \u0027NB_PRISON.ALO\u0027", "context": [ - "Prop_Swamp_Bush00", - "Tag: Land_Model_Name" + "Imperial_Prison_Facility", + "Tag: Land_Model_Name", + "NB_PRISON.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_05_STATION_D.ALO\u0027", + "asset": "CIN_DEATHSTAR_HIGH.ALO", + "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", "context": [ - "Underworld_Star_Base_5_Death_Clone", - "Tag: Space_Model_Name", - "UB_05_STATION_D.ALO" + "Death_Star_Whole", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_CORUSCANT.ALO", - "message": "Unable to find Alamo file \u0027Cin_Coruscant.alo\u0027", + "asset": "CIN_OFFICER.ALO", + "message": "Unable to find Alamo file \u0027Cin_Officer.alo\u0027", "context": [ - "Corusant_Backdrop_Large 6x", + "FIN_Officer", "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_FIRE_HUGE.ALO", - "message": "Unable to find Alamo file \u0027CIN_Fire_Huge.alo\u0027", + "asset": "CIN_RV_XWINGPROP.ALO", + "message": "Unable to find Alamo file \u0027Cin_rv_XWingProp.alo\u0027", "context": [ - "Fin_Fire_Huge", + "Cin_X-WingProp", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CINE_EV_STARDESTROYER.ALO", - "message": "Unable to find Alamo file \u0027CINE_EV_StarDestroyer.ALO\u0027", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_LOW.ALO\u0027", "context": [ - "CIN_Star_Destroyer3X", - "Tag: Space_Model_Name" + "Stars_Low", + "Tag: Space_Model_Name", + "W_STARS_LOW.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_BIKER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Biker_Row.alo\u0027", + "asset": "CIN_DEATHSTAR_HANGAR.ALO", + "message": "Unable to find Alamo file \u0027CIN_DeathStar_Hangar.alo\u0027", "context": [ - "Cin_Biker_Row", + "Cin_DeathStar_Hangar", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_KAMINO_REFLECT.ALO", - "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", + "asset": "P_mptl-2a_Die", + "message": "Proxy particle \u0027P_mptl-2a_Die\u0027 not found for model \u0027RV_MPTL-2A.ALO\u0027", "context": [ - "Prop_Kamino_Reflection_00", - "Tag: Land_Model_Name" + "MPTL", + "Tag: Land_Model_Name", + "RV_MPTL-2A.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_HIGH.ALO", - "message": "Unable to find Alamo file \u0027Cin_DeathStar_High.alo\u0027", + "asset": "Cin_Reb_CelebHall_Wall_B.tga", + "message": "Could not find texture \u0027Cin_Reb_CelebHall_Wall_B.tga\u0027 for context: [Cin_w_tile--\u003ETag: Land_Model_Name--\u003EW_TILE.ALO].", "context": [ - "Death_Star_Whole", - "Tag: Space_Model_Name" + "Cin_w_tile", + "Tag: Land_Model_Name", + "W_TILE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "w_grenade.tga", - "message": "Could not find texture \u0027w_grenade.tga\u0027 for context: [Proj_Merc_Concussion_Grenade--\u003ETag: Land_Model_Name--\u003EW_GRENADE.ALO--\u003EW_GRENADE.ALO].", + "asset": "W_SITH_ARCH.ALO", + "message": "Unable to find Alamo file \u0027w_sith_arch.alo\u0027", "context": [ - "Proj_Merc_Concussion_Grenade", - "Tag: Land_Model_Name", - "W_GRENADE.ALO" + "Cin_sith_arch", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_hp_archammer-damage", - "message": "Proxy particle \u0027p_hp_archammer-damage\u0027 not found for model \u0027EV_ARCHAMMER.ALO\u0027", + "asset": "W_ALLSHADERS.ALO", + "message": "Unable to find Alamo file \u0027W_AllShaders.ALO\u0027", "context": [ - "Arc_Hammer", - "Tag: Space_Model_Name", - "EV_ARCHAMMER.ALO" + "Prop_AllShaders", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_cold_tiny01", - "message": "Proxy particle \u0027p_cold_tiny01\u0027 not found for model \u0027NB_SCH.ALO\u0027", + "asset": "p_steam_small", + "message": "Proxy particle \u0027p_steam_small\u0027 not found for model \u0027RB_HEAVYVEHICLEFACTORY.ALO\u0027", "context": [ - "Arctic_Civilian_Spawn_House", + "R_Ground_Heavy_Vehicle_Factory", "Tag: Land_Model_Name", - "NB_SCH.ALO" + "RB_HEAVYVEHICLEFACTORY.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_PROTONS.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_protons.alo\u0027", + "asset": "NB_YsalamiriTree_B.tga", + "message": "Could not find texture \u0027NB_YsalamiriTree_B.tga\u0027 for context: [Ysalamiri_Tree--\u003ETag: Land_Model_Name--\u003ENB_YSALAMIRI_TREE.ALO].", "context": [ - "Protons_DStar_Xplode", - "Tag: Space_Model_Name" + "Ysalamiri_Tree", + "Tag: Land_Model_Name", + "NB_YSALAMIRI_TREE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "P_SPLASH_WAKE_LAVA.ALO", - "message": "Unable to find Alamo file \u0027p_splash_wake_lava.alo\u0027", + "asset": "w_grenade.tga", + "message": "Could not find texture \u0027w_grenade.tga\u0027 for context: [Proj_Merc_Concussion_Grenade--\u003ETag: Land_Model_Name--\u003EW_GRENADE.ALO].", "context": [ - "Splash_Wake_Lava", + "Proj_Merc_Concussion_Grenade", + "Tag: Land_Model_Name", + "W_GRENADE.ALO" + ] + }, + { + "id": "FILE00", + "severity": "Error", + "asset": "W_KAMINO_REFLECT.ALO", + "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", + "context": [ + "Prop_Kamino_Reflection_01", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_uwstation_death", - "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_02_STATION_D.ALO\u0027", + "asset": "CIN_DSTAR_PROTONS.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_protons.alo\u0027", "context": [ - "Underworld_Star_Base_2_Death_Clone", - "Tag: Space_Model_Name", - "UB_02_STATION_D.ALO" + "Protons_DStar_Xplode", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DEATHSTAR_HANGAR.ALO", - "message": "Unable to find Alamo file \u0027CIN_DeathStar_Hangar.alo\u0027", + "asset": "Cin_DeathStar.tga", + "message": "Could not find texture \u0027Cin_DeathStar.tga\u0027 for context: [Test_Base_Hector--\u003ETag: Land_Model_Name--\u003EALTTEST.ALO].", "context": [ - "Cin_DeathStar_Hangar", - "Tag: Land_Model_Name" + "Test_Base_Hector", + "Tag: Land_Model_Name", + "ALTTEST.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_DSTAR_TURRETLASERS.ALO", - "message": "Unable to find Alamo file \u0027Cin_DStar_TurretLasers.alo\u0027", + "asset": "lookat", + "message": "Proxy particle \u0027lookat\u0027 not found for model \u0027UV_ECLIPSE_UC.ALO\u0027", "context": [ - "TurretLasers_DStar_Xplode", - "Tag: Space_Model_Name" + "Eclipse_Super_Star_Destroyer", + "Tag: Space_Model_Name", + "UV_ECLIPSE_UC.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Lensflare0", - "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE.ALO\u0027", + "asset": "p_explosion_smoke_small_thin5", + "message": "Proxy particle \u0027p_explosion_smoke_small_thin5\u0027 not found for model \u0027NB_NOGHRI_HUT.ALO\u0027", "context": [ - "Stars_Cinematic", - "Tag: Space_Model_Name", - "W_STARS_CINE.ALO" + "Noghri_Spawn_House", + "Tag: Land_Model_Name", + "NB_NOGHRI_HUT.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Default.fx", - "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_MDU_SENSORNODE.ALO\u0027.", + "asset": "W_KAMINO_REFLECT.ALO", + "message": "Unable to find Alamo file \u0027W_Kamino_Reflect.ALO\u0027", "context": [ - "Empire_Offensive_Sensor_Node", - "Tag: Land_Model_Name", - "EV_MDU_SENSORNODE.ALO" + "Prop_Kamino_Reflection_00", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_OFFICER_ROW.ALO", - "message": "Unable to find Alamo file \u0027CIN_Officer_Row.alo\u0027", + "asset": "CIN_PROBE_DROID.ALO", + "message": "Unable to find Alamo file \u0027CIN_Probe_Droid.alo\u0027", "context": [ - "Cin_Officer_Row", + "Empire_Droid", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Default.fx", - "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027EV_TIE_LANCET.ALO\u0027.", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_HIGH.ALO\u0027", "context": [ - "Lancet_Air_Artillery", - "Tag: Land_Model_Name", - "EV_TIE_LANCET.ALO" + "Stars_High", + "Tag: Space_Model_Name", + "W_STARS_HIGH.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "p_explosion_small_delay00", - "message": "Proxy particle \u0027p_explosion_small_delay00\u0027 not found for model \u0027EB_COMMANDCENTER.ALO\u0027", + "asset": "Lensflare0", + "message": "Proxy particle \u0027Lensflare0\u0027 not found for model \u0027W_STARS_CINE.ALO\u0027", "context": [ - "Imperial_Command_Center", - "Tag: Land_Model_Name", - "EB_COMMANDCENTER.ALO" + "Stars_Cinematic", + "Tag: Space_Model_Name", + "W_STARS_CINE.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_REB_CELEBCHARACTERS.ALO", - "message": "Unable to find Alamo file \u0027CIN_REb_CelebCharacters.alo\u0027", + "asset": "p_desert_ground_dust", + "message": "Proxy particle \u0027p_desert_ground_dust\u0027 not found for model \u0027UI_SABOTEUR.ALO\u0027", "context": [ - "REb_CelebCharacters", - "Tag: Land_Model_Name" + "Underworld_Saboteur", + "Tag: Land_Model_Name", + "UI_SABOTEUR.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_TE_Rock_f_02_b.tga", - "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [The_Peacebringer--\u003ETag: Space_Model_Name--\u003EUV_KRAYTCLASSDESTROYER_TYBER.ALO--\u003EUV_KRAYTCLASSDESTROYER_TYBER.ALO].", + "asset": "p_uwstation_death", + "message": "Proxy particle \u0027p_uwstation_death\u0027 not found for model \u0027UB_04_STATION_D.ALO\u0027", "context": [ - "The_Peacebringer", + "Underworld_Star_Base_4_Death_Clone", "Tag: Space_Model_Name", - "UV_KRAYTCLASSDESTROYER_TYBER.ALO" + "UB_04_STATION_D.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "W_VOLCANO_ROCK02.ALO", - "message": "Unable to find Alamo file \u0027W_Volcano_Rock02.ALO\u0027", + "asset": "CIN_DSTAR_LEVERPANEL.ALO", + "message": "Unable to find Alamo file \u0027Cin_DStar_LeverPanel.alo\u0027", "context": [ - "Prop_Volcano_RockForm03", - "Tag: Land_Model_Name" + "Death_Star_LeverPanel", + "Tag: Space_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_SOLDIER_GROUP.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier_Group.alo\u0027", + "asset": "W_TE_Rock_f_02_b.tga", + "message": "Could not find texture \u0027W_TE_Rock_f_02_b.tga\u0027 for context: [The_Peacebringer--\u003ETag: Space_Model_Name--\u003EUV_KRAYTCLASSDESTROYER_TYBER.ALO].", "context": [ - "Cin_Rebel_SoldierRow", - "Tag: Land_Model_Name" + "The_Peacebringer", + "Tag: Space_Model_Name", + "UV_KRAYTCLASSDESTROYER_TYBER.ALO" ] }, { "id": "FILE00", "severity": "Error", - "asset": "Default.fx", - "message": "Shader effect \u0027Default.fx\u0027 not found for model \u0027UV_CRUSADERCLASSCORVETTE.ALO\u0027.", + "asset": "W_BUSH_SWMP00.ALO", + "message": "Unable to find Alamo file \u0027W_Bush_Swmp00.ALO\u0027", "context": [ - "Crusader_Gunship", - "Tag: Space_Model_Name", - "UV_CRUSADERCLASSCORVETTE.ALO" + "Prop_Swamp_Bush00", + "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_FIRE_MEDIUM.ALO", - "message": "Unable to find Alamo file \u0027CIN_Fire_Medium.alo\u0027", + "asset": "CIN_RBEL_SOLDIER.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier.alo\u0027", "context": [ - "Fin_Fire_Medium", + "Cin_Rebel_soldier", "Tag: Land_Model_Name" ] }, { "id": "FILE00", "severity": "Error", - "asset": "CIN_RBEL_GREYGROUP.ALO", - "message": "Unable to find Alamo file \u0027CIN_Rbel_GreyGroup.alo\u0027", + "asset": "CIN_RBEL_SOLDIER_GROUP.ALO", + "message": "Unable to find Alamo file \u0027CIN_Rbel_Soldier_Group.alo\u0027", "context": [ - "Cin_Rebel_GreyGroup", + "Cin_Rebel_SoldierRow", "Tag: Land_Model_Name" ] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_ground_icon", - "message": "The CommandBar component \u0027g_ground_icon\u0027 is not connected to a shell component.", + "asset": "zoomed_back", + "message": "The CommandBar component \u0027zoomed_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "remote_bomb_icon", - "message": "The CommandBar component \u0027remote_bomb_icon\u0027 is not connected to a shell component.", + "asset": "zoomed_header_text", + "message": "The CommandBar component \u0027zoomed_header_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_power", - "message": "The CommandBar component \u0027st_power\u0027 is not connected to a shell component.", + "asset": "g_planet_value", + "message": "The CommandBar component \u0027g_planet_value\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_radar_view", - "message": "The CommandBar component \u0027g_radar_view\u0027 is not connected to a shell component.", + "asset": "generic_collision", + "message": "The CommandBar component \u0027generic_collision\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bribe_display", - "message": "The CommandBar component \u0027bribe_display\u0027 is not connected to a shell component.", + "asset": "g_corruption_icon", + "message": "The CommandBar component \u0027g_corruption_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_icon", - "message": "The CommandBar component \u0027encyclopedia_icon\u0027 is not connected to a shell component.", + "asset": "encyclopedia_right_text", + "message": "The CommandBar component \u0027encyclopedia_right_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_corruption_text", - "message": "The CommandBar component \u0027g_corruption_text\u0027 is not connected to a shell component.", + "asset": "g_ground_level_pips", + "message": "The CommandBar component \u0027g_ground_level_pips\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_shields_medium", - "message": "The CommandBar component \u0027st_shields_medium\u0027 is not connected to a shell component.", + "asset": "zoomed_center_text", + "message": "The CommandBar component \u0027zoomed_center_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "surface_mod_icon", - "message": "The CommandBar component \u0027surface_mod_icon\u0027 is not connected to a shell component.", + "asset": "g_planet_name", + "message": "The CommandBar component \u0027g_planet_name\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_back", - "message": "The CommandBar component \u0027zoomed_back\u0027 is not connected to a shell component.", + "asset": "generic_flytext", + "message": "The CommandBar component \u0027generic_flytext\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "b_planet_left", - "message": "The CommandBar component \u0027b_planet_left\u0027 is not connected to a shell component.", + "asset": "st_health_medium", + "message": "The CommandBar component \u0027st_health_medium\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health_bar", - "message": "The CommandBar component \u0027st_health_bar\u0027 is not connected to a shell component.", + "asset": "st_bracket_medium", + "message": "The CommandBar component \u0027st_bracket_medium\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_header_text", - "message": "The CommandBar component \u0027encyclopedia_header_text\u0027 is not connected to a shell component.", + "asset": "objective_header_text", + "message": "The CommandBar component \u0027objective_header_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_ability_icon", - "message": "The CommandBar component \u0027st_ability_icon\u0027 is not connected to a shell component.", + "asset": "help_back", + "message": "The CommandBar component \u0027help_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_icon", - "message": "The CommandBar component \u0027tooltip_icon\u0027 is not connected to a shell component.", + "asset": "tooltip_price", + "message": "The CommandBar component \u0027tooltip_price\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_smuggled", - "message": "The CommandBar component \u0027g_smuggled\u0027 is not connected to a shell component.", + "asset": "g_political_control", + "message": "The CommandBar component \u0027g_political_control\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_hero_icon", - "message": "The CommandBar component \u0027st_hero_icon\u0027 is not connected to a shell component.", + "asset": "st_health_bar", + "message": "The CommandBar component \u0027st_health_bar\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_space_icon", - "message": "The CommandBar component \u0027g_space_icon\u0027 is not connected to a shell component.", + "asset": "radar_blip", + "message": "The CommandBar component \u0027radar_blip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_back", - "message": "The CommandBar component \u0027objective_back\u0027 is not connected to a shell component.", + "asset": "g_space_level_pips", + "message": "The CommandBar component \u0027g_space_level_pips\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_build", - "message": "The CommandBar component \u0027g_build\u0027 is not connected to a shell component.", + "asset": "cs_ability_button", + "message": "The CommandBar component \u0027cs_ability_button\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_right_text", - "message": "The CommandBar component \u0027encyclopedia_right_text\u0027 is not connected to a shell component.", + "asset": "zoomed_cost_text", + "message": "The CommandBar component \u0027zoomed_cost_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_text", - "message": "The CommandBar component \u0027encyclopedia_text\u0027 is not connected to a shell component.", + "asset": "g_planet_fleet", + "message": "The CommandBar component \u0027g_planet_fleet\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_back", - "message": "The CommandBar component \u0027tooltip_back\u0027 is not connected to a shell component.", + "asset": "g_space_level", + "message": "The CommandBar component \u0027g_space_level\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_credit_bar", - "message": "The CommandBar component \u0027g_credit_bar\u0027 is not connected to a shell component.", + "asset": "remote_bomb_icon", + "message": "The CommandBar component \u0027remote_bomb_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_shields_large", - "message": "The CommandBar component \u0027st_shields_large\u0027 is not connected to a shell component.", + "asset": "g_ground_sell", + "message": "The CommandBar component \u0027g_ground_sell\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health", - "message": "The CommandBar component \u0027st_health\u0027 is not connected to a shell component.", + "asset": "encyclopedia_cost_text", + "message": "The CommandBar component \u0027encyclopedia_cost_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_cost_text", - "message": "The CommandBar component \u0027encyclopedia_cost_text\u0027 is not connected to a shell component.", + "asset": "g_special_ability", + "message": "The CommandBar component \u0027g_special_ability\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tutorial_text_back", - "message": "The CommandBar component \u0027tutorial_text_back\u0027 is not connected to a shell component.", + "asset": "g_planet_ability", + "message": "The CommandBar component \u0027g_planet_ability\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tactical_sell", - "message": "The CommandBar component \u0027tactical_sell\u0027 is not connected to a shell component.", + "asset": "st_garrison_icon", + "message": "The CommandBar component \u0027st_garrison_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_shields", - "message": "The CommandBar component \u0027st_shields\u0027 is not connected to a shell component.", + "asset": "g_space_icon", + "message": "The CommandBar component \u0027g_space_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_name", - "message": "The CommandBar component \u0027g_planet_name\u0027 is not connected to a shell component.", + "asset": "garrison_slot_icon", + "message": "The CommandBar component \u0027garrison_slot_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_value", - "message": "The CommandBar component \u0027g_planet_value\u0027 is not connected to a shell component.", + "asset": "encyclopedia_icon", + "message": "The CommandBar component \u0027encyclopedia_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_left_text", - "message": "The CommandBar component \u0027tooltip_left_text\u0027 is not connected to a shell component.", + "asset": "g_planet_ring", + "message": "The CommandBar component \u0027g_planet_ring\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_name", - "message": "The CommandBar component \u0027tooltip_name\u0027 is not connected to a shell component.", + "asset": "g_radar_blip", + "message": "The CommandBar component \u0027g_radar_blip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "lt_weather_icon", - "message": "The CommandBar component \u0027lt_weather_icon\u0027 is not connected to a shell component.", + "asset": "tooltip_left_text", + "message": "The CommandBar component \u0027tooltip_left_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_ground_sell", - "message": "The CommandBar component \u0027g_ground_sell\u0027 is not connected to a shell component.", + "asset": "objective_text", + "message": "The CommandBar component \u0027objective_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_hero_health", - "message": "The CommandBar component \u0027st_hero_health\u0027 is not connected to a shell component.", + "asset": "zoomed_right_text", + "message": "The CommandBar component \u0027zoomed_right_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health_large", - "message": "The CommandBar component \u0027st_health_large\u0027 is not connected to a shell component.", + "asset": "tutorial_text", + "message": "The CommandBar component \u0027tutorial_text\u0027 is not connected to a shell component.", "context": [] }, { @@ -2575,330 +2581,347 @@ { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_header_text", - "message": "The CommandBar component \u0027zoomed_header_text\u0027 is not connected to a shell component.", + "asset": "st_health", + "message": "The CommandBar component \u0027st_health\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_fleet", - "message": "The CommandBar component \u0027g_planet_fleet\u0027 is not connected to a shell component.", + "asset": "g_ground_level", + "message": "The CommandBar component \u0027g_ground_level\u0027 is not connected to a shell component.", + "context": [] + }, + { + "id": "CMDBAR03", + "severity": "Information", + "asset": "g_credit_bar", + "message": "The CommandBar component \u0027g_credit_bar\u0027 is not supported by the game.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_text", - "message": "The CommandBar component \u0027zoomed_text\u0027 is not connected to a shell component.", + "asset": "g_conflict", + "message": "The CommandBar component \u0027g_conflict\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_space_level", - "message": "The CommandBar component \u0027g_space_level\u0027 is not connected to a shell component.", + "asset": "encyclopedia_header_text", + "message": "The CommandBar component \u0027encyclopedia_header_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "help_back", - "message": "The CommandBar component \u0027help_back\u0027 is not connected to a shell component.", + "asset": "g_hero_icon", + "message": "The CommandBar component \u0027g_hero_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_weather", - "message": "The CommandBar component \u0027g_weather\u0027 is not connected to a shell component.", + "asset": "bribe_display", + "message": "The CommandBar component \u0027bribe_display\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "generic_flytext", - "message": "The CommandBar component \u0027generic_flytext\u0027 is not connected to a shell component.", + "asset": "st_bracket_large", + "message": "The CommandBar component \u0027st_bracket_large\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_smuggler", - "message": "The CommandBar component \u0027g_smuggler\u0027 is not connected to a shell component.", + "asset": "g_build", + "message": "The CommandBar component \u0027g_build\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bm_title_4010", - "message": "The CommandBar component \u0027bm_title_4010\u0027 is not connected to a shell component.", + "asset": "lt_weather_icon", + "message": "The CommandBar component \u0027lt_weather_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_conflict", - "message": "The CommandBar component \u0027g_conflict\u0027 is not connected to a shell component.", + "asset": "st_bracket_small", + "message": "The CommandBar component \u0027st_bracket_small\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tutorial_text", - "message": "The CommandBar component \u0027tutorial_text\u0027 is not connected to a shell component.", + "asset": "objective_icon", + "message": "The CommandBar component \u0027objective_icon\u0027 is not connected to a shell component.", "context": [] }, + { + "id": "FILE00", + "severity": "Error", + "asset": "i_button_blank.tga", + "message": "Could not find texture \u0027i_button_blank.tga\u0027 for context: [map_shell--\u003EPLANETARY_MODE.ALO].", + "context": [ + "map_shell", + "PLANETARY_MODE.ALO" + ] + }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "garrison_slot_icon", - "message": "The CommandBar component \u0027garrison_slot_icon\u0027 is not connected to a shell component.", + "asset": "g_radar_view", + "message": "The CommandBar component \u0027g_radar_view\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "generic_collision", - "message": "The CommandBar component \u0027generic_collision\u0027 is not connected to a shell component.", + "asset": "g_hero", + "message": "The CommandBar component \u0027g_hero\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "b_quick_ref", - "message": "The CommandBar component \u0027b_quick_ref\u0027 is not connected to a shell component.", + "asset": "gui_dialog_tooltip", + "message": "The CommandBar component \u0027gui_dialog_tooltip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_center_text", - "message": "The CommandBar component \u0027zoomed_center_text\u0027 is not connected to a shell component.", + "asset": "st_shields_large", + "message": "The CommandBar component \u0027st_shields_large\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_ability", - "message": "The CommandBar component \u0027g_planet_ability\u0027 is not connected to a shell component.", + "asset": "st_control_group", + "message": "The CommandBar component \u0027st_control_group\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_ring", - "message": "The CommandBar component \u0027g_planet_ring\u0027 is not connected to a shell component.", + "asset": "b_planet_left", + "message": "The CommandBar component \u0027b_planet_left\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_hero", - "message": "The CommandBar component \u0027g_hero\u0027 is not connected to a shell component.", + "asset": "objective_back", + "message": "The CommandBar component \u0027objective_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_space_level_pips", - "message": "The CommandBar component \u0027g_space_level_pips\u0027 is not connected to a shell component.", + "asset": "tooltip_icon_land", + "message": "The CommandBar component \u0027tooltip_icon_land\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_ground_level", - "message": "The CommandBar component \u0027g_ground_level\u0027 is not connected to a shell component.", + "asset": "balance_pip", + "message": "The CommandBar component \u0027balance_pip\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_back", - "message": "The CommandBar component \u0027encyclopedia_back\u0027 is not connected to a shell component.", + "asset": "tooltip_icon", + "message": "The CommandBar component \u0027tooltip_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "encyclopedia_center_text", - "message": "The CommandBar component \u0027encyclopedia_center_text\u0027 is not connected to a shell component.", + "asset": "g_planet_land_forces", + "message": "The CommandBar component \u0027g_planet_land_forces\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_bracket_large", - "message": "The CommandBar component \u0027st_bracket_large\u0027 is not connected to a shell component.", + "asset": "st_hero_health", + "message": "The CommandBar component \u0027st_hero_health\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_text", - "message": "The CommandBar component \u0027objective_text\u0027 is not connected to a shell component.", + "asset": "skirmish_upgrade", + "message": "The CommandBar component \u0027skirmish_upgrade\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_right_text", - "message": "The CommandBar component \u0027zoomed_right_text\u0027 is not connected to a shell component.", + "asset": "st_health_large", + "message": "The CommandBar component \u0027st_health_large\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_bracket_medium", - "message": "The CommandBar component \u0027st_bracket_medium\u0027 is not connected to a shell component.", + "asset": "tactical_sell", + "message": "The CommandBar component \u0027tactical_sell\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_political_control", - "message": "The CommandBar component \u0027g_political_control\u0027 is not connected to a shell component.", + "asset": "encyclopedia_text", + "message": "The CommandBar component \u0027encyclopedia_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_icon_land", - "message": "The CommandBar component \u0027tooltip_icon_land\u0027 is not connected to a shell component.", + "asset": "g_weather", + "message": "The CommandBar component \u0027g_weather\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "gui_dialog_tooltip", - "message": "The CommandBar component \u0027gui_dialog_tooltip\u0027 is not connected to a shell component.", + "asset": "st_power", + "message": "The CommandBar component \u0027st_power\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_radar_blip", - "message": "The CommandBar component \u0027g_radar_blip\u0027 is not connected to a shell component.", + "asset": "b_quick_ref", + "message": "The CommandBar component \u0027b_quick_ref\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_enemy_hero", - "message": "The CommandBar component \u0027g_enemy_hero\u0027 is not connected to a shell component.", + "asset": "g_ground_icon", + "message": "The CommandBar component \u0027g_ground_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "balance_pip", - "message": "The CommandBar component \u0027balance_pip\u0027 is not connected to a shell component.", + "asset": "reinforcement_counter", + "message": "The CommandBar component \u0027reinforcement_counter\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_planet_land_forces", - "message": "The CommandBar component \u0027g_planet_land_forces\u0027 is not connected to a shell component.", + "asset": "bm_title_4011", + "message": "The CommandBar component \u0027bm_title_4011\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "cs_ability_button", - "message": "The CommandBar component \u0027cs_ability_button\u0027 is not connected to a shell component.", + "asset": "st_ability_icon", + "message": "The CommandBar component \u0027st_ability_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_health_medium", - "message": "The CommandBar component \u0027st_health_medium\u0027 is not connected to a shell component.", + "asset": "encyclopedia_back", + "message": "The CommandBar component \u0027encyclopedia_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "cs_ability_text", - "message": "The CommandBar component \u0027cs_ability_text\u0027 is not connected to a shell component.", + "asset": "st_grab_bar", + "message": "The CommandBar component \u0027st_grab_bar\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_icon", - "message": "The CommandBar component \u0027objective_icon\u0027 is not connected to a shell component.", + "asset": "g_credit_bar", + "message": "The CommandBar component \u0027g_credit_bar\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_special_ability", - "message": "The CommandBar component \u0027g_special_ability\u0027 is not connected to a shell component.", + "asset": "st_hero_icon", + "message": "The CommandBar component \u0027st_hero_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "objective_header_text", - "message": "The CommandBar component \u0027objective_header_text\u0027 is not connected to a shell component.", + "asset": "g_corruption_text", + "message": "The CommandBar component \u0027g_corruption_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "skirmish_upgrade", - "message": "The CommandBar component \u0027skirmish_upgrade\u0027 is not connected to a shell component.", + "asset": "cs_ability_text", + "message": "The CommandBar component \u0027cs_ability_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_hero_icon", - "message": "The CommandBar component \u0027g_hero_icon\u0027 is not connected to a shell component.", + "asset": "tutorial_text_back", + "message": "The CommandBar component \u0027tutorial_text_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "radar_blip", - "message": "The CommandBar component \u0027radar_blip\u0027 is not connected to a shell component.", + "asset": "st_shields_medium", + "message": "The CommandBar component \u0027st_shields_medium\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "reinforcement_counter", - "message": "The CommandBar component \u0027reinforcement_counter\u0027 is not connected to a shell component.", + "asset": "bribed_icon", + "message": "The CommandBar component \u0027bribed_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_bounty_hunter", - "message": "The CommandBar component \u0027g_bounty_hunter\u0027 is not connected to a shell component.", + "asset": "g_enemy_hero", + "message": "The CommandBar component \u0027g_enemy_hero\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_control_group", - "message": "The CommandBar component \u0027st_control_group\u0027 is not connected to a shell component.", + "asset": "g_smuggler", + "message": "The CommandBar component \u0027g_smuggler\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_corruption_icon", - "message": "The CommandBar component \u0027g_corruption_icon\u0027 is not connected to a shell component.", + "asset": "st_shields", + "message": "The CommandBar component \u0027st_shields\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "zoomed_cost_text", - "message": "The CommandBar component \u0027zoomed_cost_text\u0027 is not connected to a shell component.", + "asset": "g_smuggled", + "message": "The CommandBar component \u0027g_smuggled\u0027 is not connected to a shell component.", "context": [] }, { @@ -2908,77 +2931,60 @@ "message": "The CommandBar component \u0027garrison_respawn_counter\u0027 is not connected to a shell component.", "context": [] }, - { - "id": "FILE00", - "severity": "Error", - "asset": "i_button_blank.tga", - "message": "Could not find texture \u0027i_button_blank.tga\u0027 for context: [map_shell--\u003EPLANETARY_MODE.ALO--\u003EPLANETARY_MODE.ALO].", - "context": [ - "map_shell", - "PLANETARY_MODE.ALO" - ] - }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bm_title_4011", - "message": "The CommandBar component \u0027bm_title_4011\u0027 is not connected to a shell component.", + "asset": "b_beacon_t", + "message": "The CommandBar component \u0027b_beacon_t\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "g_ground_level_pips", - "message": "The CommandBar component \u0027g_ground_level_pips\u0027 is not connected to a shell component.", + "asset": "bm_title_4010", + "message": "The CommandBar component \u0027bm_title_4010\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_garrison_icon", - "message": "The CommandBar component \u0027st_garrison_icon\u0027 is not connected to a shell component.", + "asset": "zoomed_text", + "message": "The CommandBar component \u0027zoomed_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_bracket_small", - "message": "The CommandBar component \u0027st_bracket_small\u0027 is not connected to a shell component.", + "asset": "encyclopedia_center_text", + "message": "The CommandBar component \u0027encyclopedia_center_text\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "tooltip_price", - "message": "The CommandBar component \u0027tooltip_price\u0027 is not connected to a shell component.", - "context": [] - }, - { - "id": "CMDBAR03", - "severity": "Information", - "asset": "g_credit_bar", - "message": "The CommandBar component \u0027g_credit_bar\u0027 is not supported by the game.", + "asset": "surface_mod_icon", + "message": "The CommandBar component \u0027surface_mod_icon\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "bribed_icon", - "message": "The CommandBar component \u0027bribed_icon\u0027 is not connected to a shell component.", + "asset": "tooltip_back", + "message": "The CommandBar component \u0027tooltip_back\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "st_grab_bar", - "message": "The CommandBar component \u0027st_grab_bar\u0027 is not connected to a shell component.", + "asset": "tooltip_name", + "message": "The CommandBar component \u0027tooltip_name\u0027 is not connected to a shell component.", "context": [] }, { "id": "CMDBAR04", "severity": "Warning", - "asset": "b_beacon_t", - "message": "The CommandBar component \u0027b_beacon_t\u0027 is not connected to a shell component.", + "asset": "g_bounty_hunter", + "message": "The CommandBar component \u0027g_bounty_hunter\u0027 is not connected to a shell component.", "context": [] } ] diff --git a/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs b/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs index 36c8509..521fb8c 100644 --- a/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs +++ b/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs @@ -25,10 +25,10 @@ internal abstract class BaseModVerifyOptions "The argument cannot be combined with any of --mods, --game or --fallbackGame")] public string? TargetPath { get; init; } - [Option("mods", SetName = "manualPaths", Required = false, Default = null, Separator = ';', - HelpText = "The path of the mod to verify. To support submods, multiple paths can be separated using the ';' (semicolon) character. " + + [Option("mods", SetName = "manualPaths", Required = false, Default = null, + HelpText = "The path of the mod to verify. To support submods, multiple paths can be separated using the platform-specific path separator (';' on Windows, ':' on Linux). " + "Leave empty, if you want to verify a game. If you want to use the interactive mode, leave this, --game and --fallbackGame empty.")] - public IList? ModPaths { get; init; } + public string? ModPaths { get; init; } [Option("game", SetName = "manualPaths", Required = false, Default = null, HelpText = "The path of the base game. For FoC mods this points to the FoC installation, for EaW mods this points to the EaW installation. " + @@ -47,10 +47,10 @@ internal abstract class BaseModVerifyOptions public GameEngineType? Engine { get; init; } - [Option("additionalFallbackPaths", Required = false, Separator = ';', + [Option("additionalFallbackPaths", Required = false, HelpText = "Additional fallback paths, which may contain assets that shall be included when doing the verification. Do not add EaW here. " + - "Multiple paths can be separated using the ';' (semicolon) character.")] - public IList? AdditionalFallbackPath { get; init; } + "Multiple paths can be separated using the platform-specific path separator (';' on Windows, ':' on Linux).")] + public string? AdditionalFallbackPath { get; init; } [Option("parallel", Default = false, HelpText = "When set, game verifiers will run in parallel. " + diff --git a/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs b/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs index e3be836..5e01c5c 100644 --- a/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs +++ b/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs @@ -29,13 +29,17 @@ internal sealed class VerifyVerbOption : BaseModVerifyOptions public bool IgnoreAsserts { get; init; } - [Option("baseline", SetName = "baselineSelection", Required = false, - HelpText = "Path to a JSON baseline file. Cannot be used together with --searchBaseline.")] + [Option("baseline", Required = false, + HelpText = "Path to a JSON baseline file. Cannot be used together with --searchBaseline or --useDefaultBaseline.")] public string? Baseline { get; init; } - [Option("searchBaseline", SetName = "baselineSelection", Required = false, - HelpText = "When set, the application will search for baseline files and use them for verification. Cannot be used together with --baseline")] + [Option("searchBaseline", Required = false, + HelpText = "When set, the application will search for baseline files and use them for verification. Cannot be used together with --baseline or --useDefaultBaseline")] public bool SearchBaselineLocally { get; init; } + [Option("useDefaultBaseline", Required = false, + HelpText = "When set, the application will use the default embedded baseline for the detected game engine. Cannot be used together with --baseline or --searchBaseline.")] + public bool UseDefaultBaseline { get; init; } + public bool IsRunningWithoutArguments { get; init; } } \ No newline at end of file diff --git a/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs b/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs index add39a5..0610728 100644 --- a/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs +++ b/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs @@ -17,6 +17,7 @@ public sealed class VerifyReportSettings : AppReportSettings { public string? BaselinePath { get; init; } public bool SearchBaselineLocally { get; init; } + public bool UseDefaultBaseline { get; init; } } internal abstract class AppSettingsBase(AppReportSettings reportSettings) diff --git a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs index 62bca7a..fe31595 100644 --- a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs +++ b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO.Abstractions; +using System.Linq; namespace AET.ModVerify.App.Settings; @@ -57,6 +58,20 @@ void ValidateVerb() throw new AppArgumentException($"Options {searchOption} and {baselineOption} cannot be used together."); } + if (verifyOptions.UseDefaultBaseline && !string.IsNullOrEmpty(verifyOptions.Baseline)) + { + var useDefaultOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.UseDefaultBaseline)); + var baselineOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.Baseline)); + throw new AppArgumentException($"Options {useDefaultOption} and {baselineOption} cannot be used together."); + } + + if (verifyOptions is { UseDefaultBaseline: true, SearchBaselineLocally: true }) + { + var useDefaultOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.UseDefaultBaseline)); + var searchOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.SearchBaselineLocally)); + throw new AppArgumentException($"Options {useDefaultOption} and {searchOption} cannot be used together."); + } + if (verifyOptions is { FailFast: true, MinimumFailureSeverity: null }) { var failFast = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.FailFast)); @@ -86,6 +101,7 @@ VerifyReportSettings BuildReportSettings() BaselinePath = verifyOptions.Baseline, MinimumReportSeverity = verifyOptions.MinimumSeverity, SearchBaselineLocally = verifyOptions.SearchBaselineLocally, + UseDefaultBaseline = verifyOptions.UseDefaultBaseline, SuppressionsPath = verifyOptions.Suppressions, Verbose = verifyOptions.Verbose }; @@ -121,24 +137,20 @@ AppReportSettings BuildReportSettings() private VerificationTargetSettings BuildTargetSettings(BaseModVerifyOptions options) { + var separator = _fileSystem.Path.PathSeparator; + var modPaths = new List(); - if (options.ModPaths is not null) + if (!string.IsNullOrEmpty(options.ModPaths)) { - foreach (var mod in options.ModPaths) - { - if (!string.IsNullOrEmpty(mod)) - modPaths.Add(_fileSystem.Path.GetFullPath(mod)); - } + var split = options.ModPaths!.Split([separator], StringSplitOptions.RemoveEmptyEntries); + modPaths.AddRange(split.Select(s => _fileSystem.Path.GetFullPath(s))); } var fallbackPaths = new List(); - if (options.AdditionalFallbackPath is not null) + if (!string.IsNullOrEmpty(options.AdditionalFallbackPath)) { - foreach (var fallback in options.AdditionalFallbackPath) - { - if (!string.IsNullOrEmpty(fallback)) - fallbackPaths.Add(_fileSystem.Path.GetFullPath(fallback)); - } + var split = options.AdditionalFallbackPath!.Split([separator], StringSplitOptions.RemoveEmptyEntries); + fallbackPaths.AddRange(split.Select(s => _fileSystem.Path.GetFullPath(s))); } var gamePath = options.GamePath; diff --git a/src/ModVerify/GameVerifierService.cs b/src/ModVerify/GameVerifierService.cs index f6d8f07..efdf326 100644 --- a/src/ModVerify/GameVerifierService.cs +++ b/src/ModVerify/GameVerifierService.cs @@ -37,7 +37,8 @@ public async Task VerifyAsync( VerificationCompletionStatus completionStatus; var start = DateTime.UtcNow; - + + Exception? exception = null; try { await pipeline.RunAsync(token).ConfigureAwait(false); @@ -46,9 +47,14 @@ public async Task VerifyAsync( catch (OperationCanceledException) { completionStatus = settings.FailFastSettings.IsFailFast - ? VerificationCompletionStatus.CompletedFailFast + ? VerificationCompletionStatus.CompletedFailFast : VerificationCompletionStatus.Cancelled; } + catch (Exception e) + { + exception = e; + completionStatus = VerificationCompletionStatus.Failed; + } var duration = DateTime.UtcNow - start; @@ -60,7 +66,8 @@ public async Task VerifyAsync( Target = verificationTarget, UsedBaseline = baseline, UsedSuppressions = suppressions, - Verifiers = pipeline.Verifiers + Verifiers = pipeline.Verifiers, + Exception = exception }; } } \ No newline at end of file diff --git a/src/ModVerify/ModVerify.csproj b/src/ModVerify/ModVerify.csproj index 96656cc..385250a 100644 --- a/src/ModVerify/ModVerify.csproj +++ b/src/ModVerify/ModVerify.csproj @@ -27,8 +27,8 @@ - - + + diff --git a/src/ModVerify/Reporting/VerificationCompletionStatus.cs b/src/ModVerify/Reporting/VerificationCompletionStatus.cs index 94fde18..7d01e8f 100644 --- a/src/ModVerify/Reporting/VerificationCompletionStatus.cs +++ b/src/ModVerify/Reporting/VerificationCompletionStatus.cs @@ -5,4 +5,5 @@ public enum VerificationCompletionStatus Completed, CompletedFailFast, Cancelled, + Failed } \ No newline at end of file diff --git a/src/ModVerify/Reporting/VerificationResult.cs b/src/ModVerify/Reporting/VerificationResult.cs index a4dbe1c..97c08e5 100644 --- a/src/ModVerify/Reporting/VerificationResult.cs +++ b/src/ModVerify/Reporting/VerificationResult.cs @@ -41,4 +41,6 @@ public required VerificationTarget Target } public required TimeSpan Duration { get; init; } + + public Exception? Exception { get; init; } } \ No newline at end of file diff --git a/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs b/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs index 4537a64..5fb7e70 100644 --- a/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs +++ b/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs @@ -14,6 +14,8 @@ public class AudioFileVerifier : GameVerifier { private readonly IAlreadyVerifiedCache? _alreadyVerifiedCache; + public override string FriendlyName => "Audio File format"; + public AudioFileVerifier(GameVerifierBase parent) : base(parent) { _alreadyVerifiedCache = Services.GetService(); @@ -27,8 +29,6 @@ public AudioFileVerifier(IGameVerifierInfo? parent, _alreadyVerifiedCache = serviceProvider.GetService(); } - public override string FriendlyName => "Audio File format"; - public override void Verify(AudioFileInfo sampleInfo, IReadOnlyCollection contextInfo, CancellationToken token) { var cached = _alreadyVerifiedCache?.GetEntry(sampleInfo.SampleName); diff --git a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs index 0376750..0df4dd6 100644 --- a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs +++ b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using AET.ModVerify.Reporting; using AET.ModVerify.Settings; @@ -15,8 +16,7 @@ using PG.StarWarsGame.Files.ALO.Files.Models; using PG.StarWarsGame.Files.ALO.Files.Particles; using PG.StarWarsGame.Files.Binary; - -#if NETSTANDARD2_0 || NETFRAMEWORK +#if NETFRAMEWORK || NETSTANDARD2_0 using AnakinRaW.CommonUtilities.FileSystem; #endif @@ -145,7 +145,7 @@ public SingleModelVerifier( $"Expected Alamo object of type {typeof(T).Name}, but got {modelClass.RenderableContent.GetType().Name}", VerificationSeverity.Error, contextInfo, - modelClass.File.FileName.ToUpperInvariant())); + NormalizeFileName(modelClass.File.FileName))); return null; } @@ -183,14 +183,31 @@ private void VerifyModelClass(ModelClass modelClass, IReadOnlyCollection } catch (BinaryCorruptedException e) { - var message = $"'{fileName}' is corrupted: {e.Message}"; - AddError(VerificationError.Create( - this, - VerifierErrorCodes.BinaryFileCorrupt, - message, - VerificationSeverity.Critical, - contextInfo, - fileName.ToUpperInvariant())); + if (!CheckBinaryCorruptedFileIsActuallyRenderable(fileName, out var actualFilePath)) + { + var message = $"Possible file CRC32 collision: '{fileName}' was requested but '{actualFilePath}' was found by the engine."; + AddError(VerificationError.Create( + this, + VerifierErrorCodes.UnexpectedFileLoad, + message, + // Error, because loading a model/particle directly impacts game behavior and would be very hard to debug + // for mod creators, unaware of the CRC32 collision issue. + VerificationSeverity.Error, + contextInfo, + NormalizeFileName(fileName))); + } + else + { + var message = $"'{fileName}' is corrupted: {e.Message}"; + AddError(VerificationError.Create( + this, + VerifierErrorCodes.BinaryFileCorrupt, + message, + VerificationSeverity.Critical, + contextInfo, + NormalizeFileName(fileName))); + } + exists = true; return null; } @@ -209,29 +226,51 @@ private void VerifyModelClass(ModelClass modelClass, IReadOnlyCollection var animationCollection = AnimationCollection.Empty; if (alamoFile.Content is AlamoModel) - { - // TODO: Enable once we support verifying animations as well. - //animationCollection = GameEngine.PGRender.LoadAnimations(alamoFile.FileName, alamoFile.Directory, true, - // (_, _, alaFile) => - // { - // AddError(VerificationError.Create( - // this, - // VerifierErrorCodes.BinaryFileCorrupt, - // $"Invalid animation file '{alaFile}' for model '{alamoFile.FileName}'", - // VerificationSeverity.Error, - // [alamoFile.FileName.ToUpperInvariant()], - // alaFile.ToUpperInvariant())); - // }); + { + animationCollection = GameEngine.PGRender.LoadAnimations( + alamoFile.FileName, @"DATA\ART\MODELS", true, + (_, _, alaFile) => + { + var alaFileName = NormalizeFileName(alaFile); + + if (!CheckBinaryCorruptedFileIsActuallyRenderable(alaFileName, out var actualFilePath)) + { + var message = + $"Possible file CRC32 collision: '{fileName}' was requested but '{actualFilePath}' was found by the engine."; + AddError(VerificationError.Create( + this, + VerifierErrorCodes.UnexpectedFileLoad, + message, + // Information, because for animations, as there is more likely to be a CRC32 collision than an actual corrupted file. + // This is because the engine attempts to load all possible animations for each model and thus + // there are simply more chances for a CRC32 collision. + VerificationSeverity.Information, + contextInfo, + NormalizeFileName(fileName))); + } + else + { + AddError(VerificationError.Create( + this, + VerifierErrorCodes.BinaryFileCorrupt, + $"Invalid animation file '{alaFileName}' for model '{alamoFile.FileName}'", + VerificationSeverity.Error, + [NormalizeFileName(alamoFile.FileName)], + alaFileName)); + } + }); } return new ModelClass(alamoFile, animationCollection); } - + private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection contextInfo) { + IReadOnlyList particleContext = [.. contextInfo, NormalizeFileName(file.FileName)]; + foreach (var texture in file.Content.Textures) { - GuardedVerify(() => VerifyTextureExists(file, texture, contextInfo), + GuardedVerify(() => VerifyTextureExists(file, texture, particleContext), e => e is ArgumentException, _ => { @@ -240,12 +279,12 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c VerifierErrorCodes.InvalidFilePath, $"Invalid texture file name '{texture}' in particle '{file.FileName}'", VerificationSeverity.Error, - [file.FileName.ToUpperInvariant()], + particleContext, texture)); }); } - var fileName = FileSystem.Path.GetFileNameWithoutExtension(file.FilePath.AsSpan()); + var fileName = FileSystem.Path.GetFileNameWithoutExtension(file.FileName.AsSpan()); var name = file.Content.Name.AsSpan(); if (!fileName.Equals(name, StringComparison.OrdinalIgnoreCase)) @@ -255,7 +294,7 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c VerifierErrorCodes.InvalidParticleName, $"The particle name '{file.Content.Name}' does not match file name '{file.FileName}'", VerificationSeverity.Error, - [file.FileName.ToUpperInvariant()], + particleContext, file.Content.Name)); } @@ -263,7 +302,7 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c private void VerifyModel(IAloModelFile file, AnimationCollection animations, IReadOnlyCollection contextInfo, CancellationToken token) { - IReadOnlyList modelContext = [.. contextInfo, file.FileName.ToUpperInvariant()]; + IReadOnlyList modelContext = [.. contextInfo, NormalizeFileName(file.FileName)]; foreach (var texture in file.Content.Textures) { @@ -329,16 +368,37 @@ private void VerifyAnimation(IAloAnimationFile file, IReadOnlyCollection // Is there actually anything to verify for animation without looking at the model? } - private void VerifyTextureExists(IPetroglyphFileHolder model, string texture, IReadOnlyCollection contextInfo) + private void VerifyTextureExists(IPetroglyphFileHolder file, string texture, IReadOnlyCollection contextInfo) { + if (string.IsNullOrEmpty(texture)) + { + AddError(VerificationError.Create(this, + VerifierErrorCodes.InvalidValue, + $"Texture string in model or particle '{file.FileName}' is empty.'", + VerificationSeverity.Error, + contextInfo, + NormalizeFileName(file.FileName))); + return; + } if (texture == "None") return; - _textureVerifier.Verify(texture, [..contextInfo, model.FileName.ToUpperInvariant()], CancellationToken.None); + _textureVerifier.Verify(texture, contextInfo, CancellationToken.None); } private void VerifyProxyExists(IPetroglyphFileHolder model, string proxy, IReadOnlyCollection contextInfo, CancellationToken token) { var proxyName = ModelClass.GetProxyName(proxy).ToString(); + + if (string.IsNullOrEmpty(proxyName)) + { + AddError(VerificationError.Create(this, + VerifierErrorCodes.InvalidValue, + $"Proxy name in model '{model.FileName}' is empty.'", + VerificationSeverity.Error, + contextInfo, + NormalizeFileName(model.FileName))); + return; + } VerifyWithCache(proxyName, contextInfo, _ => { @@ -367,12 +427,32 @@ private void VerifyShaderExists(IPetroglyphFileHolder model, string shader, IRea VerifierErrorCodes.FileNotFound, message, VerificationSeverity.Error, - [..contextInfo, model.FileName.ToUpperInvariant()], + [..contextInfo, NormalizeFileName(model.FileName)], shader); AddError(error); } } + // NB: This method assures that the BinaryCorruptedException resulted from a file + // that is actually an Alamo file (and thus should be reported as a corrupted file), + // and not from some other file that was found due to e.g., CRC32 collision. + private bool CheckBinaryCorruptedFileIsActuallyRenderable(string fileName, out string actualFilePath) + { + var filePath = FileSystem.Path.Join(@"DATA\ART\MODELS", fileName); + var exists = GameEngine.GameRepository.FileExists(filePath, false, out _, out actualFilePath!); + Debug.Assert(exists); + + var extension = FileSystem.Path.GetExtension(actualFilePath); + + return string.IsNullOrEmpty(actualFilePath) || extension.Equals(".alo", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".ala", StringComparison.OrdinalIgnoreCase); + } + + private string NormalizeFileName(string fileName) + { + return GameEngine.GameRepository.PGFileSystem.GetFileName(fileName).ToUpperInvariant(); + } + private void AddNotExistError(string fileName, IReadOnlyCollection contextInfo) { AddError(VerificationError.Create( @@ -381,7 +461,7 @@ private void AddNotExistError(string fileName, IReadOnlyCollection conte $"Unable to find Alamo file '{fileName}'", VerificationSeverity.Error, contextInfo, - fileName.ToUpperInvariant())); + NormalizeFileName(fileName))); } private void OnTextureError(object sender, VerificationErrorEventArgs e) diff --git a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs index a33f900..5c567f0 100644 --- a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs +++ b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs @@ -111,6 +111,17 @@ private void VerifyGuiComponentTexturesExist(string component) continue; } + if (texture.Texture.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + // We can ignore "none" textures completely, due to two reasons: + // 1. If we are in special mode, the engine already filters for "none" textures and ignores them. + // 2. If we are in MegaTexture mode, the texture is rendered as a view from the mega texture. + // When the engine does not find a texture in the MegaTexture, the view becomes a rect of (0,0,0,0) + // and thus does not render anything, which is the intended effect of "none" textures. + // The engine does not log any warnings for missing textures in the MegaTexture, so we won't either. + continue; + } + var cached = _cache?.GetEntry(texture.Texture); if (cached?.AlreadyVerified is true) { @@ -119,7 +130,11 @@ private void VerifyGuiComponentTexturesExist(string component) componentType is not GuiComponentType.ButtonMiddle && componentType is not GuiComponentType.Scanlines && componentType is not GuiComponentType.FrameBackground) + { + if (!cached.Value.AssetExists) + AddNotFoundError(texture, component, null); continue; + } } var exists = GameEngine.GuiDialogManager.TextureExists( @@ -141,8 +156,9 @@ componentType is not GuiComponentType.Scanlines && AddNotFoundError(texture, component, origin); } } - - _cache?.TryAddEntry(texture.Texture, exists); + + // If the texture is "none" we store it as "asset exists" in order to reduce false warnings + _cache?.TryAddEntry(texture.Texture, exists || isNone); } finally { @@ -154,17 +170,19 @@ componentType is not GuiComponentType.Scanlines && private void AddNotFoundError(ComponentTextureEntry texture, string component, GuiTextureOrigin? origin) { - var sb = new StringBuilder($"Could not find GUI texture '{texture.Texture}'"); - if (origin is not null) - sb.Append($" at location '{origin}'"); + var sb = new StringBuilder($"Could not find GUI texture '{texture.Texture}' of type '{texture.ComponentType}'"); + if (origin is not null) + sb.Append($" at origin '{origin}'"); + sb.Append($" for component '{component}'"); sb.Append('.'); if (texture.Texture.Length > PGConstants.MaxMegEntryPathLength) sb.Append(" The file name is too long."); AddError(VerificationError.Create(this, VerifierErrorCodes.FileNotFound, - sb.ToString(), VerificationSeverity.Error, - [component, origin.ToString()], texture.Texture)); + sb.ToString(), VerificationSeverity.Error, + [component], // Origin is not interesting for context, but might be for the error message + texture.Texture)); } private IReadOnlyDictionary GetTextureEntriesForComponents(string component, out bool defined) @@ -176,9 +194,4 @@ private IReadOnlyDictionary GetTextureE } return GameEngine.GuiDialogManager.GetTextureEntries(component, out defined); } - - private void OnTextureError(object sender, VerificationErrorEventArgs e) - { - AddError(e.Error); - } } \ No newline at end of file diff --git a/src/ModVerify/Verifiers/VerifierErrorCodes.cs b/src/ModVerify/Verifiers/VerifierErrorCodes.cs index 2656902..9f18866 100644 --- a/src/ModVerify/Verifiers/VerifierErrorCodes.cs +++ b/src/ModVerify/Verifiers/VerifierErrorCodes.cs @@ -12,11 +12,13 @@ public static class VerifierErrorCodes public const string BinaryFileCorrupt = "BIN00"; public const string UnexpectedBinaryFormat = "BIN01"; + public const string InvalidValue = "BIN02"; public const string FileNotFound = "FILE00"; public const string FilePathTooLong = "FILE01"; public const string InvalidFilePath = "FILE02"; + public const string UnexpectedFileLoad = "FILE03"; public const string Duplicate = "DUP00"; public const string MissingXRef = "XREF00"; diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs new file mode 100644 index 0000000..feb8e62 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Utilities; +using Testably.Abstractions; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +public abstract class FileExistsStrategyTestBase : TestBaseWithPGFileSystem, IDisposable +{ + private readonly List _tempDirs = []; + + protected override IFileSystem CreateFileSystem() + { + return new RealFileSystem(); + } + + protected abstract override void ConfigureStrategy(PetroglyphFileSystem fs); + + protected virtual void AssertResolvedPath(string expectedOnDiskPath, string actualResult) + { + var expected = expectedOnDiskPath.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar); + var actual = actualResult.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar); + var ignoreCase = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + Assert.Equal(expected, actual, ignoreCase: ignoreCase); + } + + public virtual void Dispose() + { + foreach (var dir in _tempDirs) + { + try + { + if (FileSystem.Directory.Exists(dir)) + FileSystem.Directory.Delete(dir, recursive: true); + } + catch + { + // Ignore + } + } + GC.SuppressFinalize(this); + } + + protected string NewTempDir() + { + var dir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + return dir; + } + + // --------------------------------------------------------------------------------------------- + // Shared tests — every strategy must satisfy. + // --------------------------------------------------------------------------------------------- + + [Theory] + [InlineData("/gameDir")] + [InlineData(null)] + public void FileExists_ExistingFullyQualifiedFile_ReturnsTrue(string? gameDir) + { + var dir = NewTempDir(); + var file = FileSystem.Path.Combine(dir, "tmp.dat"); + FileSystem.File.WriteAllText(file, "x"); + + var fullGameDir = gameDir is null ? null : FileSystem.Path.GetFullPath(gameDir); + + var sb = new ValueStringBuilder(); + var exists = PgFileSystem.FileExists(file.AsSpan(), ref sb, fullGameDir.AsSpan()); + + Assert.True(exists); + AssertResolvedPath(file, sb.ToString()); + } + + [Theory] + [InlineData("/gameDir")] + [InlineData(null)] + public void FileExists_NonExistingFullyQualifiedFile_ReturnsFalse(string? gameDir) + { + var missing = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var fullGameDir = gameDir is null ? null : FileSystem.Path.GetFullPath(gameDir); + + var exists = FileExists(missing.AsSpan(), fullGameDir.AsSpan()); + + Assert.False(exists); + } + + [Fact] + public void FileExists_RelativePathUnderGameDirectory_ReturnsTrue() + { + var dir = NewTempDir(); + var file = FileSystem.Path.Combine(dir, "test.txt"); + FileSystem.File.WriteAllText(file, "x"); + + var sb = new ValueStringBuilder(); + var exists = PgFileSystem.FileExists("test.txt".AsSpan(), ref sb, dir.AsSpan()); + + Assert.True(exists); + AssertResolvedPath(file, sb.ToString()); + } + + [Fact] + public void FileExists_GameDirectoryWithTrailingSeparator_ReturnsTrue() + { + var dir = NewTempDir(); + var dirWithTrailing = dir + FileSystem.Path.DirectorySeparatorChar; + var file = FileSystem.Path.Combine(dir, "test.txt"); + FileSystem.File.WriteAllText(file, "x"); + + var sb = new ValueStringBuilder(); + var exists = PgFileSystem.FileExists("test.txt".AsSpan(), ref sb, dirWithTrailing.AsSpan()); + + Assert.True(exists); + AssertResolvedPath(file, sb.ToString()); + } + + [Fact] + public void FileExists_MissingIntermediateDirectory_ReturnsFalse() + { + var dir = NewTempDir(); + // Create dir/a/c.txt — note: no "b" intermediate. + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "a")); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "a", "c.txt"), "x"); + + var exists = FileExists("a/b/c.txt".AsSpan(), dir.AsSpan()); + + Assert.False(exists); + } + + [Fact] + public void FileExists_FullyQualifiedPathOutsideGameDirectory_ReturnsTrue() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "game"); + var otherDir = FileSystem.Path.Combine(root, "other", "DATA"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(otherDir); + var file = FileSystem.Path.Combine(otherDir, "FILE.TXT"); + FileSystem.File.WriteAllText(file, "x"); + + // Pass a fully-qualified path with mismatched casing for the leaf; gameDir is unrelated. + var input = FileSystem.Path.Combine(FileSystem.Path.GetDirectoryName(otherDir)!, "data", "file.txt"); + + var sb = new ValueStringBuilder(); + var exists = PgFileSystem.FileExists(input.AsSpan(), ref sb, gameDir.AsSpan()); + + Assert.True(exists); + AssertResolvedPath(file, sb.ToString()); + } + + [Fact] + public void FileExists_DotSegmentInInputPath_ReturnsTrue() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "DATA")); + var file = FileSystem.Path.Combine(dir, "DATA", "FILE.TXT"); + FileSystem.File.WriteAllText(file, "x"); + + // Leading "./" plus mismatched casing — the dispatcher must normalize the dot away. + var sb = new ValueStringBuilder(); + var exists = PgFileSystem.FileExists(@".\DATA\file.txt".AsSpan(), ref sb, dir.AsSpan()); + + Assert.True(exists); + AssertResolvedPath(file, sb.ToString()); + } + + [Fact] + public void FileExists_DotDotSegmentInInputPath_ReturnsTrue() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "DATA")); + var file = FileSystem.Path.Combine(dir, "DATA", "FILE.TXT"); + FileSystem.File.WriteAllText(file, "x"); + + // Other/.. cancels out so the dispatcher must resolve to dir/DATA/file.txt. + var sb = new ValueStringBuilder(); + var exists = PgFileSystem.FileExists(@"Other\..\DATA\file.txt".AsSpan(), ref sb, dir.AsSpan()); + + Assert.True(exists); + AssertResolvedPath(file, sb.ToString()); + } + + [Theory] + // Resolves to the game directory itself. + [InlineData(".")] + [InlineData("./")] + [InlineData(@".\")] + // Resolves to a subdirectory. + [InlineData("Data")] + [InlineData("Data/")] + [InlineData(@"Data\")] + [InlineData("Data/.")] + [InlineData("Data/./")] + [InlineData(@"Data\.\")] + // Resolves to a deeper subdirectory (with case-folded variant). + [InlineData("Data/foo")] + [InlineData("DATA/FOO")] + public void FileExists_PathResolvesToDirectory_ReturnsFalse(string input) + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Data", "foo")); + + var exists = FileExists(input.AsSpan(), dir.AsSpan()); + + Assert.False(exists); + } + + [Fact] + public void FileExists_EmptyRelativePath_ReturnsFalse() + { + // After joining "" with the base directory, the dispatcher hands the strategy the bare + // base directory. Every strategy must treat that as not-a-file rather than reporting the + // directory itself as existing. + var dir = NewTempDir(); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "x.txt"), "x"); + + Assert.False(FileExists(string.Empty.AsSpan(), dir.AsSpan())); + } + + [Theory] + [InlineData("test.txt", "TEST.txt")] + [InlineData("dir/test.txt", "DIR/TEST.txt")] + [InlineData("a/b/c.txt", "A/B/C.txt")] + [InlineData("A/B/C.txt", "a/b/c.txt")] + [InlineData("a/B/c.txt", "A/b/C.txt")] + [InlineData("a/B/C.txt", "a/B/c.txt")] + [InlineData("a/b/C/D.txt", "a/b/c/d.txt")] + public void FileExists_AnyCasing_ReturnsTrue(string inputPath, string actualPathOnDisk) + { + var dir = NewTempDir(); + var fullOnDisk = FileSystem.Path.Combine(dir, actualPathOnDisk.Replace('/', FileSystem.Path.DirectorySeparatorChar)); + var fullOnDiskParent = FileSystem.Path.GetDirectoryName(fullOnDisk); + if (fullOnDiskParent != null) + FileSystem.Directory.CreateDirectory(fullOnDiskParent); + FileSystem.File.WriteAllText(fullOnDisk, "x"); + + var sb = new ValueStringBuilder(); + var exists = PgFileSystem.FileExists(inputPath.AsSpan(), ref sb, dir.AsSpan()); + + Assert.True(exists); + AssertResolvedPath(fullOnDisk, sb.ToString()); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/TrackingFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/TrackingFileExistsStrategy.cs new file mode 100644 index 0000000..ed01ae8 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/TrackingFileExistsStrategy.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +internal sealed class TrackingFileExistsStrategy(IFileSystem fileSystem) : FileExistsStrategy(fileSystem) +{ + public int CallCount { get; private set; } + + public List InvokedPaths { get; } = []; + + public bool ReturnValue { get; set; } + + public string? ResolvedPath { get; set; } + + public override bool FileExists(ReadOnlySpan gameDirectory, ref ValueStringBuilder stringBuilder) + { + CallCount++; + InvokedPaths.Add(stringBuilder.AsSpan().ToString()); + if (ReturnValue && ResolvedPath is not null) + { + stringBuilder.Length = 0; + stringBuilder.Append(ResolvedPath); + } + return ReturnValue; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualDirectoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualDirectoryTests.cs new file mode 100644 index 0000000..65bdd1a --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualDirectoryTests.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +public class VirtualDirectoryTests +{ + [Fact] + public void Ctor_ExposesAssignedFields() + { + var files = new Dictionary { ["foo.xml"] = "FOO.xml" }; + + var dir = new VirtualDirectory("/some/dir", files); + + Assert.Equal("/some/dir", dir.OnDiskPath); + Assert.Same(files, dir.Files); + Assert.Equal("FOO.xml", dir.Files["foo.xml"]); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs new file mode 100644 index 0000000..eb546dd --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +#if Windows +public sealed class VirtualFileExistsStrateg_Windows : VirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + fs.UseVirtualStrategy(windowsFallback: true); + } +} +#endif + +public sealed class VirtualFileExistsStrategy_Wine : VirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + => fs.UseVirtualStrategy(); +} + +public abstract class VirtualFileExistsStrategyTests : FileExistsStrategyTestBase +{ + [Fact] + public void FileExists_RepeatedCallsSameDirectory_BothResolveFromSnapshot() + { + var dir = NewTempDir(); + var dataDir = Path.Combine(dir, "Mods", "Test", "Data", "Xml"); + Directory.CreateDirectory(dataDir); + var foo = Path.Combine(dataDir, "foo.xml"); + var bar = Path.Combine(dataDir, "bar.xml"); + File.WriteAllText(foo, "1"); + File.WriteAllText(bar, "2"); + + var sb1 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("MODS/TEST/DATA/XML/FOO.XML".AsSpan(), ref sb1, dir.AsSpan())); + AssertResolvedPath(foo, sb1.ToString()); + + var sb2 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("mods/test/data/xml/BAR.XML".AsSpan(), ref sb2, dir.AsSpan())); + AssertResolvedPath(bar, sb2.ToString()); + } + + [Fact] + public void FileExists_MissingDirectoryUnderGameRoot_RemainsMissing() + { + var dir = NewTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "Mods", "Test", "Data", "Xml")); + File.WriteAllText(Path.Combine(dir, "Mods", "Test", "Data", "Xml", "foo.xml"), "1"); + + Assert.False(FileExists("MODS/TEST/DATA/OTHER/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("mods/test/data/other/bar.xml".AsSpan(), dir.AsSpan())); + } + + [Fact] + public void FileExists_AfterFirstResolve_SnapshotServesSubsequentLookups() + { + var dir = NewTempDir(); + var dataDir = Path.Combine(dir, "Data"); + Directory.CreateDirectory(dataDir); + var file = Path.Combine(dataDir, "foo.xml"); + File.WriteAllText(file, "x"); + + Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); + + File.Delete(file); + + Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); + } + + [Fact] + public void FileExists_PathOutsideGameDirectory_DelegatesToUnderlying() + { + var root = NewTempDir(); + var gameDir = Path.Combine(root, "game"); + var outsideDir = Path.Combine(root, "outside"); + Directory.CreateDirectory(gameDir); + Directory.CreateDirectory(outsideDir); + var file = Path.Combine(outsideDir, "FILE.TXT"); + File.WriteAllText(file, "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = file }; + PgFileSystem.UseVirtualStrategy(tracking); + + var sb = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists(file.AsSpan(), ref sb, gameDir.AsSpan())); + AssertResolvedPath(file, sb.ToString()); + + Assert.Equal(1, tracking.CallCount); + } + + [Fact] + public void FileExists_PathUnderGameDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = Path.Combine(dir, "Data"); + Directory.CreateDirectory(dataDir); + File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + PgFileSystem.UseVirtualStrategy(tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_RepeatedLookupInSnapshottedDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = Path.Combine(dir, "Data"); + Directory.CreateDirectory(dataDir); + File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); + File.WriteAllText(Path.Combine(dataDir, "bar.xml"), "y"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + PgFileSystem.UseVirtualStrategy(tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/missing.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() + { + var dir = NewTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "Data")); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + PgFileSystem.UseVirtualStrategy(tracking); + + Assert.False(FileExists("Data/Other/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/Other/bar.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs new file mode 100644 index 0000000..0ee13a7 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs @@ -0,0 +1,20 @@ +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.IO; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +/// +/// Inherits the strategy-agnostic suite. The Windows strategy delegates to CreateFileA, +/// so the OS resolves casing — the buffer goes through unmodified, which is the looser +/// "case-insensitive equality" assertion in the base class. +/// +public sealed class WindowsFileExistsStrategyTests : FileExistsStrategyTestBase +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Skip("Windows strategy requires a Windows host."); + fs.UseWindowsStrategy(); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs new file mode 100644 index 0000000..50e2aff --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs @@ -0,0 +1,9 @@ +using PG.StarWarsGame.Engine.IO; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +public sealed class WineFileExistsStrategyTests : FileExistsStrategyTestBase +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + => fs.UseWineStrategy(); +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs new file mode 100644 index 0000000..5da9a79 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; +#if NETFRAMEWORK +using AnakinRaW.CommonUtilities.FileSystem; +#endif + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public partial class PetroglyphFileSystemTests +{ + [Theory] +#if Windows + [InlineData("a", "b", "a\\b")] + [InlineData("a/", "b", "a/b")] + [InlineData("a\\", "b", "a\\b")] + [InlineData("", "b", "b")] + [InlineData("a", "", "a")] + [InlineData("/", "b", "/b")] + [InlineData("a", "/b", "/b")] + [InlineData("a", "\\b", "\\b")] + [InlineData("a/b", "c/d", "a/b\\c/d")] + [InlineData("a\\b", "c\\d", "a\\b\\c\\d")] + [InlineData("a/b/", "c/d", "a/b/c/d")] + [InlineData("a\\b\\", "c\\d", "a\\b\\c\\d")] +#else + [InlineData("a", "b", "a/b")] + [InlineData("a/", "b", "a/b")] + [InlineData("a\\", "b", "a\\b")] + [InlineData("", "b", "b")] + [InlineData("a", "", "a")] + [InlineData("/", "b", "/b")] + [InlineData("a", "/b", "/b")] + [InlineData("a", "\\b", "\\b")] + [InlineData("a/b", "c/d", "a/b/c/d")] + [InlineData("a\\b", "c\\d", "a\\b/c\\d")] + [InlineData("a/b/", "c/d", "a/b/c/d")] + [InlineData("a\\b\\", "c\\d", "a\\b\\c\\d")] +#endif + public void CombinePath(string pathA, string pathB, string expected) + { + var result = _pgFileSystem.CombinePath(pathA, pathB); + Assert.Equal(expected, result); +#if Windows + Assert.Equal(Path.Combine(pathA, pathB), result); +#endif + } + + [Theory] +#if Windows + [InlineData("a", "b", "a\\b")] + [InlineData("a/", "b", "a/b")] + [InlineData("a\\", "b", "a\\b")] + [InlineData("", "b", "b")] + [InlineData("a", "", "a")] + [InlineData("/", "b", "/b")] + [InlineData("a", "/b", "a/b")] + [InlineData("a", "\\b", "a\\b")] + [InlineData("a/b", "c/d", "a/b\\c/d")] + [InlineData("a\\b", "c\\d", "a\\b\\c\\d")] +#else + [InlineData("a", "b", "a/b")] + [InlineData("a/", "b", "a/b")] + [InlineData("a\\", "b", "a\\b")] + [InlineData("", "b", "b")] + [InlineData("a", "", "a")] + [InlineData("/", "b", "/b")] + [InlineData("a", "/b", "a/b")] + [InlineData("a", "\\b", "a\\b")] + [InlineData("a/b", "c/d", "a/b/c/d")] + [InlineData("a\\b", "c\\d", "a\\b/c\\d")] +#endif + public void JoinPath(string path1, string path2, string expected) + { + var vsb = new ValueStringBuilder(); + try + { + _pgFileSystem.JoinPath(path1.AsSpan(), path2.AsSpan(), ref vsb); + var result = vsb.ToString(); + Assert.Equal(expected, result); +#if Windows + Assert.Equal(result, _fileSystem.Path.Join(path1, path2)); +#endif + } + finally + { + vsb.Dispose(); + } + } + + [Fact] + public void CombinePath_FirstArgNull_Throws() + { + Assert.Throws(() => _pgFileSystem.CombinePath(null!, "b")); + } + + [Fact] + public void CombinePath_SecondArgNull_Throws() + { + Assert.Throws(() => _pgFileSystem.CombinePath("a", null!)); + } + + [Fact] + public void JoinPath_BothEmpty_LeavesBufferUntouched() + { + var vsb = new ValueStringBuilder(); + try + { + vsb.Append("preexisting"); + _pgFileSystem.JoinPath(ReadOnlySpan.Empty, ReadOnlySpan.Empty, ref vsb); + Assert.Equal("preexisting", vsb.ToString()); + } + finally + { + vsb.Dispose(); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs new file mode 100644 index 0000000..a94443b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs @@ -0,0 +1,31 @@ +using System; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public partial class PetroglyphFileSystemTests +{ + [Theory] +#if Windows + [InlineData("C:\\test.txt", true)] + [InlineData("C:/test.txt", true)] + [InlineData("D:\\path\\to\\file", true)] + [InlineData("/test.txt", false)] + [InlineData("\\test.txt", false)] +#else + [InlineData("/test.txt", true)] + [InlineData("/", true)] + [InlineData("/a/b/c", true)] + [InlineData("\\test.txt", false)] + [InlineData("C:\\test.txt", false)] +#endif + [InlineData("test.txt", false)] + [InlineData("a/b/c", false)] + [InlineData("./file.txt", false)] + [InlineData("../file.txt", false)] + [InlineData("", false)] + public void IsPathFullyQualified_Exists(string path, bool expected) + { + Assert.Equal(expected, _pgFileSystem.IsPathFullyQualified_Exists(path.AsSpan())); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs new file mode 100644 index 0000000..8ce48de --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using Xunit; +#if NETFRAMEWORK +using AnakinRaW.CommonUtilities.FileSystem; +#endif + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public partial class PetroglyphFileSystemTests +{ + public static TheoryData TestData_GetFileName => new() + { + { ".", "." }, + { "..", ".." }, + { "file", "file" }, + { "file.", "file." }, + { "file.exe", "file.exe" }, + { " . ", " . " }, + { " .. ", " .. " }, + { "fi le", "fi le" }, + { Path.Combine("baz", "file.exe"), "file.exe" }, + { Path.Combine("baz", "file.exe") + "/", "" }, + { Path.Combine("bar", "baz", "file.exe"), "file.exe" }, + { Path.Combine("bar", "baz", "file.exe") + "\\", "" }, + + { "foo\\bar/file.exe", "file.exe" }, + { "foo/bar\\file.exe", "file.exe" }, + }; + + [Theory, MemberData(nameof(TestData_GetFileName))] + public void GetFileName_Span(string path, string expected) + { + PathAssert.Equal(expected.AsSpan(), _pgFileSystem.GetFileName(path.AsSpan())); + Assert.Equal(expected, _pgFileSystem.GetFileName(path)); + } + + public static TheoryData TestData_GetFileNameWithoutExtension => new() + { + { "", "" }, + { "file", "file" }, + { "file.exe", "file" }, + { "bar\\baz/file.exe", "file" }, + { "bar/baz\\file.exe", "file" }, + { Path.Combine("bar", "baz") + "\\", "" }, + { Path.Combine("bar", "baz") + "/", "" }, + }; + + [Theory, MemberData(nameof(TestData_GetFileNameWithoutExtension))] + public void GetFileNameWithoutExtension_Span(string path, string expected) + { + PathAssert.Equal(expected.AsSpan(), _pgFileSystem.GetFileNameWithoutExtension(path.AsSpan())); + Assert.Equal(expected, _pgFileSystem.GetFileNameWithoutExtension(path)); +#if Windows + Assert.Equal(_pgFileSystem.GetFileName(path), _fileSystem.Path.GetFileName(path.AsSpan())); +#endif + } + + [Theory, + InlineData(null, null, null), + InlineData(null, "exe", null), + InlineData("", "", ""), + InlineData("file.exe", null, "file"), + InlineData("file.exe", "", "file."), + InlineData("file", "exe", "file.exe"), + InlineData("file", ".exe", "file.exe"), + InlineData("file.txt", "exe", "file.exe"), + InlineData("file.txt", ".exe", "file.exe"), + InlineData("file.txt.bin", "exe", "file.txt.exe"), + InlineData("dir/file.t", "exe", "dir/file.exe"), + InlineData("dir\\file.t", "exe", "dir\\file.exe"), + InlineData("dir/file.exe", "t", "dir/file.t"), + InlineData("dir\\file.exe", "t", "dir\\file.t"), + InlineData("dir/file", "exe", "dir/file.exe"), + InlineData("dir\\file", "exe", "dir\\file.exe")] + public void ChangeExtension(string? path, string? newExtension, string? expected) + { + Assert.Equal(expected, _pgFileSystem.ChangeExtension(path, newExtension)); + +#if Windows + Assert.Equal(_pgFileSystem.ChangeExtension(path, newExtension), _fileSystem.Path.ChangeExtension(path, newExtension)); +#endif + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs new file mode 100644 index 0000000..1f17944 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs @@ -0,0 +1,94 @@ +using System.IO; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public partial class PetroglyphFileSystemTests +{ + [Theory] +#if Windows + [InlineData("dir\\file.txt", "dir\\file.txt")] + [InlineData("dir/file.txt", "dir\\file.txt")] + [InlineData("\\dir\\subdir\\", "\\dir\\subdir\\")] + [InlineData("/dir\\subdir/", "\\dir\\subdir\\")] +#else + [InlineData("dir\\file.txt", "dir/file.txt")] + [InlineData("dir/file.txt", "dir/file.txt")] + [InlineData("\\dir\\subdir\\", "/dir/subdir/")] + [InlineData("/dir\\subdir/", "/dir/subdir/")] +#endif + public void NormalizePath(string path, string expected) + { + var vsb = new ValueStringBuilder(); + try + { + vsb.Append(path); + _pgFileSystem.NormalizePath(ref vsb); + + Assert.Equal(expected, vsb.ToString()); + } + finally + { + vsb.Dispose(); + } + } + + [Theory] + [InlineData("", "")] + [InlineData("/", "/")] + [InlineData("/foo", "/foo")] + [InlineData("/foo/bar", "/foo/bar")] + [InlineData("/a/b/c.xml", "/a/b/c.xml")] + [InlineData("/Mods/Test/Data/Xml/foo.xml", "/Mods/Test/Data/Xml/foo.xml")] + [InlineData("/.", "/")] + [InlineData("/./foo", "/foo")] + [InlineData("/foo/.", "/foo")] + [InlineData("/foo/./bar", "/foo/bar")] + [InlineData("/foo/././bar", "/foo/bar")] + [InlineData("/./foo/./bar/.", "/foo/bar")] + [InlineData("/foo/..", "/")] + [InlineData("/foo/../bar", "/bar")] + [InlineData("/foo/bar/..", "/foo")] + [InlineData("/foo/bar/../baz", "/foo/baz")] + [InlineData("/a/b/c/../../d", "/a/d")] + [InlineData("/..", "/")] + [InlineData("/../foo", "/foo")] + [InlineData("/foo/../..", "/")] + [InlineData("/foo/../../bar", "/bar")] + [InlineData("/a/./b/../c", "/a/c")] + [InlineData("/a/b/./../c", "/a/c")] + [InlineData("/Other/../Mods/./Test", "/Mods/Test")] + [InlineData("/foo//bar", "/foo/bar")] + [InlineData("/a///b", "/a/b")] + [InlineData("//foo", "/foo")] + [InlineData("/foo/", "/foo")] + [InlineData("/foo/bar/", "/foo/bar")] + [InlineData("/foo/bar//", "/foo/bar")] + [InlineData("/...", "/...")] + [InlineData("/.foo", "/.foo")] + [InlineData("/foo/...", "/foo/...")] + [InlineData("/foo/..bar", "/foo/..bar")] + [InlineData("/foo/.bar/baz", "/foo/.bar/baz")] + public void NormalizeDotSegmentsInPlace_RewritesBufferCorrectly(string input, string expected) + { + // The function operates on host-native separators; test data uses '/' for readability + // and is converted at entry. + input = input.Replace('/', Path.DirectorySeparatorChar); + expected = expected.Replace('/', Path.DirectorySeparatorChar); + + var vsb = new ValueStringBuilder(); + try + { + vsb.Append(input); + _pgFileSystem.NormalizeDotSegmentsInPlace(ref vsb); + + Assert.Equal(expected, vsb.ToString()); + } + finally + { + vsb.Dispose(); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs new file mode 100644 index 0000000..c53eefa --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs @@ -0,0 +1,26 @@ +using AnakinRaW.CommonUtilities.FileSystem; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public partial class PetroglyphFileSystemTests +{ + [Theory] + [InlineData("dir/file.txt", "DIR\\FILE.TXT", true)] + [InlineData("dir/file.txt", "dir/other.txt", false)] + [InlineData("a/b/c", "a\\b\\c", true)] + [InlineData("a/b/c", "a/b/c", true)] + [InlineData("a/b/c", "A/B/C", true)] + [InlineData("a/b/c", "a/b/c/d", false)] + [InlineData("a/b/c/d", "a/b/c", false)] + [InlineData("Mods/Test/Data/foo.xml", "MODS\\TEST\\DATA\\FOO.XML", true)] + [InlineData("a//b", "a\\b", true)] + [InlineData("a/b/", "a\\b\\", true)] + public void PathsAreEqual(string pathA, string pathB, bool expected) + { + Assert.Equal(expected, _pgFileSystem.PathsAreEqual(pathA, pathB)); +#if Windows + Assert.Equal(_pgFileSystem.PathsAreEqual(pathA, pathB), _fileSystem.Path.AreEqual(pathA, pathB)); +#endif + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs new file mode 100644 index 0000000..243e432 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs @@ -0,0 +1,117 @@ +using System; +using System.IO.Abstractions; +using System.Runtime.InteropServices; +using AnakinRaW.CommonUtilities.Testing.Attributes; +using PG.StarWarsGame.Engine.Utilities; +using Testably.Abstractions; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public class FileExistsStrategySwitchingTests : TestBaseWithPGFileSystem, IDisposable +{ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private readonly string _tempDir; + + public FileExistsStrategySwitchingTests() + { + _tempDir = FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(_tempDir); + var filePath = FileSystem.Path.Combine(_tempDir, "test.txt"); + FileSystem.File.WriteAllText(filePath, "x"); + } + + protected override IFileSystem CreateFileSystem() + { + return new RealFileSystem(); + } + + public void Dispose() + { + try + { + if (FileSystem.Directory.Exists(_tempDir)) + FileSystem.Directory.Delete(_tempDir, recursive: true); + } + catch + { + /* best-effort cleanup */ + } + GC.SuppressFinalize(this); + } + + [Fact] + public void DefaultStrategy_ResolvesFilesAfterConstruction() + { + AssertExists(); + } + + [Fact] + public void UseWineStrategy_Resolves() + { + PgFileSystem.UseWineStrategy(); + AssertExists(); + } + + [Fact] + public void UseVirtualStrategy_DefaultFallback_Resolves() + { + PgFileSystem.UseVirtualStrategy(); + AssertExists(); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Windows)] + public void UseWindowsStrategy_OnWindows_Resolves() + { + PgFileSystem.UseWindowsStrategy(); + AssertExists(); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void UseWindowsStrategy_OnNonWindows_Throws() + { + Assert.Throws(() => PgFileSystem.UseWindowsStrategy()); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void UseVirtualStrategy_WindowsFallback_OnNonWindows_Throws() + { + Assert.Throws(() => PgFileSystem.UseVirtualStrategy(windowsFallback: true)); + } + + [Fact] + public void Switching_BetweenStrategies_LeavesFileSystemUsable() + { + PgFileSystem.UseWineStrategy(); + AssertExists(); + + PgFileSystem.UseVirtualStrategy(); + AssertExists(); + + if (IsWindows) + { + PgFileSystem.UseWindowsStrategy(); + AssertExists(); + } + } + + [Fact] + public void Switching_FromVirtual_DoesNotLeakStaleAnswers() + { + PgFileSystem.UseVirtualStrategy(); + AssertExists(); + + var second = FileSystem.Path.Combine(_tempDir, "second.txt"); + FileSystem.File.WriteAllText(second, "y"); + + PgFileSystem.UseWineStrategy(); + + AssertExists("second.txt"); + } + + private void AssertExists(string file = "test.txt") + { + Assert.True(FileExists(file.AsSpan(), _tempDir.AsSpan())); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs new file mode 100644 index 0000000..2c9e912 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs @@ -0,0 +1,85 @@ +using System; +using System.IO.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Engine.IO; +using Testably.Abstractions; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public partial class PetroglyphFileSystemTests +{ + private readonly IFileSystem _fileSystem; + private readonly PetroglyphFileSystem _pgFileSystem; + + public PetroglyphFileSystemTests() + { + _fileSystem = new RealFileSystem(); + var sc = new ServiceCollection(); + sc.AddSingleton(_fileSystem); + IServiceProvider serviceProvider = sc.BuildServiceProvider(); + _pgFileSystem = new PetroglyphFileSystem(serviceProvider); + } + + [Fact] + public void Ctor_Null_Throws() + { + Assert.Throws(() => new PetroglyphFileSystem(null!)); + } + + [Fact] + public void UnderlyingFileSystem_ReturnsCorrectInstance() + { + Assert.Same(_fileSystem, _pgFileSystem.UnderlyingFileSystem); + } + + [Theory] + [InlineData("dir/", true)] + [InlineData("dir\\", true)] + [InlineData("dir/file.txt", false)] + [InlineData("file.txt", false)] + [InlineData("", false)] + [InlineData("/", true)] + [InlineData("\\", true)] + [InlineData("a", false)] + [InlineData(".", false)] + [InlineData("..", false)] + [InlineData("dir//", true)] + [InlineData("dir\\\\", true)] + public void HasTrailingDirectorySeparator(string path, bool expected) + { + Assert.Equal(expected, _pgFileSystem.HasTrailingDirectorySeparator(path.AsSpan())); + } + + [Fact] + public void OpenRead_ExistingFile_ReturnsReadableStream() + { + var dir = _fileSystem.Path.Combine(_fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + _fileSystem.Directory.CreateDirectory(dir); + try + { + var file = _fileSystem.Path.Combine(dir, "openread.bin"); + _fileSystem.File.WriteAllBytes(file, new byte[] { 1, 2, 3, 4 }); + + using var stream = _pgFileSystem.OpenRead(file); + + Assert.True(stream.CanRead); + Assert.False(stream.CanWrite); + Assert.Equal(4, stream.Length); + var buf = new byte[4]; + Assert.Equal(4, stream.Read(buf, 0, 4)); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, buf); + } + finally + { + _fileSystem.Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void OpenRead_MissingFile_Throws() + { + var missing = _fileSystem.Path.Combine(_fileSystem.Path.GetTempPath(), Guid.NewGuid() + ".missing"); + Assert.ThrowsAny(() => _pgFileSystem.OpenRead(missing)); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj new file mode 100644 index 0000000..e236240 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj @@ -0,0 +1,48 @@ + + + + net10.0 + $(TargetFrameworks);net481 + preview + + + + false + true + Exe + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + true + true + + + + Windows + + + Linux + + + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs new file mode 100644 index 0000000..7ae5172 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs @@ -0,0 +1,18 @@ +using System; + +namespace PG.StarWarsGame.Engine.FileSystem.Test; + +internal static class PathAssert +{ + public static void Equal(ReadOnlySpan expected, ReadOnlySpan actual) + { + if (!actual.SequenceEqual(expected)) + throw Xunit.Sdk.EqualException.ForMismatchedValues(expected.ToString(), actual.ToString()); + } + + public static void Empty(ReadOnlySpan actual) + { + if (actual.Length > 0) + throw Xunit.Sdk.NotEmptyException.ForNonEmptyCollection(); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/TestBaseWithPGFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/TestBaseWithPGFileSystem.cs new file mode 100644 index 0000000..dd1f9e5 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/TestBaseWithPGFileSystem.cs @@ -0,0 +1,40 @@ +using System; +using AnakinRaW.CommonUtilities.Testing; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.FileSystem.Test; + +public abstract class TestBaseWithPGFileSystem : TestBaseWithFileSystem +{ + protected PetroglyphFileSystem PgFileSystem { get; } + + protected TestBaseWithPGFileSystem() + { + PgFileSystem = new PetroglyphFileSystem(ServiceProvider); + ConfigureStrategy(PgFileSystem); + } + + /// Install the strategy under test on the freshly constructed file system. + protected virtual void ConfigureStrategy(PetroglyphFileSystem fs) + { + // Use default + } + + /// + /// Test helper that allocates a , runs FileExists, and + /// disposes the buffer. Use when the resolved path is irrelevant to the assertion. + /// + protected bool FileExists(ReadOnlySpan filePath, ReadOnlySpan gameDirectory) + { + var sb = new ValueStringBuilder(); + try + { + return PgFileSystem.FileExists(filePath, ref sb, gameDirectory); + } + finally + { + sb.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/LowLevelPathTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/LowLevelPathTests.cs new file mode 100644 index 0000000..563f92c --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/LowLevelPathTests.cs @@ -0,0 +1,69 @@ +using System; +using AnakinRaW.CommonUtilities.Testing.Attributes; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.Utilities; + +public class LowLevelPathTests +{ + [Theory] + [InlineData("C:/Games/EAW/Data/foo.xml", "C:/Games/EAW", 12)] + [InlineData("/home/user/eaw/Data/foo.xml", "/home/user/eaw", 14)] + [InlineData("C:/Games/EAW/Data/foo.xml", "C:/Games/EAW/", 13)] + [InlineData("C:/Games/EAW/Data", "C:/Games/EAWX", 9)] + [InlineData("C:/Games", "C:/Games/EAW", 8)] + [InlineData("C:/Games/EAW", "C:/Games/EAW", 12)] + [InlineData("D:/Games/EAW", "C:/Games/EAW", 0)] + [InlineData("", "C:/Games/EAW", 0)] + [InlineData("C:/foo", "", 0)] + public void GetCommonDirectoryPrefixLength_Cases(string path, string directory, int expected) + { + Assert.Equal(expected, LowLevelPath.GetCommonDirectoryPrefixLength(path.AsSpan(), directory.AsSpan())); + } + + [PlatformSpecificTheory(TestPlatformIdentifier.Windows)] + [InlineData("C:/Games/EAW/Data/foo.xml", "C:\\Games\\EAW", 12)] + [InlineData("C:\\Games\\EAW\\Data\\foo.xml", "C:\\Games\\EAW", 12)] + public void GetCommonDirectoryPrefixLength_BackslashSeparator_Windows(string path, string directory, int expected) + { + Assert.Equal(expected, LowLevelPath.GetCommonDirectoryPrefixLength(path.AsSpan(), directory.AsSpan())); + } + + [PlatformSpecificTheory(TestPlatformIdentifier.Windows)] + [InlineData("c:/games/eaw/Data/foo.xml", "C:\\Games\\EAW", 12)] + [InlineData("C:/GAMES/EAW/Data/foo.xml", "C:/games/eaw", 12)] + public void GetCommonDirectoryPrefixLength_CaseInsensitive_Windows(string path, string directory, int expected) + { + Assert.Equal(expected, LowLevelPath.GetCommonDirectoryPrefixLength(path.AsSpan(), directory.AsSpan())); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Windows)] + public void IsHostFileSystemCaseSensitive_Windows_IsFalse() + { + Assert.False(LowLevelPath.IsHostFileSystemCaseSensitive); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void IsHostFileSystemCaseSensitive_Linux_IsTrue() + { + Assert.True(LowLevelPath.IsHostFileSystemCaseSensitive); + } + + [Theory] + // Sibling at root level: path and directory diverge before any separator → no shared prefix. + [InlineData("foo/bar", "baz/qux", 0)] + // Trailing separator on path side, directory does not have one — must match the no-trailing form. + [InlineData("a/b/", "a/b", 3)] + [InlineData("a/b", "a/b/", 3)] + // Sibling whose name is a prefix of the other — shared prefix is the parent dir, not the + // longest character match. "a/b" vs "a/ba" must NOT be reported as 3 chars in common. + [InlineData("a/b", "a/ba", 2)] + [InlineData("a/ba", "a/b", 2)] + [InlineData("a/foo", "a/foobar", 2)] + [InlineData("a/foobar", "a/foo", 2)] + public void GetCommonDirectoryPrefixLength_BoundaryCases(string path, string directory, int expected) + { + Assert.Equal(expected, LowLevelPath.GetCommonDirectoryPrefixLength(path.AsSpan(), directory.AsSpan())); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs new file mode 100644 index 0000000..a095903 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs @@ -0,0 +1,371 @@ +using System; +using System.Text; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.Utilities; + +public class ValueStringBuilderTests +{ + [Fact] + public void Ctor_Default_CanAppend() + { + var vsb = default(ValueStringBuilder); + Assert.Equal(0, vsb.Length); + + vsb.Append('a'); + Assert.Equal(1, vsb.Length); + Assert.Equal("a", vsb.ToString()); + } + + [Fact] + public void Ctor_Span_CanAppend() + { + var vsb = new ValueStringBuilder(new char[1]); + Assert.Equal(0, vsb.Length); + + vsb.Append('a'); + Assert.Equal(1, vsb.Length); + Assert.Equal("a", vsb.ToString()); + } + + [Fact] + public void Ctor_InitialCapacity_CanAppend() + { + var vsb = new ValueStringBuilder(1); + Assert.Equal(0, vsb.Length); + + vsb.Append('a'); + Assert.Equal(1, vsb.Length); + Assert.Equal("a", vsb.ToString()); + } + + [Fact] + public void Append_Char_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + for (var i = 1; i <= 100; i++) + { + sb.Append((char)i); + vsb.Append((char)i); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void Append_String_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + for (var i = 1; i <= 100; i++) + { + var s = i.ToString(); + sb.Append(s); + vsb.Append(s); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Theory] + [InlineData(0, 4 * 1024 * 1024)] + [InlineData(1025, 4 * 1024 * 1024)] + [InlineData(3 * 1024 * 1024, 6 * 1024 * 1024)] + public void Append_String_Large_MatchesStringBuilder(int initialLength, int stringLength) + { + var sb = new StringBuilder(initialLength); + var vsb = new ValueStringBuilder(new char[initialLength]); + + var s = new string('a', stringLength); + sb.Append(s); + vsb.Append(s); + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void Append_CharInt_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + for (var i = 1; i <= 100; i++) + { + sb.Append((char)i, i); + vsb.Append((char)i, i); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void AppendSpan_Capacity() + { + var vsb = new ValueStringBuilder(); + + vsb.AppendSpan(17); + Assert.Equal(32, vsb.Capacity); + + vsb.AppendSpan(100); + Assert.Equal(128, vsb.Capacity); + } + + [Fact] + public void AppendSpan_DataAppendedCorrectly() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + + for (var i = 1; i <= 1000; i++) + { + var s = i.ToString(); + + sb.Append(s); + + var span = vsb.AppendSpan(s.Length); + Assert.Equal(sb.Length, vsb.Length); + + s.AsSpan().CopyTo(span); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void Insert_IntCharInt_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + var rand = new Random(42); + + for (var i = 1; i <= 100; i++) + { + var index = rand.Next(sb.Length); + sb.Insert(index, new string((char)i, 1), i); + vsb.Insert(index, (char)i, i); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void Insert_IntString_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + + sb.Insert(0, new string('a', 6)); + vsb.Insert(0, new string('a', 6)); + Assert.Equal(6, vsb.Length); + Assert.Equal(16, vsb.Capacity); + + sb.Insert(0, new string('b', 11)); + vsb.Insert(0, new string('b', 11)); + Assert.Equal(17, vsb.Length); + Assert.Equal(32, vsb.Capacity); + + sb.Insert(0, new string('c', 15)); + vsb.Insert(0, new string('c', 15)); + Assert.Equal(32, vsb.Length); + Assert.Equal(32, vsb.Capacity); + + sb.Length = 24; + vsb.Length = 24; + + sb.Insert(0, new string('d', 40)); + vsb.Insert(0, new string('d', 40)); + Assert.Equal(64, vsb.Length); + Assert.Equal(64, vsb.Capacity); + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void AsSpan_ReturnsCorrectValue_DoesntClearBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + + for (var i = 1; i <= 100; i++) + { + var s = i.ToString(); + sb.Append(s); + vsb.Append(s); + } + + var resultString = vsb.AsSpan().ToString(); + Assert.Equal(sb.ToString(), resultString); + + Assert.NotEqual(0, sb.Length); + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void ToString_ClearsBuilder_ThenReusable() + { + const string Text1 = "test"; + var vsb = new ValueStringBuilder(); + + vsb.Append(Text1); + Assert.Equal(Text1.Length, vsb.Length); + + var s = vsb.ToString(); + Assert.Equal(Text1, s); + + Assert.Equal(0, vsb.Length); + Assert.Equal(string.Empty, vsb.ToString()); + + const string Text2 = "another test"; + vsb.Append(Text2); + Assert.Equal(Text2.Length, vsb.Length); + Assert.Equal(Text2, vsb.ToString()); + } + + [Fact] + public void Dispose_ClearsBuilder_ThenReusable() + { + const string Text1 = "test"; + var vsb = new ValueStringBuilder(); + + vsb.Append(Text1); + Assert.Equal(Text1.Length, vsb.Length); + + vsb.Dispose(); + + Assert.Equal(0, vsb.Length); + Assert.Equal(string.Empty, vsb.ToString()); + + const string Text2 = "another test"; + vsb.Append(Text2); + Assert.Equal(Text2.Length, vsb.Length); + Assert.Equal(Text2, vsb.ToString()); + } + + [Fact] + public void Indexer() + { + const string Text1 = "foobar"; + var vsb = new ValueStringBuilder(); + + vsb.Append(Text1); + + Assert.Equal('b', vsb[3]); + vsb[3] = 'c'; + Assert.Equal('c', vsb[3]); + vsb.Dispose(); + } + + [Fact] + public void Remove_ZeroLength_NoOp() + { + var vsb = new ValueStringBuilder(); + vsb.Append("abc"); + vsb.Remove(1, 0); + Assert.Equal("abc", vsb.ToString()); + } + + [Fact] + public void Remove_Start() + { + var vsb = new ValueStringBuilder(); + vsb.Append("abcde"); + vsb.Remove(0, 2); + var res = vsb.ToString(); + Assert.Equal("cde", res); + } + + [Fact] + public void Remove_Middle() + { + var vsb = new ValueStringBuilder(); + vsb.Append("abcde"); + vsb.Remove(1, 3); + var res = vsb.ToString(); + Assert.Equal("ae", res); + } + + [Fact] + public void Remove_End() + { + var vsb = new ValueStringBuilder(); + vsb.Append("abcde"); + vsb.Remove(3, 2); + var res = vsb.ToString(); + Assert.Equal("abc", res); + } + + [Fact] + public void Remove_EntireContent() + { + var vsb = new ValueStringBuilder(); + vsb.Append("abcde"); + vsb.Remove(0, 5); + Assert.Equal(0, vsb.Length); + Assert.Equal(string.Empty, vsb.ToString()); + } + + [Theory] + [InlineData(-1, 1)] // negative startIndex + [InlineData(0, -1)] // negative length + [InlineData(0, 6)] // length too large + [InlineData(3, 3)] // range too large + public void Remove_Invalid_ThrowsArgumentOutOfRangeException(int startIndex, int length) + { + var vsb = new ValueStringBuilder(); + vsb.Append("abcde"); + try + { + vsb.Remove(startIndex, length); + Assert.Fail("Expected ArgumentOutOfRangeException"); + } + catch (ArgumentOutOfRangeException) + { + // Expected + } + } + + [Fact] + public void EnsureCapacity_IfRequestedCapacityWins() + { + // Note: constants used here may be dependent on minimal buffer size + // the ArrayPool is able to return. + var builder = new ValueStringBuilder(stackalloc char[32]); + + builder.EnsureCapacity(65); + + Assert.Equal(128, builder.Capacity); + } + + [Fact] + public void EnsureCapacity_IfBufferTimesTwoWins() + { + var builder = new ValueStringBuilder(stackalloc char[32]); + + builder.EnsureCapacity(33); + + Assert.Equal(64, builder.Capacity); + builder.Dispose(); + } + + [Fact] + public void EnsureCapacity_NoAllocIfNotNeeded() + { + // Note: constants used here may be dependent on minimal buffer size + // the ArrayPool is able to return. + var builder = new ValueStringBuilder(stackalloc char[64]); + + builder.EnsureCapacity(16); + + Assert.Equal(64, builder.Capacity); + builder.Dispose(); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs new file mode 100644 index 0000000..a179fe6 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly:InternalsVisibleTo("PG.StarWarsGame.Engine")] +[assembly:InternalsVisibleTo("PG.StarWarsGame.Engine.FileSystem.Test")] \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs new file mode 100644 index 0000000..43191cd --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/FileExistsStrategy.cs @@ -0,0 +1,14 @@ +using System; +using System.IO.Abstractions; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal abstract class FileExistsStrategy(IFileSystem fileSystem) : IDisposable +{ + protected readonly IFileSystem FileSystem = fileSystem; + + public abstract bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder); + + public virtual void Dispose() { } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs new file mode 100644 index 0000000..f4f077c --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +/// +/// Immutable snapshot of a single directory's file listing. Files only — no subdirectory recursion. +/// Built once by and never mutated thereafter. +/// +internal sealed class VirtualDirectory(string onDiskPath, Dictionary files) +{ + /// The directory's path with the on-disk casing. + public string OnDiskPath { get; } = onDiskPath; + + /// + /// Filename map. Keys compare case-insensitively (so callers can look up "FOO.XML" against + /// the on-disk "foo.xml") and the value carries the case-preserved on-disk filename used + /// when joining the result back into a full path. + /// + public Dictionary Files { get; } = files; +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs new file mode 100644 index 0000000..5583ec7 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal sealed class VirtualFileExistsStrategy(IFileSystem fileSystem, FileExistsStrategy underlying) + : FileExistsStrategy(fileSystem) +{ + private readonly ConcurrentDictionary _store = + new(StringComparer.OrdinalIgnoreCase); + + public override void Dispose() + { + _store.Clear(); + underlying.Dispose(); + } + + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + var filePath = stringBuilder.AsSpan(); + + if (!IsUnderBaseDirectory(filePath, baseDirectory)) + return underlying.FileExists(baseDirectory, ref stringBuilder); + + var lastSep = filePath.LastIndexOf(Path.DirectorySeparatorChar); + if (lastSep <= 0) + return underlying.FileExists(baseDirectory, ref stringBuilder); + + var dirSpan = filePath.Slice(0, lastSep); + var fileName = filePath.Slice(lastSep + 1); + if (fileName.IsEmpty) + return underlying.FileExists(baseDirectory, ref stringBuilder); + + var dirKey = dirSpan.ToString(); + if (!_store.TryGetValue(dirKey, out var virtualDir)) + { + virtualDir = TrySnapshot(dirKey); + _store.TryAdd(dirKey, virtualDir); + } + + if (virtualDir is null) + return false; + + if (virtualDir.Files.TryGetValue(fileName.ToString(), out var onDiskName)) + { + stringBuilder.Length = 0; + stringBuilder.Append(virtualDir.OnDiskPath); + if (stringBuilder.Length > 0 && !LowLevelPath.IsDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) + stringBuilder.Append(Path.DirectorySeparatorChar); + stringBuilder.Append(onDiskName); + return true; + } + + return false; + } + + private VirtualDirectory? TrySnapshot(string inputDirPath) + { + var onDiskPath = TryResolveDirectory(inputDirPath); + if (onDiskPath is null) + return null; + + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in FileSystem.Directory.EnumerateFiles(onDiskPath)) + { + var name = FileSystem.Path.GetFileName(entry); + files[name] = name; + } + return new VirtualDirectory(onDiskPath, files); + } + + private string? TryResolveDirectory(string dirPath) + { + if (string.IsNullOrEmpty(dirPath)) + return null; + + if (FileSystem.Directory.Exists(dirPath)) + return dirPath; + + var path = dirPath.AsSpan(); + var rootLen = FileSystem.Path.GetPathRoot(path).Length; + if (rootLen == 0) + return null; + + var currentDir = dirPath.Substring(0, rootLen); + if (!FileSystem.Directory.Exists(currentDir)) + return null; + + var sb = new ValueStringBuilder(stackalloc char[260]); + try + { + sb.Append(currentDir); + + var pos = rootLen; + if (pos < path.Length && path[pos] == Path.DirectorySeparatorChar) + pos++; + + while (pos < path.Length) + { + var rest = path.Slice(pos); + var nextSlash = rest.IndexOf(Path.DirectorySeparatorChar); + var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; + var component = path.Slice(pos, componentEnd - pos); + + if (component.IsEmpty) + { + pos = componentEnd + 1; + continue; + } + + var savedLen = sb.Length; + if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) + sb.Append(Path.DirectorySeparatorChar); + sb.Append(component); + + var literalPath = sb.AsSpan().ToString(); + if (FileSystem.Directory.Exists(literalPath)) + { + currentDir = literalPath; + pos = componentEnd + 1; + continue; + } + + sb.Length = savedLen; + + var found = false; + foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) + { + if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) + { + sb.Length = 0; + sb.Append(entry); + currentDir = entry; + found = true; + break; + } + } + + if (!found) + return null; + + pos = componentEnd + 1; + } + + return currentDir; + } + finally + { + sb.Dispose(); + } + } + + private bool IsUnderBaseDirectory(ReadOnlySpan path, ReadOnlySpan gameDirectory) + { + return !gameDirectory.IsEmpty && FileSystem.Path.IsChildOf(gameDirectory, path); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WindowsFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WindowsFileExistsStrategy.cs new file mode 100644 index 0000000..41cb3e7 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WindowsFileExistsStrategy.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +/// +/// Thin wrapper over Win32 CreateFileA. The OS resolves casing, so no per-component walk +/// and no path canonicalization is needed — the buffer goes through verbatim. Each call re-stats +/// from scratch (complete mediation), making this the safe default on Windows hosts. +/// +internal sealed class WindowsFileExistsStrategy(IFileSystem fileSystem) : FileExistsStrategy(fileSystem) +{ + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + // We *could* also use the slightly faster GetFileAttributesA. However, CreateFileA and + // GetFileAttributesA are implemented entirely independently in Windows; the game uses + // CreateFileA, so we stick to it to remain as close to engine behavior as possible. + // NB: GetPinnableReference(true) zero-terminates so CreateFileA gets a valid C string. + var fileHandle = CreateFile( + in stringBuilder.GetPinnableReference(true), + FileAccess.Read, + FileShare.Read, + IntPtr.Zero, + FileMode.Open, + FileAttributes.Normal, + IntPtr.Zero); + + return IsValidAndClose(fileHandle); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidAndClose(IntPtr handle) + { + var isValid = handle != IntPtr.Zero && handle != new IntPtr(-1); + if (isValid) + CloseHandle(handle); + return isValid; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern IntPtr CreateFile( + in char lpFileName, + [MarshalAs(UnmanagedType.U4)] FileAccess access, + [MarshalAs(UnmanagedType.U4)] FileShare share, + IntPtr securityAttributes, + [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition, + [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr hObject); +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WineFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WineFileExistsStrategy.cs new file mode 100644 index 0000000..d102771 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/WineFileExistsStrategy.cs @@ -0,0 +1,168 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using PG.StarWarsGame.Engine.Utilities; +#if NETSTANDARD2_0 +using AnakinRaW.CommonUtilities.FileSystem; +#endif + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal sealed class WineFileExistsStrategy(IFileSystem fileSystem) : FileExistsStrategy(fileSystem) +{ + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + var pathString = stringBuilder.AsSpan().ToString(); + if (pathString.Length == 0) + return false; + + if (FileSystem.File.Exists(pathString)) + return true; + + var path = pathString.AsSpan(); + + var lastSep = path.LastIndexOf(Path.DirectorySeparatorChar); + if (lastSep < 0) + return false; + + var fileName = path.Slice(lastSep + 1); + if (fileName.IsEmpty) + return false; + + var rootLen = FileSystem.Path.GetPathRoot(path).Length; + var parentLen = Math.Max(lastSep, rootLen); + if (parentLen == 0) + return false; + var parentDirInput = pathString.Substring(0, parentLen); + + var resolvedParent = ResolveDirectory(parentDirInput, baseDirectory, rootLen); + if (resolvedParent is null) + return false; + + return ResolveLeafIn(resolvedParent, fileName, ref stringBuilder); + } + + private string? ResolveDirectory(string dirInput, ReadOnlySpan baseDirectory, int rootLen) + { + var path = dirInput.AsSpan(); + var knownGoodPrefixLength = LowLevelPath.GetCommonDirectoryPrefixLength(path, baseDirectory); + + int prefixEnd; + if (knownGoodPrefixLength > rootLen) + { + prefixEnd = Math.Min(knownGoodPrefixLength, path.Length); + while (prefixEnd > rootLen && path[prefixEnd - 1] == Path.DirectorySeparatorChar) + prefixEnd--; + } + else + { + prefixEnd = rootLen; + } + + if (prefixEnd == 0) + return null; + + var currentDir = dirInput.Substring(0, prefixEnd); + if (!FileSystem.Directory.Exists(currentDir)) + return null; + + var pos = prefixEnd; + if (pos < path.Length && path[pos] == Path.DirectorySeparatorChar) + pos++; + + var sb = new ValueStringBuilder(stackalloc char[260]); + try + { + sb.Append(currentDir); + + while (pos < path.Length) + { + var rest = path.Slice(pos); + var nextSlash = rest.IndexOf(Path.DirectorySeparatorChar); + var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; + var component = path.Slice(pos, componentEnd - pos); + + if (component.IsEmpty) + { + pos = componentEnd + 1; + continue; + } + + var savedLen = sb.Length; + if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) + sb.Append(Path.DirectorySeparatorChar); + sb.Append(component); + + var literalAttempt = sb.AsSpan().ToString(); + if (FileSystem.Directory.Exists(literalAttempt)) + { + currentDir = literalAttempt; + pos = componentEnd + 1; + continue; + } + + sb.Length = savedLen; + + var found = false; + foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) + { + if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) + { + sb.Length = 0; + sb.Append(entry); + currentDir = entry; + found = true; + break; + } + } + + if (!found) + return null; + + pos = componentEnd + 1; + } + + return currentDir; + } + finally + { + sb.Dispose(); + } + } + + private bool ResolveLeafIn(string parentOnDisk, ReadOnlySpan fileName, ref ValueStringBuilder outBuffer) + { + var sb = new ValueStringBuilder(stackalloc char[260]); + try + { + sb.Append(parentOnDisk); + if (sb.Length == 0 || !LowLevelPath.IsDirectorySeparator(sb[sb.Length - 1])) + sb.Append(Path.DirectorySeparatorChar); + sb.Append(fileName); + + var literalAttempt = sb.AsSpan().ToString(); + if (FileSystem.File.Exists(literalAttempt)) + { + outBuffer.Length = 0; + outBuffer.Append(literalAttempt); + return true; + } + } + finally + { + sb.Dispose(); + } + + foreach (var entry in FileSystem.Directory.EnumerateFiles(parentOnDisk)) + { + if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + outBuffer.Length = 0; + outBuffer.Append(entry); + return true; + } + } + + return false; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs new file mode 100644 index 0000000..d9f3cd8 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs @@ -0,0 +1,63 @@ +using System; +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + public string CombinePath(string pathA, string pathB) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.Combine(pathA, pathB); + + if (pathA == null) + throw new ArgumentNullException(nameof(pathA)); + if (pathB == null) + throw new ArgumentNullException(nameof(pathB)); + return CombineInternal(pathA, pathB); + } + + internal void JoinPath(ReadOnlySpan path1, ReadOnlySpan path2, ref ValueStringBuilder stringBuilder) + { + if (path1.Length == 0 && path2.Length == 0) + return; + + if (path1.Length == 0 || path2.Length == 0) + { + ref var pathToUse = ref path1.Length == 0 ? ref path2 : ref path1; + stringBuilder.Append(pathToUse); + return; + } + + stringBuilder.Append(path1); + + var hasSeparator = IsDirectorySeparator(path1[path1.Length - 1]) || IsDirectorySeparator(path2[0]); + if (!hasSeparator) + stringBuilder.Append(_underlyingFileSystem.Path.DirectorySeparatorChar); + + stringBuilder.Append(path2); + } + + private string CombineInternal(string first, string second) + { + if (string.IsNullOrEmpty(first)) + return second; + + if (string.IsNullOrEmpty(second)) + return first; + + if (IsPathRooted(second.AsSpan())) + return second; + + return JoinInternal(first, second); + } + + private string JoinInternal(string first, string second) + { + var hasSeparator = IsDirectorySeparator(first[first.Length - 1]) || IsDirectorySeparator(second[0]); + return hasSeparator + ? string.Concat(first, second) + : string.Concat(first, _underlyingFileSystem.Path.DirectorySeparatorChar, second); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs new file mode 100644 index 0000000..f28f9e1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -0,0 +1,111 @@ +using System; +using PG.StarWarsGame.Engine.Utilities; +#if NETSTANDARD2_0 +using AnakinRaW.CommonUtilities.FileSystem; +#endif + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + /// + /// Resolves against , normalizes + /// the path, and dispatches the lookup to the active FileExists strategy. + /// + /// + /// Fully-qualified inputs are taken as-is; relative inputs are joined to + /// first. The buffer is then unified to the host's native + /// directory separator and dot segments (. and ..) are resolved, after which + /// the active strategy answers the lookup. On the buffer contains + /// the resolved on-disk path; on the buffer content is unspecified. + /// + /// The path to resolve. + /// + /// A scratch buffer used to build and return the resolved path. The caller owns the buffer's + /// lifetime and is responsible for disposing it. + /// + /// + /// The base directory used as the base when is relative. + /// + /// + /// if the file exists; otherwise, . + /// + internal bool FileExists(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder, ReadOnlySpan baseDirectory) + { + stringBuilder.Length = 0; + + if (IsPathFullyQualified_Exists(filePath)) + stringBuilder.Append(filePath); + else + JoinPath(baseDirectory, filePath, ref stringBuilder); + + // Canonicalize once for every strategy: unify separators to the host's native form, then + // strip "." / ".." and trailing/duplicated separators. After this the buffer is ready to + // hand directly to host FS APIs. + NormalizePath(ref stringBuilder); + NormalizeDotSegmentsInPlace(ref stringBuilder); + + return _strategy.FileExists(baseDirectory, ref stringBuilder); + } + + internal void NormalizeDotSegmentsInPlace(ref ValueStringBuilder sb) + { + var len = sb.Length; + if (len == 0) + return; + + var dirSeparator = _underlyingFileSystem.Path.DirectorySeparatorChar; + + var rootLen = GetPathRoot(sb.AsSpan()).Length; + var writeEnd = rootLen; + var readPos = rootLen; + + while (readPos < len && sb[readPos] == dirSeparator) + readPos++; + + while (readPos < len) + { + var segStart = readPos; + while (readPos < len && sb[readPos] != dirSeparator) + readPos++; + var segLen = readPos - segStart; + + while (readPos < len && sb[readPos] == dirSeparator) + readPos++; + + if (segLen == 1 && sb[segStart] == '.') + continue; + + if (segLen == 2 && sb[segStart] == '.' && sb[segStart + 1] == '.') + { + if (writeEnd > rootLen) + { + while (writeEnd > rootLen && sb[writeEnd - 1] != dirSeparator) + writeEnd--; + if (writeEnd > rootLen) + writeEnd--; + } + continue; + } + + if (writeEnd > rootLen) + sb[writeEnd++] = dirSeparator; + + for (var i = 0; i < segLen; i++) + sb[writeEnd++] = sb[segStart + i]; + } + + sb.Length = writeEnd; + } + + internal bool IsPathFullyQualified_Exists(ReadOnlySpan path) + { + // This is really tricky, because under Windows "/" or "\" do NOT + // indicate a fully qualified path, under Linux however "/" does. + // The PGFileSystem is implemented to treat backslashes as directory separators. + // However, this must not happen here, since we are operating on the actual file system. + // E.g, \\Data\\Art\\... MUST not be treated as a fully qualified path. + // This means, ultimately, we can just delegate to the underlying file system. + return _underlyingFileSystem.Path.IsPathFullyQualified(path); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs new file mode 100644 index 0000000..1d5648b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs @@ -0,0 +1,221 @@ +using System; +using System.Runtime.InteropServices; +#if NETSTANDARD2_0 +using AnakinRaW.CommonUtilities.FileSystem; +#endif +#if NETSTANDARD2_1 || NET +using System.Diagnostics.CodeAnalysis; +#endif + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + /// + /// The path string from which to obtain the file name and extension. + /// + /// + /// The characters after the last directory separator character in . + /// If the last character of is a directory or volume separator character, this method returns Empty. + /// If is , this method returns . + /// + /// The returned value is if the file path is . + /// The separator characters used to determine the start of the file name are ("/") and ("\"). + /// +#if NETSTANDARD2_1 || NET + [return: NotNullIfNotNull(nameof(path))] +#endif + public string? GetFileName(string? path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.GetFileName(path); + + if (path == null) + return null; + var result = GetFileName(path.AsSpan()); + if (path.Length == result.Length) + return path; + return result.ToString(); + } + + /// + /// Returns the file name and extension of a file path that is represented by a read-only character span. + /// + /// A read-only span that contains the path from which to obtain the file name and extension. + /// The characters after the last directory separator character in . + /// + /// The returned read-only span contains the characters of the path that follow the last separator in path. + /// If the last character in path is a volume or directory separator character, the method returns . + /// If path contains no separator character, the method returns path. + /// + public ReadOnlySpan GetFileName(ReadOnlySpan path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.GetFileName(path); + + var root = GetPathRoot(path).Length; + var i = path.LastIndexOfAny(DirectorySeparatorChar, AltDirectorySeparatorChar); + return path.Slice(i < root ? root : i + 1); + } + + /// + /// Returns the file name of the specified path string without the extension. + /// + /// The path of the file. + /// The string returned by , minus the last period (.) and all characters following it. +#if NETSTANDARD2_1 || NET + [return: NotNullIfNotNull(nameof(path))] +#endif + public string? GetFileNameWithoutExtension(string? path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path); + + if (path == null) + return null; + + var result = GetFileNameWithoutExtension(path.AsSpan()); + return path.Length == result.Length + ? path + : result.ToString(); + } + + /// + /// Returns the file name without the extension of a file path that is represented by a read-only character span. + /// + /// A read-only span that contains the path from which to obtain the file name without the extension. + /// The characters in the read-only span returned by , minus the last period (.) and all characters following it. + public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path); + var fileName = GetFileName(path); + var lastPeriod = fileName.LastIndexOf('.'); + return lastPeriod < 0 + ? fileName + : // No extension was found + fileName.Slice(0, lastPeriod); + } + + /// + /// Changes the extension of a path string. + /// + /// The path information to modify. + /// The new extension (with or without a leading period). Specify to remove an existing extension from . + /// + /// The modified path information. + /// + /// If is or an empty string (""), the path information is returned unmodified. + /// If is , the returned string contains the specified path with its extension removed. + /// If has no extension, and is not , + /// the returned path string contains appended to the end of . + /// + /// + /// + /// + /// If neither nor contains a period (.), ChangeExtension adds the period. + /// + /// + /// The parameter can contain multiple periods and any valid path characters, and can be any length. If is , + /// the returned string contains the contents of with the last period and all characters following it removed. + /// + /// + /// If is an empty string, the returned path string contains the contents of with any characters following the last period removed. + /// + /// + /// If does not have an extension and is not , + /// the returned string contains followed by . + /// + /// + /// If is not and does not contain a leading period, the period is added. + /// + /// + /// If contains a multiple extension separated by multiple periods, + /// the returned string contains the contents of with the last period and all characters following it replaced by . + /// For example, if is "\Dir1\examples\pathtests.csx.txt" and is "cs", + /// the modified path is "\Dir1\examples\pathtests.csx.cs". + /// + /// + /// It is not possible to verify that the returned results are valid in all scenarios. For example, + /// if is empty, is appended. + /// + /// +#if NETSTANDARD2_1 || NET + [return: NotNullIfNotNull(nameof(path))] +#endif + public string? ChangeExtension(string? path, string? extension) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.ChangeExtension(path, extension); + + if (path == null) + return null; + + var subLength = path.Length; + if (subLength == 0) + return string.Empty; + + for (var i = path.Length - 1; i >= 0; i--) + { + var ch = path[i]; + + if (ch == '.') + { + subLength = i; + break; + } + + if (IsDirectorySeparator(ch)) + break; + } + + if (extension == null) + return path.Substring(0, subLength); + +#if NETCOREAPP3_0_OR_GREATER + var subpath = path.AsSpan(0, subLength); + return extension.StartsWith('.') ? + string.Concat(subpath, extension) : + string.Concat(subpath, ".", extension); +#else + var subPath = path.Substring(0, subLength); + if (extension.Length >= 1 && extension[0] == '.') + return string.Concat(subPath, extension); + return string.Concat(subPath, ".", extension); +#endif + } + + /// + /// Returns the directory information for the specified path represented by a character span. + /// + /// The path to retrieve the directory information from. + /// Directory information for , or an empty span if is , + /// an empty span, or a root (such as \, C:, or \server\share). + public ReadOnlySpan GetDirectoryName(ReadOnlySpan path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.GetDirectoryName(path); + + if (IsEffectivelyEmpty(path)) + return ReadOnlySpan.Empty; + + var end = GetDirectoryNameOffset(path); + return end >= 0 ? path.Slice(0, end) : ReadOnlySpan.Empty; + } + + private static int GetDirectoryNameOffset(ReadOnlySpan path) + { + var rootLength = GetRootLength(path); + var end = path.Length; + if (end <= rootLength) + return -1; + + while (end > rootLength && !IsDirectorySeparator(path[--end])) ; + + // Trim off any remaining separators (to deal with C:\foo\\bar) + while (end > rootLength && IsDirectorySeparator(path[end - 1])) + end--; + + return end; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs new file mode 100644 index 0000000..2bfb737 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs @@ -0,0 +1,28 @@ +using System; +using AnakinRaW.CommonUtilities.FileSystem.Normalization; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + /// + /// Rewrites in place so all directory separators are unified + /// to the host's native form. + /// + /// + /// Both "/" and "\" are recognized as directory separators on every host — + /// matching the Windows-like path semantics this file system simulates on Linux. The output + /// uses the System's default directory separator. + /// + /// The buffer whose contents are rewritten in place. + internal void NormalizePath(ref ValueStringBuilder stringBuilder) + { + NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length)); + } + + private static void NormalizePath(Span path) + { + PathNormalizer.Normalize(path, path, PGFileSystemDirectorySeparatorNormalizeOptions); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs new file mode 100644 index 0000000..4afa311 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs @@ -0,0 +1,51 @@ +using System; +using System.Runtime.InteropServices; +using AnakinRaW.CommonUtilities.FileSystem; +using AnakinRaW.CommonUtilities.FileSystem.Normalization; + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + /// + /// Determines whether two file system paths are considered equal. + /// + /// The first path to compare. + /// The second path to compare. + /// + /// if the paths are considered equal; otherwise, . + /// + public bool PathsAreEqual(string pathA, string pathB) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.AreEqual(pathA, pathB); + + var normalizedA = PathNormalizer.Normalize(pathA, PGFileSystemDirectorySeparatorNormalizeOptions); + var normalizedB = PathNormalizer.Normalize(pathB, PGFileSystemDirectorySeparatorNormalizeOptions); + + var fullA = _underlyingFileSystem.Path.GetFullPath(normalizedA); + var fullB = _underlyingFileSystem.Path.GetFullPath(normalizedB); + + return PathsEqual(fullA.AsSpan(), fullB.AsSpan(), Math.Max(fullA.Length, fullB.Length)); + } + + private static bool PathsEqual(ReadOnlySpan path1, ReadOnlySpan path2, int length) + { + if (path1.Length < length || path2.Length < length) + return false; + + for (var i = 0; i < length; i++) + { + if (!PathCharEqual(path1[i], path2[i])) + return false; + } + return true; + } + + private static bool PathCharEqual(char x, char y) + { + if (IsDirectorySeparator(x) && IsDirectorySeparator(y)) + return true; + return char.ToUpperInvariant(x) == char.ToUpperInvariant(y); + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs new file mode 100644 index 0000000..c7da143 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -0,0 +1,76 @@ +using System; +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + private FileExistsStrategy _strategy; + + /// + /// Switches the active file-exists strategy to one that issues a Win32 CreateFileA call per lookup. + /// + /// + /// Supported on Windows hosts only. Each call re-stats the file with no caching. + /// + /// The host is not Windows. + public void UseWindowsStrategy() => SwapStrategy(CreateWindowsStrategy()); + + /// + /// Switches the active file-exists strategy to a case-folding, component-by-component walk. + /// + /// + /// + /// Selecting this strategy directly is rarely correct. Prefer + /// on non-Windows hosts and + /// on Windows. This method exists primarily to support the search engine used internally by + /// for paths outside the game directory. + /// + /// Provides full mediation: every lookup re-walks the path with no caching. + /// + public void UseWineStrategy() => SwapStrategy(new WineFileExistsStrategy(_underlyingFileSystem)); + + /// + /// Switches the active file-exists strategy to an immutable per-directory snapshot scoped to the game directory. + /// + /// + /// Lookups under the game directory are answered from a directory snapshot taken on first + /// access. Lookups outside the game directory delegate to an underlying strategy. + /// + /// + /// to delegate outside-game-directory lookups to the Windows + /// CreateFileA strategy; to delegate them to the Wine search + /// engine; to pick the Windows strategy on Windows hosts and the Wine + /// strategy otherwise. + /// + /// + /// is and the host is not Windows. + /// + public void UseVirtualStrategy(bool? windowsFallback = null) + { + var useWindows = windowsFallback ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + FileExistsStrategy fallback = useWindows + ? CreateWindowsStrategy() + : new WineFileExistsStrategy(_underlyingFileSystem); + UseVirtualStrategy(fallback); + } + + internal void UseVirtualStrategy(FileExistsStrategy underlying) + => SwapStrategy(new VirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + + private WindowsFileExistsStrategy CreateWindowsStrategy() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + throw new PlatformNotSupportedException( + "The Windows file-exists strategy relies on Win32 CreateFileA and is only supported on Windows hosts."); + return new WindowsFileExistsStrategy(_underlyingFileSystem); + } + + private void SwapStrategy(FileExistsStrategy next) + { + var old = _strategy; + _strategy = next; + old?.Dispose(); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs new file mode 100644 index 0000000..ce47d47 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -0,0 +1,135 @@ +using AnakinRaW.CommonUtilities.FileSystem.Normalization; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.IO; +using System.IO.Abstractions; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; + +namespace PG.StarWarsGame.Engine.IO; + +/// +/// A file system abstraction for the Petroglyph game engine. +/// +/// +/// This file system abstraction simulates Windows-like behavior for all its public methods on Linux, +/// such as correct handling of paths containing backslashes ("\"). On Windows itself the behavior is unchanged. +/// +public sealed partial class PetroglyphFileSystem +{ + private const char DirectorySeparatorChar = '/'; + private const char AltDirectorySeparatorChar = '\\'; + + // ReSharper disable once InconsistentNaming + private static readonly PathNormalizeOptions PGFileSystemDirectorySeparatorNormalizeOptions = new() + { + TreatBackslashAsSeparator = true, // Ensure that we treat backslashes as separators on Linux + UnifyDirectorySeparators = true, + UnifySeparatorKind = DirectorySeparatorKind.System + }; + + private readonly IFileSystem _underlyingFileSystem; + + /// + /// Gets the underlying file system abstraction. + /// + public IFileSystem UnderlyingFileSystem => _underlyingFileSystem; + + /// + /// Initializes a new instance of the class. + /// + /// The used to resolve dependencies required by the file system. + /// is . + public PetroglyphFileSystem(IServiceProvider serviceProvider) + { + if (serviceProvider == null) + throw new ArgumentNullException(nameof(serviceProvider)); + _underlyingFileSystem = serviceProvider.GetRequiredService(); + + _strategy = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new WindowsFileExistsStrategy(_underlyingFileSystem) + : new VirtualFileExistsStrategy(_underlyingFileSystem, new WineFileExistsStrategy(_underlyingFileSystem)); + } + + /// + /// Determines whether the specified path ends with a directory separator character. + /// + /// The path to check for a trailing directory separator. + /// + /// if the path ends with a directory separator character; otherwise, . + /// + /// + /// This method always considers both '/' and '\\' as valid directory separator characters. + /// + public bool HasTrailingDirectorySeparator(ReadOnlySpan path) + { + return path.Length > 0 && IsDirectorySeparator(path[path.Length - 1]); + } + + internal FileSystemStream OpenRead(string filePath) + { + return _underlyingFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + } + + private static bool IsPathRooted(ReadOnlySpan path) + { + // The original implementation, obviously, also checks for drive signatures (e.g, c:, X:). + // We don't expect such paths ever when running in linux mode, so we simply ignore these + var length = path.Length; + return length >= 1 && IsDirectorySeparator(path[0]); + } + + internal static ReadOnlySpan GetPathRoot(ReadOnlySpan path) + { + if (IsEffectivelyEmpty(path)) + return ReadOnlySpan.Empty; + + var pathRoot = GetRootLength(path); + return pathRoot <= 0 ? ReadOnlySpan.Empty : path.Slice(0, pathRoot); + } + + /// + /// Returns the length of the path's root: 1 for a leading separator (/foo), or 3 for a + /// Windows-style drive root (C:\foo or C:/foo). 0 otherwise. Both / and \ + /// are accepted as separators so this is safe to call on host-OS paths from either platform. + /// + private static int GetRootLength(ReadOnlySpan path) + { + if (path.Length == 0) + return 0; + + if (IsDirectorySeparator(path[0])) + return 1; + + if (path.Length >= 3 && IsAsciiLetter(path[0]) && path[1] == ':' && IsDirectorySeparator(path[2])) + return 3; + + return 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAsciiLetter(char c) + { + return c is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + } + + private static bool IsEffectivelyEmpty(ReadOnlySpan path) + { + if (path.IsEmpty) + return true; + + foreach (var c in path) + { + if (c != ' ') + return false; + } + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsDirectorySeparator(char c) + { + return c is DirectorySeparatorChar or AltDirectorySeparatorChar; + } +} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj new file mode 100644 index 0000000..02421b1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -0,0 +1,25 @@ + + + netstandard2.0;netstandard2.1;net10.0 + PG.StarWarsGame.Engine + PG.StarWarsGame.Engine.FileSystem + PG.StarWarsGame.Engine.FileSystem + AlamoEngineTools.PG.StarWarsGame.Engine.FileSystem + alamo,petroglyph,glyphx + + + + true + true + + + true + snupkg + true + preview + + + + + + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/LowLevelPath.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/LowLevelPath.cs new file mode 100644 index 0000000..c595f94 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/LowLevelPath.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace PG.StarWarsGame.Engine.Utilities; + +internal static class LowLevelPath +{ + public static readonly bool IsHostFileSystemCaseSensitive = + !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsDirectorySeparator(char c) + { + return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + } + + public static int GetCommonDirectoryPrefixLength(ReadOnlySpan path, ReadOnlySpan directory) + { + var minLen = Math.Min(path.Length, directory.Length); + var lastSlash = 0; + var caseSensitive = IsHostFileSystemCaseSensitive; + int i; + + for (i = 0; i < minLen; i++) + { + var pc = path[i]; + var dc = directory[i]; + var pcIsSep = IsDirectorySeparator(pc); + + var charsEqual = pc == dc || (!caseSensitive && char.ToUpperInvariant(pc) == char.ToUpperInvariant(dc)); + + if (!charsEqual && !(pcIsSep && IsDirectorySeparator(dc))) + break; + + if (pcIsSep) + lastSlash = i + 1; + } + + if (i == minLen) + { + if (path.Length == directory.Length + || (i == directory.Length && IsDirectorySeparator(path[i])) + || (i == path.Length && IsDirectorySeparator(directory[i]))) + return i; + } + + return lastSlash; + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs similarity index 95% rename from src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs rename to src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs index 0c6b95c..167c3cd 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs @@ -6,6 +6,7 @@ namespace PG.StarWarsGame.Engine.Utilities; +[DebuggerDisplay("{DebuggerDisplay,nq}")] internal ref struct ValueStringBuilder { private char[]? _arrayToReturnToPool; @@ -82,6 +83,9 @@ public ref char this[int index] return ref _chars[index]; } } + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay => AsSpan().ToString(); public override string ToString() { @@ -231,22 +235,6 @@ public void Append(char c, int count) _pos += count; } - public unsafe void Append(char* value, int length) - { - var pos = _pos; - if (pos > _chars.Length - length) - { - Grow(length); - } - - var dst = _chars.Slice(_pos, length); - for (var i = 0; i < dst.Length; i++) - { - dst[i] = *value++; - } - _pos += length; - } - public void Append(scoped ReadOnlySpan value) { var pos = _pos; @@ -293,13 +281,13 @@ private void Grow(int additionalCapacityBeyondPos) Debug.Assert(additionalCapacityBeyondPos > 0); Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); - const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + const uint arrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try // to double the size if possible, bounding the doubling to not go beyond the max array length. var newCapacity = (int)Math.Max( (uint)(_pos + additionalCapacityBeyondPos), - Math.Min((uint)_chars.Length * 2, ArrayMaxLength)); + Math.Min((uint)_chars.Length * 2, arrayMaxLength)); // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. // This could also go negative if the actual required length wraps around. diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs index 4835ed3..69e5147 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs @@ -196,7 +196,7 @@ private void SetMegaTexture() if (Components.FirstOrDefault(x => x is CommandBarShellComponent) is null) return; // Note: The tag is not used by the engine - var mtdPath = FileSystem.Path.Combine("DATA\\ART\\TEXTURES", $"{CommandBarConstants.MegaTextureBaseName}.mtd"); + var mtdPath = PGFileSystem.CombinePath("DATA\\ART\\TEXTURES", $"{CommandBarConstants.MegaTextureBaseName}.mtd"); using var megaTexture = GameRepository.TryOpenFile(mtdPath); try @@ -211,7 +211,7 @@ private void SetMegaTexture() } GameRepository.TextureRepository.FileExists($"{CommandBarConstants.MegaTextureBaseName}.tga", false, out _, out var actualFilePath); - MegaTextureFileName = FileSystem.Path.GetFileName(actualFilePath); + MegaTextureFileName = PGFileSystem.GetFileName(actualFilePath); } private void SetComponentGroup(IEnumerable components) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs index c3dd55d..3213387 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO.Abstractions; using System.Threading; using System.Threading.Tasks; using AnakinRaW.CommonUtilities.Collections; @@ -8,6 +7,7 @@ using Microsoft.Extensions.Logging; using PG.Commons.Hashing; using PG.StarWarsGame.Engine.ErrorReporting; +using PG.StarWarsGame.Engine.IO; using PG.StarWarsGame.Engine.IO.Repositories; namespace PG.StarWarsGame.Engine; @@ -37,7 +37,9 @@ internal abstract class GameManagerBase private bool _initialized; private protected readonly GameRepository GameRepository; protected readonly IServiceProvider ServiceProvider; - protected readonly IFileSystem FileSystem; + + // ReSharper disable once InconsistentNaming + protected readonly PetroglyphFileSystem PGFileSystem; protected readonly ILogger? Logger; protected readonly GameEngineErrorReporterWrapper ErrorReporter; @@ -55,7 +57,7 @@ protected GameManagerBase( ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); EngineType = repository.EngineType; Logger = serviceProvider.GetService()?.CreateLogger(GetType()); - FileSystem = serviceProvider.GetRequiredService(); + PGFileSystem = repository.PGFileSystem; ErrorReporter = errorReporter ?? throw new ArgumentNullException(nameof(errorReporter)); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs index 8bd517d..a8ddf4c 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs @@ -29,7 +29,7 @@ private void ParseGameObjectDatabases() }, ServiceProvider, ErrorReporter); var xmlFileList = gameParser.ParseFileList(@"DATA\XML\GAMEOBJECTFILES.XML").Files - .Select(x => FileSystem.Path.Combine(@".\DATA\XML\", x)) + .Select(x => PGFileSystem.CombinePath(@".\DATA\XML\", x)) .Where(VerifyFilePathLength) .ToList(); @@ -111,7 +111,7 @@ private void PostLoadFixup() private bool IsSameFile(string filePathA, string filePathB) { - return FileSystem.Path.AreEqual(filePathA, filePathB); + return PGFileSystem.PathsAreEqual(filePathA, filePathB); } private void OnGameObjectParsed(object sender, GameObjectParsedEventArgs e) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs index d6b4ca9..60ee820 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs @@ -116,39 +116,31 @@ public bool TryGetTextureEntry(string component, GuiComponentType key, out Compo return textures.TryGetValue(key, out texture); } - + + private static bool IsNone(string texture) + { + return texture.Equals("none", StringComparison.OrdinalIgnoreCase); + } + public bool TextureExists( in ComponentTextureEntry textureInfo, out GuiTextureOrigin textureOrigin, out bool isNone, bool buttonMiddleInRepoMode = false) { - if (textureInfo.Texture == "none") - { - textureOrigin = default; - isNone = true; - return false; - } - isNone = false; - - // Apparently, Scanlines only use the repository and not the MTD. + + // Scanlines use the repository and not the MTD. if (textureInfo.ComponentType == GuiComponentType.Scanlines) - { - textureOrigin = GuiTextureOrigin.Repository; - return GameRepository.TextureRepository.FileExists(textureInfo.Texture); - } + return GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone); // The engine uses ButtonMiddle to switch to the special button mode. // It searches first in the repo and then falls back to MTD // (but only for this very type; the variants do not fallback to MTD). if (textureInfo.ComponentType == GuiComponentType.ButtonMiddle) { - if (GameRepository.TextureRepository.FileExists(textureInfo.Texture)) - { - textureOrigin = GuiTextureOrigin.Repository; + if (GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone)) return true; - } } // The engine does not fallback to MTD once it is in this special Button mode. @@ -156,10 +148,7 @@ public bool TextureExists( GuiComponentType.ButtonMiddleDisabled or GuiComponentType.ButtonMiddleMouseOver or GuiComponentType.ButtonMiddlePressed) - { - textureOrigin = GuiTextureOrigin.Repository; - return GameRepository.TextureRepository.FileExists(textureInfo.Texture); - } + return GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone); if (textureInfo.Texture.Length <= 63 && MtdFile is not null && _megaTextureExists) { @@ -173,12 +162,25 @@ GuiComponentType.ButtonMiddleMouseOver or // The background image for frames include a fallback the repository. if (textureInfo.ComponentType == GuiComponentType.FrameBackground) - { - textureOrigin = GuiTextureOrigin.Repository; - return GameRepository.TextureRepository.FileExists(textureInfo.Texture); - } + return GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone); textureOrigin = default; return false; } + + private bool GuiSpecialTextureExists( + in ComponentTextureEntry textureInfo, + out GuiTextureOrigin textureOrigin, + out bool isNone) + { + isNone = IsNone(textureInfo.Texture); + if (isNone) + { + textureOrigin = default; + return false; + } + + textureOrigin = GuiTextureOrigin.Repository; + return GameRepository.TextureRepository.FileExists(textureInfo.Texture); + } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs index 2bac738..a22aff5 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs @@ -36,10 +36,9 @@ protected override Task InitializeCoreAsync(CancellationToken token) GameManager = ToString(), Message = "Unable to parse GuiDialogs.xml" }); - return; } - InitializeTextures(guiDialogs.TextureData); + InitializeTextures(guiDialogs?.TextureData ?? new GuiDialogsXmlTextureData([], default)); GuiDialogsXml = guiDialogs; }, token); } @@ -146,7 +145,7 @@ private void InitializeMegaTextures(GuiDialogsXmlTextureData guiDialogs) } else { - var mtdPath = FileSystem.Path.Combine("DATA\\ART\\TEXTURES", $"{guiDialogs.MegaTexture}.mtd"); + var mtdPath = PGFileSystem.CombinePath("DATA\\ART\\TEXTURES", $"{guiDialogs.MegaTexture}.mtd"); if (mtdPath.Length > MegaTextureMaxFilePathLength) { diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs index 32fff7a..09183c8 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs @@ -7,9 +7,12 @@ public interface IGameRepository : IRepository /// /// Gets the full qualified path of this repository with a trailing directory separator /// - public string Path { get; } + string Path { get; } + + // ReSharper disable once InconsistentNaming + PetroglyphFileSystem PGFileSystem { get; } - public GameEngineType EngineType { get; } + GameEngineType EngineType { get; } IRepository EffectsRepository { get; } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs index fd95e5b..44a8b11 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs @@ -1,18 +1,15 @@ -using Microsoft.Extensions.DependencyInjection; -using PG.StarWarsGame.Engine.IO.Repositories; +using PG.StarWarsGame.Engine.IO.Repositories; using PG.StarWarsGame.Engine.Utilities; using System; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.IO.Abstractions; namespace PG.StarWarsGame.Engine.IO; -internal abstract class MultiPassRepository(GameRepository baseRepository, IServiceProvider serviceProvider) : IRepository +internal abstract class MultiPassRepository(GameRepository baseRepository) : IRepository { - protected readonly IFileSystem FileSystem = serviceProvider.GetRequiredService(); protected readonly GameRepository BaseRepository = baseRepository; - + public Stream OpenFile(string filePath, bool megFileOnly = false) { return OpenFile(filePath.AsSpan(), megFileOnly); @@ -35,16 +32,22 @@ public bool FileExists(string filePath, bool megFileOnly, out bool inMeg, [NotNu { var multiPassSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); var destinationSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - var result = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); - var fileFound = result.FileFound; - inMeg = result.InMeg; - if (!fileFound) - actualFilePath = null; - else - actualFilePath = result.InMeg ? result.MegDataEntryReference.Path : result.FilePath.ToString(); - multiPassSb.Dispose(); - destinationSb.Dispose(); - return fileFound; + try + { + var result = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); + var fileFound = result.FileFound; + inMeg = result.InMeg; + if (!fileFound) + actualFilePath = null; + else + actualFilePath = result.InMeg ? result.MegDataEntryReference.Path : result.FilePath.ToString(); + return fileFound; + } + finally + { + multiPassSb.Dispose(); + destinationSb.Dispose(); + } } public bool FileExists(ReadOnlySpan filePath, bool megFileOnly = false) @@ -56,12 +59,17 @@ public bool FileExists(ReadOnlySpan filePath, bool megFileOnly, out bool p { var multiPassSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); var destinationSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - var result = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); - var fileFound = result.FileFound; - pathTooLong = result.PathTooLong; - multiPassSb.Dispose(); - destinationSb.Dispose(); - return fileFound; + try + { + var result = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); + pathTooLong = result.PathTooLong; + return result.FileFound; + } + finally + { + multiPassSb.Dispose(); + destinationSb.Dispose(); + } } private protected abstract FileFoundInfo MultiPassAction( @@ -79,10 +87,15 @@ private protected abstract FileFoundInfo MultiPassAction( { var multiPassSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); var destinationSb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - var fileFound = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); - var result = BaseRepository.OpenFileCore(fileFound); - multiPassSb.Dispose(); - destinationSb.Dispose(); - return result; + try + { + var fileFound = MultiPassAction(filePath, ref multiPassSb, ref destinationSb, megFileOnly); + return BaseRepository.OpenFileCore(fileFound); + } + finally + { + multiPassSb.Dispose(); + destinationSb.Dispose(); + } } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs index 22f4949..1557a7e 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs @@ -1,11 +1,9 @@ using System; -using PG.StarWarsGame.Engine.IO.Utilities; using PG.StarWarsGame.Engine.Utilities; namespace PG.StarWarsGame.Engine.IO.Repositories; -internal class EffectsRepository(GameRepository baseRepository, IServiceProvider serviceProvider) - : MultiPassRepository(baseRepository, serviceProvider) +internal class EffectsRepository(GameRepository baseRepository) : MultiPassRepository(baseRepository) { private static readonly string[] LookupPaths = [ @@ -73,7 +71,7 @@ private FileFoundInfo FindEffect( multiPassStringBuilder.Length = 0; if (directory != ReadOnlySpan.Empty) - FileSystem.Path.Join(directory, strippedName, ref multiPassStringBuilder); + BaseRepository.PGFileSystem.JoinPath(directory, strippedName, ref multiPassStringBuilder); else multiPassStringBuilder.Append(strippedName); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs index a1b07fb..fbe9812 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs @@ -25,9 +25,9 @@ public FocGameRepository(GameLocations gameLocations, GameEngineErrorReporterWra if (firstFallback is not null) { var eawMegs = LoadMegArchivesFromXml(firstFallback); - var eawPatch = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\Patch.meg")); - var eawPatch2 = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\Patch2.meg")); - var eaw64Patch = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\64Patch.meg")); + var eawPatch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/Patch.meg")); + var eawPatch2 = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/Patch2.meg")); + var eaw64Patch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/64Patch.meg")); megsToConsider.AddRange(eawMegs); if (eawPatch is not null) @@ -39,9 +39,9 @@ public FocGameRepository(GameLocations gameLocations, GameEngineErrorReporterWra } var focOrModMegs = LoadMegArchivesFromXml("."); - var focPatch = LoadMegArchive("Data\\Patch.meg"); - var focPatch2 = LoadMegArchive("Data\\Patch2.meg"); - var foc64Patch = LoadMegArchive("Data\\64Patch.meg"); + var focPatch = LoadMegArchive("Data/Patch.meg"); + var focPatch2 = LoadMegArchive("Data/Patch2.meg"); + var foc64Patch = LoadMegArchive("Data/64Patch.meg"); megsToConsider.AddRange(focOrModMegs); if (focPatch is not null) @@ -62,7 +62,7 @@ protected internal override FileFoundInfo FindFile(ReadOnlySpan filePath, if (fileFoundInfo.FileFound) return fileFoundInfo; - fileFoundInfo = FindFileCore(filePath, ref pathStringBuilder); + fileFoundInfo = FindFileCore(filePath, ref pathStringBuilder, GameDirectory.AsSpan()); if (fileFoundInfo.FileFound) return fileFoundInfo; } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs index d35254d..7d0f1a1 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs @@ -1,6 +1,4 @@ -using AnakinRaW.CommonUtilities.FileSystem; -using Microsoft.Extensions.Logging; -using PG.StarWarsGame.Engine.IO.Utilities; +using Microsoft.Extensions.Logging; using PG.StarWarsGame.Engine.Utilities; using PG.StarWarsGame.Files.MEG.Binary; using System; @@ -8,8 +6,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; namespace PG.StarWarsGame.Engine.IO.Repositories; @@ -21,7 +17,7 @@ public bool FileExists(string filePath, string[] extensions, bool megFileOnly = { foreach (var extension in extensions) { - var newPath = FileSystem.Path.ChangeExtension(filePath, extension); + var newPath = PGFileSystem.ChangeExtension(filePath, extension); if (FileExists(newPath, megFileOnly)) return true; } @@ -36,15 +32,21 @@ public bool FileExists(string filePath, bool megFileOnly = false) public bool FileExists(string filePath, bool megFileOnly, out bool inMeg, [NotNullWhen(true)] out string? actualFilePath) { var sb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - var fileFound = FindFile(filePath, ref sb, megFileOnly); - var fileExists = fileFound.FileFound; - inMeg = fileFound.InMeg; - if (!fileExists) - actualFilePath = null; - else - actualFilePath = fileFound.InMeg ? fileFound.MegDataEntryReference.Path : fileFound.FilePath.ToString(); - sb.Dispose(); - return fileExists; + try + { + var fileFound = FindFile(filePath, ref sb, megFileOnly); + var fileExists = fileFound.FileFound; + inMeg = fileFound.InMeg; + if (!fileExists) + actualFilePath = null; + else + actualFilePath = fileFound.InMeg ? fileFound.MegDataEntryReference.Path : fileFound.FilePath.ToString(); + return fileExists; + } + finally + { + sb.Dispose(); + } } public bool FileExists(ReadOnlySpan filePath, bool megFileOnly = false) @@ -55,11 +57,17 @@ public bool FileExists(ReadOnlySpan filePath, bool megFileOnly = false) public bool FileExists(ReadOnlySpan filePath, bool megFileOnly, out bool pathTooLong) { var sb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - var fileFound = FindFile(filePath, ref sb, megFileOnly); - var fileExists = fileFound.FileFound; - pathTooLong = fileFound.PathTooLong; - sb.Dispose(); - return fileExists; + try + { + var fileFound = FindFile(filePath, ref sb, megFileOnly); + var fileExists = fileFound.FileFound; + pathTooLong = fileFound.PathTooLong; + return fileExists; + } + finally + { + sb.Dispose(); + } } public Stream OpenFile(string filePath, bool megFileOnly = false) @@ -84,12 +92,24 @@ public Stream OpenFile(ReadOnlySpan filePath, bool megFileOnly = false) public Stream? TryOpenFile(ReadOnlySpan filePath, bool megFileOnly) { var sb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - var fileFoundInfo = FindFile(filePath, ref sb, megFileOnly); - var fileStream = OpenFileCore(fileFoundInfo); - sb.Dispose(); - return fileStream; + try + { + var fileFoundInfo = FindFile(filePath, ref sb, megFileOnly); + return OpenFileCore(fileFoundInfo); + } + finally + { + sb.Dispose(); + } } - + + /// + /// The core routine for finding a file using the game's specific lookup rules. + /// + /// The file path. + /// The string builder used for constructing the file path. + /// Whether to only search for files in MEG archives. + /// The file found information. protected internal abstract FileFoundInfo FindFile(ReadOnlySpan filePath, ref ValueStringBuilder pathStringBuilder, bool megFileOnly = false); @@ -99,23 +119,30 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath) if (filePath.Length > PGConstants.MaxMegEntryPathLength) { - Logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FilePath}'", filePath.ToString()); + _logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FileName}'", filePath.ToString()); return default; } - + + var sb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); Span fileNameSpan = stackalloc char[PGConstants.MaxMegEntryPathLength]; + bool normalized; + int length; + try + { + sb.Append(filePath); + PGFileSystem.NormalizePath(ref sb); + normalized = _megPathNormalizer.TryNormalize(sb.AsSpan(), fileNameSpan, out length); + } + finally + { + sb.Dispose(); + } - if (!_megPathNormalizer.TryNormalize(filePath, fileNameSpan, out var length)) + if (!normalized) return default; var fileName = fileNameSpan.Slice(0, length); - if (fileName.Length > PGConstants.MaxMegEntryPathLength) - { - Logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters after normalization: '{FileName}'", fileName.ToString()); - return default; - } - var crc = _crc32HashingService.GetCrc32(fileName, MegFileConstants.MegDataEntryPathEncoding); var entry = MasterMegArchive!.EntriesWithCrc(crc).FirstOrDefault(); @@ -123,41 +150,13 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath) return new FileFoundInfo(entry); } - protected FileFoundInfo FindFileCore(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder) + protected FileFoundInfo FindFileCore( + ReadOnlySpan filePath, + ref ValueStringBuilder stringBuilder, + ReadOnlySpan lookupDirectory) { - bool exists; - - stringBuilder.Length = 0; - - if (FileSystem.Path.IsPathFullyQualified(filePath)) - stringBuilder.Append(filePath); - else - FileSystem.Path.Join(GameDirectory.AsSpan(), filePath, ref stringBuilder); - - var actualFilePath = stringBuilder.AsSpan(); - - // We accept a *possible* difference here between platforms, - // unless it's proven the differences are too significant. - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - exists = FileSystem.File.Exists(actualFilePath.ToString()); - else - { - // We *could* also use the slightly faster GetFileAttributesA. - // However, CreateFileA and GetFileAttributesA are implemented complete independent. - // The game uses CreateFileA. - // Thus, we should stick to what the game uses in order to be as close to the engine as possible - // NB: It's also important that the string builder is zero-terminated, as otherwise CreateFileA might get invalid data. - var fileHandle = CreateFile( - in stringBuilder.GetPinnableReference(true), - FileAccess.Read, - FileShare.Read, - IntPtr.Zero, - FileMode.Open, - FileAttributes.Normal, IntPtr.Zero); - - exists = IsValidAndClose(fileHandle); - } - return !exists ? new FileFoundInfo() : new FileFoundInfo(actualFilePath); + var exists = PGFileSystem.FileExists(filePath, ref stringBuilder, lookupDirectory); + return !exists ? default : new FileFoundInfo(stringBuilder.AsSpan()); } protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList fallbackPaths, ref ValueStringBuilder pathStringBuilder) @@ -173,32 +172,43 @@ protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList path, out int cutoffLength) + + private bool PathStartsWithDataDirectory(ReadOnlySpan path, out int cutoffLength) { cutoffLength = 0; if (path.Length < 5) return false; - foreach (var prefix in DataPathPrefixes) + + var sb = new ValueStringBuilder(stackalloc char[265]); + sb.Append(path); + PGFileSystem.NormalizePath(ref sb); + try { - if (path.StartsWith(prefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) + foreach (var prefix in DataPathPrefixes) { - if (path[0] == '.') - cutoffLength = 2; - return true; + if (sb.AsSpan().StartsWith(prefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + if (path[0] == '.') + cutoffLength = 2; + return true; + } } + return false; + } + finally + { + sb.Dispose(); } - return false; } internal Stream? OpenFileCore(FileFoundInfo fileFoundInfo) @@ -209,29 +219,6 @@ private static bool PathStartsWithDataDirectory(ReadOnlySpan path, out int if (fileFoundInfo.InMeg) return _megExtractor.GetData(fileFoundInfo.MegDataEntryReference.Location); - return FileSystem.FileStream.New(fileFoundInfo.FilePath.ToString(), FileMode.Open, FileAccess.Read, FileShare.Read); + return PGFileSystem.OpenRead(fileFoundInfo.FilePath.ToString()); } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsValidAndClose(IntPtr handle) - { - var isValid = handle != IntPtr.Zero && handle != new IntPtr(-1); - if (isValid) - CloseHandle(handle); - return isValid; - } - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] - private static extern IntPtr CreateFile( - in char lpFileName, - [MarshalAs(UnmanagedType.U4)] FileAccess access, - [MarshalAs(UnmanagedType.U4)] FileShare share, - IntPtr securityAttributes, - [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition, - [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes, - IntPtr templateFile); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CloseHandle(IntPtr hObject); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs index ec40d64..c96cd67 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs @@ -5,8 +5,8 @@ using AnakinRaW.CommonUtilities.FileSystem; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using PG.Commons.Hashing; -using PG.Commons.Services; using PG.StarWarsGame.Engine.ErrorReporting; using PG.StarWarsGame.Engine.Localization; using PG.StarWarsGame.Files.MEG.Data.Archives; @@ -19,7 +19,7 @@ namespace PG.StarWarsGame.Engine.IO.Repositories; -internal abstract partial class GameRepository : ServiceBase, IGameRepository +internal abstract partial class GameRepository : IGameRepository { private readonly IMegFileService _megFileService; private readonly IMegFileExtractor _megExtractor; @@ -28,6 +28,9 @@ internal abstract partial class GameRepository : ServiceBase, IGameRepository private readonly IVirtualMegArchiveBuilder _virtualMegBuilder; private readonly IGameLanguageManagerProvider _languageManagerProvider; private readonly GameEngineErrorReporterWrapper _errorReporter; + + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; protected readonly string GameDirectory; @@ -35,23 +38,32 @@ internal abstract partial class GameRepository : ServiceBase, IGameRepository protected readonly IList FallbackPaths = new List(); private bool _sealed; + + public PetroglyphFileSystem PGFileSystem { get; } public string Path { get; } + public abstract GameEngineType EngineType { get; } public IRepository EffectsRepository { get; } public IRepository TextureRepository { get; } public IRepository ModelRepository { get; } - + private readonly List _loadedMegFiles = new(); protected IVirtualMegArchive? MasterMegArchive { get; private set; } - protected GameRepository(GameLocations gameLocations, GameEngineErrorReporterWrapper errorReporter, IServiceProvider serviceProvider) : base(serviceProvider) + protected GameRepository( + GameLocations gameLocations, + GameEngineErrorReporterWrapper errorReporter, + IServiceProvider serviceProvider) { if (gameLocations == null) throw new ArgumentNullException(nameof(gameLocations)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = serviceProvider.GetService()?.CreateLogger(GetType()) ?? NullLogger.Instance; + _megExtractor = serviceProvider.GetRequiredService(); _megFileService = serviceProvider.GetRequiredService(); _virtualMegBuilder = serviceProvider.GetRequiredService(); @@ -60,39 +72,44 @@ protected GameRepository(GameLocations gameLocations, GameEngineErrorReporterWra _languageManagerProvider = serviceProvider.GetRequiredService(); _errorReporter = errorReporter; + PGFileSystem = new PetroglyphFileSystem(serviceProvider); + foreach (var mod in gameLocations.ModPaths) { if (string.IsNullOrEmpty(mod)) throw new InvalidOperationException("Mods with empty paths are not valid."); - ModPaths.Add(FileSystem.Path.GetFullPath(mod)); + ModPaths.Add(PGFileSystem.UnderlyingFileSystem.Path.GetFullPath(mod)); } - GameDirectory = FileSystem.Path.GetFullPath(gameLocations.GamePath); + // NB: We are using the native file system here, because we want to make sure that + // the paths are normalized to the actual file system of the current system. + GameDirectory = PGFileSystem.UnderlyingFileSystem.Path.GetFullPath(gameLocations.GamePath); foreach (var fallbackPath in gameLocations.FallbackPaths) { if (string.IsNullOrEmpty(fallbackPath)) { - Logger.LogTrace("Skipping null or empty fallback path."); + _logger.LogTrace("Skipping null or empty fallback path."); continue; } - FallbackPaths.Add(FileSystem.Path.GetFullPath(fallbackPath)); + + FallbackPaths.Add(PGFileSystem.UnderlyingFileSystem.Path.GetFullPath(fallbackPath)); } - EffectsRepository = new EffectsRepository(this, serviceProvider); - TextureRepository = new TextureRepository(this, serviceProvider); - ModelRepository = new ModelRepository(this, serviceProvider); + EffectsRepository = new EffectsRepository(this); + TextureRepository = new TextureRepository(this); + ModelRepository = new ModelRepository(this); var path = ModPaths.Any() ? ModPaths.First() : GameDirectory; - if (!FileSystem.Path.HasTrailingDirectorySeparator(path)) - path += FileSystem.Path.DirectorySeparatorChar; - + if (!PGFileSystem.UnderlyingFileSystem.Path.HasTrailingDirectorySeparator(path)) + path += PGFileSystem.UnderlyingFileSystem.Path.DirectorySeparatorChar; + Path = path; } - + public void AddMegFiles(IList megFiles) { ThrowIfSealed(); @@ -116,9 +133,9 @@ public void AddMegFile(string megFile) if (megArchive is null) { if (IsSpeechMeg(megFile)) - Logger.LogDebug("Unable to find Speech MEG file at '{MegFile}'", megFile); + _logger.LogDebug("Unable to find Speech MEG file at '{MegFile}'", megFile); else - Logger.LogWarning("Unable to find MEG file at '{MegFile}'", megFile); + _logger.LogWarning("Unable to find MEG file at '{MegFile}'", megFile); return; } @@ -140,7 +157,7 @@ public bool IsLanguageInstalled(LanguageType language) foreach (var loadedMegFile in _loadedMegFiles) { - var file = FileSystem.Path.GetFileName(loadedMegFile.AsSpan()); + var file = PGFileSystem.UnderlyingFileSystem.Path.GetFileName(loadedMegFile.AsSpan()); var speechFileName = languageFiles.SpeechMegFileName.AsSpan(); if (file.Equals(speechFileName, StringComparison.OrdinalIgnoreCase)) @@ -159,8 +176,10 @@ public IEnumerable InitializeInstalledSfxMegFiles() var firstFallback = FallbackPaths.FirstOrDefault(); if (firstFallback is not null) { - var fallback2dNonLocalized = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG")); - var fallback3dNonLocalized = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG")); + var fallback2dNonLocalized = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path + .Combine(firstFallback, "DATA/AUDIO/SFX/SFX2D_NON_LOCALIZED.MEG")); + var fallback3dNonLocalized = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path + .Combine(firstFallback, "DATA/AUDIO/SFX/SFX3D_NON_LOCALIZED.MEG")); if (fallback2dNonLocalized is not null) megsToAdd.Add(fallback2dNonLocalized); @@ -169,8 +188,8 @@ public IEnumerable InitializeInstalledSfxMegFiles() megsToAdd.Add(fallback3dNonLocalized); } - var nonLocalized2d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG"); - var nonLocalized3d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG"); + var nonLocalized2d = LoadMegArchive("DATA/AUDIO/SFX/SFX2D_NON_LOCALIZED.MEG"); + var nonLocalized3d = LoadMegArchive("DATA/AUDIO/SFX/SFX3D_NON_LOCALIZED.MEG"); if (nonLocalized2d is not null) megsToAdd.Add(nonLocalized2d); @@ -191,7 +210,8 @@ public IEnumerable InitializeInstalledSfxMegFiles() if (firstFallback is not null) { - var fallback2dLang = LoadMegArchive(FileSystem.Path.Combine(firstFallback, languageFiles.Sfx2dMegFilePath)); + var fallback2dLang = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path + .Combine(firstFallback, languageFiles.Sfx2dMegFilePath)); if (fallback2dLang is not null) megsToAdd.Add(fallback2dLang); } @@ -202,7 +222,7 @@ public IEnumerable InitializeInstalledSfxMegFiles() } if (languages.Count == 0) - Logger.LogWarning("Unable to initialize any language."); + _logger.LogWarning("Unable to initialize any language."); AddMegFiles(megsToAdd); @@ -211,17 +231,17 @@ public IEnumerable InitializeInstalledSfxMegFiles() protected IList LoadMegArchivesFromXml(string lookupPath) { - var megFilesXmlPath = FileSystem.Path.Combine(lookupPath, "Data\\MegaFiles.xml"); + var megFilesXmlPath = PGFileSystem.CombinePath(lookupPath, "Data/MegaFiles.xml"); using var xmlStream = TryOpenFile(megFilesXmlPath); if (xmlStream is null) { - Logger.LogWarning("Unable to find MegaFiles.xml at '{LookupPath}'", lookupPath); + _logger.LogWarning("Unable to find MegaFiles.xml at '{LookupPath}'", lookupPath); return Array.Empty(); } - var parser = new XmlFileListParser(Services ,_errorReporter); + var parser = new XmlFileListParser(_serviceProvider ,_errorReporter); var megaFilesXml = parser.ParseFile(xmlStream); if (megaFilesXml is null) @@ -231,7 +251,7 @@ protected IList LoadMegArchivesFromXml(string lookupPath) foreach (var file in megaFilesXml.Files.Select(x => x.Trim())) { - var megPath = FileSystem.Path.Combine(lookupPath, file); + var megPath = PGFileSystem.CombinePath(lookupPath, file); var megFile = LoadMegArchive(megPath); if (megFile is not null) megs.Add(megFile); @@ -251,12 +271,12 @@ internal void Seal() if (megFileStream is not FileSystemStream fileSystemStream) { if (IsSpeechMeg(megPath)) - Logger.LogDebug("Unable to find Speech MEG file '{MegPath}'", megPath); + _logger.LogDebug("Unable to find Speech MEG file '{MegPath}'", megPath); else { var message = $"Unable to find MEG file '{megPath}'"; _errorReporter.Assert(EngineAssert.Create(EngineAssertKind.FileNotFound, megPath, [], message)); - Logger.LogWarning("Unable to find MEG file '{MegPath}'", megPath); + _logger.LogWarning("Unable to find MEG file '{MegPath}'", megPath); } return null; } @@ -271,7 +291,7 @@ internal void Seal() private bool IsSpeechMeg(string megFile) { - return FileSystem.Path.GetFileName(megFile.AsSpan()).EndsWith("Speech.meg".AsSpan(), StringComparison.OrdinalIgnoreCase); + return PGFileSystem.GetFileName(megFile.AsSpan()).EndsWith("Speech.meg".AsSpan(), StringComparison.OrdinalIgnoreCase); } private void ThrowIfSealed() @@ -294,8 +314,8 @@ public LanguageFiles(LanguageType language) { Language = language; var languageString = language.ToString().ToUpperInvariant(); - MasterTextDatFilePath = $"DATA\\TEXT\\MasterTextFile_{languageString}.DAT"; - Sfx2dMegFilePath = $"DATA\\AUDIO\\SFX\\SFX2D_{languageString}.MEG"; + MasterTextDatFilePath = $"DATA/TEXT/MasterTextFile_{languageString}.DAT"; + Sfx2dMegFilePath = $"DATA/AUDIO/SFX/SFX2D_{languageString}.MEG"; SpeechMegFileName = $"{languageString}SPEECH.MEG"; } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs index 1b6cb6d..e736852 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs @@ -1,15 +1,10 @@ using System; using System.Runtime.CompilerServices; -using PG.StarWarsGame.Engine.IO.Utilities; using PG.StarWarsGame.Engine.Utilities; -#if NETSTANDARD2_0 || NETFRAMEWORK -using AnakinRaW.CommonUtilities.FileSystem; -#endif namespace PG.StarWarsGame.Engine.IO.Repositories; -internal class ModelRepository(GameRepository baseRepository, IServiceProvider serviceProvider) - : MultiPassRepository(baseRepository, serviceProvider) +internal class ModelRepository(GameRepository baseRepository) : MultiPassRepository(baseRepository) { private protected override FileFoundInfo MultiPassAction( ReadOnlySpan filePath, @@ -28,8 +23,8 @@ private protected override FileFoundInfo MultiPassAction( var stripped = StripFileName(filePath); - var path = FileSystem.Path.GetDirectoryName(filePath); - FileSystem.Path.Join(path, stripped, ref reusableStringBuilder); + var path = BaseRepository.PGFileSystem.GetDirectoryName(filePath); + BaseRepository.PGFileSystem.JoinPath(path, stripped, ref reusableStringBuilder); reusableStringBuilder.Append(".ALO"); var alternatePath = reusableStringBuilder.AsSpan(); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs index 82f4045..a8e1432 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs @@ -3,8 +3,7 @@ namespace PG.StarWarsGame.Engine.IO.Repositories; -internal class TextureRepository(GameRepository baseRepository, IServiceProvider serviceProvider) : - MultiPassRepository(baseRepository, serviceProvider) +internal class TextureRepository(GameRepository baseRepository) : MultiPassRepository(baseRepository) { private static readonly string DdsExtension = ".dds"; private static readonly string TexturePath = "./Data/art/Textures/"; diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs deleted file mode 100644 index f012fdc..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Abstractions; - -namespace PG.StarWarsGame.Engine.IO.Utilities; - -// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing - -/// -/// Wraps to be used with -/// -internal sealed class DirectoryInfoGlobbingWrapper : Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase -{ - private readonly IFileSystem _fileSystem; - private readonly IDirectoryInfo _directoryInfo; - private readonly bool _isParentPath; - - /// - public override string Name => _isParentPath ? ".." : _directoryInfo.Name; - - /// - public override string FullName => _directoryInfo.FullName; - - /// - public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? ParentDirectory => - _directoryInfo.Parent is null - ? null - : new DirectoryInfoGlobbingWrapper(_fileSystem, _directoryInfo.Parent); - - /// - /// Construct a new instance of - /// - /// The filesystem - /// The directory - public DirectoryInfoGlobbingWrapper(IFileSystem fileSystem, IDirectoryInfo directoryInfo) - : this(fileSystem, directoryInfo, isParentPath: false) - { - } - - private DirectoryInfoGlobbingWrapper(IFileSystem fileSystem, IDirectoryInfo directoryInfo, bool isParentPath) - { - _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); - _directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo)); - _isParentPath = isParentPath; - } - - /// - public override IEnumerable EnumerateFileSystemInfos() - { - if (_directoryInfo.Exists) - { - IEnumerable fileSystemInfos; - try - { - fileSystemInfos = _directoryInfo.EnumerateFileSystemInfos("*", SearchOption.TopDirectoryOnly); - } - catch (DirectoryNotFoundException) - { - yield break; - } - - foreach (var fileSystemInfo in fileSystemInfos) - { - yield return fileSystemInfo switch - { - IDirectoryInfo directoryInfo => new DirectoryInfoGlobbingWrapper(_fileSystem, directoryInfo), - IFileInfo fileInfo => new FileInfoGlobbingWrapper(_fileSystem, fileInfo), - _ => throw new NotSupportedException() - }; - } - } - } - - /// - public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? GetDirectory(string path) - { - var isParentPath = string.Equals(path, "..", StringComparison.Ordinal); - - if (isParentPath) - { - return new DirectoryInfoGlobbingWrapper(_fileSystem, - _fileSystem.DirectoryInfo.New(Path.Combine(_directoryInfo.FullName, path)), isParentPath); - } - - var dirs = _directoryInfo.GetDirectories(path); - - return dirs switch - { - { Length: 1 } - => new DirectoryInfoGlobbingWrapper(_fileSystem, dirs[0], isParentPath), - { Length: 0 } => null, - // This shouldn't happen. The parameter name isn't supposed to contain wild card. - _ - => throw new InvalidOperationException( - $"More than one sub directories are found under {_directoryInfo.FullName} with name {path}." - ), - }; - } - - /// - public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.FileInfoBase GetFile(string path) - { - return new FileInfoGlobbingWrapper(_fileSystem, _fileSystem.FileInfo.New(Path.Combine(FullName, path))); - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs deleted file mode 100644 index fef3dd9..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.IO.Abstractions; - -namespace PG.StarWarsGame.Engine.IO.Utilities; - -// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing - -internal sealed class FileInfoGlobbingWrapper - : Microsoft.Extensions.FileSystemGlobbing.Abstractions.FileInfoBase -{ - private readonly IFileSystem _fileSystem; - private readonly IFileInfo _fileInfo; - - /// - public override string Name => _fileInfo.Name; - - /// - public override string FullName => _fileInfo.FullName; - - /// - public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? ParentDirectory => - _fileInfo.Directory is null - ? null - : new DirectoryInfoGlobbingWrapper(_fileSystem, _fileInfo.Directory); - - /// - /// InitializeAsync a new instance - /// - /// The filesystem - /// The file - public FileInfoGlobbingWrapper(IFileSystem fileSystem, IFileInfo fileInfo) - { - _fileSystem = fileSystem; - _fileInfo = fileInfo; - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs deleted file mode 100644 index 48ddfa1..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO.Abstractions; -using System.Linq; -using Microsoft.Extensions.FileSystemGlobbing; - -namespace PG.StarWarsGame.Engine.IO.Utilities; - -// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing - -/// -/// Provides extensions for to support -/// -internal static class MatcherExtensions -{ - /// - /// Searches the directory specified for all files matching patterns added to this instance of - /// - /// The matcher - /// The filesystem - /// The root directory for the search - /// Always returns instance of , even if no files were matched - public static PatternMatchingResult Execute(this Matcher matcher, IFileSystem fileSystem, string directoryPath) - { - if (matcher == null) - throw new ArgumentNullException(nameof(matcher)); - if (fileSystem == null) - throw new ArgumentNullException(nameof(fileSystem)); - return Execute(matcher, fileSystem, fileSystem.DirectoryInfo.New(directoryPath)); - } - - /// - public static PatternMatchingResult Execute(this Matcher matcher, IFileSystem fileSystem, IDirectoryInfo directoryInfo) - { - if (matcher == null) - throw new ArgumentNullException(nameof(matcher)); - if (fileSystem == null) - throw new ArgumentNullException(nameof(fileSystem)); - if (directoryInfo == null) - throw new ArgumentNullException(nameof(directoryInfo)); - return matcher.Execute(new DirectoryInfoGlobbingWrapper(fileSystem, directoryInfo)); - } - - /// - /// Searches the directory specified for all files matching patterns added to this instance of - /// - /// The matcher - /// The filesystem - /// The root directory for the search - /// Absolute file paths of all files matched. Empty enumerable if no files matched given patterns. - public static IEnumerable GetResultsInFullPath(this Matcher matcher, IFileSystem fileSystem, string directoryPath) - { - if (matcher == null) - throw new ArgumentNullException(nameof(matcher)); - if (fileSystem == null) - throw new ArgumentNullException(nameof(fileSystem)); - return GetResultsInFullPath(matcher, fileSystem, fileSystem.DirectoryInfo.New(directoryPath)); - } - - /// - public static IEnumerable GetResultsInFullPath(this Matcher matcher, IFileSystem fileSystem, IDirectoryInfo directoryInfo) - { - var matches = Execute(matcher, fileSystem, directoryInfo); - - if (!matches.HasMatches) - return Enumerable.Empty(); - - var fsPath = fileSystem.Path; - var directoryFullName = directoryInfo.FullName; - - return matches.Files.Select(GetFullPath); - - string GetFullPath(FilePatternMatch match) - { - return fsPath.GetFullPath(fsPath.Combine(directoryFullName, match.Path)); - } - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs deleted file mode 100644 index 471b5a8..0000000 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.FileSystem; -using PG.StarWarsGame.Engine.Utilities; - -namespace PG.StarWarsGame.Engine.IO.Utilities; - -internal static class PathExtensions -{ - public static void Join(this IPath _, ReadOnlySpan path1, ReadOnlySpan path2, ref ValueStringBuilder stringBuilder) - { - if (path1.Length == 0 && path2.Length == 0) - return; - - if (path1.Length == 0 || path2.Length == 0) - { - ref var pathToUse = ref path1.Length == 0 ? ref path2 : ref path1; - stringBuilder.Append(pathToUse); - return; - } - - var needsSeparator = !(_.HasTrailingDirectorySeparator(path1) || _.HasLeadingDirectorySeparator(path2)); - - stringBuilder.Append(path1); - if (needsSeparator) - stringBuilder.Append(_.DirectorySeparatorChar); - - stringBuilder.Append(path2); - } -} \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Localization/GameLanguageManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Localization/GameLanguageManager.cs index 64b2222..119e405 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Localization/GameLanguageManager.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Localization/GameLanguageManager.cs @@ -123,24 +123,34 @@ public string LocalizeFileName(string fileName, LanguageType language, out bool } var stringBuilder = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]); - LocalizeFileName(fileName.AsSpan(), language, ref stringBuilder, out localized); - if (!localized) + try + { + LocalizeFileName(fileName.AsSpan(), language, ref stringBuilder, out localized); + if (!localized) + return fileName; + + Debug.Assert(stringBuilder.Length == fileName.Length); + return stringBuilder.ToString(); + } + finally { stringBuilder.Dispose(); - return fileName; } - - Debug.Assert(stringBuilder.Length == fileName.Length); - return stringBuilder.ToString(); } public int LocalizeFileName(ReadOnlySpan fileName, LanguageType language, Span destination, out bool localized) { var sb = new ValueStringBuilder(destination.Length); - LocalizeFileName(fileName, language, ref sb, out localized); - sb.TryCopyTo(destination, out var written); - sb.Dispose(); - return written; + try + { + LocalizeFileName(fileName, language, ref sb, out localized); + sb.TryCopyTo(destination, out var written); + return written; + } + finally + { + sb.Dispose(); + } } public void LocalizeFileName(ReadOnlySpan fileName, LanguageType language, ref ValueStringBuilder stringBuilder, out bool localized) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj index 96091cb..6beee66 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj @@ -18,16 +18,10 @@ preview - - - - - - - + + - - + @@ -35,7 +29,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs index 76af023..1d48a3f 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs @@ -1,5 +1,4 @@ -using AnakinRaW.CommonUtilities.FileSystem; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using PG.Commons.Hashing; using PG.StarWarsGame.Engine.ErrorReporting; @@ -12,7 +11,6 @@ using PG.StarWarsGame.Files.ALO.Services; using PG.StarWarsGame.Files.Binary; using System; -using System.IO.Abstractions; using PG.StarWarsGame.Engine.Rendering.Animations; namespace PG.StarWarsGame.Engine.Rendering; @@ -24,7 +22,7 @@ internal class PGRender( { private readonly IAloFileService _aloFileService = serviceProvider.GetRequiredService(); private readonly IRepository _modelRepository = gameRepository.ModelRepository; - private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService(); + private readonly PetroglyphFileSystem _pgFileSystem = gameRepository.PGFileSystem; private readonly ICrc32HashingService _hashingService = serviceProvider.GetRequiredService(); private readonly ILogger? _logger = serviceProvider.GetService()?.CreateLogger(typeof(PGRender)); @@ -81,11 +79,11 @@ internal class PGRender( if (!aloFile.FileInformation.IsModel) return new ModelClass(aloFile); - var directory = _fileSystem.Path.GetDirectoryName(path); - var fileName = _fileSystem.Path.GetFileNameWithoutExtension(path); + var directory = _pgFileSystem.GetDirectoryName(path); + var fileName = _pgFileSystem.GetFileNameWithoutExtension(path); if (!string.IsNullOrEmpty(animOverrideName)) - fileName = _fileSystem.Path.GetFileNameWithoutExtension(animOverrideName.AsSpan()); + fileName = _pgFileSystem.GetFileNameWithoutExtension(animOverrideName.AsSpan()); var animations = LoadAnimations(fileName, directory, metadataOnly, throwsException ? AnimationCorruptedHandler : null); @@ -103,7 +101,7 @@ public AnimationCollection LoadAnimations( bool metadataOnly = true, Action? corruptedAnimationHandler = null) { - modelFileName = _fileSystem.Path.GetFileNameWithoutExtension(modelFileName); + modelFileName = _pgFileSystem.GetFileNameWithoutExtension(modelFileName); var animations = new AnimationCollection(); @@ -119,40 +117,41 @@ public AnimationCollection LoadAnimations( while (loadingNumberedAnimations) { var stringBuilder = new ValueStringBuilder(stringBuffer); - - CreateAnimationFilePath(ref stringBuilder, modelFileName, animationData.Value, subIndex); - var animationFilenameWithoutExtension = - _fileSystem.Path.GetFileNameWithoutExtension(stringBuilder.AsSpan()); - InsertPath(ref stringBuilder, directory); - - if (stringBuilder.Length > PGConstants.MaxAnimationFileName) - { - var animFile = stringBuilder.AsSpan().ToString(); - errorReporter.Assert( - EngineAssert.Create(EngineAssertKind.ValueOutOfRange, animFile, [], - $"Cannot get animation file '{animFile}' , because animation file path is too long.")); - continue; - } - try { - var animationAsset = Load3DAsset(stringBuilder.AsSpan(), metadataOnly, throwsOnLoad); - if (animationAsset is IAloAnimationFile animationFile) + CreateAnimationFilePath(ref stringBuilder, modelFileName, animationData.Value, subIndex); + var animationFilenameWithoutExtension = + _pgFileSystem.GetFileNameWithoutExtension(stringBuilder.AsSpan()); + InsertPath(ref stringBuilder, directory); + + if (stringBuilder.Length > PGConstants.MaxAnimationFileName) { - loadingNumberedAnimations = true; - var crc = _hashingService.GetCrc32(animationFilenameWithoutExtension, - PGConstants.DefaultPGEncoding); - animations.AddAnimation(animationData.Key, animationFile, crc); + var animFile = stringBuilder.AsSpan().ToString(); + errorReporter.Assert( + EngineAssert.Create(EngineAssertKind.ValueOutOfRange, animFile, [], + $"Cannot get animation file '{animFile}' , because animation file path is too long.")); + continue; } - else + + try { - loadingNumberedAnimations = false; + var animationAsset = Load3DAsset(stringBuilder.AsSpan(), metadataOnly, throwsOnLoad); + if (animationAsset is IAloAnimationFile animationFile) + { + loadingNumberedAnimations = true; + var crc = _hashingService.GetCrc32(animationFilenameWithoutExtension, + PGConstants.DefaultPGEncoding); + animations.AddAnimation(animationData.Key, animationFile, crc); + } + else + { + loadingNumberedAnimations = false; + } + } + catch (BinaryCorruptedException e) + { + corruptedAnimationHandler?.Invoke(e, animationData.Key, stringBuilder.AsSpan().ToString()); } - } - catch (BinaryCorruptedException e) - { - // NB: Loading a corrupted animation does not break the loading of other numbered animations - corruptedAnimationHandler?.Invoke(e, animationData.Key, stringBuilder.AsSpan().ToString()); } finally { @@ -166,8 +165,12 @@ public AnimationCollection LoadAnimations( private void InsertPath(ref ValueStringBuilder stringBuilder, ReadOnlySpan directory) { - if (!_fileSystem.Path.HasTrailingDirectorySeparator(directory)) + if (!_pgFileSystem.HasTrailingDirectorySeparator(directory)) + { + // This MUST NOT be changed to "/" as it will break the loading on linux + // (because "/" indicates an absolute path) stringBuilder.Insert(0, '\\', 1); + } stringBuilder.Insert(0, directory); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs index f849d05..2aa7798 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs @@ -5,8 +5,8 @@ using AnakinRaW.CommonUtilities.Collections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using PG.Commons.Hashing; -using PG.Commons.Services; using PG.StarWarsGame.Engine.ErrorReporting; using PG.StarWarsGame.Engine.IO; using PG.StarWarsGame.Files.XML; @@ -16,26 +16,32 @@ namespace PG.StarWarsGame.Engine.Xml; -public sealed class PetroglyphStarWarsGameXmlParser : ServiceBase, IPetroglyphXmlParserInfo +public sealed class PetroglyphStarWarsGameXmlParser : IPetroglyphXmlParserInfo { private readonly IGameRepository _gameRepository; + private readonly PetroglyphFileSystem _pgFileSystem; private readonly PetroglyphStarWarsGameXmlParseSettings _settings; private readonly IGameEngineErrorReporter _reporter; private readonly IPetroglyphXmlFileParserFactory _fileParserFactory; - + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + public string Name { get; } public PetroglyphStarWarsGameXmlParser( IGameRepository gameRepository, PetroglyphStarWarsGameXmlParseSettings settings, IServiceProvider serviceProvider, - IGameEngineErrorReporter reporter) - : base(serviceProvider) + IGameEngineErrorReporter reporter) { - _gameRepository = gameRepository; - _settings = settings; - _reporter = reporter; + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _gameRepository = gameRepository ?? throw new ArgumentNullException(nameof(gameRepository)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _reporter = reporter ?? throw new ArgumentNullException(nameof(reporter)); + _pgFileSystem = gameRepository.PGFileSystem; _fileParserFactory = serviceProvider.GetRequiredService(); + _logger = serviceProvider.GetService()?.CreateLogger(GetType()) ?? NullLogger.Instance; + Name = GetType().FullName!; } @@ -47,7 +53,7 @@ public PetroglyphStarWarsGameXmlParser( public XmlFileList ParseFileList(string xmlFile) { return ParseCore(xmlFile, - stream => new XmlFileListParser(Services, _reporter).ParseFile(stream), + stream => new XmlFileListParser(_serviceProvider, _reporter).ParseFile(stream), () => XmlFileList.Empty(new XmlLocationInfo(xmlFile, null))); } @@ -59,9 +65,9 @@ public void ParseEntriesFromFileListXml( { var container = ParseFileList(xmlFile); - var xmlFiles = container.Files.Select(x => FileSystem.Path.Combine(lookupPath, x)).ToList(); + var xmlFiles = container.Files.Select(x => _pgFileSystem.CombinePath(lookupPath, x)).ToList(); - var parser = new XmlContainerFileParser(Services, + var parser = new XmlContainerFileParser(_serviceProvider, _fileParserFactory.CreateNamedXmlObjectParser(_gameRepository.EngineType, _reporter), _reporter); foreach (var file in xmlFiles) @@ -86,14 +92,14 @@ public bool ParseObjectsFromContainerFile( private T ParseCore(string xmlFile, Func parseAction, Func invalidFileAction) { - Logger.LogDebug("Parsing file '{XmlFile}'", xmlFile); + _logger.LogDebug("Parsing file '{XmlFile}'", xmlFile); using var fileStream = _gameRepository.TryOpenFile(xmlFile); if (fileStream is null) { var message = $"Could not find XML file '{xmlFile}'"; - Logger.LogWarning(message); + _logger.LogWarning(message); _reporter.Report(new XmlError(this, locationInfo: new XmlLocationInfo(xmlFile, null)) { diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj index 000b3a6..7767e2b 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj @@ -17,6 +17,6 @@ preview - + \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj index 4a8bced..82d5b54 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj @@ -18,7 +18,7 @@ preview - + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs index 2ac7f6c..4c72f4a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.IO.Abstractions; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Xml; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; @@ -15,16 +17,13 @@ namespace PG.StarWarsGame.Files.XML.Parsers; public abstract class PetroglyphXmlFileParserBase(IServiceProvider serviceProvider, IXmlParserErrorReporter? errorReporter) : PetroglyphXmlParserBase(errorReporter) { - protected readonly IServiceProvider ServiceProvider = - serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - protected readonly IFileSystem FileSystem = serviceProvider.GetRequiredService(); protected virtual bool LoadLineInfo => true; protected XElement GetRootElement(Stream xmlStream, out string fileName) { - fileName = GetStrippedFileName(xmlStream.GetFilePath()); + fileName = GetStrippedFilePath(xmlStream.GetFilePath()); if (string.IsNullOrEmpty(fileName)) throw new InvalidOperationException("Unable to parse XML from unnamed stream. Either parse from a file or MEG stream."); @@ -62,18 +61,25 @@ protected XElement GetRootElement(Stream xmlStream, out string fileName) return root; } - - private string GetStrippedFileName(string filePath) + + private string GetStrippedFilePath(string filePath) { if (!FileSystem.Path.IsPathFullyQualified(filePath)) return filePath; - - var pathPartIndex = filePath.LastIndexOf("DATA\\XML\\", StringComparison.OrdinalIgnoreCase); + + var pathPartIndex = filePath.LastIndexOf(GetXmlDataFolder(), StringComparison.OrdinalIgnoreCase); if (pathPartIndex == -1) return filePath; - return filePath.Substring(pathPartIndex); + return filePath[pathPartIndex..]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static string GetXmlDataFolder() + { + // Required because we don't have access to the PGFileSystem here. + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"DATA\XML\" : "DATA/XML/"; + } } diff --git a/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj b/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj index f28c11c..6777b35 100644 --- a/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj +++ b/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj @@ -16,16 +16,16 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs index c5caf92..ac98cfc 100644 --- a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs +++ b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs @@ -1,4 +1,5 @@ using AET.ModVerify.App.Settings.CommandLine; +using AET.ModVerify.Reporting; using AnakinRaW.ApplicationBase.Environment; using System; using System.IO.Abstractions; @@ -211,4 +212,73 @@ public void Parse_CreateBaseline_MissingRequired_Fails(string argString) Assert.Null(settings.ModVerifyOptions); Assert.Null(settings.UpdateOptions); } + + [Theory] + [InlineData("verify --mods myMod --baseline myBaseline.json", "myBaseline.json", false, false)] + [InlineData("verify --mods myMod --searchBaseline", null, true, false)] + [InlineData("verify --path myMod --useDefaultBaseline", null, false, true)] + public void Parse_Verify_BaselineOptions(string argString, string? expectedBaseline, bool expectedSearchBaseline, bool expectedUseDefaultBaseline) + { + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.True(settings.HasOptions); + var verify = Assert.IsType(settings.ModVerifyOptions); + Assert.Equal(expectedBaseline, verify.Baseline); + Assert.Equal(expectedSearchBaseline, verify.SearchBaselineLocally); + Assert.Equal(expectedUseDefaultBaseline, verify.UseDefaultBaseline); + } + + [Fact] + public void Parse_Verify_Baseline_And_SearchBaseline_CanBeParsedTogether() + { + // Mutual exclusivity of --baseline and --searchBaseline is enforced later by SettingsBuilder, not by the parser. + const string argString = "verify --mods myMod --baseline myBaseline.json --searchBaseline"; + + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.True(settings.HasOptions); + var verify = Assert.IsType(settings.ModVerifyOptions); + Assert.Equal("myBaseline.json", verify.Baseline); + Assert.True(verify.SearchBaselineLocally); + } + + [Theory] + [InlineData("verify --path myMod --outDir myOut", "myOut")] + [InlineData("verify --path myMod -o myOut", "myOut")] + [InlineData("verify --path myMod", null)] + public void Parse_Verify_OutputDirectory(string argString, string? expectedOutDir) + { + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.True(settings.HasOptions); + var verify = Assert.IsType(settings.ModVerifyOptions); + Assert.Equal(expectedOutDir, verify.OutputDirectory); + } + + [Theory] + [InlineData("verify --path myMod --failFast --minFailSeverity Critical", true, "Critical")] + [InlineData("verify --path myMod --failFast --minFailSeverity Warning", true, "Warning")] + [InlineData("verify --path myMod", false, null)] + public void Parse_Verify_FailFastOptions(string argString, bool expectedFailFast, string? expectedMinSeverity) + { + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.True(settings.HasOptions); + var verify = Assert.IsType(settings.ModVerifyOptions); + Assert.Equal(expectedFailFast, verify.FailFast); + var expectedSeverity = expectedMinSeverity is null ? (VerificationSeverity?)null : Enum.Parse(expectedMinSeverity); + Assert.Equal(expectedSeverity, verify.MinimumFailureSeverity); + } + + [Theory] + [InlineData("verify --path myMod --ignoreAsserts", true)] + [InlineData("verify --path myMod", false)] + public void Parse_Verify_IgnoreAsserts(string argString, bool expectedIgnoreAsserts) + { + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.True(settings.HasOptions); + var verify = Assert.IsType(settings.ModVerifyOptions); + Assert.Equal(expectedIgnoreAsserts, verify.IgnoreAsserts); + } } \ No newline at end of file diff --git a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs new file mode 100644 index 0000000..429567b --- /dev/null +++ b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs @@ -0,0 +1,110 @@ +using AET.ModVerify.App; +using AET.ModVerify.App.Settings; +using AET.ModVerify.App.Settings.CommandLine; +using System.IO.Abstractions; +using Testably.Abstractions.Testing; +using Xunit; +using AnakinRaW.CommonUtilities.Testing; + +namespace ModVerify.CliApp.Test; + +public class SettingsBuilderTest : TestBaseWithFileSystem +{ + private readonly SettingsBuilder _builder; + + public SettingsBuilderTest() + { + _builder = new SettingsBuilder(ServiceProvider); + } + + protected override IFileSystem CreateFileSystem() + { + return new MockFileSystem(); + } + + [Theory] + [InlineData("path1", "path2")] + public void BuildSettings_Paths_SplitsCorrectly(string p1, string p2) + { + var separator = FileSystem.Path.PathSeparator; + var paths = $"{p1}{separator}{p2}"; + var expected = new[] { FileSystem.Path.GetFullPath(p1), FileSystem.Path.GetFullPath(p2) }; + + var options = new VerifyVerbOption + { + ModPaths = paths, + AdditionalFallbackPath = paths, + TargetPath = "myPath" + }; + + var settings = _builder.BuildSettings(options); + + Assert.Equal(expected, settings.VerificationTargetSettings.ModPaths); + Assert.Equal(expected, settings.VerificationTargetSettings.AdditionalFallbackPaths); + } + + [Fact] + public void BuildSettings_FallbackGamePath_RequiresGamePath() + { + var gamePath = "game"; + var fallbackPath = "fallback"; + + var options = new VerifyVerbOption + { + GamePath = gamePath, + FallbackGamePath = fallbackPath, + TargetPath = "myPath" + }; + + var settings = _builder.BuildSettings(options); + Assert.Equal(FileSystem.Path.GetFullPath(fallbackPath), settings.VerificationTargetSettings.FallbackGamePath); + + var optionsNoGame = new VerifyVerbOption + { + FallbackGamePath = fallbackPath, + TargetPath = "myPath" + }; + + var settingsNoGame = _builder.BuildSettings(optionsNoGame); + Assert.Null(settingsNoGame.VerificationTargetSettings.FallbackGamePath); + } + + [Fact] + public void BuildSettings_UseDefaultBaseline_And_Baseline_Throws() + { + var options = new VerifyVerbOption + { + UseDefaultBaseline = true, + Baseline = "myBaseline.json", + TargetPath = "myPath", + }; + + Assert.Throws(() => _builder.BuildSettings(options)); + } + + [Fact] + public void BuildSettings_UseDefaultBaseline_And_SearchBaseline_Throws() + { + var options = new VerifyVerbOption + { + UseDefaultBaseline = true, + SearchBaselineLocally = true, + TargetPath = "myPath", + }; + + Assert.Throws(() => _builder.BuildSettings(options)); + } + + [Fact] + public void BuildSettings_UseDefaultBaseline_Alone_DoesNotThrow() + { + var options = new VerifyVerbOption + { + UseDefaultBaseline = true, + TargetPath = "myPath", + }; + + var settings = _builder.BuildSettings(options); + Assert.NotNull(settings); + } +} diff --git a/test/ModVerify.CliApp.Test/Utilities/StringExtensions.cs b/test/ModVerify.CliApp.Test/Utilities/Extensions.cs similarity index 54% rename from test/ModVerify.CliApp.Test/Utilities/StringExtensions.cs rename to test/ModVerify.CliApp.Test/Utilities/Extensions.cs index 052c322..19250ad 100644 --- a/test/ModVerify.CliApp.Test/Utilities/StringExtensions.cs +++ b/test/ModVerify.CliApp.Test/Utilities/Extensions.cs @@ -2,12 +2,22 @@ namespace ModVerify.CliApp.Test.Utilities; -internal static class StringExtensions +internal static class Extensions { #if NETFRAMEWORK public static string[] Split(this string str, char separator, StringSplitOptions options) { return str.Split([separator], options); } + + + extension(Enum) + { + public static T Parse(string value) where T : Enum + { + return (T)Enum.Parse(typeof(T), value); + } + } + #endif } \ No newline at end of file