diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2d7d7f3..d33c33c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -47,6 +47,8 @@ jobs:
- name: Create Net Core Release
# use publish for .NET Core
run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --output ./releases/net10.0 /p:DebugType=None /p:DebugSymbols=false
+ - name: Create Linux Release
+ run: dotnet publish ${{ env.TOOL_PROJ_PATH }} --configuration Release -f net10.0 --runtime linux-x64 --self-contained true --output ./releases/linux-x64 /p:DebugType=None /p:DebugSymbols=false
- name: Upload a Build Artifact
uses: actions/upload-artifact@v7
with:
@@ -117,4 +119,5 @@ jobs:
generate_release_notes: true
files: |
./releases/net481/ModVerify.exe
- ./releases/ModVerify-Net10.zip
\ No newline at end of file
+ ./releases/ModVerify-Net10.zip
+ ./releases/linux-x64/ModVerify
\ No newline at end of file
diff --git a/ModVerify.slnx b/ModVerify.slnx
index 3527ff4..917a44f 100644
--- a/ModVerify.slnx
+++ b/ModVerify.slnx
@@ -17,6 +17,8 @@
+
+
diff --git a/README.md b/README.md
index d3aa24c..2725d72 100644
--- a/README.md
+++ b/README.md
@@ -80,13 +80,42 @@ In general ModVerify has two operation mods.
1. `verify` Verifying a game or mod
2. `createBaseline` Creating a baseline for a game or mod, that can be used for further verifications in order to verify you did not add more errors to your mods.
-### Example
-This is an example run configuration that analyzes a specific mod, uses a the FoC basline and writes the output into a dedicated directory:
+### Examples
-```bash
+#### Example 1: Auto-detection with a custom baseline
+Analyzes a specific mod, uses the FoC baseline and writes the output into a dedicated directory:
+
+**Windows:**
+```bat
.\ModVerify.exe verify --path "C:\My Games\FoC\Mods\MyMod" --outDir "C:\My Games\FoC\Mods\MyMod\verifyResults" --baseline ./focBaseline.json
```
+**Linux:**
+```bash
+./ModVerify verify \
+ --path "/home/user/games/FoC/Mods/MyMod" \
+ --outDir "/home/user/games/FoC/Mods/MyMod/verifyResults" \
+ --baseline ./focBaseline.json
+```
+
+#### Example 2: Manual mod setup with sub-mods, EaW fallback and default baseline
+Uses manual mod setup, including sub-mods and the EaW fallback game, and uses the default embedded baseline:
+
+**Windows:**
+```bat
+.\ModVerify.exe verify --mods "C:\My Games\FoC\Mods\MySubMod;C:\My Games\FoC\Mods\MyMod" --game "C:\My Games\FoC" --fallbackGame "C:\My Games\EaW" --engine FOC --useDefaultBaseline
+```
+
+**Linux:**
+```bash
+./ModVerify verify \
+ --mods "/home/user/games/FoC/Mods/MySubMod:/home/user/games/FoC/Mods/MyMod" \
+ --game "/home/user/games/FoC" \
+ --fallbackGame "/home/user/games/EaW" \
+ --engine FOC \
+ --useDefaultBaseline
+```
+
---
## Available Checks
@@ -116,6 +145,14 @@ The following verifiers are currently implemented:
If you want to create your own baseline use the `createBaseline` option.
### Example
+
+**Windows**
```bash
ModVerify.exe createBaseline --outFile myBaseline.json --path "C:\My Games\FoC\Mods\MyMod"
```
+**Linux**
+```bash
+./ModVerify createBaseline \
+ --outFile myBaseline.json \
+ --path "C:\My Games\FoC\Mods\MyMod"
+```
diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase
index da072f4..3516a42 160000
--- a/modules/ModdingToolBase
+++ b/modules/ModdingToolBase
@@ -1 +1 @@
-Subproject commit da072f43e6b85aab35b43d11f6b36eab61bdcfa6
+Subproject commit 3516a4292c5d42bf14f4a2420604f2261c0f824d
diff --git a/src/ModVerify.CliApp/App/VerifyAction.cs b/src/ModVerify.CliApp/App/VerifyAction.cs
index e17d7bc..deeb053 100644
--- a/src/ModVerify.CliApp/App/VerifyAction.cs
+++ b/src/ModVerify.CliApp/App/VerifyAction.cs
@@ -61,7 +61,8 @@ protected override VerificationBaseline GetBaseline(VerificationTarget verificat
{
Console.WriteLine();
ModVerifyConsoleUtilities.WriteBaselineInfo(baseline, baselinePath);
- Logger?.LogDebug("Using baseline {Baseline} from location '{Path}'", baseline.ToString(), baselinePath);
+ Logger?.LogDebug("Using baseline {Baseline} from location '{Path}'",
+ baseline.ToString(), baselinePath ?? "Embedded");
Console.WriteLine();
}
return baseline;
diff --git a/src/ModVerify.CliApp/ModVerify.CliApp.csproj b/src/ModVerify.CliApp/ModVerify.CliApp.csproj
index f7b7ff8..cb6593c 100644
--- a/src/ModVerify.CliApp/ModVerify.CliApp.csproj
+++ b/src/ModVerify.CliApp/ModVerify.CliApp.csproj
@@ -37,11 +37,11 @@
-
-
-
-
-
+
+
+
+
+
@@ -62,15 +62,15 @@
-
+
compile
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
compile
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
@@ -82,6 +82,10 @@
+
+
+
+
diff --git a/src/ModVerify.CliApp/Reporting/BaselineSelector.cs b/src/ModVerify.CliApp/Reporting/BaselineSelector.cs
index efcaae5..4fff812 100644
--- a/src/ModVerify.CliApp/Reporting/BaselineSelector.cs
+++ b/src/ModVerify.CliApp/Reporting/BaselineSelector.cs
@@ -42,7 +42,7 @@ public VerificationBaseline SelectBaseline(VerificationTarget verificationTarget
}
}
- if (!settings.ReportSettings.SearchBaselineLocally)
+ if (settings.ReportSettings is { SearchBaselineLocally: false, UseDefaultBaseline: false })
{
_logger?.LogDebug(ModVerifyConstants.ConsoleEventId,
"No baseline path specified and local search is not enabled. Using empty baseline.");
@@ -134,7 +134,7 @@ internal static VerificationBaseline LoadEmbeddedBaseline(GameEngineType engineT
private VerificationBaseline FindBaselineNonInteractive(VerificationTarget target, out string? usedPath)
{
if (_baselineFactory.TryFindBaselineInDirectory(
- target.Location.TargetPath,
+ target.Location.TargetPath,
b => IsBaselineCompatible(b, target),
out var baseline,
out usedPath))
@@ -144,6 +144,20 @@ private VerificationBaseline FindBaselineNonInteractive(VerificationTarget targe
}
_logger?.LogTrace("No baseline file found in taget path '{TargetPath}'.", target.Location.TargetPath);
usedPath = null;
+ if (settings.ReportSettings.UseDefaultBaseline)
+ {
+ try
+ {
+ var defaultBaseline = LoadEmbeddedBaseline(target.Engine);
+ _logger?.LogInformation(ModVerifyConstants.ConsoleEventId, "Automatically applying default embedded baseline for engine '{Engine}'.", target.Engine);
+ return defaultBaseline;
+ }
+ catch (InvalidBaselineException)
+ {
+ throw new InvalidOperationException(
+ "Invalid baseline packed along ModVerify App. Please reach out to the creators. Thanks!");
+ }
+ }
return VerificationBaseline.Empty;
}
diff --git a/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs b/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs
index 36c8509..521fb8c 100644
--- a/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs
+++ b/src/ModVerify.CliApp/Settings/CommandLine/BaseModVerifyOptions.cs
@@ -25,10 +25,10 @@ internal abstract class BaseModVerifyOptions
"The argument cannot be combined with any of --mods, --game or --fallbackGame")]
public string? TargetPath { get; init; }
- [Option("mods", SetName = "manualPaths", Required = false, Default = null, Separator = ';',
- HelpText = "The path of the mod to verify. To support submods, multiple paths can be separated using the ';' (semicolon) character. " +
+ [Option("mods", SetName = "manualPaths", Required = false, Default = null,
+ HelpText = "The path of the mod to verify. To support submods, multiple paths can be separated using the platform-specific path separator (';' on Windows, ':' on Linux). " +
"Leave empty, if you want to verify a game. If you want to use the interactive mode, leave this, --game and --fallbackGame empty.")]
- public IList? ModPaths { get; init; }
+ public string? ModPaths { get; init; }
[Option("game", SetName = "manualPaths", Required = false, Default = null,
HelpText = "The path of the base game. For FoC mods this points to the FoC installation, for EaW mods this points to the EaW installation. " +
@@ -47,10 +47,10 @@ internal abstract class BaseModVerifyOptions
public GameEngineType? Engine { get; init; }
- [Option("additionalFallbackPaths", Required = false, Separator = ';',
+ [Option("additionalFallbackPaths", Required = false,
HelpText = "Additional fallback paths, which may contain assets that shall be included when doing the verification. Do not add EaW here. " +
- "Multiple paths can be separated using the ';' (semicolon) character.")]
- public IList? AdditionalFallbackPath { get; init; }
+ "Multiple paths can be separated using the platform-specific path separator (';' on Windows, ':' on Linux).")]
+ public string? AdditionalFallbackPath { get; init; }
[Option("parallel", Default = false,
HelpText = "When set, game verifiers will run in parallel. " +
diff --git a/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs b/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs
index e3be836..5e01c5c 100644
--- a/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs
+++ b/src/ModVerify.CliApp/Settings/CommandLine/VerifyVerbOption.cs
@@ -29,13 +29,17 @@ internal sealed class VerifyVerbOption : BaseModVerifyOptions
public bool IgnoreAsserts { get; init; }
- [Option("baseline", SetName = "baselineSelection", Required = false,
- HelpText = "Path to a JSON baseline file. Cannot be used together with --searchBaseline.")]
+ [Option("baseline", Required = false,
+ HelpText = "Path to a JSON baseline file. Cannot be used together with --searchBaseline or --useDefaultBaseline.")]
public string? Baseline { get; init; }
- [Option("searchBaseline", SetName = "baselineSelection", Required = false,
- HelpText = "When set, the application will search for baseline files and use them for verification. Cannot be used together with --baseline")]
+ [Option("searchBaseline", Required = false,
+ HelpText = "When set, the application will search for baseline files and use them for verification. Cannot be used together with --baseline or --useDefaultBaseline")]
public bool SearchBaselineLocally { get; init; }
+ [Option("useDefaultBaseline", Required = false,
+ HelpText = "When set, the application will use the default embedded baseline for the detected game engine. Cannot be used together with --baseline or --searchBaseline.")]
+ public bool UseDefaultBaseline { get; init; }
+
public bool IsRunningWithoutArguments { get; init; }
}
\ No newline at end of file
diff --git a/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs b/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs
index add39a5..0610728 100644
--- a/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs
+++ b/src/ModVerify.CliApp/Settings/ModVerifyAppSettings.cs
@@ -17,6 +17,7 @@ public sealed class VerifyReportSettings : AppReportSettings
{
public string? BaselinePath { get; init; }
public bool SearchBaselineLocally { get; init; }
+ public bool UseDefaultBaseline { get; init; }
}
internal abstract class AppSettingsBase(AppReportSettings reportSettings)
diff --git a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs
index 62bca7a..fe31595 100644
--- a/src/ModVerify.CliApp/Settings/SettingsBuilder.cs
+++ b/src/ModVerify.CliApp/Settings/SettingsBuilder.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
+using System.Linq;
namespace AET.ModVerify.App.Settings;
@@ -57,6 +58,20 @@ void ValidateVerb()
throw new AppArgumentException($"Options {searchOption} and {baselineOption} cannot be used together.");
}
+ if (verifyOptions.UseDefaultBaseline && !string.IsNullOrEmpty(verifyOptions.Baseline))
+ {
+ var useDefaultOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.UseDefaultBaseline));
+ var baselineOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.Baseline));
+ throw new AppArgumentException($"Options {useDefaultOption} and {baselineOption} cannot be used together.");
+ }
+
+ if (verifyOptions is { UseDefaultBaseline: true, SearchBaselineLocally: true })
+ {
+ var useDefaultOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.UseDefaultBaseline));
+ var searchOption = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.SearchBaselineLocally));
+ throw new AppArgumentException($"Options {useDefaultOption} and {searchOption} cannot be used together.");
+ }
+
if (verifyOptions is { FailFast: true, MinimumFailureSeverity: null })
{
var failFast = typeof(VerifyVerbOption).GetOptionName(nameof(VerifyVerbOption.FailFast));
@@ -86,6 +101,7 @@ VerifyReportSettings BuildReportSettings()
BaselinePath = verifyOptions.Baseline,
MinimumReportSeverity = verifyOptions.MinimumSeverity,
SearchBaselineLocally = verifyOptions.SearchBaselineLocally,
+ UseDefaultBaseline = verifyOptions.UseDefaultBaseline,
SuppressionsPath = verifyOptions.Suppressions,
Verbose = verifyOptions.Verbose
};
@@ -121,24 +137,20 @@ AppReportSettings BuildReportSettings()
private VerificationTargetSettings BuildTargetSettings(BaseModVerifyOptions options)
{
+ var separator = _fileSystem.Path.PathSeparator;
+
var modPaths = new List();
- if (options.ModPaths is not null)
+ if (!string.IsNullOrEmpty(options.ModPaths))
{
- foreach (var mod in options.ModPaths)
- {
- if (!string.IsNullOrEmpty(mod))
- modPaths.Add(_fileSystem.Path.GetFullPath(mod));
- }
+ var split = options.ModPaths!.Split([separator], StringSplitOptions.RemoveEmptyEntries);
+ modPaths.AddRange(split.Select(s => _fileSystem.Path.GetFullPath(s)));
}
var fallbackPaths = new List();
- if (options.AdditionalFallbackPath is not null)
+ if (!string.IsNullOrEmpty(options.AdditionalFallbackPath))
{
- foreach (var fallback in options.AdditionalFallbackPath)
- {
- if (!string.IsNullOrEmpty(fallback))
- fallbackPaths.Add(_fileSystem.Path.GetFullPath(fallback));
- }
+ var split = options.AdditionalFallbackPath!.Split([separator], StringSplitOptions.RemoveEmptyEntries);
+ fallbackPaths.AddRange(split.Select(s => _fileSystem.Path.GetFullPath(s)));
}
var gamePath = options.GamePath;
diff --git a/src/ModVerify/ModVerify.csproj b/src/ModVerify/ModVerify.csproj
index 96656cc..b53b442 100644
--- a/src/ModVerify/ModVerify.csproj
+++ b/src/ModVerify/ModVerify.csproj
@@ -27,8 +27,8 @@
-
-
+
+
@@ -42,4 +42,8 @@
+
+
+
+
diff --git a/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs b/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs
index 4537a64..5fb7e70 100644
--- a/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs
+++ b/src/ModVerify/Verifiers/Commons/AudioFileVerifier.cs
@@ -14,6 +14,8 @@ public class AudioFileVerifier : GameVerifier
{
private readonly IAlreadyVerifiedCache? _alreadyVerifiedCache;
+ public override string FriendlyName => "Audio File format";
+
public AudioFileVerifier(GameVerifierBase parent) : base(parent)
{
_alreadyVerifiedCache = Services.GetService();
@@ -27,8 +29,6 @@ public AudioFileVerifier(IGameVerifierInfo? parent,
_alreadyVerifiedCache = serviceProvider.GetService();
}
- public override string FriendlyName => "Audio File format";
-
public override void Verify(AudioFileInfo sampleInfo, IReadOnlyCollection contextInfo, CancellationToken token)
{
var cached = _alreadyVerifiedCache?.GetEntry(sampleInfo.SampleName);
diff --git a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs
index 0376750..643b352 100644
--- a/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs
+++ b/src/ModVerify/Verifiers/Commons/SingleModelVerifier.cs
@@ -16,10 +16,6 @@
using PG.StarWarsGame.Files.ALO.Files.Particles;
using PG.StarWarsGame.Files.Binary;
-#if NETSTANDARD2_0 || NETFRAMEWORK
-using AnakinRaW.CommonUtilities.FileSystem;
-#endif
-
namespace AET.ModVerify.Verifiers.Commons;
public sealed class SingleModelVerifier : GameVerifierBase
@@ -229,9 +225,11 @@ private void VerifyModelClass(ModelClass modelClass, IReadOnlyCollection
private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection contextInfo)
{
+ IReadOnlyList particleContext = [.. contextInfo, NormalizeFileName(file.FileName)];
+
foreach (var texture in file.Content.Textures)
{
- GuardedVerify(() => VerifyTextureExists(file, texture, contextInfo),
+ GuardedVerify(() => VerifyTextureExists(texture, particleContext),
e => e is ArgumentException,
_ =>
{
@@ -240,12 +238,12 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c
VerifierErrorCodes.InvalidFilePath,
$"Invalid texture file name '{texture}' in particle '{file.FileName}'",
VerificationSeverity.Error,
- [file.FileName.ToUpperInvariant()],
+ particleContext,
texture));
});
}
- var fileName = FileSystem.Path.GetFileNameWithoutExtension(file.FilePath.AsSpan());
+ var fileName = GameEngine.GameRepository.PGFileSystem.GetFileNameWithoutExtension(file.FilePath.AsSpan());
var name = file.Content.Name.AsSpan();
if (!fileName.Equals(name, StringComparison.OrdinalIgnoreCase))
@@ -255,7 +253,7 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c
VerifierErrorCodes.InvalidParticleName,
$"The particle name '{file.Content.Name}' does not match file name '{file.FileName}'",
VerificationSeverity.Error,
- [file.FileName.ToUpperInvariant()],
+ particleContext,
file.Content.Name));
}
@@ -263,11 +261,11 @@ private void VerifyParticle(IAloParticleFile file, IReadOnlyCollection c
private void VerifyModel(IAloModelFile file, AnimationCollection animations, IReadOnlyCollection contextInfo, CancellationToken token)
{
- IReadOnlyList modelContext = [.. contextInfo, file.FileName.ToUpperInvariant()];
+ IReadOnlyList modelContext = [.. contextInfo, NormalizeFileName(file.FileName)];
foreach (var texture in file.Content.Textures)
{
- GuardedVerify(() => VerifyTextureExists(file, texture, modelContext),
+ GuardedVerify(() => VerifyTextureExists(texture, modelContext),
e => e is ArgumentException,
_ =>
{
@@ -329,11 +327,11 @@ private void VerifyAnimation(IAloAnimationFile file, IReadOnlyCollection
// Is there actually anything to verify for animation without looking at the model?
}
- private void VerifyTextureExists(IPetroglyphFileHolder model, string texture, IReadOnlyCollection contextInfo)
+ private void VerifyTextureExists(string texture, IReadOnlyCollection contextInfo)
{
if (texture == "None")
return;
- _textureVerifier.Verify(texture, [..contextInfo, model.FileName.ToUpperInvariant()], CancellationToken.None);
+ _textureVerifier.Verify(texture, [..contextInfo], CancellationToken.None);
}
private void VerifyProxyExists(IPetroglyphFileHolder model, string proxy, IReadOnlyCollection contextInfo, CancellationToken token)
@@ -367,12 +365,17 @@ private void VerifyShaderExists(IPetroglyphFileHolder model, string shader, IRea
VerifierErrorCodes.FileNotFound,
message,
VerificationSeverity.Error,
- [..contextInfo, model.FileName.ToUpperInvariant()],
+ [..contextInfo, NormalizeFileName(model.FileName)],
shader);
AddError(error);
}
}
+ private string NormalizeFileName(string fileName)
+ {
+ return GameEngine.GameRepository.PGFileSystem.GetFileName(fileName).ToUpperInvariant();
+ }
+
private void AddNotExistError(string fileName, IReadOnlyCollection contextInfo)
{
AddError(VerificationError.Create(
diff --git a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs
index a33f900..26a073e 100644
--- a/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs
+++ b/src/ModVerify/Verifiers/GuiDialogs/GuiDialogsVerifier.cs
@@ -119,7 +119,11 @@ private void VerifyGuiComponentTexturesExist(string component)
componentType is not GuiComponentType.ButtonMiddle &&
componentType is not GuiComponentType.Scanlines &&
componentType is not GuiComponentType.FrameBackground)
+ {
+ if (!cached.Value.AssetExists)
+ AddNotFoundError(texture, component, null);
continue;
+ }
}
var exists = GameEngine.GuiDialogManager.TextureExists(
@@ -141,8 +145,9 @@ componentType is not GuiComponentType.Scanlines &&
AddNotFoundError(texture, component, origin);
}
}
-
- _cache?.TryAddEntry(texture.Texture, exists);
+
+ // If the texture is "none" we store it as "asset exists" in order to reduce false warnings
+ _cache?.TryAddEntry(texture.Texture, exists || isNone);
}
finally
{
@@ -155,16 +160,18 @@ componentType is not GuiComponentType.Scanlines &&
private void AddNotFoundError(ComponentTextureEntry texture, string component, GuiTextureOrigin? origin)
{
var sb = new StringBuilder($"Could not find GUI texture '{texture.Texture}'");
- if (origin is not null)
- sb.Append($" at location '{origin}'");
+ if (origin is not null)
+ sb.Append($" at origin '{origin}'");
+ sb.Append($" for component '{component}'");
sb.Append('.');
if (texture.Texture.Length > PGConstants.MaxMegEntryPathLength)
sb.Append(" The file name is too long.");
AddError(VerificationError.Create(this, VerifierErrorCodes.FileNotFound,
- sb.ToString(), VerificationSeverity.Error,
- [component, origin.ToString()], texture.Texture));
+ sb.ToString(), VerificationSeverity.Error,
+ [component], // Origin is not interesting for context, but might be for the error message
+ texture.Texture));
}
private IReadOnlyDictionary GetTextureEntriesForComponents(string component, out bool defined)
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs
new file mode 100644
index 0000000..ab108c2
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.CombineJoin.cs
@@ -0,0 +1,82 @@
+using System;
+using System.IO;
+using AnakinRaW.CommonUtilities.FileSystem;
+using PG.StarWarsGame.Engine.Utilities;
+using Xunit;
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test.IO;
+
+public partial class PetroglyphFileSystemTests
+{
+ [Theory]
+#if Windows
+ [InlineData("a", "b", "a\\b")]
+ [InlineData("a/", "b", "a/b")]
+ [InlineData("a\\", "b", "a\\b")]
+ [InlineData("", "b", "b")]
+ [InlineData("a", "", "a")]
+ [InlineData("/", "b", "/b")]
+ [InlineData("a", "/b", "/b")]
+ [InlineData("a", "\\b", "\\b")]
+ [InlineData("a/b", "c/d", "a/b\\c/d")]
+ [InlineData("a\\b", "c\\d", "a\\b\\c\\d")]
+ [InlineData("a/b/", "c/d", "a/b/c/d")]
+ [InlineData("a\\b\\", "c\\d", "a\\b\\c\\d")]
+#else
+ [InlineData("a", "b", "a/b")]
+ [InlineData("a/", "b", "a/b")]
+ [InlineData("a\\", "b", "a\\b")]
+ [InlineData("", "b", "b")]
+ [InlineData("a", "", "a")]
+ [InlineData("/", "b", "/b")]
+ [InlineData("a", "/b", "/b")]
+ [InlineData("a", "\\b", "\\b")]
+ [InlineData("a/b", "c/d", "a/b/c/d")]
+ [InlineData("a\\b", "c\\d", "a\\b/c\\d")]
+ [InlineData("a/b/", "c/d", "a/b/c/d")]
+ [InlineData("a\\b\\", "c\\d", "a\\b\\c\\d")]
+#endif
+ public void CombinePath(string pathA, string pathB, string expected)
+ {
+ var result = _pgFileSystem.CombinePath(pathA, pathB);
+ Assert.Equal(expected, result);
+#if Windows
+ Assert.Equal(Path.Combine(pathA, pathB), result);
+#endif
+ }
+
+ [Theory]
+#if Windows
+ [InlineData("a", "b", "a\\b")]
+ [InlineData("a/", "b", "a/b")]
+ [InlineData("a\\", "b", "a\\b")]
+ [InlineData("", "b", "b")]
+ [InlineData("a", "", "a")]
+ [InlineData("/", "b", "/b")]
+ [InlineData("a", "/b", "a/b")]
+ [InlineData("a", "\\b", "a\\b")]
+ [InlineData("a/b", "c/d", "a/b\\c/d")]
+ [InlineData("a\\b", "c\\d", "a\\b\\c\\d")]
+#else
+ [InlineData("a", "b", "a/b")]
+ [InlineData("a/", "b", "a/b")]
+ [InlineData("a\\", "b", "a\\b")]
+ [InlineData("", "b", "b")]
+ [InlineData("a", "", "a")]
+ [InlineData("/", "b", "/b")]
+ [InlineData("a", "/b", "a/b")]
+ [InlineData("a", "\\b", "a\\b")]
+ [InlineData("a/b", "c/d", "a/b/c/d")]
+ [InlineData("a\\b", "c\\d", "a\\b/c\\d")]
+#endif
+ public void JoinPath(string path1, string path2, string expected)
+ {
+ var vsb = new ValueStringBuilder();
+ _pgFileSystem.JoinPath(path1.AsSpan(), path2.AsSpan(), ref vsb);
+ var result = vsb.ToString();
+ Assert.Equal(expected, result);
+#if Windows
+ Assert.Equal(result, _fileSystem.Path.Join(path1, path2));
+#endif
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs
new file mode 100644
index 0000000..7d88abf
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Exist.cs
@@ -0,0 +1,237 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using AnakinRaW.CommonUtilities.Testing.Attributes;
+using PG.StarWarsGame.Engine.Utilities;
+using Xunit;
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test.IO;
+
+public partial class PetroglyphFileSystemTests
+{
+ [Fact]
+ public void FileExists_EmptyGameDirectory_Works()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var vsb = new ValueStringBuilder();
+ var exists = _pgFileSystem.FileExists(tempFile.AsSpan(), ref vsb, ReadOnlySpan.Empty);
+ Assert.True(exists);
+ Assert.Equal(tempFile, vsb.ToString());
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void FileExists_FileExists()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var vsb = new ValueStringBuilder();
+ var exists = _pgFileSystem.FileExists(tempFile.AsSpan(), ref vsb, string.Empty.AsSpan());
+ Assert.True(exists);
+ Assert.Equal(tempFile, vsb.ToString());
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void FileExists_FileDoesNotExist()
+ {
+ var tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ var vsb = new ValueStringBuilder();
+ var exists = _pgFileSystem.FileExists(tempFile.AsSpan(), ref vsb, string.Empty.AsSpan());
+ Assert.False(exists);
+ }
+
+ [Fact]
+ public void FileExists_RelativePath()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+ var tempFile = Path.Combine(tempDir, "test.txt");
+ File.WriteAllText(tempFile, "test");
+ try
+ {
+ var vsb = new ValueStringBuilder();
+ var exists = _pgFileSystem.FileExists("test.txt".AsSpan(), ref vsb, tempDir.AsSpan());
+ Assert.True(exists);
+
+ // On Windows, JoinPath might use backslashes.
+ // PetroglyphFileSystem.JoinPath uses _underlyingFileSystem.Path.DirectorySeparatorChar if no separator is present.
+ // Since _fileSystem is RealFileSystem, it will be \ on Windows.
+ var expected = Path.Combine(tempDir, "test.txt");
+ Assert.Equal(expected, vsb.ToString());
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Theory]
+ [InlineData("test.txt", "TEST.txt")]
+ [InlineData("dir/test.txt", "DIR/TEST.txt")]
+ [InlineData("a/b/c.txt", "A/B/C.txt")]
+ [InlineData("A/B/C.txt", "a/b/c.txt")]
+ [InlineData("a/B/c.txt", "A/b/C.txt")]
+ [InlineData("a/B/C.txt", "a/B/c.txt")]
+ [InlineData("a/b/C/D.txt", "a/b/c/d.txt")]
+ public void FileExists_CaseInsensitive(string inputPath, string actualPathOnDisk)
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ var fullPathOnDisk = Path.Combine(tempDir, actualPathOnDisk.Replace('/', Path.DirectorySeparatorChar));
+ var fullPathOnDiskDir = Path.GetDirectoryName(fullPathOnDisk);
+ if (fullPathOnDiskDir != null)
+ Directory.CreateDirectory(fullPathOnDiskDir);
+
+ File.WriteAllText(fullPathOnDisk, "test");
+
+ try
+ {
+ var vsb = new ValueStringBuilder();
+ // On Windows, CreateFile is case-insensitive by default.
+ // On Linux, FileExistsCaseInsensitive handles it.
+ var exists = _pgFileSystem.FileExists(inputPath.AsSpan(), ref vsb, tempDir.AsSpan());
+ Assert.True(exists);
+
+ var resultPath = vsb.ToString();
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // Windows CreateFile doesn't change the path in the string builder to the actual case-sensitive path if it just found it.
+ // It stays as what was passed to it (with gameDirectory joined).
+ var expected = _fileSystem.Path.Combine(tempDir, inputPath);
+ Assert.Equal(expected, resultPath);
+ }
+ else
+ {
+ // On Linux, FileExistsCaseInsensitive DOES update the string builder:
+ // stringBuilder.Length = 0;
+ // stringBuilder.Append(file);
+ // It should be the exact path on disk.
+ Assert.Equal(fullPathOnDisk, resultPath);
+ }
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void FileExists_GameDirectory_WithTrailingSeparator()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()) + Path.DirectorySeparatorChar;
+ Directory.CreateDirectory(tempDir);
+ var tempFile = Path.Combine(tempDir, "test.txt");
+ File.WriteAllText(tempFile, "test");
+ try
+ {
+ var vsb = new ValueStringBuilder();
+ var exists = _pgFileSystem.FileExists("test.txt".AsSpan(), ref vsb, tempDir.AsSpan());
+ Assert.True(exists);
+ Assert.Equal(tempFile, vsb.ToString());
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [PlatformSpecificFact(TestPlatformIdentifier.Linux)]
+ public void FileExists_CaseInsensitive_DotSegmentInPath_ReturnsTrue()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ // Create the actual file at tempDir/DATA/FILE.TXT (uppercase)
+ Directory.CreateDirectory(Path.Combine(tempDir, "DATA"));
+ File.WriteAllText(Path.Combine(tempDir, "DATA", "FILE.TXT"), "test");
+
+ // Input path uses a leading ".\" (dot-segment) AND different casing.
+ // After normalization + join: tempDir/./DATA/file.txt
+ // File.Exists fast-path fails (case mismatch), so the impl must resolve case-insensitively.
+ // Correct implementations must handle "." as a valid path segment that resolves to the current directory,
+ // not treat it as a literal directory name to look up via GetDirectories.
+ var vsb = new ValueStringBuilder();
+ var exists = _pgFileSystem.FileExists(@".\DATA\file.txt".AsSpan(), ref vsb, tempDir.AsSpan());
+
+ Assert.True(exists);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [PlatformSpecificFact(TestPlatformIdentifier.Linux)]
+ public void FileExists_CaseInsensitive_MissingIntermediateDirectory_ReturnsFalse()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ // Create tempDir/a/c.txt — no "b" directory at all
+ Directory.CreateDirectory(Path.Combine(tempDir, "a"));
+ File.WriteAllText(Path.Combine(tempDir, "a", "c.txt"), "test");
+
+ // Input path references a non-existent intermediate segment "b"
+ var vsb = new ValueStringBuilder();
+ var exists = _pgFileSystem.FileExists("a/b/c.txt".AsSpan(), ref vsb, tempDir.AsSpan());
+
+ Assert.False(exists);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Theory]
+#if Windows
+ [InlineData("C:\\test.txt", true)]
+ [InlineData("/test.txt", false)] // On Windows, /test.txt is NOT fully qualified (it's root-relative to current drive)
+ [InlineData("\\test.txt", false)]
+#else
+ [InlineData("/test.txt", true)]
+ [InlineData("C:\\test.txt", false)] // On Linux, C:\ is not a root
+#endif
+ [InlineData("test.txt", false)]
+ public void IsPathFullyQualified_Exists_Internal(string path, bool expected)
+ {
+ // This method is internal/private, but we can indirectly test it through FileExists
+
+ const string gameDir = "Z:\\non-existent-dir";
+ var vsb = new ValueStringBuilder();
+ _pgFileSystem.FileExists(path.AsSpan(), ref vsb, gameDir.AsSpan());
+
+ var resultPath = vsb.ToString().Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
+ var expectedGameDir = gameDir.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
+
+ if (expected)
+ {
+ Assert.StartsWith(path.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar), resultPath);
+ Assert.DoesNotContain(expectedGameDir, resultPath);
+ }
+ else
+ {
+ Assert.Contains(expectedGameDir, resultPath);
+ }
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs
new file mode 100644
index 0000000..1aadea5
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Names.cs
@@ -0,0 +1,84 @@
+using System;
+using System.IO;
+using Xunit;
+#if NETFRAMEWORK
+using AnakinRaW.CommonUtilities.FileSystem;
+#endif
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test.IO;
+
+public partial class PetroglyphFileSystemTests
+{
+ public static TheoryData TestData_GetFileName => new()
+ {
+ { ".", "." },
+ { "..", ".." },
+ { "file", "file" },
+ { "file.", "file." },
+ { "file.exe", "file.exe" },
+ { " . ", " . " },
+ { " .. ", " .. " },
+ { "fi le", "fi le" },
+ { Path.Combine("baz", "file.exe"), "file.exe" },
+ { Path.Combine("baz", "file.exe") + "/", "" },
+ { Path.Combine("bar", "baz", "file.exe"), "file.exe" },
+ { Path.Combine("bar", "baz", "file.exe") + "\\", "" },
+
+ { "foo\\bar/file.exe", "file.exe" },
+ { "foo/bar\\file.exe", "file.exe" },
+ };
+
+ [Theory, MemberData(nameof(TestData_GetFileName))]
+ public void GetFileName_Span(string path, string expected)
+ {
+ PathAssert.Equal(expected.AsSpan(), _pgFileSystem.GetFileName(path.AsSpan()));
+ Assert.Equal(expected, _pgFileSystem.GetFileName(path));
+ }
+
+ public static TheoryData TestData_GetFileNameWithoutExtension => new()
+ {
+ { "", "" },
+ { "file", "file" },
+ { "file.exe", "file" },
+ { "bar\\baz/file.exe", "file" },
+ { "bar/baz\\file.exe", "file" },
+ { Path.Combine("bar", "baz") + "\\", "" },
+ { Path.Combine("bar", "baz") + "/", "" },
+ };
+
+ [Theory, MemberData(nameof(TestData_GetFileNameWithoutExtension))]
+ public void GetFileNameWithoutExtension_Span(string path, string expected)
+ {
+ PathAssert.Equal(expected.AsSpan(), _pgFileSystem.GetFileNameWithoutExtension(path.AsSpan()));
+ Assert.Equal(expected, _pgFileSystem.GetFileNameWithoutExtension(path));
+#if Windows
+ Assert.Equal(_pgFileSystem.GetFileName(path), _fileSystem.Path.GetFileName(path.AsSpan()));
+#endif
+ }
+
+ [Theory,
+ InlineData(null, null, null),
+ InlineData(null, "exe", null),
+ InlineData("", "", ""),
+ InlineData("file.exe", null, "file"),
+ InlineData("file.exe", "", "file."),
+ InlineData("file", "exe", "file.exe"),
+ InlineData("file", ".exe", "file.exe"),
+ InlineData("file.txt", "exe", "file.exe"),
+ InlineData("file.txt", ".exe", "file.exe"),
+ InlineData("file.txt.bin", "exe", "file.txt.exe"),
+ InlineData("dir/file.t", "exe", "dir/file.exe"),
+ InlineData("dir\\file.t", "exe", "dir\\file.exe"),
+ InlineData("dir/file.exe", "t", "dir/file.t"),
+ InlineData("dir\\file.exe", "t", "dir\\file.t"),
+ InlineData("dir/file", "exe", "dir/file.exe"),
+ InlineData("dir\\file", "exe", "dir\\file.exe")]
+ public void ChangeExtension(string? path, string? newExtension, string? expected)
+ {
+ Assert.Equal(expected, _pgFileSystem.ChangeExtension(path, newExtension));
+
+#if Windows
+ Assert.Equal(_pgFileSystem.ChangeExtension(path, newExtension), _fileSystem.Path.ChangeExtension(path, newExtension));
+#endif
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs
new file mode 100644
index 0000000..fb15bc1
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.Normalize.cs
@@ -0,0 +1,28 @@
+using PG.StarWarsGame.Engine.Utilities;
+using Xunit;
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test.IO;
+
+public partial class PetroglyphFileSystemTests
+{
+ [Theory]
+#if Windows
+ [InlineData("dir\\file.txt", "dir\\file.txt")]
+ [InlineData("dir/file.txt", "dir\\file.txt")]
+ [InlineData("\\dir\\subdir\\", "\\dir\\subdir\\")]
+ [InlineData("/dir\\subdir/", "\\dir\\subdir\\")]
+#else
+ [InlineData("dir\\file.txt", "dir/file.txt")]
+ [InlineData("dir/file.txt", "dir/file.txt")]
+ [InlineData("\\dir\\subdir\\", "/dir/subdir/")]
+ [InlineData("/dir\\subdir/", "/dir/subdir/")]
+#endif
+ public void NormalizePath(string path, string expected)
+ {
+ var vsb = new ValueStringBuilder();
+ vsb.Append(path);
+ _pgFileSystem.NormalizePath(ref vsb);
+
+ Assert.Equal(expected, vsb.ToString());
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs
new file mode 100644
index 0000000..084af8a
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.PathEqual.cs
@@ -0,0 +1,19 @@
+using AnakinRaW.CommonUtilities.FileSystem;
+using Xunit;
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test.IO;
+
+public partial class PetroglyphFileSystemTests
+{
+ [Theory]
+ [InlineData("dir/file.txt", "DIR\\FILE.TXT", true)]
+ [InlineData("dir/file.txt", "dir/other.txt", false)]
+ [InlineData("a/b/c", "a\\b\\c", true)]
+ public void PathsAreEqual(string pathA, string pathB, bool expected)
+ {
+ Assert.Equal(expected, _pgFileSystem.PathsAreEqual(pathA, pathB));
+#if Windows
+ Assert.Equal(_pgFileSystem.PathsAreEqual(pathA, pathB), _fileSystem.Path.AreEqual(pathA, pathB));
+#endif
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs
new file mode 100644
index 0000000..3390f3b
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.cs
@@ -0,0 +1,46 @@
+using System;
+using System.IO.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+using PG.StarWarsGame.Engine.IO;
+using Testably.Abstractions;
+using Xunit;
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test.IO;
+
+public partial class PetroglyphFileSystemTests
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly PetroglyphFileSystem _pgFileSystem;
+
+ public PetroglyphFileSystemTests()
+ {
+ _fileSystem = new RealFileSystem();
+ var sc = new ServiceCollection();
+ sc.AddSingleton(_fileSystem);
+ IServiceProvider serviceProvider = sc.BuildServiceProvider();
+ _pgFileSystem = new PetroglyphFileSystem(serviceProvider);
+ }
+
+ [Fact]
+ public void Ctor_Null_Throws()
+ {
+ Assert.Throws(() => new PetroglyphFileSystem(null!));
+ }
+
+ [Fact]
+ public void UnderlyingFileSystem_ReturnsCorrectInstance()
+ {
+ Assert.Same(_fileSystem, _pgFileSystem.UnderlyingFileSystem);
+ }
+
+ [Theory]
+ [InlineData("dir/", true)]
+ [InlineData("dir\\", true)]
+ [InlineData("dir/file.txt", false)]
+ [InlineData("file.txt", false)]
+ [InlineData("", false)]
+ public void HasTrailingDirectorySeparator(string path, bool expected)
+ {
+ Assert.Equal(expected, _pgFileSystem.HasTrailingDirectorySeparator(path.AsSpan()));
+ }
+}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj
new file mode 100644
index 0000000..2f621bc
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PG.StarWarsGame.Engine.FileSystem.Test.csproj
@@ -0,0 +1,52 @@
+
+
+
+ net10.0
+ $(TargetFrameworks);net481
+ preview
+
+
+
+ false
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+ Windows
+
+
+ Linux
+
+
+
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs
new file mode 100644
index 0000000..7ae5172
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/PathAssert.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test;
+
+internal static class PathAssert
+{
+ public static void Equal(ReadOnlySpan expected, ReadOnlySpan actual)
+ {
+ if (!actual.SequenceEqual(expected))
+ throw Xunit.Sdk.EqualException.ForMismatchedValues(expected.ToString(), actual.ToString());
+ }
+
+ public static void Empty(ReadOnlySpan actual)
+ {
+ if (actual.Length > 0)
+ throw Xunit.Sdk.NotEmptyException.ForNonEmptyCollection();
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs
new file mode 100644
index 0000000..8ad1307
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/Utilities/ValueStringBuilderTests.cs
@@ -0,0 +1,371 @@
+using System;
+using System.Text;
+using PG.StarWarsGame.Engine.Utilities;
+using Xunit;
+
+namespace PG.StarWarsGame.Engine.FileSystem.Test.Utilities;
+
+public class ValueStringBuilderTests
+{
+ [Fact]
+ public void Ctor_Default_CanAppend()
+ {
+ var vsb = default(ValueStringBuilder);
+ Assert.Equal(0, vsb.Length);
+
+ vsb.Append('a');
+ Assert.Equal(1, vsb.Length);
+ Assert.Equal("a", vsb.ToString());
+ }
+
+ [Fact]
+ public void Ctor_Span_CanAppend()
+ {
+ var vsb = new ValueStringBuilder(new char[1]);
+ Assert.Equal(0, vsb.Length);
+
+ vsb.Append('a');
+ Assert.Equal(1, vsb.Length);
+ Assert.Equal("a", vsb.ToString());
+ }
+
+ [Fact]
+ public void Ctor_InitialCapacity_CanAppend()
+ {
+ var vsb = new ValueStringBuilder(1);
+ Assert.Equal(0, vsb.Length);
+
+ vsb.Append('a');
+ Assert.Equal(1, vsb.Length);
+ Assert.Equal("a", vsb.ToString());
+ }
+
+ [Fact]
+ public void Append_Char_MatchesStringBuilder()
+ {
+ var sb = new StringBuilder();
+ var vsb = new ValueStringBuilder();
+ for (var i = 1; i <= 100; i++)
+ {
+ sb.Append((char)i);
+ vsb.Append((char)i);
+ }
+
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Fact]
+ public void Append_String_MatchesStringBuilder()
+ {
+ var sb = new StringBuilder();
+ var vsb = new ValueStringBuilder();
+ for (var i = 1; i <= 100; i++)
+ {
+ var s = i.ToString();
+ sb.Append(s);
+ vsb.Append(s);
+ }
+
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Theory]
+ [InlineData(0, 4 * 1024 * 1024)]
+ [InlineData(1025, 4 * 1024 * 1024)]
+ [InlineData(3 * 1024 * 1024, 6 * 1024 * 1024)]
+ public void Append_String_Large_MatchesStringBuilder(int initialLength, int stringLength)
+ {
+ var sb = new StringBuilder(initialLength);
+ var vsb = new ValueStringBuilder(new char[initialLength]);
+
+ var s = new string('a', stringLength);
+ sb.Append(s);
+ vsb.Append(s);
+
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Fact]
+ public void Append_CharInt_MatchesStringBuilder()
+ {
+ var sb = new StringBuilder();
+ var vsb = new ValueStringBuilder();
+ for (var i = 1; i <= 100; i++)
+ {
+ sb.Append((char)i, i);
+ vsb.Append((char)i, i);
+ }
+
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Fact]
+ public void AppendSpan_Capacity()
+ {
+ var vsb = new ValueStringBuilder();
+
+ vsb.AppendSpan(17);
+ Assert.Equal(32, vsb.Capacity);
+
+ vsb.AppendSpan(100);
+ Assert.Equal(128, vsb.Capacity);
+ }
+
+ [Fact]
+ public void AppendSpan_DataAppendedCorrectly()
+ {
+ var sb = new StringBuilder();
+ var vsb = new ValueStringBuilder();
+
+ for (var i = 1; i <= 1000; i++)
+ {
+ var s = i.ToString();
+
+ sb.Append(s);
+
+ var span = vsb.AppendSpan(s.Length);
+ Assert.Equal(sb.Length, vsb.Length);
+
+ s.AsSpan().CopyTo(span);
+ }
+
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Fact]
+ public void Insert_IntCharInt_MatchesStringBuilder()
+ {
+ var sb = new StringBuilder();
+ var vsb = new ValueStringBuilder();
+ var rand = new Random(42);
+
+ for (var i = 1; i <= 100; i++)
+ {
+ var index = rand.Next(sb.Length);
+ sb.Insert(index, new string((char)i, 1), i);
+ vsb.Insert(index, (char)i, i);
+ }
+
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Fact]
+ public void Insert_IntString_MatchesStringBuilder()
+ {
+ var sb = new StringBuilder();
+ var vsb = new ValueStringBuilder();
+
+ sb.Insert(0, new string('a', 6));
+ vsb.Insert(0, new string('a', 6));
+ Assert.Equal(6, vsb.Length);
+ Assert.Equal(16, vsb.Capacity);
+
+ sb.Insert(0, new string('b', 11));
+ vsb.Insert(0, new string('b', 11));
+ Assert.Equal(17, vsb.Length);
+ Assert.Equal(32, vsb.Capacity);
+
+ sb.Insert(0, new string('c', 15));
+ vsb.Insert(0, new string('c', 15));
+ Assert.Equal(32, vsb.Length);
+ Assert.Equal(32, vsb.Capacity);
+
+ sb.Length = 24;
+ vsb.Length = 24;
+
+ sb.Insert(0, new string('d', 40));
+ vsb.Insert(0, new string('d', 40));
+ Assert.Equal(64, vsb.Length);
+ Assert.Equal(64, vsb.Capacity);
+
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Fact]
+ public void AsSpan_ReturnsCorrectValue_DoesntClearBuilder()
+ {
+ var sb = new StringBuilder();
+ var vsb = new ValueStringBuilder();
+
+ for (var i = 1; i <= 100; i++)
+ {
+ var s = i.ToString();
+ sb.Append(s);
+ vsb.Append(s);
+ }
+
+ var resultString = vsb.AsSpan().ToString();
+ Assert.Equal(sb.ToString(), resultString);
+
+ Assert.NotEqual(0, sb.Length);
+ Assert.Equal(sb.Length, vsb.Length);
+ Assert.Equal(sb.ToString(), vsb.ToString());
+ }
+
+ [Fact]
+ public void ToString_ClearsBuilder_ThenReusable()
+ {
+ const string Text1 = "test";
+ var vsb = new ValueStringBuilder();
+
+ vsb.Append(Text1);
+ Assert.Equal(Text1.Length, vsb.Length);
+
+ var s = vsb.ToString();
+ Assert.Equal(Text1, s);
+
+ Assert.Equal(0, vsb.Length);
+ Assert.Equal(string.Empty, vsb.ToString());
+
+ const string Text2 = "another test";
+ vsb.Append(Text2);
+ Assert.Equal(Text2.Length, vsb.Length);
+ Assert.Equal(Text2, vsb.ToString());
+ }
+
+ [Fact]
+ public void Dispose_ClearsBuilder_ThenReusable()
+ {
+ const string Text1 = "test";
+ var vsb = new ValueStringBuilder();
+
+ vsb.Append(Text1);
+ Assert.Equal(Text1.Length, vsb.Length);
+
+ vsb.Dispose();
+
+ Assert.Equal(0, vsb.Length);
+ Assert.Equal(string.Empty, vsb.ToString());
+
+ const string Text2 = "another test";
+ vsb.Append(Text2);
+ Assert.Equal(Text2.Length, vsb.Length);
+ Assert.Equal(Text2, vsb.ToString());
+ }
+
+ [Fact]
+ public void Indexer()
+ {
+ const string Text1 = "foobar";
+ var vsb = new ValueStringBuilder();
+
+ vsb.Append(Text1);
+
+ Assert.Equal('b', vsb[3]);
+ vsb[3] = 'c';
+ Assert.Equal('c', vsb[3]);
+ vsb.Dispose();
+ }
+
+ [Fact]
+ public void Remove_ZeroLength_NoOp()
+ {
+ var vsb = new ValueStringBuilder();
+ vsb.Append("abc");
+ vsb.Remove(1, 0);
+ Assert.Equal("abc", vsb.ToString());
+ }
+
+ [Fact]
+ public void Remove_Start()
+ {
+ var vsb = new ValueStringBuilder();
+ vsb.Append("abcde");
+ vsb.Remove(0, 2);
+ var res = vsb.ToString();
+ Assert.Equal("cde", res);
+ }
+
+ [Fact]
+ public void Remove_Middle()
+ {
+ var vsb = new ValueStringBuilder();
+ vsb.Append("abcde");
+ vsb.Remove(1, 3);
+ var res = vsb.ToString();
+ Assert.Equal("ae", res);
+ }
+
+ [Fact]
+ public void Remove_End()
+ {
+ var vsb = new ValueStringBuilder();
+ vsb.Append("abcde");
+ vsb.Remove(3, 2);
+ var res = vsb.ToString();
+ Assert.Equal("abc", res);
+ }
+
+ [Fact]
+ public void Remove_EntireContent()
+ {
+ var vsb = new ValueStringBuilder();
+ vsb.Append("abcde");
+ vsb.Remove(0, 5);
+ Assert.Equal(0, vsb.Length);
+ Assert.Equal(string.Empty, vsb.ToString());
+ }
+
+ [Theory]
+ [InlineData(-1, 1)] // negative startIndex
+ [InlineData(0, -1)] // negative length
+ [InlineData(0, 6)] // length too large
+ [InlineData(3, 3)] // range too large
+ public void Remove_Invalid_ThrowsArgumentOutOfRangeException(int startIndex, int length)
+ {
+ var vsb = new ValueStringBuilder();
+ vsb.Append("abcde");
+ try
+ {
+ vsb.Remove(startIndex, length);
+ Assert.Fail("Expected ArgumentOutOfRangeException");
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ // Expected
+ }
+ }
+
+ [Fact]
+ public void EnsureCapacity_IfRequestedCapacityWins()
+ {
+ // Note: constants used here may be dependent on minimal buffer size
+ // the ArrayPool is able to return.
+ var builder = new ValueStringBuilder(stackalloc char[32]);
+
+ builder.EnsureCapacity(65);
+
+ Assert.Equal(128, builder.Capacity);
+ }
+
+ [Fact]
+ public void EnsureCapacity_IfBufferTimesTwoWins()
+ {
+ var builder = new ValueStringBuilder(stackalloc char[32]);
+
+ builder.EnsureCapacity(33);
+
+ Assert.Equal(64, builder.Capacity);
+ builder.Dispose();
+ }
+
+ [Fact]
+ public void EnsureCapacity_NoAllocIfNotNeeded()
+ {
+ // Note: constants used here may be dependent on minimal buffer size
+ // the ArrayPool is able to return.
+ var builder = new ValueStringBuilder(stackalloc char[64]);
+
+ builder.EnsureCapacity(16);
+
+ Assert.Equal(64, builder.Capacity);
+ builder.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs
new file mode 100644
index 0000000..a179fe6
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/AssemblyAttributes.cs
@@ -0,0 +1,4 @@
+using System.Runtime.CompilerServices;
+
+[assembly:InternalsVisibleTo("PG.StarWarsGame.Engine")]
+[assembly:InternalsVisibleTo("PG.StarWarsGame.Engine.FileSystem.Test")]
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs
new file mode 100644
index 0000000..d9f3cd8
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Runtime.InteropServices;
+using PG.StarWarsGame.Engine.Utilities;
+
+namespace PG.StarWarsGame.Engine.IO;
+
+public sealed partial class PetroglyphFileSystem
+{
+ public string CombinePath(string pathA, string pathB)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.Combine(pathA, pathB);
+
+ if (pathA == null)
+ throw new ArgumentNullException(nameof(pathA));
+ if (pathB == null)
+ throw new ArgumentNullException(nameof(pathB));
+ return CombineInternal(pathA, pathB);
+ }
+
+ internal void JoinPath(ReadOnlySpan path1, ReadOnlySpan path2, ref ValueStringBuilder stringBuilder)
+ {
+ if (path1.Length == 0 && path2.Length == 0)
+ return;
+
+ if (path1.Length == 0 || path2.Length == 0)
+ {
+ ref var pathToUse = ref path1.Length == 0 ? ref path2 : ref path1;
+ stringBuilder.Append(pathToUse);
+ return;
+ }
+
+ stringBuilder.Append(path1);
+
+ var hasSeparator = IsDirectorySeparator(path1[path1.Length - 1]) || IsDirectorySeparator(path2[0]);
+ if (!hasSeparator)
+ stringBuilder.Append(_underlyingFileSystem.Path.DirectorySeparatorChar);
+
+ stringBuilder.Append(path2);
+ }
+
+ private string CombineInternal(string first, string second)
+ {
+ if (string.IsNullOrEmpty(first))
+ return second;
+
+ if (string.IsNullOrEmpty(second))
+ return first;
+
+ if (IsPathRooted(second.AsSpan()))
+ return second;
+
+ return JoinInternal(first, second);
+ }
+
+ private string JoinInternal(string first, string second)
+ {
+ var hasSeparator = IsDirectorySeparator(first[first.Length - 1]) || IsDirectorySeparator(second[0]);
+ return hasSeparator
+ ? string.Concat(first, second)
+ : string.Concat(first, _underlyingFileSystem.Path.DirectorySeparatorChar, second);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs
new file mode 100644
index 0000000..6dd3de7
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Exist.cs
@@ -0,0 +1,270 @@
+using System;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using PG.StarWarsGame.Engine.Utilities;
+using System.IO;
+using AnakinRaW.CommonUtilities.FileSystem;
+#if NETSTANDARD2_1 || NET
+using System.Diagnostics.CodeAnalysis;
+#endif
+
+namespace PG.StarWarsGame.Engine.IO;
+
+public sealed partial class PetroglyphFileSystem
+{
+ internal bool FileExists(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder, ReadOnlySpan gameDirectory)
+ {
+ stringBuilder.Length = 0;
+
+ if (IsPathFullyQualified_Exists(filePath))
+ stringBuilder.Append(filePath);
+ else
+ JoinPath(gameDirectory, filePath, ref stringBuilder);
+
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ NormalizePath(ref stringBuilder);
+
+ var actualFilePath = stringBuilder.AsSpan();
+ return FileExistsCaseInsensitive(actualFilePath, ref stringBuilder);
+ }
+
+ // We *could* also use the slightly faster GetFileAttributesA.
+ // However, CreateFileA and GetFileAttributesA are implemented complete independent.
+ // The game uses CreateFileA.
+ // Thus, we should stick to what the game uses in order to be as close to the engine as possible
+ // NB: It's also important that the string builder is zero-terminated, as otherwise CreateFileA might get invalid data.
+ var fileHandle = CreateFile(
+ in stringBuilder.GetPinnableReference(true),
+ FileAccess.Read,
+ FileShare.Read,
+ IntPtr.Zero,
+ FileMode.Open,
+ FileAttributes.Normal, IntPtr.Zero);
+
+ return IsValidAndClose(fileHandle);
+ }
+
+ // NB: This method assumes backslashes have been normalized to forward slashes
+ // NB: This method operates on the actual file system
+ private bool FileExistsCaseInsensitive(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder)
+ {
+ Debug.Assert(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows));
+
+ var pathString = filePath.ToString();
+ if (_underlyingFileSystem.File.Exists(pathString))
+ return true;
+
+ var directory = _underlyingFileSystem.Path.GetDirectoryName(pathString);
+ var fileName = _underlyingFileSystem.Path.GetFileName(pathString);
+
+ if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName))
+ return false;
+
+ if (!_underlyingFileSystem.Directory.Exists(directory))
+ {
+ if (!FileExistsCaseInsensitive(directory.AsSpan(), ref stringBuilder))
+ return false;
+
+ directory = stringBuilder.AsSpan().ToString();
+ }
+
+ var files = _underlyingFileSystem.Directory.GetFiles(directory);
+ var directories = _underlyingFileSystem.Directory.GetDirectories(directory);
+
+ foreach (var file in files)
+ {
+ var name = _underlyingFileSystem.Path.GetFileName(file);
+ if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase))
+ {
+ stringBuilder.Length = 0;
+ stringBuilder.Append(file);
+ return true;
+ }
+ }
+
+ foreach (var dir in directories)
+ {
+ var name = _underlyingFileSystem.Path.GetFileName(dir);
+ if (name.Equals(fileName, StringComparison.OrdinalIgnoreCase))
+ {
+ stringBuilder.Length = 0;
+ stringBuilder.Append(dir);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private bool IsPathFullyQualified_Exists(ReadOnlySpan path)
+ {
+ // This is really tricky, because under Windows "/" or "\" do NOT
+ // indicate a fully qualified path, under Linux however "/" does.
+ // The PGFileSystem is implemented to treat backslashes as directory separators.
+ // However, this must not happen here, since we are operating on the actual file system.
+ // E.g, \\Data\\Art\\... MUST not be treated as a fully qualified path
+ // This means, ultimately, we can just delegate to the underlying file system.
+
+ return _underlyingFileSystem.Path.IsPathFullyQualified(path);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsValidAndClose(IntPtr handle)
+ {
+ var isValid = handle != IntPtr.Zero && handle != new IntPtr(-1);
+ if (isValid)
+ CloseHandle(handle);
+ return isValid;
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ private static extern IntPtr CreateFile(
+ in char lpFileName,
+ [MarshalAs(UnmanagedType.U4)] FileAccess access,
+ [MarshalAs(UnmanagedType.U4)] FileShare share,
+ IntPtr securityAttributes,
+ [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
+ [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes,
+ IntPtr templateFile);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(IntPtr hObject);
+
+
+
+
+ ///
+ /// Checks whether a file exists using case-insensitive path resolution.
+ /// On success, contains the actual on-disk path.
+ ///
+ ///
+ ///
+ /// Strategy:
+ /// 1. Fast path: single stat() for exact-case match.
+ /// 2. Find deepest existing directory prefix, starting from the hint position.
+ /// With a correct hint this costs 1 stat. Without a hint (or bad hint),
+ /// walks backward — graceful degradation, never throws.
+ /// 3. Forward resolve: lazily enumerate only the mismatched components.
+ ///
+ ///
+ /// No exceptions occur in normal flow: Directory.Exists returns bool,
+ /// and we only enumerate directories whose existence has been confirmed.
+ ///
+ ///
+ ///
+ /// Normalized absolute path with forward slashes. May alias stringBuilder's buffer.
+ ///
+ ///
+ /// On success, overwritten with the actual on-disk path.
+ ///
+ ///
+ /// Length of the path prefix known to exist with correct casing (typically gameDirectory.Length).
+ /// Pass 0 if unknown — the method falls back to a backward walk.
+ ///
+ private bool FileExistsCaseInsensitive(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder, int knownGoodPrefixLength)
+ {
+ Debug.Assert(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows));
+
+ var pathString = filePath.ToString();
+
+ // Fast path: exact case match — single stat() syscall
+ if (_underlyingFileSystem.File.Exists(pathString))
+ return true;
+
+ if (pathString.Length == 0)
+ return false;
+
+
+ var path = pathString.AsSpan();
+
+ var rootLen = path[0] == '/' ? 1 : 0;
+ var resolvedEnd = rootLen;
+
+ int searchEnd;
+ if (knownGoodPrefixLength > 0)
+ {
+ searchEnd = knownGoodPrefixLength;
+ while (searchEnd > 1 && path[searchEnd - 1] == '/')
+ searchEnd--;
+ }
+ else
+ {
+ var lastSlash = path.LastIndexOf('/');
+ searchEnd = lastSlash >= 0 ? (lastSlash == 0 ? 1 : lastSlash) : 0;
+ }
+
+ // Walk backward until we find an existing directory.
+ // Save the successful prefix string to reuse as the first currentDir.
+ string? resolvedPrefix = null;
+ while (searchEnd > resolvedEnd)
+ {
+ var prefix = pathString.Substring(0, searchEnd);
+ if (_underlyingFileSystem.Directory.Exists(prefix))
+ {
+ resolvedEnd = searchEnd;
+ resolvedPrefix = prefix;
+ break;
+ }
+
+ var slash = path.Slice(0, searchEnd).LastIndexOf('/');
+ if (slash < 0)
+ break;
+ searchEnd = slash == 0 ? 1 : slash;
+ }
+
+ if (resolvedEnd == 0)
+ return false;
+
+ // Reuse the prefix from Directory.Exists if available, otherwise allocate once.
+ var currentDir = resolvedPrefix ?? pathString.Substring(0, resolvedEnd);
+
+ stringBuilder.Length = 0;
+ stringBuilder.Append(currentDir);
+
+ var pos = resolvedEnd;
+ if (pos < path.Length && path[pos] == '/')
+ pos++;
+
+ while (pos < path.Length)
+ {
+ var nextSlash = path.Slice(pos).IndexOf('/');
+ var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length;
+ var component = path.Slice(pos, componentEnd - pos);
+
+ if (component.IsEmpty)
+ {
+ pos = componentEnd + 1;
+ continue;
+ }
+
+ var isLast = componentEnd >= path.Length;
+
+ var entries = isLast
+ ? _underlyingFileSystem.Directory.EnumerateFiles(currentDir)
+ : _underlyingFileSystem.Directory.EnumerateDirectories(currentDir);
+
+ var found = false;
+ foreach (var entry in entries)
+ {
+ if (_underlyingFileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase))
+ {
+ stringBuilder.Length = 0;
+ stringBuilder.Append(entry);
+ currentDir = entry; // entry is already a string — reuse it
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ return false;
+
+ pos = componentEnd + 1;
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs
new file mode 100644
index 0000000..d7bb058
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Names.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Runtime.InteropServices;
+using AnakinRaW.CommonUtilities.FileSystem;
+#if NETSTANDARD2_1 || NET
+using System.Diagnostics.CodeAnalysis;
+#endif
+
+namespace PG.StarWarsGame.Engine.IO;
+
+public sealed partial class PetroglyphFileSystem
+{
+
+ ///
+ /// The path string from which to obtain the file name and extension.
+ ///
+ ///
+ /// The characters after the last directory separator character in .
+ /// If the last character of is a directory or volume separator character, this method returns Empty.
+ /// If is , this method returns .
+ ///
+ /// The returned value is if the file path is .
+ /// The separator characters used to determine the start of the file name are ("/") and ("\").
+ ///
+#if NETSTANDARD2_1 || NET
+ [return: NotNullIfNotNull(nameof(path))]
+#endif
+ public string? GetFileName(string? path)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.GetFileName(path);
+
+ if (path == null)
+ return null;
+ var result = GetFileName(path.AsSpan());
+ if (path.Length == result.Length)
+ return path;
+ return result.ToString();
+ }
+
+ ///
+ /// Returns the file name and extension of a file path that is represented by a read-only character span.
+ ///
+ /// A read-only span that contains the path from which to obtain the file name and extension.
+ /// The characters after the last directory separator character in .
+ ///
+ /// The returned read-only span contains the characters of the path that follow the last separator in path.
+ /// If the last character in path is a volume or directory separator character, the method returns .
+ /// If path contains no separator character, the method returns path.
+ ///
+ public ReadOnlySpan GetFileName(ReadOnlySpan path)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.GetFileName(path);
+
+ var root = GetPathRoot(path).Length;
+ var i = path.LastIndexOfAny(DirectorySeparatorChar, AltDirectorySeparatorChar);
+ return path.Slice(i < root ? root : i + 1);
+ }
+
+ ///
+ /// Returns the file name of the specified path string without the extension.
+ ///
+ /// The path of the file.
+ /// The string returned by , minus the last period (.) and all characters following it.
+#if NETSTANDARD2_1 || NET
+ [return: NotNullIfNotNull(nameof(path))]
+#endif
+ public string? GetFileNameWithoutExtension(string? path)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path);
+
+ if (path == null)
+ return null;
+
+ var result = GetFileNameWithoutExtension(path.AsSpan());
+ return path.Length == result.Length
+ ? path
+ : result.ToString();
+ }
+
+ ///
+ /// Returns the file name without the extension of a file path that is represented by a read-only character span.
+ ///
+ /// A read-only span that contains the path from which to obtain the file name without the extension.
+ /// The characters in the read-only span returned by , minus the last period (.) and all characters following it.
+ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.GetFileNameWithoutExtension(path);
+ var fileName = GetFileName(path);
+ var lastPeriod = fileName.LastIndexOf('.');
+ return lastPeriod < 0
+ ? fileName
+ : // No extension was found
+ fileName.Slice(0, lastPeriod);
+ }
+
+ ///
+ /// Changes the extension of a path string.
+ ///
+ /// The path information to modify.
+ /// The new extension (with or without a leading period). Specify to remove an existing extension from .
+ ///
+ /// The modified path information.
+ ///
+ /// If is or an empty string (""), the path information is returned unmodified.
+ /// If is , the returned string contains the specified path with its extension removed.
+ /// If has no extension, and is not ,
+ /// the returned path string contains appended to the end of .
+ ///
+ ///
+ ///
+ ///
+ /// If neither nor contains a period (.), ChangeExtension adds the period.
+ ///
+ ///
+ /// The parameter can contain multiple periods and any valid path characters, and can be any length. If is ,
+ /// the returned string contains the contents of with the last period and all characters following it removed.
+ ///
+ ///
+ /// If is an empty string, the returned path string contains the contents of with any characters following the last period removed.
+ ///
+ ///
+ /// If does not have an extension and is not ,
+ /// the returned string contains followed by .
+ ///
+ ///
+ /// If is not and does not contain a leading period, the period is added.
+ ///
+ ///
+ /// If contains a multiple extension separated by multiple periods,
+ /// the returned string contains the contents of with the last period and all characters following it replaced by .
+ /// For example, if is "\Dir1\examples\pathtests.csx.txt" and is "cs",
+ /// the modified path is "\Dir1\examples\pathtests.csx.cs".
+ ///
+ ///
+ /// It is not possible to verify that the returned results are valid in all scenarios. For example,
+ /// if is empty, is appended.
+ ///
+ ///
+#if NETSTANDARD2_1 || NET
+ [return: NotNullIfNotNull(nameof(path))]
+#endif
+ public string? ChangeExtension(string? path, string? extension)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.ChangeExtension(path, extension);
+
+ if (path == null)
+ return null;
+
+ var subLength = path.Length;
+ if (subLength == 0)
+ return string.Empty;
+
+ for (var i = path.Length - 1; i >= 0; i--)
+ {
+ var ch = path[i];
+
+ if (ch == '.')
+ {
+ subLength = i;
+ break;
+ }
+
+ if (IsDirectorySeparator(ch))
+ break;
+ }
+
+ if (extension == null)
+ return path.Substring(0, subLength);
+
+#if NETCOREAPP3_0_OR_GREATER
+ var subpath = path.AsSpan(0, subLength);
+ return extension.StartsWith('.') ?
+ string.Concat(subpath, extension) :
+ string.Concat(subpath, ".", extension);
+#else
+ var subPath = path.Substring(0, subLength);
+ if (extension.Length >= 1 && extension[0] == '.')
+ return string.Concat(subPath, extension);
+ return string.Concat(subPath, ".", extension);
+#endif
+ }
+
+ ///
+ /// Returns the directory information for the specified path represented by a character span.
+ ///
+ /// The path to retrieve the directory information from.
+ /// Directory information for , or an empty span if is ,
+ /// an empty span, or a root (such as \, C:, or \server\share).
+ public ReadOnlySpan GetDirectoryName(ReadOnlySpan path)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.GetDirectoryName(path);
+
+ if (IsEffectivelyEmpty(path))
+ return ReadOnlySpan.Empty;
+
+ var end = GetDirectoryNameOffset(path);
+ return end >= 0 ? path.Slice(0, end) : ReadOnlySpan.Empty;
+ }
+
+ private static int GetDirectoryNameOffset(ReadOnlySpan path)
+ {
+ var rootLength = GetRootLength(path);
+ var end = path.Length;
+ if (end <= rootLength)
+ return -1;
+
+ while (end > rootLength && !IsDirectorySeparator(path[--end])) ;
+
+ // Trim off any remaining separators (to deal with C:\foo\\bar)
+ while (end > rootLength && IsDirectorySeparator(path[end - 1]))
+ end--;
+
+ return end;
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs
new file mode 100644
index 0000000..8f67e6d
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Normalize.cs
@@ -0,0 +1,18 @@
+using System;
+using AnakinRaW.CommonUtilities.FileSystem.Normalization;
+using PG.StarWarsGame.Engine.Utilities;
+
+namespace PG.StarWarsGame.Engine.IO;
+
+public sealed partial class PetroglyphFileSystem
+{
+ internal void NormalizePath(ref ValueStringBuilder stringBuilder)
+ {
+ NormalizePath(stringBuilder.RawChars.Slice(0, stringBuilder.Length));
+ }
+
+ private static void NormalizePath(Span path)
+ {
+ PathNormalizer.Normalize(path, path, PGFileSystemDirectorySeparatorNormalizeOptions);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs
new file mode 100644
index 0000000..4afa311
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.PathEqual.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Runtime.InteropServices;
+using AnakinRaW.CommonUtilities.FileSystem;
+using AnakinRaW.CommonUtilities.FileSystem.Normalization;
+
+namespace PG.StarWarsGame.Engine.IO;
+
+public sealed partial class PetroglyphFileSystem
+{
+ ///
+ /// Determines whether two file system paths are considered equal.
+ ///
+ /// The first path to compare.
+ /// The second path to compare.
+ ///
+ /// if the paths are considered equal; otherwise, .
+ ///
+ public bool PathsAreEqual(string pathA, string pathB)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return _underlyingFileSystem.Path.AreEqual(pathA, pathB);
+
+ var normalizedA = PathNormalizer.Normalize(pathA, PGFileSystemDirectorySeparatorNormalizeOptions);
+ var normalizedB = PathNormalizer.Normalize(pathB, PGFileSystemDirectorySeparatorNormalizeOptions);
+
+ var fullA = _underlyingFileSystem.Path.GetFullPath(normalizedA);
+ var fullB = _underlyingFileSystem.Path.GetFullPath(normalizedB);
+
+ return PathsEqual(fullA.AsSpan(), fullB.AsSpan(), Math.Max(fullA.Length, fullB.Length));
+ }
+
+ private static bool PathsEqual(ReadOnlySpan path1, ReadOnlySpan path2, int length)
+ {
+ if (path1.Length < length || path2.Length < length)
+ return false;
+
+ for (var i = 0; i < length; i++)
+ {
+ if (!PathCharEqual(path1[i], path2[i]))
+ return false;
+ }
+ return true;
+ }
+
+ private static bool PathCharEqual(char x, char y)
+ {
+ if (IsDirectorySeparator(x) && IsDirectorySeparator(y))
+ return true;
+ return char.ToUpperInvariant(x) == char.ToUpperInvariant(y);
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs
new file mode 100644
index 0000000..ee36824
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.cs
@@ -0,0 +1,111 @@
+using AnakinRaW.CommonUtilities.FileSystem.Normalization;
+using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.IO;
+using System.IO.Abstractions;
+using System.Runtime.CompilerServices;
+
+namespace PG.StarWarsGame.Engine.IO;
+
+///
+/// A file system abstraction for the Petroglyph game engine.
+///
+///
+/// This file system abstraction simulates Windows-like behavior for all its public methods on Linux,
+/// such as correct handling of paths containing backslashes ("\"). On Windows itself the behavior is unchanged.
+///
+public sealed partial class PetroglyphFileSystem
+{
+ private const char DirectorySeparatorChar = '/';
+ private const char AltDirectorySeparatorChar = '\\';
+
+ // ReSharper disable once InconsistentNaming
+ private static readonly PathNormalizeOptions PGFileSystemDirectorySeparatorNormalizeOptions = new()
+ {
+ TreatBackslashAsSeparator = true, // Ensure that we treat backslashes as separators on Linux
+ UnifyDirectorySeparators = true,
+ UnifySeparatorKind = DirectorySeparatorKind.System
+ };
+
+ private readonly IFileSystem _underlyingFileSystem;
+
+ ///
+ /// Gets the underlying file system abstraction.
+ ///
+ public IFileSystem UnderlyingFileSystem => _underlyingFileSystem;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The used to resolve dependencies required by the file system.
+ /// is .
+ public PetroglyphFileSystem(IServiceProvider serviceProvider)
+ {
+ if (serviceProvider == null)
+ throw new ArgumentNullException(nameof(serviceProvider));
+ _underlyingFileSystem = serviceProvider.GetRequiredService();
+ }
+
+ ///
+ /// Determines whether the specified path ends with a directory separator character.
+ ///
+ /// The path to check for a trailing directory separator.
+ ///
+ /// if the path ends with a directory separator character; otherwise, .
+ ///
+ ///
+ /// This method always considers both '/' and '\\' as valid directory separator characters.
+ ///
+ public bool HasTrailingDirectorySeparator(ReadOnlySpan path)
+ {
+ return path.Length > 0 && IsDirectorySeparator(path[path.Length - 1]);
+ }
+
+ internal FileSystemStream OpenRead(string filePath)
+ {
+ return _underlyingFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ }
+
+ private static bool IsPathRooted(ReadOnlySpan path)
+ {
+ // The original implementation, obviously, also checks for drive signatures (e.g, c:, X:).
+ // We don't expect such paths ever when running in linux mode, so we simply ignore these
+ var length = path.Length;
+ return length >= 1 && IsDirectorySeparator(path[0]);
+ }
+
+ private static ReadOnlySpan GetPathRoot(ReadOnlySpan path)
+ {
+ if (IsEffectivelyEmpty(path))
+ return ReadOnlySpan.Empty;
+
+ var pathRoot = GetRootLength(path);
+ return pathRoot <= 0 ? ReadOnlySpan.Empty : path.Slice(0, pathRoot);
+ }
+
+ private static int GetRootLength(ReadOnlySpan path)
+ {
+ // We don't ever expect drive signatures or UCN paths in a linux environment.
+ // Thus, we keep the simple linux check, augmented supporting backslash
+ return path.Length > 0 && IsDirectorySeparator(path[0]) ? 1 : 0;
+ }
+
+ private static bool IsEffectivelyEmpty(ReadOnlySpan path)
+ {
+ if (path.IsEmpty)
+ return true;
+
+ foreach (var c in path)
+ {
+ if (c != ' ')
+ return false;
+ }
+ return true;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsDirectorySeparator(char c)
+ {
+ return c is DirectorySeparatorChar or AltDirectorySeparatorChar;
+ }
+}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj
new file mode 100644
index 0000000..e1cd1f9
--- /dev/null
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj
@@ -0,0 +1,29 @@
+
+
+ netstandard2.0;netstandard2.1;net10.0
+ PG.StarWarsGame.Engine
+ PG.StarWarsGame.Engine.FileSystem
+ PG.StarWarsGame.Engine.FileSystem
+ AlamoEngineTools.PG.StarWarsGame.Engine.FileSystem
+ alamo,petroglyph,glyphx
+
+
+
+ true
+ true
+
+
+ true
+ snupkg
+ true
+ preview
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs
similarity index 95%
rename from src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs
rename to src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs
index 0c6b95c..167c3cd 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/Utilities/ValueStringBuilder.cs
@@ -6,6 +6,7 @@
namespace PG.StarWarsGame.Engine.Utilities;
+[DebuggerDisplay("{DebuggerDisplay,nq}")]
internal ref struct ValueStringBuilder
{
private char[]? _arrayToReturnToPool;
@@ -82,6 +83,9 @@ public ref char this[int index]
return ref _chars[index];
}
}
+
+ [DebuggerBrowsable(DebuggerBrowsableState.Never)]
+ private string DebuggerDisplay => AsSpan().ToString();
public override string ToString()
{
@@ -231,22 +235,6 @@ public void Append(char c, int count)
_pos += count;
}
- public unsafe void Append(char* value, int length)
- {
- var pos = _pos;
- if (pos > _chars.Length - length)
- {
- Grow(length);
- }
-
- var dst = _chars.Slice(_pos, length);
- for (var i = 0; i < dst.Length; i++)
- {
- dst[i] = *value++;
- }
- _pos += length;
- }
-
public void Append(scoped ReadOnlySpan value)
{
var pos = _pos;
@@ -293,13 +281,13 @@ private void Grow(int additionalCapacityBeyondPos)
Debug.Assert(additionalCapacityBeyondPos > 0);
Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed.");
- const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength
+ const uint arrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength
// Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try
// to double the size if possible, bounding the doubling to not go beyond the max array length.
var newCapacity = (int)Math.Max(
(uint)(_pos + additionalCapacityBeyondPos),
- Math.Min((uint)_chars.Length * 2, ArrayMaxLength));
+ Math.Min((uint)_chars.Length * 2, arrayMaxLength));
// Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative.
// This could also go negative if the actual required length wraps around.
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs
index 4835ed3..69e5147 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/CommandBar/CommandBarGameManager_Initialization.cs
@@ -196,7 +196,7 @@ private void SetMegaTexture()
if (Components.FirstOrDefault(x => x is CommandBarShellComponent) is null)
return;
// Note: The tag is not used by the engine
- var mtdPath = FileSystem.Path.Combine("DATA\\ART\\TEXTURES", $"{CommandBarConstants.MegaTextureBaseName}.mtd");
+ var mtdPath = PGFileSystem.CombinePath("DATA\\ART\\TEXTURES", $"{CommandBarConstants.MegaTextureBaseName}.mtd");
using var megaTexture = GameRepository.TryOpenFile(mtdPath);
try
@@ -211,7 +211,7 @@ private void SetMegaTexture()
}
GameRepository.TextureRepository.FileExists($"{CommandBarConstants.MegaTextureBaseName}.tga", false, out _, out var actualFilePath);
- MegaTextureFileName = FileSystem.Path.GetFileName(actualFilePath);
+ MegaTextureFileName = PGFileSystem.GetFileName(actualFilePath);
}
private void SetComponentGroup(IEnumerable components)
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs
index c3dd55d..3213387 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameManagerBase.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.IO.Abstractions;
using System.Threading;
using System.Threading.Tasks;
using AnakinRaW.CommonUtilities.Collections;
@@ -8,6 +7,7 @@
using Microsoft.Extensions.Logging;
using PG.Commons.Hashing;
using PG.StarWarsGame.Engine.ErrorReporting;
+using PG.StarWarsGame.Engine.IO;
using PG.StarWarsGame.Engine.IO.Repositories;
namespace PG.StarWarsGame.Engine;
@@ -37,7 +37,9 @@ internal abstract class GameManagerBase
private bool _initialized;
private protected readonly GameRepository GameRepository;
protected readonly IServiceProvider ServiceProvider;
- protected readonly IFileSystem FileSystem;
+
+ // ReSharper disable once InconsistentNaming
+ protected readonly PetroglyphFileSystem PGFileSystem;
protected readonly ILogger? Logger;
protected readonly GameEngineErrorReporterWrapper ErrorReporter;
@@ -55,7 +57,7 @@ protected GameManagerBase(
ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
EngineType = repository.EngineType;
Logger = serviceProvider.GetService()?.CreateLogger(GetType());
- FileSystem = serviceProvider.GetRequiredService();
+ PGFileSystem = repository.PGFileSystem;
ErrorReporter = errorReporter ?? throw new ArgumentNullException(nameof(errorReporter));
}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs
index 8bd517d..a8ddf4c 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GameObjects/GameObjectTypeGameManager.Initialization.cs
@@ -29,7 +29,7 @@ private void ParseGameObjectDatabases()
}, ServiceProvider, ErrorReporter);
var xmlFileList = gameParser.ParseFileList(@"DATA\XML\GAMEOBJECTFILES.XML").Files
- .Select(x => FileSystem.Path.Combine(@".\DATA\XML\", x))
+ .Select(x => PGFileSystem.CombinePath(@".\DATA\XML\", x))
.Where(VerifyFilePathLength)
.ToList();
@@ -111,7 +111,7 @@ private void PostLoadFixup()
private bool IsSameFile(string filePathA, string filePathB)
{
- return FileSystem.Path.AreEqual(filePathA, filePathB);
+ return PGFileSystem.PathsAreEqual(filePathA, filePathB);
}
private void OnGameObjectParsed(object sender, GameObjectParsedEventArgs e)
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs
index d6b4ca9..60ee820 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager.cs
@@ -116,39 +116,31 @@ public bool TryGetTextureEntry(string component, GuiComponentType key, out Compo
return textures.TryGetValue(key, out texture);
}
-
+
+ private static bool IsNone(string texture)
+ {
+ return texture.Equals("none", StringComparison.OrdinalIgnoreCase);
+ }
+
public bool TextureExists(
in ComponentTextureEntry textureInfo,
out GuiTextureOrigin textureOrigin,
out bool isNone,
bool buttonMiddleInRepoMode = false)
{
- if (textureInfo.Texture == "none")
- {
- textureOrigin = default;
- isNone = true;
- return false;
- }
-
isNone = false;
-
- // Apparently, Scanlines only use the repository and not the MTD.
+
+ // Scanlines use the repository and not the MTD.
if (textureInfo.ComponentType == GuiComponentType.Scanlines)
- {
- textureOrigin = GuiTextureOrigin.Repository;
- return GameRepository.TextureRepository.FileExists(textureInfo.Texture);
- }
+ return GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone);
// The engine uses ButtonMiddle to switch to the special button mode.
// It searches first in the repo and then falls back to MTD
// (but only for this very type; the variants do not fallback to MTD).
if (textureInfo.ComponentType == GuiComponentType.ButtonMiddle)
{
- if (GameRepository.TextureRepository.FileExists(textureInfo.Texture))
- {
- textureOrigin = GuiTextureOrigin.Repository;
+ if (GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone))
return true;
- }
}
// The engine does not fallback to MTD once it is in this special Button mode.
@@ -156,10 +148,7 @@ public bool TextureExists(
GuiComponentType.ButtonMiddleDisabled or
GuiComponentType.ButtonMiddleMouseOver or
GuiComponentType.ButtonMiddlePressed)
- {
- textureOrigin = GuiTextureOrigin.Repository;
- return GameRepository.TextureRepository.FileExists(textureInfo.Texture);
- }
+ return GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone);
if (textureInfo.Texture.Length <= 63 && MtdFile is not null && _megaTextureExists)
{
@@ -173,12 +162,25 @@ GuiComponentType.ButtonMiddleMouseOver or
// The background image for frames include a fallback the repository.
if (textureInfo.ComponentType == GuiComponentType.FrameBackground)
- {
- textureOrigin = GuiTextureOrigin.Repository;
- return GameRepository.TextureRepository.FileExists(textureInfo.Texture);
- }
+ return GuiSpecialTextureExists(textureInfo, out textureOrigin, out isNone);
textureOrigin = default;
return false;
}
+
+ private bool GuiSpecialTextureExists(
+ in ComponentTextureEntry textureInfo,
+ out GuiTextureOrigin textureOrigin,
+ out bool isNone)
+ {
+ isNone = IsNone(textureInfo.Texture);
+ if (isNone)
+ {
+ textureOrigin = default;
+ return false;
+ }
+
+ textureOrigin = GuiTextureOrigin.Repository;
+ return GameRepository.TextureRepository.FileExists(textureInfo.Texture);
+ }
}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs
index 2bac738..a22aff5 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/GuiDialog/GuiDialogGameManager_Initialization.cs
@@ -36,10 +36,9 @@ protected override Task InitializeCoreAsync(CancellationToken token)
GameManager = ToString(),
Message = "Unable to parse GuiDialogs.xml"
});
- return;
}
- InitializeTextures(guiDialogs.TextureData);
+ InitializeTextures(guiDialogs?.TextureData ?? new GuiDialogsXmlTextureData([], default));
GuiDialogsXml = guiDialogs;
}, token);
}
@@ -146,7 +145,7 @@ private void InitializeMegaTextures(GuiDialogsXmlTextureData guiDialogs)
}
else
{
- var mtdPath = FileSystem.Path.Combine("DATA\\ART\\TEXTURES", $"{guiDialogs.MegaTexture}.mtd");
+ var mtdPath = PGFileSystem.CombinePath("DATA\\ART\\TEXTURES", $"{guiDialogs.MegaTexture}.mtd");
if (mtdPath.Length > MegaTextureMaxFilePathLength)
{
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs
index 32fff7a..09183c8 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/IGameRepository.cs
@@ -7,9 +7,12 @@ public interface IGameRepository : IRepository
///
/// Gets the full qualified path of this repository with a trailing directory separator
///
- public string Path { get; }
+ string Path { get; }
+
+ // ReSharper disable once InconsistentNaming
+ PetroglyphFileSystem PGFileSystem { get; }
- public GameEngineType EngineType { get; }
+ GameEngineType EngineType { get; }
IRepository EffectsRepository { get; }
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs
index fd95e5b..9b866f8 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/MultiPassRepository.cs
@@ -1,18 +1,15 @@
-using Microsoft.Extensions.DependencyInjection;
-using PG.StarWarsGame.Engine.IO.Repositories;
+using PG.StarWarsGame.Engine.IO.Repositories;
using PG.StarWarsGame.Engine.Utilities;
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
-using System.IO.Abstractions;
namespace PG.StarWarsGame.Engine.IO;
-internal abstract class MultiPassRepository(GameRepository baseRepository, IServiceProvider serviceProvider) : IRepository
+internal abstract class MultiPassRepository(GameRepository baseRepository) : IRepository
{
- protected readonly IFileSystem FileSystem = serviceProvider.GetRequiredService();
protected readonly GameRepository BaseRepository = baseRepository;
-
+
public Stream OpenFile(string filePath, bool megFileOnly = false)
{
return OpenFile(filePath.AsSpan(), megFileOnly);
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs
index 22f4949..1557a7e 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/EffectsRepository.cs
@@ -1,11 +1,9 @@
using System;
-using PG.StarWarsGame.Engine.IO.Utilities;
using PG.StarWarsGame.Engine.Utilities;
namespace PG.StarWarsGame.Engine.IO.Repositories;
-internal class EffectsRepository(GameRepository baseRepository, IServiceProvider serviceProvider)
- : MultiPassRepository(baseRepository, serviceProvider)
+internal class EffectsRepository(GameRepository baseRepository) : MultiPassRepository(baseRepository)
{
private static readonly string[] LookupPaths =
[
@@ -73,7 +71,7 @@ private FileFoundInfo FindEffect(
multiPassStringBuilder.Length = 0;
if (directory != ReadOnlySpan.Empty)
- FileSystem.Path.Join(directory, strippedName, ref multiPassStringBuilder);
+ BaseRepository.PGFileSystem.JoinPath(directory, strippedName, ref multiPassStringBuilder);
else
multiPassStringBuilder.Append(strippedName);
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs
index a1b07fb..0c21750 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/FocGameRepository.cs
@@ -25,9 +25,9 @@ public FocGameRepository(GameLocations gameLocations, GameEngineErrorReporterWra
if (firstFallback is not null)
{
var eawMegs = LoadMegArchivesFromXml(firstFallback);
- var eawPatch = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\Patch.meg"));
- var eawPatch2 = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\Patch2.meg"));
- var eaw64Patch = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "Data\\64Patch.meg"));
+ var eawPatch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/Patch.meg"));
+ var eawPatch2 = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/Patch2.meg"));
+ var eaw64Patch = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path.Combine(firstFallback, "Data/64Patch.meg"));
megsToConsider.AddRange(eawMegs);
if (eawPatch is not null)
@@ -39,9 +39,9 @@ public FocGameRepository(GameLocations gameLocations, GameEngineErrorReporterWra
}
var focOrModMegs = LoadMegArchivesFromXml(".");
- var focPatch = LoadMegArchive("Data\\Patch.meg");
- var focPatch2 = LoadMegArchive("Data\\Patch2.meg");
- var foc64Patch = LoadMegArchive("Data\\64Patch.meg");
+ var focPatch = LoadMegArchive("Data/Patch.meg");
+ var focPatch2 = LoadMegArchive("Data/Patch2.meg");
+ var foc64Patch = LoadMegArchive("Data/64Patch.meg");
megsToConsider.AddRange(focOrModMegs);
if (focPatch is not null)
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs
index d35254d..3e31bf9 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.Files.cs
@@ -1,6 +1,4 @@
-using AnakinRaW.CommonUtilities.FileSystem;
-using Microsoft.Extensions.Logging;
-using PG.StarWarsGame.Engine.IO.Utilities;
+using Microsoft.Extensions.Logging;
using PG.StarWarsGame.Engine.Utilities;
using PG.StarWarsGame.Files.MEG.Binary;
using System;
@@ -8,8 +6,6 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
namespace PG.StarWarsGame.Engine.IO.Repositories;
@@ -21,7 +17,7 @@ public bool FileExists(string filePath, string[] extensions, bool megFileOnly =
{
foreach (var extension in extensions)
{
- var newPath = FileSystem.Path.ChangeExtension(filePath, extension);
+ var newPath = PGFileSystem.ChangeExtension(filePath, extension);
if (FileExists(newPath, megFileOnly))
return true;
}
@@ -89,7 +85,14 @@ public Stream OpenFile(ReadOnlySpan filePath, bool megFileOnly = false)
sb.Dispose();
return fileStream;
}
-
+
+ ///
+ /// The core routine for finding a file using the game's specific lookup rules.
+ ///
+ /// The file path.
+ /// The string builder used for constructing the file path.
+ /// Whether to only search for files in MEG archives.
+ /// The file found information.
protected internal abstract FileFoundInfo FindFile(ReadOnlySpan filePath,
ref ValueStringBuilder pathStringBuilder, bool megFileOnly = false);
@@ -99,23 +102,24 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath)
if (filePath.Length > PGConstants.MaxMegEntryPathLength)
{
- Logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FilePath}'", filePath.ToString());
+ _logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters: '{FileName}'", filePath.ToString());
return default;
}
+
+ var sb = new ValueStringBuilder(stackalloc char[PGConstants.MaxMegEntryPathLength]);
+ sb.Append(filePath);
+ PGFileSystem.NormalizePath(ref sb);
Span fileNameSpan = stackalloc char[PGConstants.MaxMegEntryPathLength];
-
- if (!_megPathNormalizer.TryNormalize(filePath, fileNameSpan, out var length))
+
+ var normalized = _megPathNormalizer.TryNormalize(sb.AsSpan(), fileNameSpan, out var length);
+ sb.Dispose();
+
+ if (!normalized)
return default;
var fileName = fileNameSpan.Slice(0, length);
- if (fileName.Length > PGConstants.MaxMegEntryPathLength)
- {
- Logger.LogWarning("Trying to open a MEG entry which is longer than 259 characters after normalization: '{FileName}'", fileName.ToString());
- return default;
- }
-
var crc = _crc32HashingService.GetCrc32(fileName, MegFileConstants.MegDataEntryPathEncoding);
var entry = MasterMegArchive!.EntriesWithCrc(crc).FirstOrDefault();
@@ -125,39 +129,8 @@ protected FileFoundInfo GetFileInfoFromMasterMeg(ReadOnlySpan filePath)
protected FileFoundInfo FindFileCore(ReadOnlySpan filePath, ref ValueStringBuilder stringBuilder)
{
- bool exists;
-
- stringBuilder.Length = 0;
-
- if (FileSystem.Path.IsPathFullyQualified(filePath))
- stringBuilder.Append(filePath);
- else
- FileSystem.Path.Join(GameDirectory.AsSpan(), filePath, ref stringBuilder);
-
- var actualFilePath = stringBuilder.AsSpan();
-
- // We accept a *possible* difference here between platforms,
- // unless it's proven the differences are too significant.
- if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- exists = FileSystem.File.Exists(actualFilePath.ToString());
- else
- {
- // We *could* also use the slightly faster GetFileAttributesA.
- // However, CreateFileA and GetFileAttributesA are implemented complete independent.
- // The game uses CreateFileA.
- // Thus, we should stick to what the game uses in order to be as close to the engine as possible
- // NB: It's also important that the string builder is zero-terminated, as otherwise CreateFileA might get invalid data.
- var fileHandle = CreateFile(
- in stringBuilder.GetPinnableReference(true),
- FileAccess.Read,
- FileShare.Read,
- IntPtr.Zero,
- FileMode.Open,
- FileAttributes.Normal, IntPtr.Zero);
-
- exists = IsValidAndClose(fileHandle);
- }
- return !exists ? new FileFoundInfo() : new FileFoundInfo(actualFilePath);
+ var exists = PGFileSystem.FileExists(filePath, ref stringBuilder, GameDirectory.AsSpan());
+ return !exists ? default : new FileFoundInfo(stringBuilder.AsSpan());
}
protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList fallbackPaths, ref ValueStringBuilder pathStringBuilder)
@@ -173,7 +146,7 @@ protected FileFoundInfo FileFromAltExists(ReadOnlySpan filePath, IList filePath, IList path, out int cutoffLength)
+
+ private bool PathStartsWithDataDirectory(ReadOnlySpan path, out int cutoffLength)
{
cutoffLength = 0;
if (path.Length < 5)
return false;
- foreach (var prefix in DataPathPrefixes)
+
+ var sb = new ValueStringBuilder(stackalloc char[265]);
+ sb.Append(path);
+ PGFileSystem.NormalizePath(ref sb);
+ try
{
- if (path.StartsWith(prefix.AsSpan(), StringComparison.OrdinalIgnoreCase))
+ foreach (var prefix in DataPathPrefixes)
{
- if (path[0] == '.')
- cutoffLength = 2;
- return true;
+ if (sb.AsSpan().StartsWith(prefix.AsSpan(), StringComparison.OrdinalIgnoreCase))
+ {
+ if (path[0] == '.')
+ cutoffLength = 2;
+ return true;
+ }
}
+ return false;
+ }
+ finally
+ {
+ sb.Dispose();
}
- return false;
}
internal Stream? OpenFileCore(FileFoundInfo fileFoundInfo)
@@ -209,29 +193,6 @@ private static bool PathStartsWithDataDirectory(ReadOnlySpan path, out int
if (fileFoundInfo.InMeg)
return _megExtractor.GetData(fileFoundInfo.MegDataEntryReference.Location);
- return FileSystem.FileStream.New(fileFoundInfo.FilePath.ToString(), FileMode.Open, FileAccess.Read, FileShare.Read);
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static bool IsValidAndClose(IntPtr handle)
- {
- var isValid = handle != IntPtr.Zero && handle != new IntPtr(-1);
- if (isValid)
- CloseHandle(handle);
- return isValid;
+ return PGFileSystem.OpenRead(fileFoundInfo.FilePath.ToString());
}
-
- [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
- private static extern IntPtr CreateFile(
- in char lpFileName,
- [MarshalAs(UnmanagedType.U4)] FileAccess access,
- [MarshalAs(UnmanagedType.U4)] FileShare share,
- IntPtr securityAttributes,
- [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
- [MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes,
- IntPtr templateFile);
-
- [DllImport("kernel32.dll", SetLastError = true)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool CloseHandle(IntPtr hObject);
}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs
index ec40d64..c96cd67 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/GameRepository.cs
@@ -5,8 +5,8 @@
using AnakinRaW.CommonUtilities.FileSystem;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using PG.Commons.Hashing;
-using PG.Commons.Services;
using PG.StarWarsGame.Engine.ErrorReporting;
using PG.StarWarsGame.Engine.Localization;
using PG.StarWarsGame.Files.MEG.Data.Archives;
@@ -19,7 +19,7 @@
namespace PG.StarWarsGame.Engine.IO.Repositories;
-internal abstract partial class GameRepository : ServiceBase, IGameRepository
+internal abstract partial class GameRepository : IGameRepository
{
private readonly IMegFileService _megFileService;
private readonly IMegFileExtractor _megExtractor;
@@ -28,6 +28,9 @@ internal abstract partial class GameRepository : ServiceBase, IGameRepository
private readonly IVirtualMegArchiveBuilder _virtualMegBuilder;
private readonly IGameLanguageManagerProvider _languageManagerProvider;
private readonly GameEngineErrorReporterWrapper _errorReporter;
+
+ private readonly ILogger _logger;
+ private readonly IServiceProvider _serviceProvider;
protected readonly string GameDirectory;
@@ -35,23 +38,32 @@ internal abstract partial class GameRepository : ServiceBase, IGameRepository
protected readonly IList FallbackPaths = new List();
private bool _sealed;
+
+ public PetroglyphFileSystem PGFileSystem { get; }
public string Path { get; }
+
public abstract GameEngineType EngineType { get; }
public IRepository EffectsRepository { get; }
public IRepository TextureRepository { get; }
public IRepository ModelRepository { get; }
-
+
private readonly List _loadedMegFiles = new();
protected IVirtualMegArchive? MasterMegArchive { get; private set; }
- protected GameRepository(GameLocations gameLocations, GameEngineErrorReporterWrapper errorReporter, IServiceProvider serviceProvider) : base(serviceProvider)
+ protected GameRepository(
+ GameLocations gameLocations,
+ GameEngineErrorReporterWrapper errorReporter,
+ IServiceProvider serviceProvider)
{
if (gameLocations == null)
throw new ArgumentNullException(nameof(gameLocations));
+ _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ _logger = serviceProvider.GetService()?.CreateLogger(GetType()) ?? NullLogger.Instance;
+
_megExtractor = serviceProvider.GetRequiredService();
_megFileService = serviceProvider.GetRequiredService();
_virtualMegBuilder = serviceProvider.GetRequiredService();
@@ -60,39 +72,44 @@ protected GameRepository(GameLocations gameLocations, GameEngineErrorReporterWra
_languageManagerProvider = serviceProvider.GetRequiredService();
_errorReporter = errorReporter;
+ PGFileSystem = new PetroglyphFileSystem(serviceProvider);
+
foreach (var mod in gameLocations.ModPaths)
{
if (string.IsNullOrEmpty(mod))
throw new InvalidOperationException("Mods with empty paths are not valid.");
- ModPaths.Add(FileSystem.Path.GetFullPath(mod));
+ ModPaths.Add(PGFileSystem.UnderlyingFileSystem.Path.GetFullPath(mod));
}
- GameDirectory = FileSystem.Path.GetFullPath(gameLocations.GamePath);
+ // NB: We are using the native file system here, because we want to make sure that
+ // the paths are normalized to the actual file system of the current system.
+ GameDirectory = PGFileSystem.UnderlyingFileSystem.Path.GetFullPath(gameLocations.GamePath);
foreach (var fallbackPath in gameLocations.FallbackPaths)
{
if (string.IsNullOrEmpty(fallbackPath))
{
- Logger.LogTrace("Skipping null or empty fallback path.");
+ _logger.LogTrace("Skipping null or empty fallback path.");
continue;
}
- FallbackPaths.Add(FileSystem.Path.GetFullPath(fallbackPath));
+
+ FallbackPaths.Add(PGFileSystem.UnderlyingFileSystem.Path.GetFullPath(fallbackPath));
}
- EffectsRepository = new EffectsRepository(this, serviceProvider);
- TextureRepository = new TextureRepository(this, serviceProvider);
- ModelRepository = new ModelRepository(this, serviceProvider);
+ EffectsRepository = new EffectsRepository(this);
+ TextureRepository = new TextureRepository(this);
+ ModelRepository = new ModelRepository(this);
var path = ModPaths.Any() ? ModPaths.First() : GameDirectory;
- if (!FileSystem.Path.HasTrailingDirectorySeparator(path))
- path += FileSystem.Path.DirectorySeparatorChar;
-
+ if (!PGFileSystem.UnderlyingFileSystem.Path.HasTrailingDirectorySeparator(path))
+ path += PGFileSystem.UnderlyingFileSystem.Path.DirectorySeparatorChar;
+
Path = path;
}
-
+
public void AddMegFiles(IList megFiles)
{
ThrowIfSealed();
@@ -116,9 +133,9 @@ public void AddMegFile(string megFile)
if (megArchive is null)
{
if (IsSpeechMeg(megFile))
- Logger.LogDebug("Unable to find Speech MEG file at '{MegFile}'", megFile);
+ _logger.LogDebug("Unable to find Speech MEG file at '{MegFile}'", megFile);
else
- Logger.LogWarning("Unable to find MEG file at '{MegFile}'", megFile);
+ _logger.LogWarning("Unable to find MEG file at '{MegFile}'", megFile);
return;
}
@@ -140,7 +157,7 @@ public bool IsLanguageInstalled(LanguageType language)
foreach (var loadedMegFile in _loadedMegFiles)
{
- var file = FileSystem.Path.GetFileName(loadedMegFile.AsSpan());
+ var file = PGFileSystem.UnderlyingFileSystem.Path.GetFileName(loadedMegFile.AsSpan());
var speechFileName = languageFiles.SpeechMegFileName.AsSpan();
if (file.Equals(speechFileName, StringComparison.OrdinalIgnoreCase))
@@ -159,8 +176,10 @@ public IEnumerable InitializeInstalledSfxMegFiles()
var firstFallback = FallbackPaths.FirstOrDefault();
if (firstFallback is not null)
{
- var fallback2dNonLocalized = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG"));
- var fallback3dNonLocalized = LoadMegArchive(FileSystem.Path.Combine(firstFallback, "DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG"));
+ var fallback2dNonLocalized = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path
+ .Combine(firstFallback, "DATA/AUDIO/SFX/SFX2D_NON_LOCALIZED.MEG"));
+ var fallback3dNonLocalized = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path
+ .Combine(firstFallback, "DATA/AUDIO/SFX/SFX3D_NON_LOCALIZED.MEG"));
if (fallback2dNonLocalized is not null)
megsToAdd.Add(fallback2dNonLocalized);
@@ -169,8 +188,8 @@ public IEnumerable InitializeInstalledSfxMegFiles()
megsToAdd.Add(fallback3dNonLocalized);
}
- var nonLocalized2d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX2D_NON_LOCALIZED.MEG");
- var nonLocalized3d = LoadMegArchive("DATA\\AUDIO\\SFX\\SFX3D_NON_LOCALIZED.MEG");
+ var nonLocalized2d = LoadMegArchive("DATA/AUDIO/SFX/SFX2D_NON_LOCALIZED.MEG");
+ var nonLocalized3d = LoadMegArchive("DATA/AUDIO/SFX/SFX3D_NON_LOCALIZED.MEG");
if (nonLocalized2d is not null)
megsToAdd.Add(nonLocalized2d);
@@ -191,7 +210,8 @@ public IEnumerable InitializeInstalledSfxMegFiles()
if (firstFallback is not null)
{
- var fallback2dLang = LoadMegArchive(FileSystem.Path.Combine(firstFallback, languageFiles.Sfx2dMegFilePath));
+ var fallback2dLang = LoadMegArchive(PGFileSystem.UnderlyingFileSystem.Path
+ .Combine(firstFallback, languageFiles.Sfx2dMegFilePath));
if (fallback2dLang is not null)
megsToAdd.Add(fallback2dLang);
}
@@ -202,7 +222,7 @@ public IEnumerable InitializeInstalledSfxMegFiles()
}
if (languages.Count == 0)
- Logger.LogWarning("Unable to initialize any language.");
+ _logger.LogWarning("Unable to initialize any language.");
AddMegFiles(megsToAdd);
@@ -211,17 +231,17 @@ public IEnumerable InitializeInstalledSfxMegFiles()
protected IList LoadMegArchivesFromXml(string lookupPath)
{
- var megFilesXmlPath = FileSystem.Path.Combine(lookupPath, "Data\\MegaFiles.xml");
+ var megFilesXmlPath = PGFileSystem.CombinePath(lookupPath, "Data/MegaFiles.xml");
using var xmlStream = TryOpenFile(megFilesXmlPath);
if (xmlStream is null)
{
- Logger.LogWarning("Unable to find MegaFiles.xml at '{LookupPath}'", lookupPath);
+ _logger.LogWarning("Unable to find MegaFiles.xml at '{LookupPath}'", lookupPath);
return Array.Empty();
}
- var parser = new XmlFileListParser(Services ,_errorReporter);
+ var parser = new XmlFileListParser(_serviceProvider ,_errorReporter);
var megaFilesXml = parser.ParseFile(xmlStream);
if (megaFilesXml is null)
@@ -231,7 +251,7 @@ protected IList LoadMegArchivesFromXml(string lookupPath)
foreach (var file in megaFilesXml.Files.Select(x => x.Trim()))
{
- var megPath = FileSystem.Path.Combine(lookupPath, file);
+ var megPath = PGFileSystem.CombinePath(lookupPath, file);
var megFile = LoadMegArchive(megPath);
if (megFile is not null)
megs.Add(megFile);
@@ -251,12 +271,12 @@ internal void Seal()
if (megFileStream is not FileSystemStream fileSystemStream)
{
if (IsSpeechMeg(megPath))
- Logger.LogDebug("Unable to find Speech MEG file '{MegPath}'", megPath);
+ _logger.LogDebug("Unable to find Speech MEG file '{MegPath}'", megPath);
else
{
var message = $"Unable to find MEG file '{megPath}'";
_errorReporter.Assert(EngineAssert.Create(EngineAssertKind.FileNotFound, megPath, [], message));
- Logger.LogWarning("Unable to find MEG file '{MegPath}'", megPath);
+ _logger.LogWarning("Unable to find MEG file '{MegPath}'", megPath);
}
return null;
}
@@ -271,7 +291,7 @@ internal void Seal()
private bool IsSpeechMeg(string megFile)
{
- return FileSystem.Path.GetFileName(megFile.AsSpan()).EndsWith("Speech.meg".AsSpan(), StringComparison.OrdinalIgnoreCase);
+ return PGFileSystem.GetFileName(megFile.AsSpan()).EndsWith("Speech.meg".AsSpan(), StringComparison.OrdinalIgnoreCase);
}
private void ThrowIfSealed()
@@ -294,8 +314,8 @@ public LanguageFiles(LanguageType language)
{
Language = language;
var languageString = language.ToString().ToUpperInvariant();
- MasterTextDatFilePath = $"DATA\\TEXT\\MasterTextFile_{languageString}.DAT";
- Sfx2dMegFilePath = $"DATA\\AUDIO\\SFX\\SFX2D_{languageString}.MEG";
+ MasterTextDatFilePath = $"DATA/TEXT/MasterTextFile_{languageString}.DAT";
+ Sfx2dMegFilePath = $"DATA/AUDIO/SFX/SFX2D_{languageString}.MEG";
SpeechMegFileName = $"{languageString}SPEECH.MEG";
}
}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs
index 1b6cb6d..e736852 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/ModelRepository.cs
@@ -1,15 +1,10 @@
using System;
using System.Runtime.CompilerServices;
-using PG.StarWarsGame.Engine.IO.Utilities;
using PG.StarWarsGame.Engine.Utilities;
-#if NETSTANDARD2_0 || NETFRAMEWORK
-using AnakinRaW.CommonUtilities.FileSystem;
-#endif
namespace PG.StarWarsGame.Engine.IO.Repositories;
-internal class ModelRepository(GameRepository baseRepository, IServiceProvider serviceProvider)
- : MultiPassRepository(baseRepository, serviceProvider)
+internal class ModelRepository(GameRepository baseRepository) : MultiPassRepository(baseRepository)
{
private protected override FileFoundInfo MultiPassAction(
ReadOnlySpan filePath,
@@ -28,8 +23,8 @@ private protected override FileFoundInfo MultiPassAction(
var stripped = StripFileName(filePath);
- var path = FileSystem.Path.GetDirectoryName(filePath);
- FileSystem.Path.Join(path, stripped, ref reusableStringBuilder);
+ var path = BaseRepository.PGFileSystem.GetDirectoryName(filePath);
+ BaseRepository.PGFileSystem.JoinPath(path, stripped, ref reusableStringBuilder);
reusableStringBuilder.Append(".ALO");
var alternatePath = reusableStringBuilder.AsSpan();
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs
index 82f4045..a8e1432 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Repositories/TextureRepository.cs
@@ -3,8 +3,7 @@
namespace PG.StarWarsGame.Engine.IO.Repositories;
-internal class TextureRepository(GameRepository baseRepository, IServiceProvider serviceProvider) :
- MultiPassRepository(baseRepository, serviceProvider)
+internal class TextureRepository(GameRepository baseRepository) : MultiPassRepository(baseRepository)
{
private static readonly string DdsExtension = ".dds";
private static readonly string TexturePath = "./Data/art/Textures/";
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs
deleted file mode 100644
index f012fdc..0000000
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/DirectoryInfoGlobbingWrapper.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.IO.Abstractions;
-
-namespace PG.StarWarsGame.Engine.IO.Utilities;
-
-// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing
-
-///
-/// Wraps to be used with
-///
-internal sealed class DirectoryInfoGlobbingWrapper : Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase
-{
- private readonly IFileSystem _fileSystem;
- private readonly IDirectoryInfo _directoryInfo;
- private readonly bool _isParentPath;
-
- ///
- public override string Name => _isParentPath ? ".." : _directoryInfo.Name;
-
- ///
- public override string FullName => _directoryInfo.FullName;
-
- ///
- public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? ParentDirectory =>
- _directoryInfo.Parent is null
- ? null
- : new DirectoryInfoGlobbingWrapper(_fileSystem, _directoryInfo.Parent);
-
- ///
- /// Construct a new instance of
- ///
- /// The filesystem
- /// The directory
- public DirectoryInfoGlobbingWrapper(IFileSystem fileSystem, IDirectoryInfo directoryInfo)
- : this(fileSystem, directoryInfo, isParentPath: false)
- {
- }
-
- private DirectoryInfoGlobbingWrapper(IFileSystem fileSystem, IDirectoryInfo directoryInfo, bool isParentPath)
- {
- _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
- _directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo));
- _isParentPath = isParentPath;
- }
-
- ///
- public override IEnumerable EnumerateFileSystemInfos()
- {
- if (_directoryInfo.Exists)
- {
- IEnumerable fileSystemInfos;
- try
- {
- fileSystemInfos = _directoryInfo.EnumerateFileSystemInfos("*", SearchOption.TopDirectoryOnly);
- }
- catch (DirectoryNotFoundException)
- {
- yield break;
- }
-
- foreach (var fileSystemInfo in fileSystemInfos)
- {
- yield return fileSystemInfo switch
- {
- IDirectoryInfo directoryInfo => new DirectoryInfoGlobbingWrapper(_fileSystem, directoryInfo),
- IFileInfo fileInfo => new FileInfoGlobbingWrapper(_fileSystem, fileInfo),
- _ => throw new NotSupportedException()
- };
- }
- }
- }
-
- ///
- public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? GetDirectory(string path)
- {
- var isParentPath = string.Equals(path, "..", StringComparison.Ordinal);
-
- if (isParentPath)
- {
- return new DirectoryInfoGlobbingWrapper(_fileSystem,
- _fileSystem.DirectoryInfo.New(Path.Combine(_directoryInfo.FullName, path)), isParentPath);
- }
-
- var dirs = _directoryInfo.GetDirectories(path);
-
- return dirs switch
- {
- { Length: 1 }
- => new DirectoryInfoGlobbingWrapper(_fileSystem, dirs[0], isParentPath),
- { Length: 0 } => null,
- // This shouldn't happen. The parameter name isn't supposed to contain wild card.
- _
- => throw new InvalidOperationException(
- $"More than one sub directories are found under {_directoryInfo.FullName} with name {path}."
- ),
- };
- }
-
- ///
- public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.FileInfoBase GetFile(string path)
- {
- return new FileInfoGlobbingWrapper(_fileSystem, _fileSystem.FileInfo.New(Path.Combine(FullName, path)));
- }
-}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs
deleted file mode 100644
index fef3dd9..0000000
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/FileInfoGlobbingWrapper.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System.IO.Abstractions;
-
-namespace PG.StarWarsGame.Engine.IO.Utilities;
-
-// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing
-
-internal sealed class FileInfoGlobbingWrapper
- : Microsoft.Extensions.FileSystemGlobbing.Abstractions.FileInfoBase
-{
- private readonly IFileSystem _fileSystem;
- private readonly IFileInfo _fileInfo;
-
- ///
- public override string Name => _fileInfo.Name;
-
- ///
- public override string FullName => _fileInfo.FullName;
-
- ///
- public override Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoBase? ParentDirectory =>
- _fileInfo.Directory is null
- ? null
- : new DirectoryInfoGlobbingWrapper(_fileSystem, _fileInfo.Directory);
-
- ///
- /// InitializeAsync a new instance
- ///
- /// The filesystem
- /// The file
- public FileInfoGlobbingWrapper(IFileSystem fileSystem, IFileInfo fileInfo)
- {
- _fileSystem = fileSystem;
- _fileInfo = fileInfo;
- }
-}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs
deleted file mode 100644
index 48ddfa1..0000000
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/MatcherExtensions.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO.Abstractions;
-using System.Linq;
-using Microsoft.Extensions.FileSystemGlobbing;
-
-namespace PG.StarWarsGame.Engine.IO.Utilities;
-
-// Taken from https://github.com/vipentti/Vipentti.IO.Abstractions.FileSystemGlobbing
-
-///
-/// Provides extensions for to support
-///
-internal static class MatcherExtensions
-{
- ///
- /// Searches the directory specified for all files matching patterns added to this instance of
- ///
- /// The matcher
- /// The filesystem
- /// The root directory for the search
- /// Always returns instance of , even if no files were matched
- public static PatternMatchingResult Execute(this Matcher matcher, IFileSystem fileSystem, string directoryPath)
- {
- if (matcher == null)
- throw new ArgumentNullException(nameof(matcher));
- if (fileSystem == null)
- throw new ArgumentNullException(nameof(fileSystem));
- return Execute(matcher, fileSystem, fileSystem.DirectoryInfo.New(directoryPath));
- }
-
- ///
- public static PatternMatchingResult Execute(this Matcher matcher, IFileSystem fileSystem, IDirectoryInfo directoryInfo)
- {
- if (matcher == null)
- throw new ArgumentNullException(nameof(matcher));
- if (fileSystem == null)
- throw new ArgumentNullException(nameof(fileSystem));
- if (directoryInfo == null)
- throw new ArgumentNullException(nameof(directoryInfo));
- return matcher.Execute(new DirectoryInfoGlobbingWrapper(fileSystem, directoryInfo));
- }
-
- ///
- /// Searches the directory specified for all files matching patterns added to this instance of
- ///
- /// The matcher
- /// The filesystem
- /// The root directory for the search
- /// Absolute file paths of all files matched. Empty enumerable if no files matched given patterns.
- public static IEnumerable GetResultsInFullPath(this Matcher matcher, IFileSystem fileSystem, string directoryPath)
- {
- if (matcher == null)
- throw new ArgumentNullException(nameof(matcher));
- if (fileSystem == null)
- throw new ArgumentNullException(nameof(fileSystem));
- return GetResultsInFullPath(matcher, fileSystem, fileSystem.DirectoryInfo.New(directoryPath));
- }
-
- ///
- public static IEnumerable GetResultsInFullPath(this Matcher matcher, IFileSystem fileSystem, IDirectoryInfo directoryInfo)
- {
- var matches = Execute(matcher, fileSystem, directoryInfo);
-
- if (!matches.HasMatches)
- return Enumerable.Empty();
-
- var fsPath = fileSystem.Path;
- var directoryFullName = directoryInfo.FullName;
-
- return matches.Files.Select(GetFullPath);
-
- string GetFullPath(FilePatternMatch match)
- {
- return fsPath.GetFullPath(fsPath.Combine(directoryFullName, match.Path));
- }
- }
-}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs
deleted file mode 100644
index 471b5a8..0000000
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/IO/Utilities/PathExtensions.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System;
-using System.IO.Abstractions;
-using AnakinRaW.CommonUtilities.FileSystem;
-using PG.StarWarsGame.Engine.Utilities;
-
-namespace PG.StarWarsGame.Engine.IO.Utilities;
-
-internal static class PathExtensions
-{
- public static void Join(this IPath _, ReadOnlySpan path1, ReadOnlySpan path2, ref ValueStringBuilder stringBuilder)
- {
- if (path1.Length == 0 && path2.Length == 0)
- return;
-
- if (path1.Length == 0 || path2.Length == 0)
- {
- ref var pathToUse = ref path1.Length == 0 ? ref path2 : ref path1;
- stringBuilder.Append(pathToUse);
- return;
- }
-
- var needsSeparator = !(_.HasTrailingDirectorySeparator(path1) || _.HasLeadingDirectorySeparator(path2));
-
- stringBuilder.Append(path1);
- if (needsSeparator)
- stringBuilder.Append(_.DirectorySeparatorChar);
-
- stringBuilder.Append(path2);
- }
-}
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj
index 96091cb..5389ded 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/PG.StarWarsGame.Engine.csproj
@@ -18,16 +18,10 @@
preview
-
-
-
-
-
-
-
+
+
-
-
+
@@ -35,9 +29,15 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs
index 76af023..aa1e5a9 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Rendering/PGRender.cs
@@ -1,5 +1,4 @@
-using AnakinRaW.CommonUtilities.FileSystem;
-using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PG.Commons.Hashing;
using PG.StarWarsGame.Engine.ErrorReporting;
@@ -12,7 +11,6 @@
using PG.StarWarsGame.Files.ALO.Services;
using PG.StarWarsGame.Files.Binary;
using System;
-using System.IO.Abstractions;
using PG.StarWarsGame.Engine.Rendering.Animations;
namespace PG.StarWarsGame.Engine.Rendering;
@@ -24,7 +22,7 @@ internal class PGRender(
{
private readonly IAloFileService _aloFileService = serviceProvider.GetRequiredService();
private readonly IRepository _modelRepository = gameRepository.ModelRepository;
- private readonly IFileSystem _fileSystem = serviceProvider.GetRequiredService();
+ private readonly PetroglyphFileSystem _pgFileSystem = gameRepository.PGFileSystem;
private readonly ICrc32HashingService _hashingService = serviceProvider.GetRequiredService();
private readonly ILogger? _logger = serviceProvider.GetService()?.CreateLogger(typeof(PGRender));
@@ -81,11 +79,11 @@ internal class PGRender(
if (!aloFile.FileInformation.IsModel)
return new ModelClass(aloFile);
- var directory = _fileSystem.Path.GetDirectoryName(path);
- var fileName = _fileSystem.Path.GetFileNameWithoutExtension(path);
+ var directory = _pgFileSystem.GetDirectoryName(path);
+ var fileName = _pgFileSystem.GetFileNameWithoutExtension(path);
if (!string.IsNullOrEmpty(animOverrideName))
- fileName = _fileSystem.Path.GetFileNameWithoutExtension(animOverrideName.AsSpan());
+ fileName = _pgFileSystem.GetFileNameWithoutExtension(animOverrideName.AsSpan());
var animations = LoadAnimations(fileName, directory, metadataOnly, throwsException ? AnimationCorruptedHandler : null);
@@ -103,7 +101,7 @@ public AnimationCollection LoadAnimations(
bool metadataOnly = true,
Action? corruptedAnimationHandler = null)
{
- modelFileName = _fileSystem.Path.GetFileNameWithoutExtension(modelFileName);
+ modelFileName = _pgFileSystem.GetFileNameWithoutExtension(modelFileName);
var animations = new AnimationCollection();
@@ -122,7 +120,7 @@ public AnimationCollection LoadAnimations(
CreateAnimationFilePath(ref stringBuilder, modelFileName, animationData.Value, subIndex);
var animationFilenameWithoutExtension =
- _fileSystem.Path.GetFileNameWithoutExtension(stringBuilder.AsSpan());
+ _pgFileSystem.GetFileNameWithoutExtension(stringBuilder.AsSpan());
InsertPath(ref stringBuilder, directory);
if (stringBuilder.Length > PGConstants.MaxAnimationFileName)
@@ -166,8 +164,12 @@ public AnimationCollection LoadAnimations(
private void InsertPath(ref ValueStringBuilder stringBuilder, ReadOnlySpan directory)
{
- if (!_fileSystem.Path.HasTrailingDirectorySeparator(directory))
+ if (!_pgFileSystem.HasTrailingDirectorySeparator(directory))
+ {
+ // This MUST NOT be changed to "/" as it will break the loading on linux
+ // (because "/" indicates an absolute path)
stringBuilder.Insert(0, '\\', 1);
+ }
stringBuilder.Insert(0, directory);
}
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs
index f849d05..2aa7798 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Xml/PetroglyphStarWarsGameXmlParser.cs
@@ -5,8 +5,8 @@
using AnakinRaW.CommonUtilities.Collections;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using PG.Commons.Hashing;
-using PG.Commons.Services;
using PG.StarWarsGame.Engine.ErrorReporting;
using PG.StarWarsGame.Engine.IO;
using PG.StarWarsGame.Files.XML;
@@ -16,26 +16,32 @@
namespace PG.StarWarsGame.Engine.Xml;
-public sealed class PetroglyphStarWarsGameXmlParser : ServiceBase, IPetroglyphXmlParserInfo
+public sealed class PetroglyphStarWarsGameXmlParser : IPetroglyphXmlParserInfo
{
private readonly IGameRepository _gameRepository;
+ private readonly PetroglyphFileSystem _pgFileSystem;
private readonly PetroglyphStarWarsGameXmlParseSettings _settings;
private readonly IGameEngineErrorReporter _reporter;
private readonly IPetroglyphXmlFileParserFactory _fileParserFactory;
-
+ private readonly ILogger _logger;
+ private readonly IServiceProvider _serviceProvider;
+
public string Name { get; }
public PetroglyphStarWarsGameXmlParser(
IGameRepository gameRepository,
PetroglyphStarWarsGameXmlParseSettings settings,
IServiceProvider serviceProvider,
- IGameEngineErrorReporter reporter)
- : base(serviceProvider)
+ IGameEngineErrorReporter reporter)
{
- _gameRepository = gameRepository;
- _settings = settings;
- _reporter = reporter;
+ _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ _gameRepository = gameRepository ?? throw new ArgumentNullException(nameof(gameRepository));
+ _settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ _reporter = reporter ?? throw new ArgumentNullException(nameof(reporter));
+ _pgFileSystem = gameRepository.PGFileSystem;
_fileParserFactory = serviceProvider.GetRequiredService();
+ _logger = serviceProvider.GetService()?.CreateLogger(GetType()) ?? NullLogger.Instance;
+
Name = GetType().FullName!;
}
@@ -47,7 +53,7 @@ public PetroglyphStarWarsGameXmlParser(
public XmlFileList ParseFileList(string xmlFile)
{
return ParseCore(xmlFile,
- stream => new XmlFileListParser(Services, _reporter).ParseFile(stream),
+ stream => new XmlFileListParser(_serviceProvider, _reporter).ParseFile(stream),
() => XmlFileList.Empty(new XmlLocationInfo(xmlFile, null)));
}
@@ -59,9 +65,9 @@ public void ParseEntriesFromFileListXml(
{
var container = ParseFileList(xmlFile);
- var xmlFiles = container.Files.Select(x => FileSystem.Path.Combine(lookupPath, x)).ToList();
+ var xmlFiles = container.Files.Select(x => _pgFileSystem.CombinePath(lookupPath, x)).ToList();
- var parser = new XmlContainerFileParser(Services,
+ var parser = new XmlContainerFileParser(_serviceProvider,
_fileParserFactory.CreateNamedXmlObjectParser(_gameRepository.EngineType, _reporter), _reporter);
foreach (var file in xmlFiles)
@@ -86,14 +92,14 @@ public bool ParseObjectsFromContainerFile(
private T ParseCore(string xmlFile, Func parseAction, Func invalidFileAction)
{
- Logger.LogDebug("Parsing file '{XmlFile}'", xmlFile);
+ _logger.LogDebug("Parsing file '{XmlFile}'", xmlFile);
using var fileStream = _gameRepository.TryOpenFile(xmlFile);
if (fileStream is null)
{
var message = $"Could not find XML file '{xmlFile}'";
- Logger.LogWarning(message);
+ _logger.LogWarning(message);
_reporter.Report(new XmlError(this, locationInfo: new XmlLocationInfo(xmlFile, null))
{
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj
index a92efb1..a25aab6 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ALO/PG.StarWarsGame.Files.ALO.csproj
@@ -24,4 +24,7 @@
+
+
+
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj
index 000b3a6..b10964f 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.ChunkFiles/PG.StarWarsGame.Files.ChunkFiles.csproj
@@ -17,6 +17,9 @@
preview
-
+
+
+
+
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj
index 4a8bced..d986505 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/PG.StarWarsGame.Files.XML.csproj
@@ -18,7 +18,7 @@
preview
-
+
@@ -26,4 +26,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
\ No newline at end of file
diff --git a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs
index 2ac7f6c..4c72f4a 100644
--- a/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs
+++ b/src/PetroglyphTools/PG.StarWarsGame.Files.XML/Parsers/Base/PetroglyphXmlFileParserBase.cs
@@ -1,6 +1,8 @@
using System;
using System.IO;
using System.IO.Abstractions;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Extensions.DependencyInjection;
@@ -15,16 +17,13 @@ namespace PG.StarWarsGame.Files.XML.Parsers;
public abstract class PetroglyphXmlFileParserBase(IServiceProvider serviceProvider, IXmlParserErrorReporter? errorReporter)
: PetroglyphXmlParserBase(errorReporter)
{
- protected readonly IServiceProvider ServiceProvider =
- serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
-
protected readonly IFileSystem FileSystem = serviceProvider.GetRequiredService();
protected virtual bool LoadLineInfo => true;
protected XElement GetRootElement(Stream xmlStream, out string fileName)
{
- fileName = GetStrippedFileName(xmlStream.GetFilePath());
+ fileName = GetStrippedFilePath(xmlStream.GetFilePath());
if (string.IsNullOrEmpty(fileName))
throw new InvalidOperationException("Unable to parse XML from unnamed stream. Either parse from a file or MEG stream.");
@@ -62,18 +61,25 @@ protected XElement GetRootElement(Stream xmlStream, out string fileName)
return root;
}
-
- private string GetStrippedFileName(string filePath)
+
+ private string GetStrippedFilePath(string filePath)
{
if (!FileSystem.Path.IsPathFullyQualified(filePath))
return filePath;
-
- var pathPartIndex = filePath.LastIndexOf("DATA\\XML\\", StringComparison.OrdinalIgnoreCase);
+
+ var pathPartIndex = filePath.LastIndexOf(GetXmlDataFolder(), StringComparison.OrdinalIgnoreCase);
if (pathPartIndex == -1)
return filePath;
- return filePath.Substring(pathPartIndex);
+ return filePath[pathPartIndex..];
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ static string GetXmlDataFolder()
+ {
+ // Required because we don't have access to the PGFileSystem here.
+ return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"DATA\XML\" : "DATA/XML/";
+ }
}
diff --git a/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj b/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj
index f28c11c..a73a2c9 100644
--- a/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj
+++ b/test/ModVerify.CliApp.Test/ModVerify.CliApp.Test.csproj
@@ -16,11 +16,11 @@
-
-
-
+
+
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -35,6 +35,10 @@
+
+
+
+
true
true
diff --git a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs
index c5caf92..ac98cfc 100644
--- a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs
+++ b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs
@@ -1,4 +1,5 @@
using AET.ModVerify.App.Settings.CommandLine;
+using AET.ModVerify.Reporting;
using AnakinRaW.ApplicationBase.Environment;
using System;
using System.IO.Abstractions;
@@ -211,4 +212,73 @@ public void Parse_CreateBaseline_MissingRequired_Fails(string argString)
Assert.Null(settings.ModVerifyOptions);
Assert.Null(settings.UpdateOptions);
}
+
+ [Theory]
+ [InlineData("verify --mods myMod --baseline myBaseline.json", "myBaseline.json", false, false)]
+ [InlineData("verify --mods myMod --searchBaseline", null, true, false)]
+ [InlineData("verify --path myMod --useDefaultBaseline", null, false, true)]
+ public void Parse_Verify_BaselineOptions(string argString, string? expectedBaseline, bool expectedSearchBaseline, bool expectedUseDefaultBaseline)
+ {
+ var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries));
+
+ Assert.True(settings.HasOptions);
+ var verify = Assert.IsType(settings.ModVerifyOptions);
+ Assert.Equal(expectedBaseline, verify.Baseline);
+ Assert.Equal(expectedSearchBaseline, verify.SearchBaselineLocally);
+ Assert.Equal(expectedUseDefaultBaseline, verify.UseDefaultBaseline);
+ }
+
+ [Fact]
+ public void Parse_Verify_Baseline_And_SearchBaseline_CanBeParsedTogether()
+ {
+ // Mutual exclusivity of --baseline and --searchBaseline is enforced later by SettingsBuilder, not by the parser.
+ const string argString = "verify --mods myMod --baseline myBaseline.json --searchBaseline";
+
+ var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries));
+
+ Assert.True(settings.HasOptions);
+ var verify = Assert.IsType(settings.ModVerifyOptions);
+ Assert.Equal("myBaseline.json", verify.Baseline);
+ Assert.True(verify.SearchBaselineLocally);
+ }
+
+ [Theory]
+ [InlineData("verify --path myMod --outDir myOut", "myOut")]
+ [InlineData("verify --path myMod -o myOut", "myOut")]
+ [InlineData("verify --path myMod", null)]
+ public void Parse_Verify_OutputDirectory(string argString, string? expectedOutDir)
+ {
+ var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries));
+
+ Assert.True(settings.HasOptions);
+ var verify = Assert.IsType(settings.ModVerifyOptions);
+ Assert.Equal(expectedOutDir, verify.OutputDirectory);
+ }
+
+ [Theory]
+ [InlineData("verify --path myMod --failFast --minFailSeverity Critical", true, "Critical")]
+ [InlineData("verify --path myMod --failFast --minFailSeverity Warning", true, "Warning")]
+ [InlineData("verify --path myMod", false, null)]
+ public void Parse_Verify_FailFastOptions(string argString, bool expectedFailFast, string? expectedMinSeverity)
+ {
+ var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries));
+
+ Assert.True(settings.HasOptions);
+ var verify = Assert.IsType(settings.ModVerifyOptions);
+ Assert.Equal(expectedFailFast, verify.FailFast);
+ var expectedSeverity = expectedMinSeverity is null ? (VerificationSeverity?)null : Enum.Parse(expectedMinSeverity);
+ Assert.Equal(expectedSeverity, verify.MinimumFailureSeverity);
+ }
+
+ [Theory]
+ [InlineData("verify --path myMod --ignoreAsserts", true)]
+ [InlineData("verify --path myMod", false)]
+ public void Parse_Verify_IgnoreAsserts(string argString, bool expectedIgnoreAsserts)
+ {
+ var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries));
+
+ Assert.True(settings.HasOptions);
+ var verify = Assert.IsType(settings.ModVerifyOptions);
+ Assert.Equal(expectedIgnoreAsserts, verify.IgnoreAsserts);
+ }
}
\ No newline at end of file
diff --git a/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs
new file mode 100644
index 0000000..429567b
--- /dev/null
+++ b/test/ModVerify.CliApp.Test/SettingsBuilderTest.cs
@@ -0,0 +1,110 @@
+using AET.ModVerify.App;
+using AET.ModVerify.App.Settings;
+using AET.ModVerify.App.Settings.CommandLine;
+using System.IO.Abstractions;
+using Testably.Abstractions.Testing;
+using Xunit;
+using AnakinRaW.CommonUtilities.Testing;
+
+namespace ModVerify.CliApp.Test;
+
+public class SettingsBuilderTest : TestBaseWithFileSystem
+{
+ private readonly SettingsBuilder _builder;
+
+ public SettingsBuilderTest()
+ {
+ _builder = new SettingsBuilder(ServiceProvider);
+ }
+
+ protected override IFileSystem CreateFileSystem()
+ {
+ return new MockFileSystem();
+ }
+
+ [Theory]
+ [InlineData("path1", "path2")]
+ public void BuildSettings_Paths_SplitsCorrectly(string p1, string p2)
+ {
+ var separator = FileSystem.Path.PathSeparator;
+ var paths = $"{p1}{separator}{p2}";
+ var expected = new[] { FileSystem.Path.GetFullPath(p1), FileSystem.Path.GetFullPath(p2) };
+
+ var options = new VerifyVerbOption
+ {
+ ModPaths = paths,
+ AdditionalFallbackPath = paths,
+ TargetPath = "myPath"
+ };
+
+ var settings = _builder.BuildSettings(options);
+
+ Assert.Equal(expected, settings.VerificationTargetSettings.ModPaths);
+ Assert.Equal(expected, settings.VerificationTargetSettings.AdditionalFallbackPaths);
+ }
+
+ [Fact]
+ public void BuildSettings_FallbackGamePath_RequiresGamePath()
+ {
+ var gamePath = "game";
+ var fallbackPath = "fallback";
+
+ var options = new VerifyVerbOption
+ {
+ GamePath = gamePath,
+ FallbackGamePath = fallbackPath,
+ TargetPath = "myPath"
+ };
+
+ var settings = _builder.BuildSettings(options);
+ Assert.Equal(FileSystem.Path.GetFullPath(fallbackPath), settings.VerificationTargetSettings.FallbackGamePath);
+
+ var optionsNoGame = new VerifyVerbOption
+ {
+ FallbackGamePath = fallbackPath,
+ TargetPath = "myPath"
+ };
+
+ var settingsNoGame = _builder.BuildSettings(optionsNoGame);
+ Assert.Null(settingsNoGame.VerificationTargetSettings.FallbackGamePath);
+ }
+
+ [Fact]
+ public void BuildSettings_UseDefaultBaseline_And_Baseline_Throws()
+ {
+ var options = new VerifyVerbOption
+ {
+ UseDefaultBaseline = true,
+ Baseline = "myBaseline.json",
+ TargetPath = "myPath",
+ };
+
+ Assert.Throws(() => _builder.BuildSettings(options));
+ }
+
+ [Fact]
+ public void BuildSettings_UseDefaultBaseline_And_SearchBaseline_Throws()
+ {
+ var options = new VerifyVerbOption
+ {
+ UseDefaultBaseline = true,
+ SearchBaselineLocally = true,
+ TargetPath = "myPath",
+ };
+
+ Assert.Throws(() => _builder.BuildSettings(options));
+ }
+
+ [Fact]
+ public void BuildSettings_UseDefaultBaseline_Alone_DoesNotThrow()
+ {
+ var options = new VerifyVerbOption
+ {
+ UseDefaultBaseline = true,
+ TargetPath = "myPath",
+ };
+
+ var settings = _builder.BuildSettings(options);
+ Assert.NotNull(settings);
+ }
+}
diff --git a/test/ModVerify.CliApp.Test/Utilities/StringExtensions.cs b/test/ModVerify.CliApp.Test/Utilities/Extensions.cs
similarity index 54%
rename from test/ModVerify.CliApp.Test/Utilities/StringExtensions.cs
rename to test/ModVerify.CliApp.Test/Utilities/Extensions.cs
index 052c322..19250ad 100644
--- a/test/ModVerify.CliApp.Test/Utilities/StringExtensions.cs
+++ b/test/ModVerify.CliApp.Test/Utilities/Extensions.cs
@@ -2,12 +2,22 @@
namespace ModVerify.CliApp.Test.Utilities;
-internal static class StringExtensions
+internal static class Extensions
{
#if NETFRAMEWORK
public static string[] Split(this string str, char separator, StringSplitOptions options)
{
return str.Split([separator], options);
}
+
+
+ extension(Enum)
+ {
+ public static T Parse(string value) where T : Enum
+ {
+ return (T)Enum.Parse(typeof(T), value);
+ }
+ }
+
#endif
}
\ No newline at end of file