diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d7d7f3..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: @@ -109,7 +111,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 }} @@ -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