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(