From a439bdb0e731141b038a776112b557b5df34da6d Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 11 Apr 2026 13:39:51 +0200 Subject: [PATCH 01/35] fix debug disposal --- .../PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs index 0c6b95c..96f0259 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/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() { From 7679e746767fae1d3422531f5a096ce6d24f78b1 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 11 Apr 2026 13:40:46 +0200 Subject: [PATCH 02/35] Handle null `TextureData` during `GuiDialog` initialization. --- .../GuiDialog/GuiDialogGameManager_Initialization.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs index 2bac738..43436aa 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); } From ac3dd96d51dbb08bd656123b6ad9e07b6cec465a Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 11 Apr 2026 13:42:05 +0200 Subject: [PATCH 03/35] Engine support for finding files on linux systems the way the game would do it wiht abstractions layers like Wine or Valve Proton --- .../IO/Repositories/GameRepository.Files.cs | 109 +++++++++++++++--- 1 file changed, 94 insertions(+), 15 deletions(-) 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..a3f46b8 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs @@ -97,25 +97,29 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath) { Debug.Assert(MasterMegArchive is not null); - if (filePath.Length > PGConstants.MaxMegEntryPathLength) + var sb = new ValueStringBuilder(stackalloc char[Math.Max(filePath.Length, PGConstants.MaxMegEntryPathLength)]); + sb.Append(filePath); + NormalizePath(ref sb); + + if (sb.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}'", sb.ToString()); + sb.Dispose(); return default; } Span fileNameSpan = stackalloc char[PGConstants.MaxMegEntryPathLength]; - if (!_megPathNormalizer.TryNormalize(filePath, fileNameSpan, out var length)) - return default; - - var fileName = fileNameSpan.Slice(0, length); - - if (fileName.Length > PGConstants.MaxMegEntryPathLength) + if (!_megPathNormalizer.TryNormalize(sb.AsSpan(), fileNameSpan, out var length)) { - Logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters after normalization: '{FileName}'", fileName.ToString()); + sb.Dispose(); return default; } + sb.Dispose(); + + var fileName = fileNameSpan.Slice(0, length); + var crc = _crc32HashingService.GetCrc32(fileName, MegFileConstants.MegDataEntryPathEncoding); var entry = MasterMegArchive!.EntriesWithCrc(crc).FirstOrDefault(); @@ -133,13 +137,15 @@ protected FileFoundInfo FindFileCore(ReadOnlySpan filePath, ref ValueStrin 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()); + { + NormalizePath(ref stringBuilder); + + var actualFilePath = stringBuilder.AsSpan(); + exists = FileSystemPathExistsCaseInsensitive(actualFilePath, ref stringBuilder); + } else { // We *could* also use the slightly faster GetFileAttributesA. @@ -157,7 +163,7 @@ in stringBuilder.GetPinnableReference(true), exists = IsValidAndClose(fileHandle); } - return !exists ? new FileFoundInfo() : new FileFoundInfo(actualFilePath); + return !exists ? new FileFoundInfo() : new FileFoundInfo(stringBuilder.AsSpan()); } protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList fallbackPaths, ref ValueStringBuilder pathStringBuilder) @@ -183,6 +189,79 @@ protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return path.Length; + + var writePos = 0; + var lastWasSeparator = false; + for (var i = 0; i < path.Length; i++) + { + var c = path[i]; + var isSeparator = c is '\\' or '/'; + if (isSeparator && lastWasSeparator) + continue; + path[writePos++] = isSeparator ? '/' : c; + lastWasSeparator = isSeparator; + } + + return writePos; + } + + private static void NormalizePath(ref ValueStringBuilder stringBuilder) + { + stringBuilder.Length = NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length)); + } + + private bool FileSystemPathExistsCaseInsensitive(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder) + { + var pathString = filePath.ToString(); + if (FileSystem.File.Exists(pathString)) + return true; + + var directory = FileSystem.Path.GetDirectoryName(pathString); + var fileName = FileSystem.Path.GetFileName(pathString); + + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) + return false; + + if (!FileSystem.Directory.Exists(directory)) + { + if (!FileSystemPathExistsCaseInsensitive(directory.AsSpan(), ref stringBuilder)) + return false; + + directory = stringBuilder.AsSpan().ToString(); + } + + var files = FileSystem.Directory.GetFiles(directory); + var directories = FileSystem.Directory.GetDirectories(directory); + + foreach (var file in files) + { + var name = FileSystem.Path.GetFileName(file); + if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + stringBuilder.Length = 0; + stringBuilder.Append(file); + return true; + } + } + + foreach (var dir in directories) + { + var name = FileSystem.Path.GetFileName(dir); + if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + stringBuilder.Length = 0; + stringBuilder.Append(dir); + return true; + } + } + + return false; + } private static bool PathStartsWithDataDirectory(ReadOnlySpan path, out int cutoffLength) { From 5b8cedda742cd4be0d271e78169158f7898bd149 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 11 Apr 2026 15:14:23 +0200 Subject: [PATCH 04/35] Add support for the `--useDefaultBaseline` option and improve baseline selection logic --- src/ModVerify.CliApp/App/VerifyAction.cs | 3 +- .../Reporting/BaselineSelector.cs | 18 ++++- .../Settings/CommandLine/VerifyVerbOption.cs | 12 ++-- .../Settings/ModVerifyAppSettings.cs | 1 + .../Settings/SettingsBuilder.cs | 15 ++++ .../Verifiers/Commons/SingleModelVerifier.cs | 15 ++-- .../ModVerifyOptionsParserTest.cs | 70 +++++++++++++++++++ .../SettingsBuilderTest.cs | 61 ++++++++++++++++ 8 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 test/ModVerify.CliApp.Test/SettingsBuilderTest.cs 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/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/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..cc7a30e 100644 --- a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs +++ b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs @@ -57,6 +57,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 +100,7 @@ VerifyReportSettings BuildReportSettings() BaselinePath = verifyOptions.Baseline, MinimumReportSeverity = verifyOptions.MinimumSeverity, SearchBaselineLocally = verifyOptions.SearchBaselineLocally, + UseDefaultBaseline = verifyOptions.UseDefaultBaseline, SuppressionsPath = verifyOptions.Suppressions, Verbose = verifyOptions.Verbose }; diff --git a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs index 0376750..bf41457 100644 --- a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs +++ b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs @@ -250,13 +250,14 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c if (!fileName.Equals(name, StringComparison.OrdinalIgnoreCase)) { - AddError(VerificationError.Create( - this, - VerifierErrorCodes.InvalidParticleName, - $"The particle name '{file.Content.Name}' does not match file name '{file.FileName}'", - VerificationSeverity.Error, - [file.FileName.ToUpperInvariant()], - file.Content.Name)); + // TODO: Re-enable + // AddError(VerificationError.Create( + // this, + // VerifierErrorCodes.InvalidParticleName, + // $"The particle name '{file.Content.Name}' does not match file name '{file.FileName}'", + // VerificationSeverity.Error, + // [file.FileName.ToUpperInvariant()], + // file.Content.Name)); } } 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..d70c398 --- /dev/null +++ b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs @@ -0,0 +1,61 @@ +using AET.ModVerify.App; +using AET.ModVerify.App.Settings; +using AET.ModVerify.App.Settings.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using System.IO.Abstractions; +using Testably.Abstractions; +using Xunit; + +namespace ModVerify.CliApp.Test; + +public class SettingsBuilderTest +{ + private readonly SettingsBuilder _builder; + + public SettingsBuilderTest() + { + var services = new ServiceCollection(); + services.AddSingleton(new RealFileSystem()); + var provider = services.BuildServiceProvider(); + _builder = new SettingsBuilder(provider); + } + + [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); + } +} From 07026bbac4493e14768db6f87af5c22b7f66b9ca Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 18:18:01 +0200 Subject: [PATCH 05/35] Remove obsolete filesystem abstraction utilities for directory and file globbing. --- .../Utilities/DirectoryInfoGlobbingWrapper.cs | 106 ------------------ .../IO/Utilities/FileInfoGlobbingWrapper.cs | 35 ------ .../IO/Utilities/MatcherExtensions.cs | 78 ------------- .../IO/Utilities/PathExtensions.cs | 30 ----- 4 files changed, 249 deletions(-) delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs delete mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs 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 From 54c833f3f6d4485b3ee8e37e5f3c857a690847ab Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 18:18:37 +0200 Subject: [PATCH 06/35] Introduce `PetroglyphFileSystem` abstraction for cross-platform filesystem operations. --- .../AssemblyAttributes.cs | 3 + .../IO/PetroglyphFileSystem.CombineJoin.cs | 63 ++++++++ .../IO/PetroglyphFileSystem.Exist.cs | 138 +++++++++++++++++ .../IO/PetroglyphFileSystem.Names.cs | 140 ++++++++++++++++++ .../IO/PetroglyphFileSystem.Normalize.cs | 39 +++++ .../IO/PetroglyphFileSystem.PathEqual.cs | 49 ++++++ .../IO/PetroglyphFileSystem.cs | 86 +++++++++++ .../PG.StarWarsGame.Engine.FileSystem.csproj | 26 ++++ .../Utilities/ValueStringBuilder.cs | 6 +- 9 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj rename src/PetroglyphTools/{PG.StarWarsGame.Engine => PG.StarWarsGame.Engine.FileSystem}/Utilities/ValueStringBuilder.cs (98%) 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..f65486d --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly:InternalsVisibleTo("PG.StarWarsGame.Engine")] \ 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..fc78ea5 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -0,0 +1,138 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; +#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); +} \ 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..cca24b6 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs @@ -0,0 +1,140 @@ +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 +{ + +#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(); + } + + 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); + } + +#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(); + } + + 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); + } + +#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 + } + + 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..6054bcc --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO; + +public sealed partial class PetroglyphFileSystem +{ + internal void NormalizePath(ref ValueStringBuilder stringBuilder) + { + stringBuilder.Length = NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length)); + } + + // TODO: Check whether we can eliminate the double slash normalization + // once we migrated to PGFileSystem + private static int NormalizePath(Span path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return path.Length; + + var writePos = 0; + var lastWasSeparator = false; + for (var i = 0; i < path.Length; i++) + { + var c = path[i]; + var isSeparator = c is '\\' or '/'; + if (isSeparator && lastWasSeparator) + continue; + path[writePos++] = isSeparator ? '/' : c; + lastWasSeparator = isSeparator; + } + + return writePos; + } +} \ 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..ebed228 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs @@ -0,0 +1,49 @@ +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 +{ + private static readonly PathNormalizeOptions LinuxDirectorySeparatorNormalizeOptions = new PathNormalizeOptions + { + TreatBackslashAsSeparator = true, + UnifyDirectorySeparators = true + }; + + public bool PathsAreEqual(string pathA, string pathB) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return _underlyingFileSystem.Path.AreEqual(pathA, pathB); + + var normalizedA = PathNormalizer.Normalize(pathA, LinuxDirectorySeparatorNormalizeOptions); + var normalizedB = PathNormalizer.Normalize(pathB, LinuxDirectorySeparatorNormalizeOptions); + + 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..ff51e65 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; + +namespace PG.StarWarsGame.Engine.IO; + +/// +/// A file system abstraction for the Petroglyph game engine. +/// +/// +/// The file system enforces Windows behavior for all its methods independent of the current operating system. +/// +public sealed partial class PetroglyphFileSystem +{ + private const char DirectorySeparatorChar = '/'; + private const char AltDirectorySeparatorChar = '\\'; + + private readonly IFileSystem _underlyingFileSystem; + + /// + /// Gets the underlying file system abstraction. + /// + public IFileSystem UnderlyingFileSystem => _underlyingFileSystem; + + public PetroglyphFileSystem(IServiceProvider serviceProvider) + { + if (serviceProvider == null) + throw new ArgumentNullException(nameof(serviceProvider)); + _underlyingFileSystem = serviceProvider.GetRequiredService(); + } + + 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, of course, 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 '\\' or '/'; + } +} \ 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..57a4725 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -0,0 +1,26 @@ + + + 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 + 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 98% rename from src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs rename to src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs index 96f0259..b58fc30 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs @@ -84,7 +84,7 @@ public ref char this[int index] } } - [DebuggerBrowsable((DebuggerBrowsableState.Never))] + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => AsSpan().ToString(); public override string ToString() @@ -297,13 +297,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. From 5298cfde664883824672b15b195a53bd84d580eb Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 18:19:19 +0200 Subject: [PATCH 07/35] Refactor `PetroglyphXmlFileParserBase`: remove redundant `IServiceProvider` field, optimize path handling with OS-specific logic. --- .../Base/PetroglyphXmlFileParserBase.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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..1f86b6d 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,9 +17,6 @@ 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; @@ -62,18 +61,24 @@ protected XElement GetRootElement(Stream xmlStream, out string fileName) return root; } - + private string GetStrippedFileName(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() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"DATA\XML\" : "DATA/XML/"; + } } From 54740439ebddf11d352407327c20b9cbb133b258 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 18:19:24 +0200 Subject: [PATCH 08/35] Add `PG.StarWarsGame.Engine.FileSystem` project to solution --- ModVerify.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/ModVerify.slnx b/ModVerify.slnx index 3527ff4..036cfc7 100644 --- a/ModVerify.slnx +++ b/ModVerify.slnx @@ -17,6 +17,7 @@ + From e40030beabbed9cd2acffea516445f229281b628 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 18:19:36 +0200 Subject: [PATCH 09/35] Update dependencies and add reference to `PG.StarWarsGame.Engine.FileSystem` project --- .../PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj index 96091cb..1af12bb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj @@ -17,16 +17,10 @@ true preview - - - - - - @@ -35,7 +29,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + From 34990300b6a096236df3b1e1f48a569cbd5e3009 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 18:20:26 +0200 Subject: [PATCH 10/35] Refactor: Replace generic filesystem abstraction with `PetroglyphFileSystem` for consistent, cross-platform path handling across the engine. --- .../CommandBarGameManager_Initialization.cs | 4 +- .../PG.StarWarsGame.Engine/GameManagerBase.cs | 8 +- ...ameObjectTypeGameManager.Initialization.cs | 4 +- .../GuiDialogGameManager_Initialization.cs | 2 +- .../IO/IGameRepository.cs | 7 +- .../IO/MultiPassRepository.cs | 9 +- .../IO/Repositories/EffectsRepository.cs | 6 +- .../IO/Repositories/FocGameRepository.cs | 12 +- .../IO/Repositories/GameRepository.Files.cs | 158 ++---------------- .../IO/Repositories/GameRepository.cs | 86 ++++++---- .../IO/Repositories/ModelRepository.cs | 11 +- .../IO/Repositories/TextureRepository.cs | 3 +- .../Rendering/PGRender.cs | 22 +-- .../Xml/PetroglyphStarWarsGameXmlParser.cs | 32 ++-- 14 files changed, 130 insertions(+), 234 deletions(-) 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_Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs index 43436aa..a22aff5 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs @@ -145,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 a3f46b8..f3fd10c 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,11 +102,11 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath) var sb = new ValueStringBuilder(stackalloc char[Math.Max(filePath.Length, PGConstants.MaxMegEntryPathLength)]); sb.Append(filePath); - NormalizePath(ref sb); + PGFileSystem.NormalizePath(ref sb); if (sb.Length > PGConstants.MaxMegEntryPathLength) { - Logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FileName}'", sb.ToString()); + _logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FileName}'", sb.ToString()); sb.Dispose(); return default; } @@ -129,41 +132,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); - - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - NormalizePath(ref stringBuilder); - - var actualFilePath = stringBuilder.AsSpan(); - exists = FileSystemPathExistsCaseInsensitive(actualFilePath, ref stringBuilder); - } - 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(stringBuilder.AsSpan()); + 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) @@ -179,7 +149,7 @@ protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList filePath, IList path) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return path.Length; - - var writePos = 0; - var lastWasSeparator = false; - for (var i = 0; i < path.Length; i++) - { - var c = path[i]; - var isSeparator = c is '\\' or '/'; - if (isSeparator && lastWasSeparator) - continue; - path[writePos++] = isSeparator ? '/' : c; - lastWasSeparator = isSeparator; - } - - return writePos; - } - - private static void NormalizePath(ref ValueStringBuilder stringBuilder) - { - stringBuilder.Length = NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length)); - } - - private bool FileSystemPathExistsCaseInsensitive(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder) - { - var pathString = filePath.ToString(); - if (FileSystem.File.Exists(pathString)) - return true; - - var directory = FileSystem.Path.GetDirectoryName(pathString); - var fileName = FileSystem.Path.GetFileName(pathString); - - if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) - return false; - - if (!FileSystem.Directory.Exists(directory)) - { - if (!FileSystemPathExistsCaseInsensitive(directory.AsSpan(), ref stringBuilder)) - return false; - - directory = stringBuilder.AsSpan().ToString(); - } - - var files = FileSystem.Directory.GetFiles(directory); - var directories = FileSystem.Directory.GetDirectories(directory); - - foreach (var file in files) - { - var name = FileSystem.Path.GetFileName(file); - if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) - { - stringBuilder.Length = 0; - stringBuilder.Append(file); - return true; - } - } - - foreach (var dir in directories) - { - var name = FileSystem.Path.GetFileName(dir); - if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) - { - stringBuilder.Length = 0; - stringBuilder.Append(dir); - return true; - } - } - - return false; - } - private static bool PathStartsWithDataDirectory(ReadOnlySpan path, out int cutoffLength) { cutoffLength = 0; @@ -288,29 +185,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/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)) { From 81dcf6f0a8336b307fa14c0f2f216e6e3e448123 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 19:32:26 +0200 Subject: [PATCH 11/35] Refactor: Simplify `NormalizePath` logic and remove unused return value --- .../IO/PetroglyphFileSystem.Normalize.cs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs index 6054bcc..fc7d43e 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs @@ -12,28 +12,20 @@ public sealed partial class PetroglyphFileSystem { internal void NormalizePath(ref ValueStringBuilder stringBuilder) { - stringBuilder.Length = NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length)); + NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length)); } // TODO: Check whether we can eliminate the double slash normalization // once we migrated to PGFileSystem - private static int NormalizePath(Span path) + private static void NormalizePath(Span path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return path.Length; - - var writePos = 0; - var lastWasSeparator = false; + return; for (var i = 0; i < path.Length; i++) { var c = path[i]; - var isSeparator = c is '\\' or '/'; - if (isSeparator && lastWasSeparator) - continue; - path[writePos++] = isSeparator ? '/' : c; - lastWasSeparator = isSeparator; + if (IsDirectorySeparator(c)) + path[i] = '/'; } - - return writePos; } } \ No newline at end of file From 5f87709245da87f665e76582635dbd0cefed4b26 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 19:32:33 +0200 Subject: [PATCH 12/35] Refactor: Use `ValueStringBuilder` in `PathStartsWithDataDirectory` for performance and normalize input paths --- .../IO/Repositories/GameRepository.Files.cs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) 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 f3fd10c..b5f8a04 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs @@ -160,21 +160,32 @@ protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList path, out int cutoffLength) + private bool PathStartsWithDataDirectory(ReadOnlySpan path, out int cutoffLength) { cutoffLength = 0; if (path.Length < 5) return false; - foreach (var prefix in DataPathPrefixes) + + var sb = new ValueStringBuilder(stackalloc char[265]); + sb.Append(path); + PGFileSystem.NormalizePath(ref sb); + try { - if (path.StartsWith(prefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) + foreach (var prefix in DataPathPrefixes) { - if (path[0] == '.') - cutoffLength = 2; - return true; + if (sb.AsSpan().StartsWith(prefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + if (path[0] == '.') + cutoffLength = 2; + return true; + } } + return false; + } + finally + { + sb.Dispose(); } - return false; } internal Stream? OpenFileCore(FileFoundInfo fileFoundInfo) From 13f26c5979cb2229ce34b45dac5ed70d48d4d110 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sun, 12 Apr 2026 19:32:46 +0200 Subject: [PATCH 13/35] Refactor: Replace direct filename handling with `NormalizeFileName` for consistency and re-enable particle name mismatch validation --- .../Verifiers/Commons/SingleModelVerifier.cs | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs index bf41457..1980ee6 100644 --- a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs +++ b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.InteropServices; using System.Threading; using AET.ModVerify.Reporting; using AET.ModVerify.Settings; @@ -240,31 +241,30 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c VerifierErrorCodes.InvalidFilePath, $"Invalid texture file name '{texture}' in particle '{file.FileName}'", VerificationSeverity.Error, - [file.FileName.ToUpperInvariant()], + [NormalizeFileName(file.FileName)], 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)) { - // TODO: Re-enable - // AddError(VerificationError.Create( - // this, - // VerifierErrorCodes.InvalidParticleName, - // $"The particle name '{file.Content.Name}' does not match file name '{file.FileName}'", - // VerificationSeverity.Error, - // [file.FileName.ToUpperInvariant()], - // file.Content.Name)); + AddError(VerificationError.Create( + this, + VerifierErrorCodes.InvalidParticleName, + $"The particle name '{file.Content.Name}' does not match file name '{file.FileName}'", + VerificationSeverity.Error, + [NormalizeFileName(file.FileName)], + file.Content.Name)); } } 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) { @@ -334,7 +334,7 @@ private void VerifyTextureExists(IPetroglyphFileHolder model, string texture, IR { if (texture == "None") return; - _textureVerifier.Verify(texture, [..contextInfo, model.FileName.ToUpperInvariant()], CancellationToken.None); + _textureVerifier.Verify(texture, [..contextInfo, NormalizeFileName(model.FileName)], CancellationToken.None); } private void VerifyProxyExists(IPetroglyphFileHolder model, string proxy, IReadOnlyCollection contextInfo, CancellationToken token) @@ -368,12 +368,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( From 3247dd51e5850a163ffd78c70d001047fde6e8c2 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 14:13:44 +0200 Subject: [PATCH 14/35] Refactor path normalization to use PathNormalizer Replaces custom path normalization logic in PetroglyphFileSystem with PathNormalizer from AnakinRaW.CommonUtilities. Moves LinuxDirectorySeparatorNormalizeOptions to a static readonly field. Cleans up unused usings and retains legacy code as comments for reference. --- .../IO/PetroglyphFileSystem.Normalize.cs | 26 ++++++++----------- .../IO/PetroglyphFileSystem.PathEqual.cs | 5 ---- .../IO/PetroglyphFileSystem.cs | 10 ++++++- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs index fc7d43e..6056269 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs @@ -1,9 +1,5 @@ using System; -using System.IO; -using System.IO.Abstractions; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using Microsoft.Extensions.DependencyInjection; +using AnakinRaW.CommonUtilities.FileSystem.Normalization; using PG.StarWarsGame.Engine.Utilities; namespace PG.StarWarsGame.Engine.IO; @@ -15,17 +11,17 @@ internal void NormalizePath(ref ValueStringBuilder stringBuilder) NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length)); } - // TODO: Check whether we can eliminate the double slash normalization - // once we migrated to PGFileSystem private static void NormalizePath(Span path) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - for (var i = 0; i < path.Length; i++) - { - var c = path[i]; - if (IsDirectorySeparator(c)) - path[i] = '/'; - } + PathNormalizer.Normalize(path, path, LinuxDirectorySeparatorNormalizeOptions); + + //if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + // return; + //for (var i = 0; i < path.Length; i++) + //{ + // var c = path[i]; + // if (IsDirectorySeparator(c)) + // path[i] = '/'; + //} } } \ 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 index ebed228..9af0c1b 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs @@ -7,11 +7,6 @@ namespace PG.StarWarsGame.Engine.IO; public sealed partial class PetroglyphFileSystem { - private static readonly PathNormalizeOptions LinuxDirectorySeparatorNormalizeOptions = new PathNormalizeOptions - { - TreatBackslashAsSeparator = true, - UnifyDirectorySeparators = true - }; public bool PathsAreEqual(string pathA, string pathB) { diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs index ff51e65..aba5540 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -1,8 +1,9 @@ +using AnakinRaW.CommonUtilities.FileSystem.Normalization; +using Microsoft.Extensions.DependencyInjection; using System; using System.IO; using System.IO.Abstractions; using System.Runtime.CompilerServices; -using Microsoft.Extensions.DependencyInjection; namespace PG.StarWarsGame.Engine.IO; @@ -17,6 +18,13 @@ public sealed partial class PetroglyphFileSystem private const char DirectorySeparatorChar = '/'; private const char AltDirectorySeparatorChar = '\\'; + private static readonly PathNormalizeOptions LinuxDirectorySeparatorNormalizeOptions = new() + { + TreatBackslashAsSeparator = true, // Ensure that we treat backslashes as separators on Linux + UnifyDirectorySeparators = true, + UnifySeparatorKind = DirectorySeparatorKind.System + }; + private readonly IFileSystem _underlyingFileSystem; /// From b4c03ccc34561efb1509540a59d69cacadc7561b Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 14:14:09 +0200 Subject: [PATCH 15/35] Rename StringExtensions and add Enum.Parse extension Renamed StringExtensions to Extensions. Added a generic Enum.Parse extension method for NETFRAMEWORK builds to simplify enum parsing from strings. --- .../Utilities/{StringExtensions.cs => Extensions.cs} | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) rename test/ModVerify.CliApp.Test/Utilities/{StringExtensions.cs => Extensions.cs} (54%) 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 From 3b58ea83527c67e964c0284b9d6b7620032a0f96 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 14:15:39 +0200 Subject: [PATCH 16/35] Refactor AudioFileVerifier: move FriendlyName, remove Verify Moved FriendlyName override to class body and removed the Verify method implementation from AudioFileVerifier. --- src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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); From cfb5053a6efb4cec96161ea9fd2a16bfd368ec72 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 18:02:53 +0200 Subject: [PATCH 17/35] Add comprehensive unit tests for `PG.StarWarsGame.Engine.FileSystem` and include test project in solution --- ModVerify.slnx | 1 + .../PetroglyphFileSystemTests.CombineJoin.cs | 82 ++++ .../IO/PetroglyphFileSystemTests.Exist.cs | 182 +++++++++ .../IO/PetroglyphFileSystemTests.Names.cs | 111 ++++++ .../IO/PetroglyphFileSystemTests.Normalize.cs | 28 ++ .../IO/PetroglyphFileSystemTests.PathEqual.cs | 19 + .../IO/PetroglyphFileSystemTests.cs | 46 +++ ...StarWarsGame.Engine.FileSystem.Test.csproj | 49 +++ .../PathAssert.cs | 18 + .../Utilities/ValueStringBuilderTests.cs | 371 ++++++++++++++++++ .../AssemblyAttributes.cs | 3 +- .../Utilities/ValueStringBuilder.cs | 16 - 12 files changed, 909 insertions(+), 17 deletions(-) create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs diff --git a/ModVerify.slnx b/ModVerify.slnx index 036cfc7..917a44f 100644 --- a/ModVerify.slnx +++ b/ModVerify.slnx @@ -17,6 +17,7 @@ + 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..e0bfe27 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +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")] + 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); + } + } + + [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 or use reflection if we want to be explicit. + // Actually, FileExists calls it. + // If it's fully qualified, it doesn't join with gameDirectory. + + var gameDir = "Z:\\non-existent-dir"; + var vsb = new ValueStringBuilder(); + _pgFileSystem.FileExists(path.AsSpan(), ref vsb, gameDir.AsSpan()); + + var resultPath = vsb.ToString(); + if (expected) + { + Assert.StartsWith(path, resultPath); + Assert.DoesNotContain(gameDir, resultPath); + } + else + { + Assert.Contains(gameDir, 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..f7ab58e --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AnakinRaW.CommonUtilities.FileSystem; +using Xunit; + +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 IEnumerable TestData_GetFileName_Volume() + { + yield return [":", ":"]; + yield return [".:", ".:"]; + yield return [".:.", ".:."]; // Not a valid drive letter + yield return ["file:", "file:"]; + yield return [":file", ":file"]; + yield return ["file:exe", "file:exe"]; + yield return ["baz\\file:exe", "file:exe"]; + yield return ["bar/baz/file:exe", "file:exe"]; + } + + [Theory, MemberData(nameof(TestData_GetFileName_Volume))] + public void GetFileName_Volume(string path, string expected) + { + // We used to break on ':' on Windows. This is a valid file name character for alternate data streams. + // Additionally, the character can show up on unix volumes mounted to Windows. +#if !NETFRAMEWORK + Assert.Equal(expected, Path.GetFileName(path)); + Assert.Equal(expected, _pgFileSystem.GetFileName(path)); +#if Windows + Assert.Equal(_pgFileSystem.GetFileName(path), _fileSystem.Path.GetFileName(path.AsSpan())); +#endif +#endif + + PathAssert.Equal(expected.AsSpan(), _pgFileSystem.GetFileName(path.AsSpan())); + } + + 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..7bf2890 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj @@ -0,0 +1,49 @@ + + + + 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 index f65486d..a179fe6 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; -[assembly:InternalsVisibleTo("PG.StarWarsGame.Engine")] \ No newline at end of file +[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/Utilities/ValueStringBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs index b58fc30..167c3cd 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs @@ -235,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; From f16178a8f28ce65ebb488f83952d5b06d982afdb Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 18:19:03 +0200 Subject: [PATCH 18/35] Remove unused test case for volume file names and update path handling in `FileExists` test --- .../IO/PetroglyphFileSystemTests.Exist.cs | 10 +++--- .../IO/PetroglyphFileSystemTests.Names.cs | 33 ++----------------- 2 files changed, 9 insertions(+), 34 deletions(-) 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 index e0bfe27..7f7746a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs @@ -168,15 +168,17 @@ public void IsPathFullyQualified_Exists_Internal(string path, bool expected) var vsb = new ValueStringBuilder(); _pgFileSystem.FileExists(path.AsSpan(), ref vsb, gameDir.AsSpan()); - var resultPath = vsb.ToString(); + var resultPath = vsb.ToString().Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + var expectedGameDir = gameDir.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + if (expected) { - Assert.StartsWith(path, resultPath); - Assert.DoesNotContain(gameDir, resultPath); + Assert.StartsWith(path.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar), resultPath); + Assert.DoesNotContain(expectedGameDir, resultPath); } else { - Assert.Contains(gameDir, resultPath); + 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 index f7ab58e..1aadea5 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs @@ -1,8 +1,9 @@ using System; -using System.Collections.Generic; using System.IO; -using AnakinRaW.CommonUtilities.FileSystem; using Xunit; +#if NETFRAMEWORK +using AnakinRaW.CommonUtilities.FileSystem; +#endif namespace PG.StarWarsGame.Engine.FileSystem.Test.IO; @@ -34,34 +35,6 @@ public void GetFileName_Span(string path, string expected) Assert.Equal(expected, _pgFileSystem.GetFileName(path)); } - public static IEnumerable TestData_GetFileName_Volume() - { - yield return [":", ":"]; - yield return [".:", ".:"]; - yield return [".:.", ".:."]; // Not a valid drive letter - yield return ["file:", "file:"]; - yield return [":file", ":file"]; - yield return ["file:exe", "file:exe"]; - yield return ["baz\\file:exe", "file:exe"]; - yield return ["bar/baz/file:exe", "file:exe"]; - } - - [Theory, MemberData(nameof(TestData_GetFileName_Volume))] - public void GetFileName_Volume(string path, string expected) - { - // We used to break on ':' on Windows. This is a valid file name character for alternate data streams. - // Additionally, the character can show up on unix volumes mounted to Windows. -#if !NETFRAMEWORK - Assert.Equal(expected, Path.GetFileName(path)); - Assert.Equal(expected, _pgFileSystem.GetFileName(path)); -#if Windows - Assert.Equal(_pgFileSystem.GetFileName(path), _fileSystem.Path.GetFileName(path.AsSpan())); -#endif -#endif - - PathAssert.Equal(expected.AsSpan(), _pgFileSystem.GetFileName(path.AsSpan())); - } - public static TheoryData TestData_GetFileNameWithoutExtension => new() { { "", "" }, From cb9e2db2f6d52d035e3db4554d7599893dff1105 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 23:05:45 +0200 Subject: [PATCH 19/35] Optimize case-insensitive file existence check in `PetroglyphFileSystem` and add additional test cases --- .../IO/PetroglyphFileSystemTests.Exist.cs | 2 + .../IO/PetroglyphFileSystem.Exist.cs | 78 +++++++++++-------- 2 files changed, 49 insertions(+), 31 deletions(-) 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 index 7f7746a..40f2f1d 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs @@ -84,6 +84,8 @@ public void FileExists_RelativePath() [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()); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs index fc78ea5..7474507 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -52,55 +52,71 @@ in stringBuilder.GetPinnableReference(true), // 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; +{ + Debug.Assert(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); - var directory = _underlyingFileSystem.Path.GetDirectoryName(pathString); - var fileName = _underlyingFileSystem.Path.GetFileName(pathString); + var pathString = filePath.ToString(); + if (_underlyingFileSystem.File.Exists(pathString)) + return true; - if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) - return false; + var segments = pathString.Split('/'); + var currentPath = segments[0].Length == 0 ? "/" : segments[0]; - if (!_underlyingFileSystem.Directory.Exists(directory)) - { - if (!FileExistsCaseInsensitive(directory.AsSpan(), ref stringBuilder)) - return false; + var lastSegmentIndex = segments.Length - 1; + while (lastSegmentIndex > 0 && string.IsNullOrEmpty(segments[lastSegmentIndex])) + lastSegmentIndex--; - directory = stringBuilder.AsSpan().ToString(); - } + for (var i = 1; i < segments.Length; i++) + { + var segment = segments[i]; + if (string.IsNullOrEmpty(segment)) + continue; - var files = _underlyingFileSystem.Directory.GetFiles(directory); - var directories = _underlyingFileSystem.Directory.GetDirectories(directory); + var isLastSegment = i == lastSegmentIndex; - foreach (var file in files) + // Guard: if currentPath doesn't exist as a directory, bail out cheaply + if (!_underlyingFileSystem.Directory.Exists(currentPath)) + return false; + + if (isLastSegment) { - var name = _underlyingFileSystem.Path.GetFileName(file); - if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + // Single IO call instead of GetFiles + GetDirectories + var entries = _underlyingFileSystem.Directory.GetFileSystemEntries(currentPath); + foreach (var entry in entries) { - stringBuilder.Length = 0; - stringBuilder.Append(file); - return true; + var name = _underlyingFileSystem.Path.GetFileName(entry); + if (name.Equals(segment, StringComparison.OrdinalIgnoreCase)) + { + stringBuilder.Length = 0; + stringBuilder.Append(entry); + return true; + } } + return false; } - foreach (var dir in directories) + // Intermediate segment: resolve case-insensitively + var subDirs = _underlyingFileSystem.Directory.GetDirectories(currentPath); + string? resolved = null; + foreach (var dir in subDirs) { var name = _underlyingFileSystem.Path.GetFileName(dir); - if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + if (name.Equals(segment, StringComparison.OrdinalIgnoreCase)) { - stringBuilder.Length = 0; - stringBuilder.Append(dir); - return true; + resolved = dir; + break; } } - return false; + if (resolved is null) + return false; + + currentPath = resolved; } - + + return false; +} + private bool IsPathFullyQualified_Exists(ReadOnlySpan path) { // This is really tricky, because under Windows "/" or "\" do NOT From 5401c7deea816e9f98d2f9e4cc0fd171689786cf Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 23:05:51 +0200 Subject: [PATCH 20/35] Update README with detailed mod verification examples for Windows and Linux --- README.md | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d3aa24c..cc41bed 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,41 @@ 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" --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" \ + --useDefaultBaseline +``` + --- ## Available Checks From 359362e9a935cce2615161df9dcf6c49e15c9da0 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 23:05:59 +0200 Subject: [PATCH 21/35] Add Linux-specific build target to release workflow --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From be6ba5d52c518067b41bc727b6597645192cefca Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 23:10:46 +0200 Subject: [PATCH 22/35] Fix mod paths in Linux instructions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc41bed..a5c1d31 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Uses manual mod setup, including sub-mods and the EaW fallback game, and uses th **Linux:** ```bash ./ModVerify verify \ - --mods "/home/user/games/FoC/Mods/MySubMod;/home/user/games/FoC/Mods/MyMod" \ + --mods "/home/user/games/FoC/Mods/MySubMod:/home/user/games/FoC/Mods/MyMod" \ --game "/home/user/games/FoC" \ --fallbackGame "/home/user/games/EaW" \ --useDefaultBaseline From 7eb735729a75bb43a684042103a014560eca0afe Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 23:33:07 +0200 Subject: [PATCH 23/35] revert to old impl --- .../IO/PetroglyphFileSystem.Exist.cs | 76 ++++++++----------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs index 7474507..c686bdb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -52,71 +52,55 @@ in stringBuilder.GetPinnableReference(true), // 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; + { + Debug.Assert(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + + var pathString = filePath.ToString(); + if (_underlyingFileSystem.File.Exists(pathString)) + return true; - var segments = pathString.Split('/'); - var currentPath = segments[0].Length == 0 ? "/" : segments[0]; + var directory = _underlyingFileSystem.Path.GetDirectoryName(pathString); + var fileName = _underlyingFileSystem.Path.GetFileName(pathString); - var lastSegmentIndex = segments.Length - 1; - while (lastSegmentIndex > 0 && string.IsNullOrEmpty(segments[lastSegmentIndex])) - lastSegmentIndex--; + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) + return false; - for (var i = 1; i < segments.Length; i++) - { - var segment = segments[i]; - if (string.IsNullOrEmpty(segment)) - continue; + if (!_underlyingFileSystem.Directory.Exists(directory)) + { + if (!FileExistsCaseInsensitive(directory.AsSpan(), ref stringBuilder)) + return false; - var isLastSegment = i == lastSegmentIndex; + directory = stringBuilder.AsSpan().ToString(); + } - // Guard: if currentPath doesn't exist as a directory, bail out cheaply - if (!_underlyingFileSystem.Directory.Exists(currentPath)) - return false; + var files = _underlyingFileSystem.Directory.GetFiles(directory); + var directories = _underlyingFileSystem.Directory.GetDirectories(directory); - if (isLastSegment) + foreach (var file in files) { - // Single IO call instead of GetFiles + GetDirectories - var entries = _underlyingFileSystem.Directory.GetFileSystemEntries(currentPath); - foreach (var entry in entries) + var name = _underlyingFileSystem.Path.GetFileName(file); + if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) { - var name = _underlyingFileSystem.Path.GetFileName(entry); - if (name.Equals(segment, StringComparison.OrdinalIgnoreCase)) - { - stringBuilder.Length = 0; - stringBuilder.Append(entry); - return true; - } + stringBuilder.Length = 0; + stringBuilder.Append(file); + return true; } - return false; } - // Intermediate segment: resolve case-insensitively - var subDirs = _underlyingFileSystem.Directory.GetDirectories(currentPath); - string? resolved = null; - foreach (var dir in subDirs) + foreach (var dir in directories) { var name = _underlyingFileSystem.Path.GetFileName(dir); - if (name.Equals(segment, StringComparison.OrdinalIgnoreCase)) + if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase)) { - resolved = dir; - break; + stringBuilder.Length = 0; + stringBuilder.Append(dir); + return true; } } - if (resolved is null) - return false; - - currentPath = resolved; + return false; } - return false; -} - private bool IsPathFullyQualified_Exists(ReadOnlySpan path) { // This is really tricky, because under Windows "/" or "\" do NOT From 01370d9ad4d32ec558a7aaac76d84a317f1d1354 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 23:44:07 +0200 Subject: [PATCH 24/35] Add Linux-specific tests for case-insensitive file existence handling in `PetroglyphFileSystem` --- .../IO/PetroglyphFileSystemTests.Exist.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 index 40f2f1d..a6095e6 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Runtime.InteropServices; +using AnakinRaW.CommonUtilities.Testing.Attributes; using PG.StarWarsGame.Engine.Utilities; using Xunit; @@ -150,6 +151,58 @@ public void FileExists_GameDirectory_WithTrailingSeparator() } } + [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 impls 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)] From df19dee45fd0e549ede79dddc2df90a2e12475ff Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Tue, 14 Apr 2026 23:58:40 +0200 Subject: [PATCH 25/35] Update README: include `--engine` parameter in examples and add Linux baseline creation example --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a5c1d31..2725d72 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Uses manual mod setup, including sub-mods and the EaW fallback game, and uses th **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" --useDefaultBaseline +.\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:** @@ -112,6 +112,7 @@ Uses manual mod setup, including sub-mods and the EaW fallback game, and uses th --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 ``` @@ -144,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" +``` From d61fce63e06614b99641f6c72a5eca6f1ecf67d0 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Wed, 15 Apr 2026 09:54:06 +0200 Subject: [PATCH 26/35] update dependencies --- modules/ModdingToolBase | 2 +- src/ModVerify.CliApp/ModVerify.CliApp.csproj | 20 +++++++++++-------- src/ModVerify/ModVerify.csproj | 8 ++++++-- ...StarWarsGame.Engine.FileSystem.Test.csproj | 12 +++++++---- .../PG.StarWarsGame.Engine.FileSystem.csproj | 5 ++++- .../PG.StarWarsGame.Engine.csproj | 10 +++++++--- .../PG.StarWarsGame.Files.ALO.csproj | 3 +++ .../PG.StarWarsGame.Files.ChunkFiles.csproj | 5 ++++- .../PG.StarWarsGame.Files.XML.csproj | 5 ++++- .../ModVerify.CliApp.Test.csproj | 12 +++++++---- 10 files changed, 57 insertions(+), 25 deletions(-) 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/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/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/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 index 7bf2890..04f1893 100644 --- 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 @@ -14,11 +14,11 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -32,6 +32,10 @@ + + + + true 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 index 57a4725..a779e26 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -20,7 +20,10 @@ - + + + + diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj index 1af12bb..5389ded 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj @@ -18,10 +18,10 @@ preview - - + + - + @@ -36,4 +36,8 @@ + + + + \ No newline at end of file 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/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 From 20788755ae22d67333dbfa56a3f55bd1d1881383 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Wed, 15 Apr 2026 14:58:03 +0200 Subject: [PATCH 27/35] Refactor file path handling in GetFileInfoFromMasterMeg Check filePath length before allocating ValueStringBuilder to improve efficiency. Update warning log to use filePath.ToString() for overlong paths. Reorganize normalization logic for clarity and ensure proper resource disposal. --- .../IO/Repositories/GameRepository.Files.cs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) 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 b5f8a04..3e31bf9 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs @@ -100,26 +100,23 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath) { Debug.Assert(MasterMegArchive is not null); - var sb = new ValueStringBuilder(stackalloc char[Math.Max(filePath.Length, PGConstants.MaxMegEntryPathLength)]); - sb.Append(filePath); - PGFileSystem.NormalizePath(ref sb); - - if (sb.Length > PGConstants.MaxMegEntryPathLength) + if (filePath.Length > PGConstants.MaxMegEntryPathLength) { - _logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FileName}'", sb.ToString()); - sb.Dispose(); + _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(sb.AsSpan(), fileNameSpan, out var length)) - { - sb.Dispose(); - return default; - } - + + var normalized = _megPathNormalizer.TryNormalize(sb.AsSpan(), fileNameSpan, out var length); sb.Dispose(); + + if (!normalized) + return default; var fileName = fileNameSpan.Slice(0, length); From d77f8d3294728f233e4e30ec30bbc5ce4895c159 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Wed, 15 Apr 2026 14:58:28 +0200 Subject: [PATCH 28/35] rename method to express intent --- .../Parsers/Base/PetroglyphXmlFileParserBase.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 1f86b6d..4c72f4a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs @@ -23,7 +23,7 @@ public abstract class PetroglyphXmlFileParserBase(IServiceProvider serviceProvid 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,7 +62,7 @@ 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; @@ -77,6 +77,7 @@ private string GetStrippedFileName(string filePath) [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/"; } } From f27972be8285167c72c37edbbf5495116103a72a Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Wed, 15 Apr 2026 14:58:45 +0200 Subject: [PATCH 29/35] formatting --- ...StarWarsGame.Engine.FileSystem.Test.csproj | 97 +++++++++---------- .../PG.StarWarsGame.Engine.FileSystem.csproj | 50 +++++----- 2 files changed, 73 insertions(+), 74 deletions(-) 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 index 04f1893..2f621bc 100644 --- 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 @@ -1,53 +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 - - + + 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/PG.StarWarsGame.Engine.FileSystem.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj index a779e26..213b3dd 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -1,29 +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 - snupkg - true - preview - - - - - - - - - + + + true + snupkg + true + preview + + + + + + + + + From 71f774236144fbcb55a61c03ef1e6de10ebe6f88 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Wed, 15 Apr 2026 15:56:02 +0200 Subject: [PATCH 30/35] Update path normalization for Windows-like behavior on Linux Replaced LinuxDirectorySeparatorNormalizeOptions with PGFileSystemDirectorySeparatorNormalizeOptions to ensure consistent Windows-like path handling on Linux. Updated class documentation to clarify behavior. Refactored IsDirectorySeparator to use defined separator constants for improved clarity. --- .../IO/PetroglyphFileSystem.Normalize.cs | 13 ++----------- .../IO/PetroglyphFileSystem.PathEqual.cs | 4 ++-- .../IO/PetroglyphFileSystem.cs | 10 ++++++---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs index 6056269..8f67e6d 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs @@ -12,16 +12,7 @@ internal void NormalizePath(ref ValueStringBuilder stringBuilder) } private static void NormalizePath(Span path) - { - PathNormalizer.Normalize(path, path, LinuxDirectorySeparatorNormalizeOptions); - - //if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - // return; - //for (var i = 0; i < path.Length; i++) - //{ - // var c = path[i]; - // if (IsDirectorySeparator(c)) - // path[i] = '/'; - //} + { + 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 index 9af0c1b..eab1e88 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs @@ -13,8 +13,8 @@ public bool PathsAreEqual(string pathA, string pathB) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return _underlyingFileSystem.Path.AreEqual(pathA, pathB); - var normalizedA = PathNormalizer.Normalize(pathA, LinuxDirectorySeparatorNormalizeOptions); - var normalizedB = PathNormalizer.Normalize(pathB, LinuxDirectorySeparatorNormalizeOptions); + var normalizedA = PathNormalizer.Normalize(pathA, PGFileSystemDirectorySeparatorNormalizeOptions); + var normalizedB = PathNormalizer.Normalize(pathB, PGFileSystemDirectorySeparatorNormalizeOptions); var fullA = _underlyingFileSystem.Path.GetFullPath(normalizedA); var fullB = _underlyingFileSystem.Path.GetFullPath(normalizedB); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs index aba5540..72f99d4 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -11,14 +11,16 @@ namespace PG.StarWarsGame.Engine.IO; /// A file system abstraction for the Petroglyph game engine. /// /// -/// The file system enforces Windows behavior for all its methods independent of the current operating system. +/// 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 = '\\'; - private static readonly PathNormalizeOptions LinuxDirectorySeparatorNormalizeOptions = new() + // ReSharper disable once InconsistentNaming + private static readonly PathNormalizeOptions PGFileSystemDirectorySeparatorNormalizeOptions = new() { TreatBackslashAsSeparator = true, // Ensure that we treat backslashes as separators on Linux UnifyDirectorySeparators = true, @@ -51,7 +53,7 @@ internal FileSystemStream OpenRead(string filePath) private static bool IsPathRooted(ReadOnlySpan path) { - // The original implementation, of course, also checks for drive signatures (e.g, c:, X:). + // 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]); @@ -89,6 +91,6 @@ private static bool IsEffectivelyEmpty(ReadOnlySpan path) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsDirectorySeparator(char c) { - return c is '\\' or '/'; + return c is DirectorySeparatorChar or AltDirectorySeparatorChar; } } \ No newline at end of file From 7a7f2d30a467953ac84e8eddf6121306224c53bb Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Wed, 15 Apr 2026 17:02:19 +0200 Subject: [PATCH 31/35] documentation --- .../IO/PetroglyphFileSystemTests.Exist.cs | 10 +-- .../IO/PetroglyphFileSystem.Exist.cs | 5 +- .../IO/PetroglyphFileSystem.Names.cs | 84 ++++++++++++++++++- .../IO/PetroglyphFileSystem.PathEqual.cs | 9 +- .../IO/PetroglyphFileSystem.cs | 15 ++++ .../PG.StarWarsGame.Engine.FileSystem.csproj | 4 +- 6 files changed, 112 insertions(+), 15 deletions(-) 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 index a6095e6..7d88abf 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs @@ -166,7 +166,7 @@ public void FileExists_CaseInsensitive_DotSegmentInPath_ReturnsTrue() // 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 impls must handle "." as a valid path segment that resolves to the current directory, + // 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()); @@ -215,11 +215,9 @@ public void FileExists_CaseInsensitive_MissingIntermediateDirectory_ReturnsFalse [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 or use reflection if we want to be explicit. - // Actually, FileExists calls it. - // If it's fully qualified, it doesn't join with gameDirectory. - - var gameDir = "Z:\\non-existent-dir"; + // 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()); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs index c686bdb..364a028 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -13,10 +13,7 @@ namespace PG.StarWarsGame.Engine.IO; public sealed partial class PetroglyphFileSystem { - internal bool FileExists( - ReadOnlySpan filePath, - ref ValueStringBuilder stringBuilder, - ReadOnlySpan gameDirectory) + internal bool FileExists(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder, ReadOnlySpan gameDirectory) { stringBuilder.Length = 0; diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs index cca24b6..d7bb058 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs @@ -9,7 +9,18 @@ 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 @@ -26,6 +37,16 @@ public sealed partial class PetroglyphFileSystem 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)) @@ -36,6 +57,11 @@ public ReadOnlySpan GetFileName(ReadOnlySpan path) 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 @@ -53,6 +79,11 @@ public ReadOnlySpan GetFileName(ReadOnlySpan 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)) @@ -65,6 +96,49 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) 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 @@ -109,7 +183,13 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) 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)) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs index eab1e88..4afa311 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs @@ -7,7 +7,14 @@ 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)) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs index 72f99d4..ee36824 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs @@ -34,6 +34,11 @@ public sealed partial class PetroglyphFileSystem /// 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) @@ -41,6 +46,16 @@ public PetroglyphFileSystem(IServiceProvider 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]); 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 index 213b3dd..e1cd1f9 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -8,9 +8,9 @@ alamo,petroglyph,glyphx - true - true--> + true true From abce4b12bb8c1d4fa347f1f2fe6d8cc5826d5f38 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Wed, 15 Apr 2026 17:05:51 +0200 Subject: [PATCH 32/35] Refactor path handling to use platform-specific separators ModPaths and AdditionalFallbackPath are now single string properties using the platform-specific path separator, instead of IList. SettingsBuilder splits and normalizes these paths accordingly. Help texts are updated, and unit tests are added to verify correct path parsing and normalization using a mock file system. --- .../CommandLine/BaseModVerifyOptions.cs | 12 ++-- .../Settings/SettingsBuilder.cs | 21 +++---- .../SettingsBuilderTest.cs | 63 ++++++++++++++++--- 3 files changed, 71 insertions(+), 25 deletions(-) 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/SettingsBuilder.cs b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs index cc7a30e..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; @@ -136,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/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs index d70c398..429567b 100644 --- a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs +++ b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs @@ -1,23 +1,72 @@ using AET.ModVerify.App; using AET.ModVerify.App.Settings; using AET.ModVerify.App.Settings.CommandLine; -using Microsoft.Extensions.DependencyInjection; using System.IO.Abstractions; -using Testably.Abstractions; +using Testably.Abstractions.Testing; using Xunit; +using AnakinRaW.CommonUtilities.Testing; namespace ModVerify.CliApp.Test; -public class SettingsBuilderTest +public class SettingsBuilderTest : TestBaseWithFileSystem { private readonly SettingsBuilder _builder; public SettingsBuilderTest() { - var services = new ServiceCollection(); - services.AddSingleton(new RealFileSystem()); - var provider = services.BuildServiceProvider(); - _builder = new SettingsBuilder(provider); + _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] From 5519163fcb6f9788976a77ad9df85960169452da Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 16 Apr 2026 15:13:42 +0200 Subject: [PATCH 33/35] fix reporting of missing texutres --- .../Verifiers/Commons/SingleModelVerifier.cs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs index 1980ee6..643b352 100644 --- a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs +++ b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Runtime.InteropServices; using System.Threading; using AET.ModVerify.Reporting; using AET.ModVerify.Settings; @@ -17,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 @@ -230,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, _ => { @@ -241,7 +238,7 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c VerifierErrorCodes.InvalidFilePath, $"Invalid texture file name '{texture}' in particle '{file.FileName}'", VerificationSeverity.Error, - [NormalizeFileName(file.FileName)], + particleContext, texture)); }); } @@ -256,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, - [NormalizeFileName(file.FileName)], + particleContext, file.Content.Name)); } @@ -268,7 +265,7 @@ private void VerifyModel(IAloModelFile file, AnimationCollection animations, IRe foreach (var texture in file.Content.Textures) { - GuardedVerify(() => VerifyTextureExists(file, texture, modelContext), + GuardedVerify(() => VerifyTextureExists(texture, modelContext), e => e is ArgumentException, _ => { @@ -330,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, NormalizeFileName(model.FileName)], CancellationToken.None); + _textureVerifier.Verify(texture, [..contextInfo], CancellationToken.None); } private void VerifyProxyExists(IPetroglyphFileHolder model, string proxy, IReadOnlyCollection contextInfo, CancellationToken token) From 12b170b3567c901def303bac6f14b2d6cf2805b8 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 16 Apr 2026 15:13:56 +0200 Subject: [PATCH 34/35] add new algorithm to evaluate --- .../IO/PetroglyphFileSystem.Exist.cs | 139 +++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs index 364a028..6dd3de7 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs @@ -1,10 +1,10 @@ using System; using System.Diagnostics; -using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using AnakinRaW.CommonUtilities.FileSystem; using PG.StarWarsGame.Engine.Utilities; +using System.IO; +using AnakinRaW.CommonUtilities.FileSystem; #if NETSTANDARD2_1 || NET using System.Diagnostics.CodeAnalysis; #endif @@ -132,4 +132,139 @@ private static extern IntPtr CreateFile( [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 From 1d5a8a2e062b960f3719da0fbb2341fddcaa8e80 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Thu, 16 Apr 2026 17:00:42 +0200 Subject: [PATCH 35/35] Handle "none" GUI textures and unify repo lookups Refactored texture existence checks by introducing IsNone and GuiSpecialTextureExists helpers. Special cases for "none" textures now avoid false warnings and are consistently cached. Centralized repository lookup logic for Scanlines, ButtonMiddle, and FrameBackground types. Improved error messages by clarifying origin labeling and adjusting error context. --- .../GuiDialogs/GuiDialogsVerifier.cs | 19 ++++--- .../GuiDialog/GuiDialogGameManager.cs | 54 ++++++++++--------- 2 files changed, 41 insertions(+), 32 deletions(-) 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/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