diff --git a/OtterGui b/OtterGui
index c53955cb..d9486ae5 160000
--- a/OtterGui
+++ b/OtterGui
@@ -1 +1 @@
-Subproject commit c53955cb6199dd418c5a9538d3251ac5942e7067
+Subproject commit d9486ae54b5a4b61cf74f79ed27daa659eb1ce5b
diff --git a/Penumbra.GameData b/Penumbra.GameData
index 8ee82929..ac9d9c78 160000
--- a/Penumbra.GameData
+++ b/Penumbra.GameData
@@ -1 +1 @@
-Subproject commit 8ee82929fa6c725b8f556904ba022fb418991b5c
+Subproject commit ac9d9c78ae0025489b80ce2e798cdaacb0b43947
diff --git a/Penumbra/Communication/MtrlShpkLoaded.cs b/Penumbra/Communication/MtrlLoaded.cs
similarity index 74%
rename from Penumbra/Communication/MtrlShpkLoaded.cs
rename to Penumbra/Communication/MtrlLoaded.cs
index 2b286bb9..224438e5 100644
--- a/Penumbra/Communication/MtrlShpkLoaded.cs
+++ b/Penumbra/Communication/MtrlLoaded.cs
@@ -6,11 +6,11 @@ namespace Penumbra.Communication;
/// - Parameter is the material resource handle for which the shader package has been loaded.
/// - Parameter is the associated game object.
///
-public sealed class MtrlShpkLoaded() : EventWrapper(nameof(MtrlShpkLoaded))
+public sealed class MtrlLoaded() : EventWrapper(nameof(MtrlLoaded))
{
public enum Priority
{
- ///
+ ///
ShaderReplacementFixer = 0,
}
}
diff --git a/Penumbra/Interop/Hooks/HookSettings.cs b/Penumbra/Interop/Hooks/HookSettings.cs
index 0c0a4020..0bc55dc5 100644
--- a/Penumbra/Interop/Hooks/HookSettings.cs
+++ b/Penumbra/Interop/Hooks/HookSettings.cs
@@ -99,7 +99,7 @@ public class HookOverrides
public struct ResourceHooks
{
public bool ApricotResourceLoad;
- public bool LoadMtrlShpk;
+ public bool LoadMtrl;
public bool LoadMtrlTex;
public bool ResolvePathHooks;
public bool ResourceHandleDestructor;
diff --git a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs
index 53b69741..d02e18bb 100644
--- a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs
+++ b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs
@@ -110,7 +110,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic
_modelRendererOnRenderMaterialHook = hooks.CreateHook("ModelRenderer.OnRenderMaterial",
Sigs.ModelRendererOnRenderMaterial, ModelRendererOnRenderMaterialDetour,
!HookOverrides.Instance.PostProcessing.ModelRendererOnRenderMaterial).Result;
- _communicator.MtrlShpkLoaded.Subscribe(OnMtrlShpkLoaded, MtrlShpkLoaded.Priority.ShaderReplacementFixer);
+ _communicator.MtrlLoaded.Subscribe(OnMtrlLoaded, MtrlLoaded.Priority.ShaderReplacementFixer);
_resourceHandleDestructor.Subscribe(OnResourceHandleDestructor, ResourceHandleDestructor.Priority.ShaderReplacementFixer);
}
@@ -118,7 +118,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic
{
_modelRendererOnRenderMaterialHook.Dispose();
_humanOnRenderMaterialHook.Dispose();
- _communicator.MtrlShpkLoaded.Unsubscribe(OnMtrlShpkLoaded);
+ _communicator.MtrlLoaded.Unsubscribe(OnMtrlLoaded);
_resourceHandleDestructor.Unsubscribe(OnResourceHandleDestructor);
_hairMaskState.ClearMaterials();
_characterOcclusionState.ClearMaterials();
@@ -147,7 +147,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic
return shpkName.SequenceEqual(mtrlResource->ShpkNameSpan);
}
- private void OnMtrlShpkLoaded(nint mtrlResourceHandle, nint gameObject)
+ private void OnMtrlLoaded(nint mtrlResourceHandle, nint gameObject)
{
var mtrl = (MaterialResourceHandle*)mtrlResourceHandle;
var shpk = mtrl->ShaderPackageResourceHandle;
diff --git a/Penumbra/Interop/Hooks/Resources/LoadMtrlShpk.cs b/Penumbra/Interop/Hooks/Resources/LoadMtrl.cs
similarity index 55%
rename from Penumbra/Interop/Hooks/Resources/LoadMtrlShpk.cs
rename to Penumbra/Interop/Hooks/Resources/LoadMtrl.cs
index 8c410ad8..f56177e4 100644
--- a/Penumbra/Interop/Hooks/Resources/LoadMtrlShpk.cs
+++ b/Penumbra/Interop/Hooks/Resources/LoadMtrl.cs
@@ -5,28 +5,28 @@ using Penumbra.Services;
namespace Penumbra.Interop.Hooks.Resources;
-public sealed unsafe class LoadMtrlShpk : FastHook
+public sealed unsafe class LoadMtrl : FastHook
{
private readonly GameState _gameState;
private readonly CommunicatorService _communicator;
- public LoadMtrlShpk(HookManager hooks, GameState gameState, CommunicatorService communicator)
+ public LoadMtrl(HookManager hooks, GameState gameState, CommunicatorService communicator)
{
_gameState = gameState;
_communicator = communicator;
- Task = hooks.CreateHook("Load Material Shaders", Sigs.LoadMtrlShpk, Detour, !HookOverrides.Instance.Resources.LoadMtrlShpk);
+ Task = hooks.CreateHook("Load Material", Sigs.LoadMtrl, Detour, !HookOverrides.Instance.Resources.LoadMtrl);
}
- public delegate byte Delegate(MaterialResourceHandle* mtrlResourceHandle);
+ public delegate byte Delegate(MaterialResourceHandle* mtrlResourceHandle, void* unk1, byte unk2);
- private byte Detour(MaterialResourceHandle* handle)
+ private byte Detour(MaterialResourceHandle* handle, void* unk1, byte unk2)
{
var last = _gameState.MtrlData.Value;
var mtrlData = _gameState.LoadSubFileHelper((nint)handle);
_gameState.MtrlData.Value = mtrlData;
- var ret = Task.Result.Original(handle);
+ var ret = Task.Result.Original(handle, unk1, unk2);
_gameState.MtrlData.Value = last;
- _communicator.MtrlShpkLoaded.Invoke((nint)handle, mtrlData.AssociatedGameObject);
+ _communicator.MtrlLoaded.Invoke((nint)handle, mtrlData.AssociatedGameObject);
return ret;
}
}
diff --git a/Penumbra/Interop/Hooks/Resources/LoadMtrlTex.cs b/Penumbra/Interop/Hooks/Resources/LoadMtrlTex.cs
index 0759d9b1..1866e859 100644
--- a/Penumbra/Interop/Hooks/Resources/LoadMtrlTex.cs
+++ b/Penumbra/Interop/Hooks/Resources/LoadMtrlTex.cs
@@ -4,6 +4,7 @@ using Penumbra.GameData;
namespace Penumbra.Interop.Hooks.Resources;
+// TODO check if this is still needed, as our hooked function is called by LoadMtrl's hooked function
public sealed unsafe class LoadMtrlTex : FastHook
{
private readonly GameState _gameState;
diff --git a/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs
new file mode 100644
index 00000000..96d9daff
--- /dev/null
+++ b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs
@@ -0,0 +1,88 @@
+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.Mods.Manager;
+using Penumbra.Services;
+using Penumbra.String;
+using Penumbra.String.Classes;
+
+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, MessageService messager, ModManager modManager)
+ : IPathPreProcessor
+{
+ public ResourceType Type
+ => ResourceType.Shpk;
+
+ public unsafe FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath originalGamePath, bool nonDefault,
+ FullPath? resolved)
+ {
+ messager.CleanTaggedMessages(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;
+
+ messager.PrintFileWarning(modManager, 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/Interop/ResourceTree/ResourceNode.cs b/Penumbra/Interop/ResourceTree/ResourceNode.cs
index 6ab48325..85d12ce7 100644
--- a/Penumbra/Interop/ResourceTree/ResourceNode.cs
+++ b/Penumbra/Interop/ResourceTree/ResourceNode.cs
@@ -15,6 +15,8 @@ public class ResourceNode : ICloneable
public readonly nint ResourceHandle;
public Utf8GamePath[] PossibleGamePaths;
public FullPath FullPath;
+ public string? ModName;
+ public string? ModRelativePath;
public CiByteString AdditionalData;
public readonly ulong Length;
public readonly List Children;
@@ -57,6 +59,8 @@ public class ResourceNode : ICloneable
ResourceHandle = other.ResourceHandle;
PossibleGamePaths = other.PossibleGamePaths;
FullPath = other.FullPath;
+ ModName = other.ModName;
+ ModRelativePath = other.ModRelativePath;
AdditionalData = other.AdditionalData;
Length = other.Length;
Children = other.Children;
diff --git a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs
index 65fac68f..9738148f 100644
--- a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs
+++ b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs
@@ -9,6 +9,7 @@ using Penumbra.GameData.Enums;
using Penumbra.GameData.Interop;
using Penumbra.Interop.PathResolving;
using Penumbra.Meta;
+using Penumbra.Mods.Manager;
using Penumbra.String.Classes;
namespace Penumbra.Interop.ResourceTree;
@@ -21,7 +22,8 @@ public class ResourceTreeFactory(
ObjectIdentification objectIdentifier,
Configuration config,
ActorManager actors,
- PathState pathState) : IService
+ PathState pathState,
+ ModManager modManager) : IService
{
private TreeBuildCache CreateTreeBuildCache()
=> new(objects, gameData, actors);
@@ -93,7 +95,10 @@ public class ResourceTreeFactory(
// This is currently unneeded as we can resolve all paths by querying the draw object:
// ResolveGamePaths(tree, collectionResolveData.ModCollection);
if (globalContext.WithUiData)
+ {
ResolveUiData(tree);
+ ResolveModData(tree);
+ }
FilterFullPaths(tree, (flags & Flags.RedactExternalPaths) != 0 ? config.ModDirectory : null);
Cleanup(tree);
@@ -123,6 +128,18 @@ public class ResourceTreeFactory(
});
}
+ private void ResolveModData(ResourceTree tree)
+ {
+ foreach (var node in tree.FlatNodes)
+ {
+ if (node.FullPath.IsRooted && modManager.TryIdentifyPath(node.FullPath.FullName, out var mod, out var relativePath))
+ {
+ node.ModName = mod.Name;
+ node.ModRelativePath = relativePath;
+ }
+ }
+ }
+
private static void FilterFullPaths(ResourceTree tree, string? onlyWithinPath)
{
foreach (var node in tree.FlatNodes)
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/Services/CommunicatorService.cs b/Penumbra/Services/CommunicatorService.cs
index cacbe689..5d745419 100644
--- a/Penumbra/Services/CommunicatorService.cs
+++ b/Penumbra/Services/CommunicatorService.cs
@@ -24,8 +24,8 @@ public class CommunicatorService : IDisposable, IService
///
public readonly CreatedCharacterBase CreatedCharacterBase = new();
- ///
- public readonly MtrlShpkLoaded MtrlShpkLoaded = new();
+ ///
+ public readonly MtrlLoaded MtrlLoaded = new();
///
public readonly ModDataChanged ModDataChanged = new();
@@ -87,7 +87,7 @@ public class CommunicatorService : IDisposable, IService
TemporaryGlobalModChange.Dispose();
CreatingCharacterBase.Dispose();
CreatedCharacterBase.Dispose();
- MtrlShpkLoaded.Dispose();
+ MtrlLoaded.Dispose();
ModDataChanged.Dispose();
ModOptionChanged.Dispose();
ModDiscoveryStarted.Dispose();
diff --git a/Penumbra/Services/MessageService.cs b/Penumbra/Services/MessageService.cs
index 08118483..a35a67f1 100644
--- a/Penumbra/Services/MessageService.cs
+++ b/Penumbra/Services/MessageService.cs
@@ -2,10 +2,14 @@ using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface;
+using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
using Lumina.Excel.GeneratedSheets;
using OtterGui.Log;
using OtterGui.Services;
+using Penumbra.Mods.Manager;
+using Penumbra.String.Classes;
+using Notification = OtterGui.Classes.Notification;
namespace Penumbra.Services;
@@ -38,4 +42,16 @@ public class MessageService(Logger log, IUiBuilder builder, IChatGui chat, INoti
Message = payload,
});
}
+
+ public void PrintFileWarning(ModManager modManager, string fullPath, Utf8GamePath originalGamePath, string messageComplement)
+ {
+ // 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;
+
+ AddTaggedMessage($"{fullPath}.{messageComplement}",
+ new Notification(
+ $"Cowardly refusing to load replacement for {originalGamePath.Filename().ToString().ToLowerInvariant()} by {mod.Name}{(messageComplement.Length > 0 ? ":\n" : ".")}{messageComplement}",
+ NotificationType.Warning, 10000));
+ }
}
diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs
index a991c948..361094c4 100644
--- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs
+++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs
@@ -316,7 +316,10 @@ public class ResourceTreeViewer
ImGui.TableNextColumn();
if (resourceNode.FullPath.FullName.Length > 0)
{
- ImGui.Selectable(resourceNode.FullPath.ToPath(), false, 0, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
+ var uiFullPathStr = resourceNode.ModName != null && resourceNode.ModRelativePath != null
+ ? $"[{resourceNode.ModName}] {resourceNode.ModRelativePath}"
+ : resourceNode.FullPath.ToPath();
+ ImGui.Selectable(uiFullPathStr, false, 0, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight));
if (ImGui.IsItemClicked())
ImGui.SetClipboardText(resourceNode.FullPath.ToPath());
ImGuiUtil.HoverTooltip(