diff --git a/src/ModVerify.CliApp/ModVerify.CliApp.csproj b/src/ModVerify.CliApp/ModVerify.CliApp.csproj index f71472d..82c06b5 100644 --- a/src/ModVerify.CliApp/ModVerify.CliApp.csproj +++ b/src/ModVerify.CliApp/ModVerify.CliApp.csproj @@ -16,8 +16,8 @@ - - + + diff --git a/src/ModVerify.CliApp/Program.cs b/src/ModVerify.CliApp/Program.cs index 56abbc6..f3a46f1 100644 --- a/src/ModVerify.CliApp/Program.cs +++ b/src/ModVerify.CliApp/Program.cs @@ -97,7 +97,7 @@ private static VerifyFocPipeline BuildPipeline(IPlayableObject playableObject, I playableObject.Game.Directory.FullName, fallbackGame.Directory.FullName); - return new VerifyFocPipeline(gameLocations, _services); + return new VerifyFocPipeline(gameLocations, VerificationSettings.Default, _services); } private static IServiceProvider CreateAppServices() diff --git a/src/ModVerify/ModVerify.csproj b/src/ModVerify/ModVerify.csproj index 63bc1c0..a77fbcc 100644 --- a/src/ModVerify/ModVerify.csproj +++ b/src/ModVerify/ModVerify.csproj @@ -21,6 +21,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/ModVerify/Steps/GameVerificationStep.cs b/src/ModVerify/Steps/GameVerificationStep.cs index 8233fa9..6a7df65 100644 --- a/src/ModVerify/Steps/GameVerificationStep.cs +++ b/src/ModVerify/Steps/GameVerificationStep.cs @@ -28,6 +28,8 @@ public abstract class GameVerificationStep(CreateGameDatabaseStep createDatabase protected abstract string LogFileName { get; } + public abstract string Name { get; } + protected sealed override void RunCore(CancellationToken token) { createDatabaseStep.Wait(); @@ -78,7 +80,7 @@ protected void GuardedVerify(Action action, Predicate handleException } private StreamWriter CreateVerificationLogFile() { - var fileName = $"VerifyLog_{LogFileName}.txt"; + var fileName = $"VerifyResult_{LogFileName}.txt"; var fs = FileSystem.FileStream.New(fileName, FileMode.Create, FileAccess.Write, FileShare.Read); return new StreamWriter(fs); } diff --git a/src/ModVerify/Steps/VerifyReferencedModelsStep.cs b/src/ModVerify/Steps/VerifyReferencedModelsStep.cs index d330ea7..293a8c3 100644 --- a/src/ModVerify/Steps/VerifyReferencedModelsStep.cs +++ b/src/ModVerify/Steps/VerifyReferencedModelsStep.cs @@ -17,7 +17,7 @@ namespace AET.ModVerify.Steps; -internal class VerifyReferencedModelsStep(CreateGameDatabaseStep createDatabaseStep, IGameRepository repository, IServiceProvider serviceProvider) +internal sealed class VerifyReferencedModelsStep(CreateGameDatabaseStep createDatabaseStep, IGameRepository repository, IServiceProvider serviceProvider) : GameVerificationStep(createDatabaseStep, repository, serviceProvider) { public const string ModelNotFound = "ALO00"; @@ -32,7 +32,9 @@ internal class VerifyReferencedModelsStep(CreateGameDatabaseStep createDatabaseS private readonly IAloFileService _modelFileService = serviceProvider.GetRequiredService(); - protected override string LogFileName => "ModelTextureShader"; + protected override string LogFileName => "Model"; + + public override string Name => "Model"; protected override void RunVerification(CancellationToken token) { diff --git a/src/ModVerify/VerifyPipeline.cs b/src/ModVerify/VerifyPipeline.cs index 97154ba..a320c69 100644 --- a/src/ModVerify/VerifyPipeline.cs +++ b/src/ModVerify/VerifyPipeline.cs @@ -1,23 +1,42 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using AET.ModVerify.Steps; using AnakinRaW.CommonUtilities.SimplePipeline; +using Microsoft.Extensions.Logging; using PG.StarWarsGame.Engine.FileSystem; using PG.StarWarsGame.Engine.Pipeline; namespace AET.ModVerify; -public class VerifyFocPipeline(GameLocations gameLocations, IServiceProvider serviceProvider) - : ParallelPipeline(serviceProvider, 4, false) + +public record VerificationSettings +{ + public static readonly VerificationSettings Default = new(); + + public bool ThrowOnError { get; init; } +} + + +public class VerifyFocPipeline : ParallelPipeline { private IList _verificationSteps = null!; + private readonly GameLocations _gameLocations; + private readonly VerificationSettings _settings; + + public VerifyFocPipeline(GameLocations gameLocations, VerificationSettings settings, IServiceProvider serviceProvider) + : base(serviceProvider, 4, false) + { + _gameLocations = gameLocations; + _settings = settings; + } protected override Task> BuildSteps() { - var repository = new FocGameRepository(gameLocations, ServiceProvider); + var repository = new FocGameRepository(_gameLocations, ServiceProvider); var buildIndexStep = new CreateGameDatabaseStep(repository, ServiceProvider); _verificationSteps = new List @@ -36,10 +55,29 @@ protected override Task> BuildSteps() public override async Task RunAsync(CancellationToken token = default) { - await base.RunAsync(token).ConfigureAwait(false); + Logger?.LogInformation("Verifying game..."); + try + { + await base.RunAsync(token).ConfigureAwait(false); - var stepsWithVerificationErrors = _verificationSteps.Where(x => x.VerifyErrors.Any()).ToList(); - if (stepsWithVerificationErrors.Any()) - throw new GameVerificationException(stepsWithVerificationErrors); + var stepsWithVerificationErrors = _verificationSteps.Where(x => x.VerifyErrors.Any()).ToList(); + + var failedSteps = new List(); + foreach (var verificationStep in _verificationSteps) + { + if (verificationStep.VerifyErrors.Any()) + { + failedSteps.Add(verificationStep); + Logger?.LogWarning($"Verifier '{verificationStep.Name}' reported errors!"); + } + } + + if (_settings.ThrowOnError && failedSteps.Count > 0) + throw new GameVerificationException(stepsWithVerificationErrors); + } + finally + { + Logger?.LogInformation("Finished game verification"); + } } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs index e8c2684..d4111bc 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/GameLanguageManager.cs @@ -8,22 +8,38 @@ namespace PG.StarWarsGame.Engine.Language; internal sealed class GameLanguageManager(IServiceProvider serviceProvider) : ServiceBase(serviceProvider), IGameLanguageManager { - private static readonly IDictionary LanguageToFileSuffixMap = + private static readonly IDictionary LanguageToFileSuffixMapMp3 = new Dictionary { - { LanguageType.English, "_ENG"}, - { LanguageType.German, "_GER"}, - { LanguageType.French, "_FRE"}, - { LanguageType.Spanish, "_SPA"}, - { LanguageType.Italian, "_ITA"}, - { LanguageType.Japanese, "_JAP"}, - { LanguageType.Korean, "_KOR"}, - { LanguageType.Chinese, "_CHI"}, - { LanguageType.Russian, "_RUS"}, - { LanguageType.Polish, "_POL"}, - { LanguageType.Thai, "_THA"}, + { LanguageType.English, "_ENG.MP3" }, + { LanguageType.German, "_GER.MP3" }, + { LanguageType.French, "_FRE.MP3" }, + { LanguageType.Spanish, "_SPA.MP3" }, + { LanguageType.Italian, "_ITA.MP3" }, + { LanguageType.Japanese, "_JAP.MP3" }, + { LanguageType.Korean, "_KOR.MP3" }, + { LanguageType.Chinese, "_CHI.MP3" }, + { LanguageType.Russian, "_RUS.MP3" }, + { LanguageType.Polish, "_POL.MP3" }, + { LanguageType.Thai, "_THA.MP3" }, }; - + + private static readonly IDictionary LanguageToFileSuffixMapWav = + new Dictionary + { + { LanguageType.English, "_ENG.WAV" }, + { LanguageType.German, "_GER.WAV" }, + { LanguageType.French, "_FRE.WAV" }, + { LanguageType.Spanish, "_SPA.WAV" }, + { LanguageType.Italian, "_ITA.WAV" }, + { LanguageType.Japanese, "_JAP.WAV" }, + { LanguageType.Korean, "_KOR.WAV" }, + { LanguageType.Chinese, "_CHI.WAV" }, + { LanguageType.Russian, "_RUS.WAV" }, + { LanguageType.Polish, "_POL.WAV" }, + { LanguageType.Thai, "_THA.WAV" }, + }; + public IReadOnlyCollection FocSupportedLanguages { get; } = new HashSet { @@ -41,38 +57,100 @@ internal sealed class GameLanguageManager(IServiceProvider serviceProvider) : Se LanguageType.Polish, LanguageType.Thai }; + + public bool IsFileNameLocalizable(string fileName, bool requiredEnglishName) + { + var fileSpan = fileName.AsSpan(); + + if (requiredEnglishName) + { + if (fileSpan.EndsWith("_ENG.WAV".AsSpan(), StringComparison.OrdinalIgnoreCase)) + return true; + if (fileSpan.EndsWith("_ENG.MP3".AsSpan(), StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + var isWav = fileSpan.EndsWith(".WAV".AsSpan(), StringComparison.OrdinalIgnoreCase); + var isMp3 = fileSpan.EndsWith(".MP3".AsSpan(), StringComparison.OrdinalIgnoreCase); + + ICollection? checkList = null; + if (isWav) + checkList = LanguageToFileSuffixMapWav.Values; + else if (isMp3) + checkList = LanguageToFileSuffixMapMp3.Values; + + if (checkList is null) + return false; + + foreach (var toCheck in checkList) + { + if (fileSpan.EndsWith(toCheck.AsSpan(), StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + public string LocalizeFileName(string fileName, LanguageType language, out bool localized) { + if (fileName.Length > 260) + throw new ArgumentOutOfRangeException(nameof(fileName), "fileName is too long"); + localized = true; + // The game assumes that all localized audio files are referenced by using their english name. + // Thus, PG takes this shortcut if (language == LanguageType.English) return fileName; - var upperFileName = fileName.ToUpper(); + var fileSpan = fileName.AsSpan(); + + var isWav = false; + var isMp3 = false; - var fileSpan = upperFileName.AsSpan(); + // The game only localizes file names iff they have the english suffix + // NB: Also note that the engine does *not* check whether the filename actually ends with this suffix + // but instead only take the first occurrence. This means that a file name like + // 'test_eng.wav_ger.wav' + // will trick the algorithm. + var engSuffixIndex = fileSpan.IndexOf("_ENG.WAV".AsSpan(), StringComparison.OrdinalIgnoreCase); + if (engSuffixIndex != -1) + isWav = true; -#if NETSTANDARD2_1_OR_GREATER || NET - var extension = FileSystem.Path.GetExtension(fileSpan); -#else - var extension = FileSystem.Path.GetExtension(upperFileName).AsSpan(); -#endif + if (!isWav) + { + engSuffixIndex = fileSpan.IndexOf("_ENG.MP3".AsSpan(), StringComparison.OrdinalIgnoreCase); + if (engSuffixIndex != -1) + isMp3 = true; + } - var withoutExtension = fileSpan.Slice(0, fileSpan.Length - extension.Length); - var engSuffixIndex = withoutExtension.LastIndexOf("_ENG".AsSpan()); - if (engSuffixIndex == -1 || - (!extension.Equals(".MP3".AsSpan(), StringComparison.Ordinal) && - !extension.Equals(".WAV".AsSpan(), StringComparison.Ordinal))) + if (engSuffixIndex == -1) { localized = false; - Logger.LogTrace($"Unable to localize '{fileName}'"); + Logger.LogWarning($"Unable to localize '{fileName}'"); return fileName; } - var withoutSuffix = withoutExtension.Slice(0, engSuffixIndex); - var localizedSuffix = LanguageToFileSuffixMap[language].AsSpan(); + var withoutSuffix = fileSpan.Slice(0, engSuffixIndex); + + ReadOnlySpan newLocalizedSuffix; + if (isWav) + newLocalizedSuffix = LanguageToFileSuffixMapWav[language].AsSpan(); + else if (isMp3) + newLocalizedSuffix = LanguageToFileSuffixMapMp3[language].AsSpan(); + else + throw new InvalidOperationException(); + + // 260 is roughly the MAX file path size on default Windows, + // so we don't expect game files to be larger. + var sb = new ValueStringBuilder(stackalloc char[260]); + + sb.Append(withoutSuffix); + sb.Append(newLocalizedSuffix); - return StringUtilities.Concat(withoutSuffix, localizedSuffix, extension); + return sb.ToString(); } } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs index 21275b4..d061c95 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Language/IGameLanguageManager.cs @@ -9,4 +9,6 @@ public interface IGameLanguageManager IReadOnlyCollection EawSupportedLanguages { get; } string LocalizeFileName(string fileName, LanguageType language, out bool localized); + + bool IsFileNameLocalizable(string fileName, bool requireEnglishName); } \ No newline at end of file diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs new file mode 100644 index 0000000..6145d3c --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine/Utilities/ValueStringBuilder.cs @@ -0,0 +1,25 @@ +using System; + +namespace PG.StarWarsGame.Engine.Utilities; + +// From https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/System/Text/ValueStringBuilder.cs +internal ref struct ValueStringBuilder(Span initialBuffer) +{ + private Span _chars = initialBuffer; + private int _pos = 0; + + public override string ToString() + { + return _chars.Slice(0, _pos).ToString(); + } + + public void Append(scoped ReadOnlySpan value) + { + var pos = _pos; + if (pos > _chars.Length - value.Length) + throw new InvalidOperationException("Value string builder is too small."); + + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } +} \ No newline at end of file