diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 9d54f4562..d5f1299fd 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -8,6 +8,7 @@ using System.Runtime.InteropServices; using Dalamud.Game.Text; using Dalamud.Interface; using Dalamud.Interface.FontIdentifier; +using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.ReShadeHandling; using Dalamud.Interface.Style; using Dalamud.IoC.Internal; @@ -445,6 +446,9 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// Gets or sets the mode specifying how to handle ReShade. public ReShadeHandlingMode ReShadeHandlingMode { get; set; } = ReShadeHandlingMode.ReShadeAddon; + /// Gets or sets the swap chain hook mode. + public SwapChainHelper.HookMode SwapChainHookMode { get; set; } = SwapChainHelper.HookMode.ByteCode; + /// /// Gets or sets hitch threshold for game network up in milliseconds. /// diff --git a/Dalamud/Hooking/Internal/ObjectVTableHook.cs b/Dalamud/Hooking/Internal/ObjectVTableHook.cs new file mode 100644 index 000000000..b4500bb5f --- /dev/null +++ b/Dalamud/Hooking/Internal/ObjectVTableHook.cs @@ -0,0 +1,286 @@ +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using Dalamud.Utility; + +using Serilog; + +namespace Dalamud.Hooking.Internal; + +/// Manages a hook that works by replacing the vtable of target object. +internal unsafe class ObjectVTableHook : IDisposable +{ + private readonly nint** ppVtbl; + private readonly int numMethods; + + private readonly nint* pVtblOriginal; + private readonly nint[] vtblOverriden; + + /// Extra data for overriden vtable entries, primarily for keeping references to delegates that are used + /// with . + private readonly object?[] vtblOverridenTag; + + private bool released; + + /// Initializes a new instance of the class. + /// Address to vtable. Usually the address of the object itself. + /// Number of methods in this vtable. + public ObjectVTableHook(nint ppVtbl, int numMethods) + { + this.ppVtbl = (nint**)ppVtbl; + this.numMethods = numMethods; + this.vtblOverridenTag = new object?[numMethods]; + this.pVtblOriginal = *this.ppVtbl; + this.vtblOverriden = GC.AllocateArray(numMethods, true); + this.OriginalVTableSpan.CopyTo(this.vtblOverriden); + } + + /// Initializes a new instance of the class. + /// Address to vtable. Usually the address of the object itself. + /// Number of methods in this vtable. + public ObjectVTableHook(void* ppVtbl, int numMethods) + : this((nint)ppVtbl, numMethods) + { + } + + /// Finalizes an instance of the class. + ~ObjectVTableHook() => this.ReleaseUnmanagedResources(); + + /// Gets the span view of original vtable. + public ReadOnlySpan OriginalVTableSpan => new(this.pVtblOriginal, this.numMethods); + + /// Gets the span view of overriden vtable. + public ReadOnlySpan OverridenVTableSpan => this.vtblOverriden.AsSpan(); + + /// Disables the hook. + public void Disable() + { + // already disabled + if (*this.ppVtbl == this.pVtblOriginal) + return; + + if (*this.ppVtbl != Unsafe.AsPointer(ref this.vtblOverriden[0])) + { + Log.Warning( + "[{who}]: the object was hooked by something else; disabling may result in a crash.", + this.GetType().Name); + } + + *this.ppVtbl = this.pVtblOriginal; + } + + /// + public void Dispose() + { + this.ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + /// Enables the hook. + public void Enable() + { + // already enabled + if (*this.ppVtbl == Unsafe.AsPointer(ref this.vtblOverriden[0])) + return; + + if (*this.ppVtbl != this.pVtblOriginal) + { + Log.Warning( + "[{who}]: the object was hooked by something else; enabling may result in a crash.", + this.GetType().Name); + } + + *this.ppVtbl = (nint*)Unsafe.AsPointer(ref this.vtblOverriden[0]); + } + + /// Gets the original method address of the given method index. + /// Index of the method. + /// Address of the original method. + public nint GetOriginalMethodAddress(int methodIndex) + { + this.EnsureMethodIndex(methodIndex); + return this.pVtblOriginal[methodIndex]; + } + + /// Gets the original method of the given method index, as a delegate of given type. + /// Index of the method. + /// Type of delegate. + /// Delegate to the original method. + public T GetOriginalMethodDelegate(int methodIndex) + where T : Delegate + { + this.EnsureMethodIndex(methodIndex); + return Marshal.GetDelegateForFunctionPointer(this.pVtblOriginal[methodIndex]); + } + + /// Resets a method to the original function. + /// Index of the method. + public void ResetVtableEntry(int methodIndex) + { + this.EnsureMethodIndex(methodIndex); + this.vtblOverriden[methodIndex] = this.pVtblOriginal[methodIndex]; + this.vtblOverridenTag[methodIndex] = null; + } + + /// Sets a method in vtable to the given address of function. + /// Index of the method. + /// Address of the detour function. + /// Additional reference to keep in memory. + public void SetVtableEntry(int methodIndex, nint pfn, object? refkeep) + { + this.EnsureMethodIndex(methodIndex); + this.vtblOverriden[methodIndex] = pfn; + this.vtblOverridenTag[methodIndex] = refkeep; + } + + /// Sets a method in vtable to the given delegate. + /// Index of the method. + /// Detour delegate. + /// Type of delegate. + public void SetVtableEntry(int methodIndex, T detourDelegate) + where T : Delegate => + this.SetVtableEntry(methodIndex, Marshal.GetFunctionPointerForDelegate(detourDelegate), detourDelegate); + + /// Sets a method in vtable to the given delegate. + /// Index of the method. + /// Detour delegate. + /// Original method delegate. + /// Type of delegate. + public void SetVtableEntry(int methodIndex, T detourDelegate, out T originalMethodDelegate) + where T : Delegate + { + originalMethodDelegate = this.GetOriginalMethodDelegate(methodIndex); + this.SetVtableEntry(methodIndex, Marshal.GetFunctionPointerForDelegate(detourDelegate), detourDelegate); + } + + /// Creates a new instance of that manages one entry in the vtable hook. + /// Index of the method. + /// Detour delegate. + /// Type of delegate. + /// A new instance of . + /// Even if a single hook is enabled, without , the hook will remain disabled. + /// + public Hook CreateHook(int methodIndex, T detourDelegate) where T : Delegate => + new SingleHook(this, methodIndex, detourDelegate); + + private void EnsureMethodIndex(int methodIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(methodIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(methodIndex, this.numMethods); + } + + private void ReleaseUnmanagedResources() + { + if (!this.released) + { + this.Disable(); + this.released = true; + } + } + + private sealed class SingleHook(ObjectVTableHook hook, int methodIndex, T detourDelegate) + : Hook((nint)hook.ppVtbl) + where T : Delegate + { + /// + public override T Original { get; } = hook.GetOriginalMethodDelegate(methodIndex); + + /// + public override bool IsEnabled => + hook.OriginalVTableSpan[methodIndex] != hook.OverridenVTableSpan[methodIndex]; + + /// + public override string BackendName => nameof(ObjectVTableHook); + + /// + public override void Enable() => hook.SetVtableEntry(methodIndex, detourDelegate); + + /// + public override void Disable() => hook.ResetVtableEntry(methodIndex); + } +} + +/// Typed version of . +/// VTable struct. +internal unsafe class ObjectVTableHook : ObjectVTableHook + where TVTable : unmanaged +{ + private static readonly string[] Fields = + typeof(TVTable).GetFields(BindingFlags.Instance | BindingFlags.Public).Select(x => x.Name).ToArray(); + + /// Initializes a new instance of the class. + /// Address to vtable. Usually the address of the object itself. + public ObjectVTableHook(void* ppVtbl) + : base(ppVtbl, Fields.Length) + { + } + + /// Gets the original vtable. + public ref readonly TVTable OriginalVTable => ref MemoryMarshal.Cast(this.OriginalVTableSpan)[0]; + + /// Gets the overriden vtable. + public ref readonly TVTable OverridenVTable => ref MemoryMarshal.Cast(this.OverridenVTableSpan)[0]; + + /// Gets the index of the method by method name. + /// Name of the method. + /// Index of the method. + public int GetMethodIndex(string methodName) => Fields.IndexOf(methodName); + + /// Gets the original method address of the given method index. + /// Name of the method. + /// Address of the original method. + public nint GetOriginalMethodAddress(string methodName) => + this.GetOriginalMethodAddress(this.GetMethodIndex(methodName)); + + /// Gets the original method of the given method index, as a delegate of given type. + /// Name of the method. + /// Type of delegate. + /// Delegate to the original method. + public T GetOriginalMethodDelegate(string methodName) + where T : Delegate + => this.GetOriginalMethodDelegate(this.GetMethodIndex(methodName)); + + /// Resets a method to the original function. + /// Name of the method. + public void ResetVtableEntry(string methodName) + => this.ResetVtableEntry(this.GetMethodIndex(methodName)); + + /// Sets a method in vtable to the given address of function. + /// Name of the method. + /// Address of the detour function. + /// Additional reference to keep in memory. + public void SetVtableEntry(string methodName, nint pfn, object? refkeep) + => this.SetVtableEntry(this.GetMethodIndex(methodName), pfn, refkeep); + + /// Sets a method in vtable to the given delegate. + /// Name of the method. + /// Detour delegate. + /// Type of delegate. + public void SetVtableEntry(string methodName, T detourDelegate) + where T : Delegate => + this.SetVtableEntry( + this.GetMethodIndex(methodName), + Marshal.GetFunctionPointerForDelegate(detourDelegate), + detourDelegate); + + /// Sets a method in vtable to the given delegate. + /// Name of the method. + /// Detour delegate. + /// Original method delegate. + /// Type of delegate. + public void SetVtableEntry(string methodName, T detourDelegate, out T originalMethodDelegate) + where T : Delegate + => this.SetVtableEntry(this.GetMethodIndex(methodName), detourDelegate, out originalMethodDelegate); + + /// Creates a new instance of that manages one entry in the vtable hook. + /// Name of the method. + /// Detour delegate. + /// Type of delegate. + /// A new instance of . + /// Even if a single hook is enabled, without , the hook will remain + /// disabled. + public Hook CreateHook(string methodName, T detourDelegate) where T : Delegate => + this.CreateHook(this.GetMethodIndex(methodName), detourDelegate); +} diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index b7c2f8765..4941ea46c 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -35,6 +35,7 @@ using JetBrains.Annotations; using PInvoke; +using TerraFX.Interop.DirectX; using TerraFX.Interop.Windows; // general dev notes, here because it's easiest @@ -94,6 +95,7 @@ internal partial class InterfaceManager : IInternalDisposableService private Hook? setCursorHook; private Hook? dxgiPresentHook; private Hook? resizeBuffersHook; + private ObjectVTableHook>? swapChainHook; private ReShadeAddonInterface? reShadeAddonInterface; private IFontAtlas? dalamudAtlas; @@ -308,6 +310,7 @@ internal partial class InterfaceManager : IInternalDisposableService Interlocked.Exchange(ref this.setCursorHook, null)?.Dispose(); Interlocked.Exchange(ref this.dxgiPresentHook, null)?.Dispose(); Interlocked.Exchange(ref this.resizeBuffersHook, null)?.Dispose(); + Interlocked.Exchange(ref this.swapChainHook, null)?.Dispose(); Interlocked.Exchange(ref this.reShadeAddonInterface, null)?.Dispose(); } } @@ -769,12 +772,13 @@ internal partial class InterfaceManager : IInternalDisposableService Log.Verbose("Unwrapped ReShade."); } + ResizeBuffersDelegate resizeBuffersDelegate; + DxgiPresentDelegate? dxgiPresentDelegate; if (this.dalamudConfiguration.ReShadeHandlingMode == ReShadeHandlingMode.ReShadeAddon && ReShadeAddonInterface.TryRegisterAddon(out this.reShadeAddonInterface)) { - this.resizeBuffersHook = Hook.FromAddress( - (nint)SwapChainHelper.GameDeviceSwapChainVtbl->ResizeBuffers, - this.AsReShadeAddonResizeBuffersDetour); + resizeBuffersDelegate = this.AsReShadeAddonResizeBuffersDetour; + dxgiPresentDelegate = null; Log.Verbose( "Registered as a ReShade({name}: 0x{addr:X}) addon.", @@ -786,21 +790,55 @@ internal partial class InterfaceManager : IInternalDisposableService } else { - this.resizeBuffersHook = Hook.FromAddress( - (nint)SwapChainHelper.GameDeviceSwapChainVtbl->ResizeBuffers, - this.AsHookResizeBuffersDetour); - - this.dxgiPresentHook = Hook.FromAddress( - (nint)SwapChainHelper.GameDeviceSwapChainVtbl->Present, - this.PresentDetour); + resizeBuffersDelegate = this.AsHookResizeBuffersDetour; + dxgiPresentDelegate = this.PresentDetour; } - Log.Verbose($"IDXGISwapChain::ResizeBuffers address: {Util.DescribeAddress(this.resizeBuffersHook.Address)}"); + switch (this.dalamudConfiguration.SwapChainHookMode) + { + case SwapChainHelper.HookMode.ByteCode: + default: + { + this.resizeBuffersHook = Hook.FromAddress( + (nint)SwapChainHelper.GameDeviceSwapChainVtbl->ResizeBuffers, + resizeBuffersDelegate); + + if (dxgiPresentDelegate is not null) + { + this.dxgiPresentHook = Hook.FromAddress( + (nint)SwapChainHelper.GameDeviceSwapChainVtbl->Present, + dxgiPresentDelegate); + } + + break; + } + + case SwapChainHelper.HookMode.VTable: + { + this.swapChainHook = new(SwapChainHelper.GameDeviceSwapChain); + this.resizeBuffersHook = this.swapChainHook.CreateHook( + nameof(IDXGISwapChain.ResizeBuffers), + resizeBuffersDelegate); + + if (dxgiPresentDelegate is not null) + { + this.dxgiPresentHook = this.swapChainHook.CreateHook( + nameof(IDXGISwapChain.Present), + dxgiPresentDelegate); + } + + break; + } + } + + Log.Verbose( + $"IDXGISwapChain::ResizeBuffers address: {Util.DescribeAddress(this.resizeBuffersHook.Address)}"); Log.Verbose($"IDXGISwapChain::Present address: {Util.DescribeAddress(this.dxgiPresentHook?.Address ?? 0)}"); this.setCursorHook.Enable(); - this.dxgiPresentHook?.Enable(); this.resizeBuffersHook.Enable(); + this.dxgiPresentHook?.Enable(); + this.swapChainHook?.Enable(); } private IntPtr SetCursorDetour(IntPtr hCursor) diff --git a/Dalamud/Interface/Internal/ReShadeHandling/ReShadeUnwrapper.cs b/Dalamud/Interface/Internal/ReShadeHandling/ReShadeUnwrapper.cs index 3682b03c0..f1210425d 100644 --- a/Dalamud/Interface/Internal/ReShadeHandling/ReShadeUnwrapper.cs +++ b/Dalamud/Interface/Internal/ReShadeHandling/ReShadeUnwrapper.cs @@ -78,28 +78,24 @@ internal static unsafe class ReShadeUnwrapper { foreach (ProcessModule processModule in Process.GetCurrentProcess().Modules) { - if (ptr < processModule.BaseAddress || ptr >= processModule.BaseAddress + processModule.ModuleMemorySize) + if (ptr < processModule.BaseAddress || + ptr >= processModule.BaseAddress + processModule.ModuleMemorySize || + !HasProcExported(processModule, "ReShadeRegisterAddon"u8) || + !HasProcExported(processModule, "ReShadeUnregisterAddon"u8) || + !HasProcExported(processModule, "ReShadeRegisterEvent"u8) || + !HasProcExported(processModule, "ReShadeUnregisterEvent"u8)) continue; - fixed (byte* pfn0 = "ReShadeRegisterAddon"u8) - fixed (byte* pfn1 = "ReShadeUnregisterAddon"u8) - fixed (byte* pfn2 = "ReShadeRegisterEvent"u8) - fixed (byte* pfn3 = "ReShadeUnregisterEvent"u8) - { - if (GetProcAddress((HMODULE)processModule.BaseAddress, (sbyte*)pfn0) == 0) - continue; - if (GetProcAddress((HMODULE)processModule.BaseAddress, (sbyte*)pfn1) == 0) - continue; - if (GetProcAddress((HMODULE)processModule.BaseAddress, (sbyte*)pfn2) == 0) - continue; - if (GetProcAddress((HMODULE)processModule.BaseAddress, (sbyte*)pfn3) == 0) - continue; - } - return true; } return false; + + static bool HasProcExported(ProcessModule m, ReadOnlySpan name) + { + fixed (byte* p = name) + return GetProcAddress((HMODULE)m.BaseAddress, (sbyte*)p) != 0; + } } private static bool IsReShadedComObject(T* obj) diff --git a/Dalamud/Interface/Internal/SwapChainHelper.cs b/Dalamud/Interface/Internal/SwapChainHelper.cs index 051e348e0..4a336ee9f 100644 --- a/Dalamud/Interface/Internal/SwapChainHelper.cs +++ b/Dalamud/Interface/Internal/SwapChainHelper.cs @@ -14,6 +14,16 @@ internal static unsafe class SwapChainHelper { private static IDXGISwapChain* foundGameDeviceSwapChain; + /// Describes how to hook methods. + public enum HookMode + { + /// Hooks by rewriting the native bytecode. + ByteCode, + + /// Hooks by providing an alternative vtable. + VTable, + } + /// Gets the game's active instance of IDXGISwapChain that is initialized. /// Address of the game's instance of IDXGISwapChain, or null if not available (yet.) public static IDXGISwapChain* GameDeviceSwapChain diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs index c51f465f9..faefe418c 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs @@ -98,7 +98,7 @@ public class SettingsTabExperimental : SettingsTab { ReShadeHandlingMode.ReShadeAddon => Loc.Localize( "DalamudSettingsReShadeHandlingModeReShadeAddonDescription", - "Dalamud will register itself as a ReShade addon. Most compatibility is expected, but multi-monitor window option won't work too well."), + "Dalamud will register itself as a ReShade addon. Most compatibility is expected, but multi-monitor window option will require reloading ReShade every time a new window is opened, or even may not work at all."), ReShadeHandlingMode.UnwrapReShade => Loc.Localize( "DalamudSettingsReShadeHandlingModeUnwrapReShadeDescription", "Dalamud will exclude itself from all ReShade handling. Multi-monitor windows should work fine with this mode, but it may not be supported and crash in future ReShade versions."), @@ -109,6 +109,17 @@ public class SettingsTabExperimental : SettingsTab }, }, + new GapSettingsEntry(5, true), + + new EnumSettingsEntry( + Loc.Localize("DalamudSettingsSwapChainHookMode", "Swap chain hooking mode"), + Loc.Localize( + "DalamudSettingsSwapChainHookModeHint", + "Depending on addons aside from Dalamud you use, you may have to use different options for Dalamud and other addons to cooperate.\nRestart is required for changes to take effect."), + c => c.SwapChainHookMode, + (v, c) => c.SwapChainHookMode = v, + fallbackValue: SwapChainHelper.HookMode.ByteCode), + /* Disabling profiles after they've been enabled doesn't make much sense, at least not if the user has already created profiles. new GapSettingsEntry(5, true),