mirror of
https://github.com/xivdev/Penumbra.git
synced 2026-01-03 14:23:43 +01:00
Sanity check ShPk mods, ban incompatible ones
This commit is contained in:
parent
700fef4f04
commit
dba85f5da3
3 changed files with 159 additions and 0 deletions
85
Penumbra/Interop/Processing/ShpkPathPreProcessor.cs
Normal file
85
Penumbra/Interop/Processing/ShpkPathPreProcessor.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Path pre-processor for shader packages that reverts redirects to known invalid files, as bad ShPks can crash the game.
|
||||
/// </summary>
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
Penumbra/UI/ChatWarningService.cs
Normal file
56
Penumbra/UI/ChatWarningService.cs
Normal file
|
|
@ -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<string, (DateTime, string)> _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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue