Skip to content

Commit dba85f5

Browse files
committed
Sanity check ShPk mods, ban incompatible ones
1 parent 700fef4 commit dba85f5

File tree

3 files changed

+159
-0
lines changed

3 files changed

+159
-0
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using FFXIVClientStructs.FFXIV.Client.System.Resource;
2+
using Penumbra.Api.Enums;
3+
using Penumbra.Collections;
4+
using Penumbra.GameData.Files;
5+
using Penumbra.GameData.Files.Utility;
6+
using Penumbra.Interop.Hooks.ResourceLoading;
7+
using Penumbra.String;
8+
using Penumbra.String.Classes;
9+
using Penumbra.UI;
10+
11+
namespace Penumbra.Interop.Processing;
12+
13+
/// <summary>
14+
/// Path pre-processor for shader packages that reverts redirects to known invalid files, as bad ShPks can crash the game.
15+
/// </summary>
16+
public sealed class ShpkPathPreProcessor(ResourceManagerService resourceManager, ChatWarningService chatWarningService) : IPathPreProcessor
17+
{
18+
public ResourceType Type
19+
=> ResourceType.Shpk;
20+
21+
public unsafe FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath originalGamePath, bool nonDefault, FullPath? resolved)
22+
{
23+
chatWarningService.CleanLastFileWarnings(false);
24+
25+
if (!resolved.HasValue)
26+
return null;
27+
28+
// 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.
29+
var resolvedPath = resolved.Value;
30+
if (!resolvedPath.IsRooted)
31+
return resolvedPath;
32+
33+
// If the ShPk is already loaded, it means that it already passed the sanity check.
34+
var existingResource = resourceManager.FindResource(ResourceCategory.Shader, ResourceType.Shpk, unchecked((uint)resolvedPath.InternalName.Crc32));
35+
if (existingResource != null)
36+
return resolvedPath;
37+
38+
var checkResult = SanityCheck(resolvedPath.FullName);
39+
if (checkResult == SanityCheckResult.Success)
40+
return resolvedPath;
41+
42+
Penumbra.Log.Warning($"Refusing to honor file redirection because of failed sanity check (result: {checkResult}). Original path: {originalGamePath} Redirected path: {resolvedPath}");
43+
chatWarningService.PrintFileWarning(resolvedPath.FullName, originalGamePath, WarningMessageComplement(checkResult));
44+
45+
return null;
46+
}
47+
48+
private static SanityCheckResult SanityCheck(string path)
49+
{
50+
try
51+
{
52+
using var file = MmioMemoryManager.CreateFromFile(path);
53+
var bytes = file.GetSpan();
54+
55+
return ShpkFile.FastIsLegacy(bytes)
56+
? SanityCheckResult.Legacy
57+
: SanityCheckResult.Success;
58+
}
59+
catch (FileNotFoundException)
60+
{
61+
return SanityCheckResult.NotFound;
62+
}
63+
catch (IOException)
64+
{
65+
return SanityCheckResult.IoError;
66+
}
67+
}
68+
69+
private static string WarningMessageComplement(SanityCheckResult result)
70+
=> result switch
71+
{
72+
SanityCheckResult.IoError => "cannot read the modded file.",
73+
SanityCheckResult.NotFound => "the modded file does not exist.",
74+
SanityCheckResult.Legacy => "this mod is not compatible with Dawntrail. Get an updated version, if possible, or disable it.",
75+
_ => string.Empty,
76+
};
77+
78+
private enum SanityCheckResult
79+
{
80+
Success,
81+
IoError,
82+
NotFound,
83+
Legacy,
84+
}
85+
}

Penumbra/Mods/Manager/ModManager.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,4 +350,22 @@ private void ScanMods()
350350
Penumbra.Log.Error($"Could not scan for mods:\n{ex}");
351351
}
352352
}
353+
354+
public bool TryIdentifyPath(string path, [NotNullWhen(true)] out Mod? mod, [NotNullWhen(true)] out string? relativePath)
355+
{
356+
var relPath = Path.GetRelativePath(BasePath.FullName, path);
357+
if (relPath != "." && (relPath.StartsWith('.') || Path.IsPathRooted(relPath)))
358+
{
359+
mod = null;
360+
relativePath = null;
361+
return false;
362+
}
363+
364+
var modDirectorySeparator = relPath.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]);
365+
366+
var modDirectory = modDirectorySeparator < 0 ? relPath : relPath[..modDirectorySeparator];
367+
relativePath = modDirectorySeparator < 0 ? string.Empty : relPath[(modDirectorySeparator + 1)..];
368+
369+
return TryGetMod(modDirectory, "\0", out mod);
370+
}
353371
}

Penumbra/UI/ChatWarningService.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Dalamud.Plugin.Services;
2+
using OtterGui.Services;
3+
using Penumbra.Mods.Manager;
4+
using Penumbra.String.Classes;
5+
6+
namespace Penumbra.UI;
7+
8+
public sealed class ChatWarningService(IChatGui chatGui, IClientState clientState, ModManager modManager) : IUiService
9+
{
10+
private readonly Dictionary<string, (DateTime, string)> _lastFileWarnings = [];
11+
private int _lastFileWarningsCleanCounter;
12+
13+
private const int LastFileWarningsCleanCycle = 100;
14+
private static readonly TimeSpan LastFileWarningsMaxAge = new(1, 0, 0);
15+
16+
public void CleanLastFileWarnings(bool force)
17+
{
18+
if (!force)
19+
{
20+
_lastFileWarningsCleanCounter = (_lastFileWarningsCleanCounter + 1) % LastFileWarningsCleanCycle;
21+
if (_lastFileWarningsCleanCounter != 0)
22+
return;
23+
}
24+
25+
var expiredDate = DateTime.Now - LastFileWarningsMaxAge;
26+
var toRemove = new HashSet<string>();
27+
foreach (var (key, value) in _lastFileWarnings)
28+
{
29+
if (value.Item1 <= expiredDate)
30+
toRemove.Add(key);
31+
}
32+
foreach (var key in toRemove)
33+
_lastFileWarnings.Remove(key);
34+
}
35+
36+
public void PrintFileWarning(string fullPath, Utf8GamePath originalGamePath, string messageComplement)
37+
{
38+
CleanLastFileWarnings(true);
39+
40+
// Don't warn twice for the same file within a certain time interval unless the reason changed.
41+
if (_lastFileWarnings.TryGetValue(fullPath, out var lastWarning) && lastWarning.Item2 == messageComplement)
42+
return;
43+
44+
// Don't warn for files managed by other plugins, or files we aren't sure about.
45+
if (!modManager.TryIdentifyPath(fullPath, out var mod, out _))
46+
return;
47+
48+
// Don't warn if there's no local player (as an approximation of no chat), so as not to trigger the cooldown.
49+
if (clientState.LocalPlayer == null)
50+
return;
51+
52+
// The wording is an allusion to tar's "Cowardly refusing to create an empty archive"
53+
chatGui.PrintError($"Cowardly refusing to load replacement for {originalGamePath.Filename().ToString().ToLowerInvariant()} by {mod.Name}{(messageComplement.Length > 0 ? ": " : ".")}{messageComplement}", "Penumbra");
54+
_lastFileWarnings[fullPath] = (DateTime.Now, messageComplement);
55+
}
56+
}

0 commit comments

Comments
 (0)