diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d7d7f3..d33c33c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,8 @@ jobs: - name: Create Net Core Release # use publish for .NET Core run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --output ./releases/net10.0 /p:DebugType=None /p:DebugSymbols=false + - name: Create Linux Release + run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --runtime linux-x64 --self-contained true --output ./releases/linux-x64 /p:DebugType=None /p:DebugSymbols=false - name: Upload a Build Artifact uses: actions/upload-artifact@v7 with: @@ -117,4 +119,5 @@ jobs: generate_release_notes: true files: | ./releases/net481/ModVerify.exe - ./releases/ModVerify-Net10.zip \ No newline at end of file + ./releases/ModVerify-Net10.zip + ./releases/linux-x64/ModVerify \ No newline at end of file diff --git a/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..3516a42 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit da072f43e6b85aab35b43d11f6b36eab61bdcfa6 +Subproject commit 3516a4292c5d42bf14f4a2420604f2261c0f824d 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..cb6593c 100644 --- a/src/ModVerify.CliApp/ModVerify.CliApp.csproj +++ b/src/ModVerify.CliApp/ModVerify.CliApp.csproj @@ -37,11 +37,11 @@ - - - - - + + + + + @@ -62,15 +62,15 @@ - + compile runtime; build; native; contentfiles; analyzers; buildtransitive - + compile runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -82,6 +82,10 @@ + + + + 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/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/ModVerify.csproj b/src/ModVerify/ModVerify.csproj index 96656cc..b53b442 100644 --- a/src/ModVerify/ModVerify.csproj +++ b/src/ModVerify/ModVerify.csproj @@ -27,8 +27,8 @@ - - + + @@ -42,4 +42,8 @@ + + + + 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..643b352 100644 --- a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs +++ b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs @@ -16,10 +16,6 @@ using PG.StarWarsGame.Files.ALO.Files.Particles; using PG.StarWarsGame.Files.Binary; -#if NETSTANDARD2_0 || NETFRAMEWORK -using AnakinRaW.CommonUtilities.FileSystem; -#endif - namespace AET.ModVerify.Verifiers.Commons; public sealed class SingleModelVerifier : GameVerifierBase @@ -229,9 +225,11 @@ private void VerifyModelClass(ModelClass modelClass, IReadOnlyCollection 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(texture, particleContext), e => e is ArgumentException, _ => { @@ -240,12 +238,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 = GameEngine.GameRepository.PGFileSystem.GetFileNameWithoutExtension(file.FilePath.AsSpan()); var name = file.Content.Name.AsSpan(); if (!fileName.Equals(name, StringComparison.OrdinalIgnoreCase)) @@ -255,7 +253,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,11 +261,11 @@ 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) { - GuardedVerify(() => VerifyTextureExists(file, texture, modelContext), + GuardedVerify(() => VerifyTextureExists(texture, modelContext), e => e is ArgumentException, _ => { @@ -329,11 +327,11 @@ 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(string texture, IReadOnlyCollection contextInfo) { 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) @@ -367,12 +365,17 @@ private void VerifyShaderExists(IPetroglyphFileHolder model, string shader, IRea VerifierErrorCodes.FileNotFound, message, VerificationSeverity.Error, - [..contextInfo, model.FileName.ToUpperInvariant()], + [..contextInfo, NormalizeFileName(model.FileName)], shader); AddError(error); } } + private string NormalizeFileName(string fileName) + { + return GameEngine.GameRepository.PGFileSystem.GetFileName(fileName).ToUpperInvariant(); + } + private void AddNotExistError(string fileName, IReadOnlyCollection contextInfo) { AddError(VerificationError.Create( diff --git a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs index a33f900..26a073e 100644 --- a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs +++ b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs @@ -119,7 +119,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 +145,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 { @@ -155,16 +160,18 @@ 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}'"); + 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) 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..ab108c2 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +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(); + _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 + } +} 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..7d88abf --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs @@ -0,0 +1,237 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using AnakinRaW.CommonUtilities.Testing.Attributes; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; + +public partial class PetroglyphFileSystemTests +{ + [Fact] + public void FileExists_EmptyGameDirectory_Works() + { + var tempFile = Path.GetTempFileName(); + try + { + var vsb = new ValueStringBuilder(); + var exists = _pgFileSystem.FileExists(tempFile.AsSpan(), ref vsb, ReadOnlySpan.Empty); + Assert.True(exists); + Assert.Equal(tempFile, vsb.ToString()); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Fact] + public void FileExists_FileExists() + { + var tempFile = Path.GetTempFileName(); + try + { + var vsb = new ValueStringBuilder(); + var exists = _pgFileSystem.FileExists(tempFile.AsSpan(), ref vsb, string.Empty.AsSpan()); + Assert.True(exists); + Assert.Equal(tempFile, vsb.ToString()); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Fact] + public void FileExists_FileDoesNotExist() + { + var tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var vsb = new ValueStringBuilder(); + var exists = _pgFileSystem.FileExists(tempFile.AsSpan(), ref vsb, string.Empty.AsSpan()); + Assert.False(exists); + } + + [Fact] + public void FileExists_RelativePath() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, "test.txt"); + File.WriteAllText(tempFile, "test"); + try + { + var vsb = new ValueStringBuilder(); + var exists = _pgFileSystem.FileExists("test.txt".AsSpan(), ref vsb, tempDir.AsSpan()); + Assert.True(exists); + + // On Windows, JoinPath might use backslashes. + // PetroglyphFileSystem.JoinPath uses _underlyingFileSystem.Path.DirectorySeparatorChar if no separator is present. + // Since _fileSystem is RealFileSystem, it will be \ on Windows. + var expected = Path.Combine(tempDir, "test.txt"); + Assert.Equal(expected, vsb.ToString()); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [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_CaseInsensitive(string inputPath, string actualPathOnDisk) + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + var fullPathOnDisk = Path.Combine(tempDir, actualPathOnDisk.Replace('/', Path.DirectorySeparatorChar)); + var fullPathOnDiskDir = Path.GetDirectoryName(fullPathOnDisk); + if (fullPathOnDiskDir != null) + Directory.CreateDirectory(fullPathOnDiskDir); + + File.WriteAllText(fullPathOnDisk, "test"); + + try + { + var vsb = new ValueStringBuilder(); + // On Windows, CreateFile is case-insensitive by default. + // On Linux, FileExistsCaseInsensitive handles it. + var exists = _pgFileSystem.FileExists(inputPath.AsSpan(), ref vsb, tempDir.AsSpan()); + Assert.True(exists); + + var resultPath = vsb.ToString(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows CreateFile doesn't change the path in the string builder to the actual case-sensitive path if it just found it. + // It stays as what was passed to it (with gameDirectory joined). + var expected = _fileSystem.Path.Combine(tempDir, inputPath); + Assert.Equal(expected, resultPath); + } + else + { + // On Linux, FileExistsCaseInsensitive DOES update the string builder: + // stringBuilder.Length = 0; + // stringBuilder.Append(file); + // It should be the exact path on disk. + Assert.Equal(fullPathOnDisk, resultPath); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void FileExists_GameDirectory_WithTrailingSeparator() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()) + Path.DirectorySeparatorChar; + Directory.CreateDirectory(tempDir); + var tempFile = Path.Combine(tempDir, "test.txt"); + File.WriteAllText(tempFile, "test"); + try + { + var vsb = new ValueStringBuilder(); + var exists = _pgFileSystem.FileExists("test.txt".AsSpan(), ref vsb, tempDir.AsSpan()); + Assert.True(exists); + Assert.Equal(tempFile, vsb.ToString()); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_CaseInsensitive_DotSegmentInPath_ReturnsTrue() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + // Create the actual file at tempDir/DATA/FILE.TXT (uppercase) + Directory.CreateDirectory(Path.Combine(tempDir, "DATA")); + File.WriteAllText(Path.Combine(tempDir, "DATA", "FILE.TXT"), "test"); + + // Input path uses a leading ".\" (dot-segment) AND different casing. + // After normalization + join: tempDir/./DATA/file.txt + // File.Exists fast-path fails (case mismatch), so the impl must resolve case-insensitively. + // Correct implementations must handle "." as a valid path segment that resolves to the current directory, + // not treat it as a literal directory name to look up via GetDirectories. + var vsb = new ValueStringBuilder(); + var exists = _pgFileSystem.FileExists(@".\DATA\file.txt".AsSpan(), ref vsb, tempDir.AsSpan()); + + Assert.True(exists); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_CaseInsensitive_MissingIntermediateDirectory_ReturnsFalse() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + // Create tempDir/a/c.txt — no "b" directory at all + Directory.CreateDirectory(Path.Combine(tempDir, "a")); + File.WriteAllText(Path.Combine(tempDir, "a", "c.txt"), "test"); + + // Input path references a non-existent intermediate segment "b" + var vsb = new ValueStringBuilder(); + var exists = _pgFileSystem.FileExists("a/b/c.txt".AsSpan(), ref vsb, tempDir.AsSpan()); + + Assert.False(exists); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Theory] +#if Windows + [InlineData("C:\\test.txt", true)] + [InlineData("/test.txt", false)] // On Windows, /test.txt is NOT fully qualified (it's root-relative to current drive) + [InlineData("\\test.txt", false)] +#else + [InlineData("/test.txt", true)] + [InlineData("C:\\test.txt", false)] // On Linux, C:\ is not a root +#endif + [InlineData("test.txt", false)] + public void IsPathFullyQualified_Exists_Internal(string path, bool expected) + { + // This method is internal/private, but we can indirectly test it through FileExists + + const string gameDir = "Z:\\non-existent-dir"; + var vsb = new ValueStringBuilder(); + _pgFileSystem.FileExists(path.AsSpan(), ref vsb, gameDir.AsSpan()); + + var resultPath = vsb.ToString().Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + var expectedGameDir = gameDir.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + + if (expected) + { + Assert.StartsWith(path.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar), resultPath); + Assert.DoesNotContain(expectedGameDir, resultPath); + } + else + { + Assert.Contains(expectedGameDir, resultPath); + } + } +} 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..1aadea5 --- /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..fb15bc1 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs @@ -0,0 +1,28 @@ +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(); + vsb.Append(path); + _pgFileSystem.NormalizePath(ref vsb); + + Assert.Equal(expected, vsb.ToString()); + } +} 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..084af8a --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs @@ -0,0 +1,19 @@ +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)] + 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.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs new file mode 100644 index 0000000..3390f3b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs @@ -0,0 +1,46 @@ +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)] + public void HasTrailingDirectorySeparator(string path, bool expected) + { + Assert.Equal(expected, _pgFileSystem.HasTrailingDirectorySeparator(path.AsSpan())); + } +} 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..2f621bc --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj @@ -0,0 +1,52 @@ + + + + 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/Utilities/ValueStringBuilderTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs new file mode 100644 index 0000000..8ad1307 --- /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/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..6dd3de7 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -0,0 +1,270 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using PG.StarWarsGame.Engine.Utilities; +using System.IO; +using AnakinRaW.CommonUtilities.FileSystem; +#if NETSTANDARD2_1 || NET +using System.Diagnostics.CodeAnalysis; +#endif + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + internal bool FileExists(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder, ReadOnlySpan gameDirectory) + { + stringBuilder.Length = 0; + + if (IsPathFullyQualified_Exists(filePath)) + stringBuilder.Append(filePath); + else + JoinPath(gameDirectory, filePath, ref stringBuilder); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + NormalizePath(ref stringBuilder); + + var actualFilePath = stringBuilder.AsSpan(); + return FileExistsCaseInsensitive(actualFilePath, ref stringBuilder); + } + + // 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); + + return IsValidAndClose(fileHandle); + } + + // NB: This method assumes backslashes have been normalized to forward slashes + // NB: This method operates on the actual file system + private bool FileExistsCaseInsensitive(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder) + { + Debug.Assert(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + + var pathString = filePath.ToString(); + if (_underlyingFileSystem.File.Exists(pathString)) + return true; + + var directory = _underlyingFileSystem.Path.GetDirectoryName(pathString); + var fileName = _underlyingFileSystem.Path.GetFileName(pathString); + + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) + return false; + + if (!_underlyingFileSystem.Directory.Exists(directory)) + { + if (!FileExistsCaseInsensitive(directory.AsSpan(), ref stringBuilder)) + return false; + + directory = stringBuilder.AsSpan().ToString(); + } + + var files = _underlyingFileSystem.Directory.GetFiles(directory); + var directories = _underlyingFileSystem.Directory.GetDirectories(directory); + + foreach (var file in files) + { + var name = _underlyingFileSystem.Path.GetFileName(file); + if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + stringBuilder.Length = 0; + stringBuilder.Append(file); + return true; + } + } + + foreach (var dir in directories) + { + var name = _underlyingFileSystem.Path.GetFileName(dir); + if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + stringBuilder.Length = 0; + stringBuilder.Append(dir); + return true; + } + } + + return false; + } + + private 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); + } + + [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); + + + + + /// + /// Checks whether a file exists using case-insensitive path resolution. + /// On success, contains the actual on-disk path. + /// + /// + /// + /// Strategy: + /// 1. Fast path: single stat() for exact-case match. + /// 2. Find deepest existing directory prefix, starting from the hint position. + /// With a correct hint this costs 1 stat. Without a hint (or bad hint), + /// walks backward — graceful degradation, never throws. + /// 3. Forward resolve: lazily enumerate only the mismatched components. + /// + /// + /// No exceptions occur in normal flow: Directory.Exists returns bool, + /// and we only enumerate directories whose existence has been confirmed. + /// + /// + /// + /// Normalized absolute path with forward slashes. May alias stringBuilder's buffer. + /// + /// + /// On success, overwritten with the actual on-disk path. + /// + /// + /// Length of the path prefix known to exist with correct casing (typically gameDirectory.Length). + /// Pass 0 if unknown — the method falls back to a backward walk. + /// + private bool FileExistsCaseInsensitive(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder, int knownGoodPrefixLength) + { + Debug.Assert(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + + var pathString = filePath.ToString(); + + // Fast path: exact case match — single stat() syscall + if (_underlyingFileSystem.File.Exists(pathString)) + return true; + + if (pathString.Length == 0) + return false; + + + var path = pathString.AsSpan(); + + var rootLen = path[0] == '/' ? 1 : 0; + var resolvedEnd = rootLen; + + int searchEnd; + if (knownGoodPrefixLength > 0) + { + searchEnd = knownGoodPrefixLength; + while (searchEnd > 1 && path[searchEnd - 1] == '/') + searchEnd--; + } + else + { + var lastSlash = path.LastIndexOf('/'); + searchEnd = lastSlash >= 0 ? (lastSlash == 0 ? 1 : lastSlash) : 0; + } + + // Walk backward until we find an existing directory. + // Save the successful prefix string to reuse as the first currentDir. + string? resolvedPrefix = null; + while (searchEnd > resolvedEnd) + { + var prefix = pathString.Substring(0, searchEnd); + if (_underlyingFileSystem.Directory.Exists(prefix)) + { + resolvedEnd = searchEnd; + resolvedPrefix = prefix; + break; + } + + var slash = path.Slice(0, searchEnd).LastIndexOf('/'); + if (slash < 0) + break; + searchEnd = slash == 0 ? 1 : slash; + } + + if (resolvedEnd == 0) + return false; + + // Reuse the prefix from Directory.Exists if available, otherwise allocate once. + var currentDir = resolvedPrefix ?? pathString.Substring(0, resolvedEnd); + + stringBuilder.Length = 0; + stringBuilder.Append(currentDir); + + var pos = resolvedEnd; + if (pos < path.Length && path[pos] == '/') + pos++; + + while (pos < path.Length) + { + var nextSlash = path.Slice(pos).IndexOf('/'); + var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; + var component = path.Slice(pos, componentEnd - pos); + + if (component.IsEmpty) + { + pos = componentEnd + 1; + continue; + } + + var isLast = componentEnd >= path.Length; + + var entries = isLast + ? _underlyingFileSystem.Directory.EnumerateFiles(currentDir) + : _underlyingFileSystem.Directory.EnumerateDirectories(currentDir); + + var found = false; + foreach (var entry in entries) + { + if (_underlyingFileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) + { + stringBuilder.Length = 0; + stringBuilder.Append(entry); + currentDir = entry; // entry is already a string — reuse it + found = true; + break; + } + } + + if (!found) + return false; + + pos = componentEnd + 1; + } + + return true; + } +} \ No newline at end of file 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..d7bb058 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs @@ -0,0 +1,220 @@ +using System; +using System.Runtime.InteropServices; +using AnakinRaW.CommonUtilities.FileSystem; +#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..8f67e6d --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs @@ -0,0 +1,18 @@ +using System; +using AnakinRaW.CommonUtilities.FileSystem.Normalization; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + 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.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs new file mode 100644 index 0000000..ee36824 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -0,0 +1,111 @@ +using AnakinRaW.CommonUtilities.FileSystem.Normalization; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.IO; +using System.IO.Abstractions; +using System.Runtime.CompilerServices; + +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(); + } + + /// + /// 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]); + } + + private static ReadOnlySpan GetPathRoot(ReadOnlySpan path) + { + if (IsEffectivelyEmpty(path)) + return ReadOnlySpan.Empty; + + var pathRoot = GetRootLength(path); + return pathRoot <= 0 ? ReadOnlySpan.Empty : path.Slice(0, pathRoot); + } + + private static int GetRootLength(ReadOnlySpan path) + { + // We don't ever expect drive signatures or UCN paths in a linux environment. + // Thus, we keep the simple linux check, augmented supporting backslash + return path.Length > 0 && IsDirectorySeparator(path[0]) ? 1 : 0; + } + + 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)] + private 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..e1cd1f9 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -0,0 +1,29 @@ + + + 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/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..9b866f8 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); 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..0c21750 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) 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..3e31bf9 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; } @@ -89,7 +85,14 @@ public Stream OpenFile(ReadOnlySpan filePath, bool megFileOnly = false) sb.Dispose(); return fileStream; } - + + /// + /// 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 +102,24 @@ 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]); + sb.Append(filePath); + PGFileSystem.NormalizePath(ref sb); Span fileNameSpan = stackalloc char[PGConstants.MaxMegEntryPathLength]; - - if (!_megPathNormalizer.TryNormalize(filePath, fileNameSpan, out var length)) + + var normalized = _megPathNormalizer.TryNormalize(sb.AsSpan(), fileNameSpan, out var length); + sb.Dispose(); + + 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(); @@ -125,39 +129,8 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath) protected FileFoundInfo FindFileCore(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder) { - 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, GameDirectory.AsSpan()); + return !exists ? default : new FileFoundInfo(stringBuilder.AsSpan()); } protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList fallbackPaths, ref ValueStringBuilder pathStringBuilder) @@ -173,7 +146,7 @@ protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList 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 +193,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); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsValidAndClose(IntPtr handle) - { - var isValid = handle != IntPtr.Zero && handle != new IntPtr(-1); - if (isValid) - CloseHandle(handle); - return isValid; + return PGFileSystem.OpenRead(fileFoundInfo.FilePath.ToString()); } - - [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/PG.StarWarsGame.Engine.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj index 96091cb..5389ded 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,9 +29,15 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs index 76af023..aa1e5a9 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(); @@ -122,7 +120,7 @@ public AnimationCollection LoadAnimations( CreateAnimationFilePath(ref stringBuilder, modelFileName, animationData.Value, subIndex); var animationFilenameWithoutExtension = - _fileSystem.Path.GetFileNameWithoutExtension(stringBuilder.AsSpan()); + _pgFileSystem.GetFileNameWithoutExtension(stringBuilder.AsSpan()); InsertPath(ref stringBuilder, directory); if (stringBuilder.Length > PGConstants.MaxAnimationFileName) @@ -166,8 +164,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.ALO/PG.StarWarsGame.Files.ALO.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj index a92efb1..a25aab6 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj @@ -24,4 +24,7 @@ + + + \ No newline at end of file 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..b10964f 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,9 @@ 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..d986505 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 - + @@ -26,4 +26,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + \ No newline at end of file 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..a73a2c9 100644 --- a/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj +++ b/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj @@ -16,11 +16,11 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -35,6 +35,10 @@ + + + + true true 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