From dba85f5da3774706f6005ddd56859bc78362afef Mon Sep 17 00:00:00 2001 From: Exter-N Date: Mon, 5 Aug 2024 03:43:18 +0200 Subject: [PATCH] Sanity check ShPk mods, ban incompatible ones --- .../Processing/ShpkPathPreProcessor.cs | 85 +++++++++++++++++++ Penumbra/Mods/Manager/ModManager.cs | 18 ++++ Penumbra/UI/ChatWarningService.cs | 56 ++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 Penumbra/Interop/Processing/ShpkPathPreProcessor.cs create mode 100644 Penumbra/UI/ChatWarningService.cs diff --git a/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs new file mode 100644 index 00000000..2c6f6901 --- /dev/null +++ b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs @@ -0,0 +1,85 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.Utility; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.UI; + +namespace Penumbra.Interop.Processing; + +/// +/// Path pre-processor for shader packages that reverts redirects to known invalid files, as bad ShPks can crash the game. +/// +public sealed class ShpkPathPreProcessor(ResourceManagerService resourceManager, ChatWarningService chatWarningService) : IPathPreProcessor +{ + public ResourceType Type + => ResourceType.Shpk; + + public unsafe FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath originalGamePath, bool nonDefault, FullPath? resolved) + { + chatWarningService.CleanLastFileWarnings(false); + + if (!resolved.HasValue) + return null; + + // Skip the sanity check for game files. We are not considering the case where the user has modified game file: it's at their own risk. + var resolvedPath = resolved.Value; + if (!resolvedPath.IsRooted) + return resolvedPath; + + // If the ShPk is already loaded, it means that it already passed the sanity check. + var existingResource = resourceManager.FindResource(ResourceCategory.Shader, ResourceType.Shpk, unchecked((uint)resolvedPath.InternalName.Crc32)); + if (existingResource != null) + return resolvedPath; + + var checkResult = SanityCheck(resolvedPath.FullName); + if (checkResult == SanityCheckResult.Success) + return resolvedPath; + + Penumbra.Log.Warning($"Refusing to honor file redirection because of failed sanity check (result: {checkResult}). Original path: {originalGamePath} Redirected path: {resolvedPath}"); + chatWarningService.PrintFileWarning(resolvedPath.FullName, originalGamePath, WarningMessageComplement(checkResult)); + + return null; + } + + private static SanityCheckResult SanityCheck(string path) + { + try + { + using var file = MmioMemoryManager.CreateFromFile(path); + var bytes = file.GetSpan(); + + return ShpkFile.FastIsLegacy(bytes) + ? SanityCheckResult.Legacy + : SanityCheckResult.Success; + } + catch (FileNotFoundException) + { + return SanityCheckResult.NotFound; + } + catch (IOException) + { + return SanityCheckResult.IoError; + } + } + + private static string WarningMessageComplement(SanityCheckResult result) + => result switch + { + SanityCheckResult.IoError => "cannot read the modded file.", + SanityCheckResult.NotFound => "the modded file does not exist.", + SanityCheckResult.Legacy => "this mod is not compatible with Dawntrail. Get an updated version, if possible, or disable it.", + _ => string.Empty, + }; + + private enum SanityCheckResult + { + Success, + IoError, + NotFound, + Legacy, + } +} diff --git a/Penumbra/Mods/Manager/ModManager.cs b/Penumbra/Mods/Manager/ModManager.cs index f170a31b..59f8906e 100644 --- a/Penumbra/Mods/Manager/ModManager.cs +++ b/Penumbra/Mods/Manager/ModManager.cs @@ -350,4 +350,22 @@ public sealed class ModManager : ModStorage, IDisposable, IService Penumbra.Log.Error($"Could not scan for mods:\n{ex}"); } } + + public bool TryIdentifyPath(string path, [NotNullWhen(true)] out Mod? mod, [NotNullWhen(true)] out string? relativePath) + { + var relPath = Path.GetRelativePath(BasePath.FullName, path); + if (relPath != "." && (relPath.StartsWith('.') || Path.IsPathRooted(relPath))) + { + mod = null; + relativePath = null; + return false; + } + + var modDirectorySeparator = relPath.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]); + + var modDirectory = modDirectorySeparator < 0 ? relPath : relPath[..modDirectorySeparator]; + relativePath = modDirectorySeparator < 0 ? string.Empty : relPath[(modDirectorySeparator + 1)..]; + + return TryGetMod(modDirectory, "\0", out mod); + } } diff --git a/Penumbra/UI/ChatWarningService.cs b/Penumbra/UI/ChatWarningService.cs new file mode 100644 index 00000000..84ede2fb --- /dev/null +++ b/Penumbra/UI/ChatWarningService.cs @@ -0,0 +1,56 @@ +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.Mods.Manager; +using Penumbra.String.Classes; + +namespace Penumbra.UI; + +public sealed class ChatWarningService(IChatGui chatGui, IClientState clientState, ModManager modManager) : IUiService +{ + private readonly Dictionary _lastFileWarnings = []; + private int _lastFileWarningsCleanCounter; + + private const int LastFileWarningsCleanCycle = 100; + private static readonly TimeSpan LastFileWarningsMaxAge = new(1, 0, 0); + + public void CleanLastFileWarnings(bool force) + { + if (!force) + { + _lastFileWarningsCleanCounter = (_lastFileWarningsCleanCounter + 1) % LastFileWarningsCleanCycle; + if (_lastFileWarningsCleanCounter != 0) + return; + } + + var expiredDate = DateTime.Now - LastFileWarningsMaxAge; + var toRemove = new HashSet(); + foreach (var (key, value) in _lastFileWarnings) + { + if (value.Item1 <= expiredDate) + toRemove.Add(key); + } + foreach (var key in toRemove) + _lastFileWarnings.Remove(key); + } + + public void PrintFileWarning(string fullPath, Utf8GamePath originalGamePath, string messageComplement) + { + CleanLastFileWarnings(true); + + // Don't warn twice for the same file within a certain time interval unless the reason changed. + if (_lastFileWarnings.TryGetValue(fullPath, out var lastWarning) && lastWarning.Item2 == messageComplement) + return; + + // Don't warn for files managed by other plugins, or files we aren't sure about. + if (!modManager.TryIdentifyPath(fullPath, out var mod, out _)) + return; + + // Don't warn if there's no local player (as an approximation of no chat), so as not to trigger the cooldown. + if (clientState.LocalPlayer == null) + return; + + // The wording is an allusion to tar's "Cowardly refusing to create an empty archive" + chatGui.PrintError($"Cowardly refusing to load replacement for {originalGamePath.Filename().ToString().ToLowerInvariant()} by {mod.Name}{(messageComplement.Length > 0 ? ": " : ".")}{messageComplement}", "Penumbra"); + _lastFileWarnings[fullPath] = (DateTime.Now, messageComplement); + } +}