mirror of
https://github.com/goatcorp/Dalamud.git
synced 2025-12-12 18:27:23 +01:00
add back DXGISwapChain::on_present hook as default for now
This commit is contained in:
parent
bd170ee74a
commit
ebee2f151e
7 changed files with 379 additions and 172 deletions
|
|
@ -54,6 +54,15 @@ internal unsafe class ObjectVTableHook : IDisposable
|
|||
/// <summary>Gets the span view of overriden vtable.</summary>
|
||||
public ReadOnlySpan<nint> OverridenVTableSpan => this.vtblOverriden.AsSpan();
|
||||
|
||||
/// <summary>Gets the address of the pointer to the vtable.</summary>
|
||||
public nint Address => (nint)this.ppVtbl;
|
||||
|
||||
/// <summary>Gets the address of the original vtable.</summary>
|
||||
public nint OriginalVTableAddress => (nint)this.pVtblOriginal;
|
||||
|
||||
/// <summary>Gets the address of the overriden vtable.</summary>
|
||||
public nint OverridenVTableAddress => (nint)Unsafe.AsPointer(ref this.vtblOverriden[0]);
|
||||
|
||||
/// <summary>Disables the hook.</summary>
|
||||
public void Disable()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,75 +1,142 @@
|
|||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
using Dalamud.Utility;
|
||||
|
||||
using TerraFX.Interop.DirectX;
|
||||
using TerraFX.Interop.Windows;
|
||||
|
||||
namespace Dalamud.Interface.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// This class manages interaction with the ImGui interface.
|
||||
/// </summary>
|
||||
internal partial class InterfaceManager
|
||||
internal unsafe partial class InterfaceManager
|
||||
{
|
||||
private IntPtr PresentDetour(IntPtr swapChain, uint syncInterval, uint presentFlags)
|
||||
// NOTE: Do not use HRESULT as return value type. It appears that .NET marshaller thinks HRESULT needs to be still
|
||||
// treated as a type that does not fit into RAX.
|
||||
|
||||
/// <summary>Delegate for <c>DXGISwapChain::on_present(UINT flags, const DXGI_PRESENT_PARAMETERS *params)</c> in
|
||||
/// <c>dxgi_swapchain.cpp</c>.</summary>
|
||||
/// <param name="swapChain">Pointer to an instance of <c>DXGISwapChain</c>, which happens to be an
|
||||
/// <see cref="IDXGISwapChain"/>.</param>
|
||||
/// <param name="flags">An integer value that contains swap-chain presentation options. These options are defined by
|
||||
/// the <c>DXGI_PRESENT</c> constants.</param>
|
||||
/// <param name="presentParams">Optional; DXGI present parameters.</param>
|
||||
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
|
||||
private delegate void ReShadeDxgiSwapChainPresentDelegate(
|
||||
ReShadeDxgiSwapChain* swapChain,
|
||||
uint flags,
|
||||
DXGI_PRESENT_PARAMETERS* presentParams);
|
||||
|
||||
/// <summary>Delegate for <see cref="IDXGISwapChain.Present"/>.
|
||||
/// <a href="https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgiswapchain-present">Microsoft
|
||||
/// Learn</a>.</summary>
|
||||
/// <param name="swapChain">Pointer to an instance of <see cref="IDXGISwapChain"/>.</param>
|
||||
/// <param name="syncInterval">An integer that specifies how to synchronize presentation of a frame with the
|
||||
/// vertical blank.</param>
|
||||
/// <param name="flags">An integer value that contains swap-chain presentation options. These options are defined by
|
||||
/// the <c>DXGI_PRESENT</c> constants.</param>
|
||||
/// <returns>A <see cref="HRESULT"/> representing the result of the operation.</returns>
|
||||
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
|
||||
private delegate int DxgiSwapChainPresentDelegate(IDXGISwapChain* swapChain, uint syncInterval, uint flags);
|
||||
|
||||
/// <summary>Detour function for <see cref="IDXGISwapChain.ResizeBuffers"/>.
|
||||
/// <a href="https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgiswapchain-resizebuffers">
|
||||
/// Microsoft Learn</a>.</summary>
|
||||
/// <param name="swapChain">Pointer to an instance of <see cref="IDXGISwapChain"/>.</param>
|
||||
/// <param name="bufferCount">The number of buffers in the swap chain (including all back and front buffers).
|
||||
/// This number can be different from the number of buffers with which you created the swap chain. This number
|
||||
/// can't be greater than <see cref="DXGI.DXGI_MAX_SWAP_CHAIN_BUFFERS"/>. Set this number to zero to preserve the
|
||||
/// existing number of buffers in the swap chain. You can't specify less than two buffers for the flip presentation
|
||||
/// model.</param>
|
||||
/// <param name="width">The new width of the back buffer. If you specify zero, DXGI will use the width of the client
|
||||
/// area of the target window. You can't specify the width as zero if you called the
|
||||
/// <see cref="IDXGIFactory2.CreateSwapChainForComposition"/> method to create the swap chain for a composition
|
||||
/// surface.</param>
|
||||
/// <param name="height">The new height of the back buffer. If you specify zero, DXGI will use the height of the
|
||||
/// client area of the target window. You can't specify the height as zero if you called the
|
||||
/// <see cref="IDXGIFactory2.CreateSwapChainForComposition"/> method to create the swap chain for a composition
|
||||
/// surface.</param>
|
||||
/// <param name="newFormat">A DXGI_FORMAT-typed value for the new format of the back buffer. Set this value to
|
||||
/// <see cref="DXGI_FORMAT.DXGI_FORMAT_UNKNOWN"/> to preserve the existing format of the back buffer. The flip
|
||||
/// presentation model supports a more restricted set of formats than the bit-block transfer (bitblt) model.</param>
|
||||
/// <param name="swapChainFlags">A combination of <see cref="DXGI_SWAP_CHAIN_FLAG"/>-typed values that are combined
|
||||
/// by using a bitwise OR operation. The resulting value specifies options for swap-chain behavior.</param>
|
||||
/// <returns>A <see cref="HRESULT"/> representing the result of the operation.</returns>
|
||||
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
|
||||
private delegate int ResizeBuffersDelegate(
|
||||
IDXGISwapChain* swapChain,
|
||||
uint bufferCount,
|
||||
uint width,
|
||||
uint height,
|
||||
DXGI_FORMAT newFormat,
|
||||
uint swapChainFlags);
|
||||
|
||||
private void ReShadeDxgiSwapChainOnPresentDetour(
|
||||
ReShadeDxgiSwapChain* swapChain,
|
||||
uint flags,
|
||||
DXGI_PRESENT_PARAMETERS* presentParams)
|
||||
{
|
||||
if (!SwapChainHelper.IsGameDeviceSwapChain(swapChain))
|
||||
return this.dxgiPresentHook!.Original(swapChain, syncInterval, presentFlags);
|
||||
Debug.Assert(
|
||||
this.reShadeDxgiSwapChainPresentHook is not null,
|
||||
"this.reShadeDxgiSwapChainPresentHook is not null");
|
||||
|
||||
Debug.Assert(this.dxgiPresentHook is not null, "How did PresentDetour get called when presentHook is null?");
|
||||
Debug.Assert(this.dalamudAtlas is not null, "dalamudAtlas should have been set already");
|
||||
// Call this first to draw Dalamud over ReShade.
|
||||
this.reShadeDxgiSwapChainPresentHook!.Original(swapChain, flags, presentParams);
|
||||
|
||||
if (this.scene == null)
|
||||
this.InitScene(swapChain);
|
||||
if (this.RenderDalamudCheckAndInitialize(swapChain->AsIDxgiSwapChain()) is { } activeScene)
|
||||
this.RenderDalamudDraw(activeScene);
|
||||
|
||||
Debug.Assert(this.scene is not null, "InitScene did not set the scene field, but did not throw an exception.");
|
||||
|
||||
if (!this.dalamudAtlas!.HasBuiltAtlas)
|
||||
{
|
||||
if (this.dalamudAtlas.BuildTask.Exception != null)
|
||||
{
|
||||
// TODO: Can we do something more user-friendly here? Unload instead?
|
||||
Log.Error(this.dalamudAtlas.BuildTask.Exception, "Failed to initialize Dalamud base fonts");
|
||||
Util.Fatal("Failed to initialize Dalamud base fonts.\nPlease report this error.", "Dalamud");
|
||||
}
|
||||
|
||||
return this.dxgiPresentHook!.Original(swapChain, syncInterval, presentFlags);
|
||||
}
|
||||
|
||||
this.CumulativePresentCalls++;
|
||||
this.IsMainThreadInPresent = true;
|
||||
|
||||
while (this.runBeforeImGuiRender.TryDequeue(out var action))
|
||||
action.InvokeSafely();
|
||||
|
||||
RenderImGui(this.scene!);
|
||||
this.PostImGuiRender();
|
||||
this.IsMainThreadInPresent = false;
|
||||
|
||||
return this.dxgiPresentHook!.Original(swapChain, syncInterval, presentFlags);
|
||||
// Upstream call to system IDXGISwapChain::Present will be called by ReShade.
|
||||
}
|
||||
|
||||
private IntPtr AsHookResizeBuffersDetour(
|
||||
IntPtr swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags)
|
||||
private int DxgiSwapChainPresentDetour(IDXGISwapChain* swapChain, uint syncInterval, uint flags)
|
||||
{
|
||||
Debug.Assert(this.dxgiSwapChainPresentHook is not null, "this.dxgiSwapChainPresentHook is not null");
|
||||
|
||||
if (this.RenderDalamudCheckAndInitialize(swapChain) is { } activeScene)
|
||||
this.RenderDalamudDraw(activeScene);
|
||||
|
||||
return this.dxgiSwapChainPresentHook!.Original(swapChain, syncInterval, flags);
|
||||
}
|
||||
|
||||
private int AsHookDxgiSwapChainResizeBuffersDetour(
|
||||
IDXGISwapChain* swapChain,
|
||||
uint bufferCount,
|
||||
uint width,
|
||||
uint height,
|
||||
DXGI_FORMAT newFormat,
|
||||
uint swapChainFlags)
|
||||
{
|
||||
if (!SwapChainHelper.IsGameDeviceSwapChain(swapChain))
|
||||
return this.resizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
return this.dxgiSwapChainResizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
|
||||
#if DEBUG
|
||||
Log.Verbose(
|
||||
$"Calling resizebuffers swap@{swapChain.ToInt64():X}{bufferCount} {width} {height} {newFormat} {swapChainFlags}");
|
||||
$"Calling resizebuffers swap@{(nint)swapChain:X}{bufferCount} {width} {height} {newFormat} {swapChainFlags}");
|
||||
#endif
|
||||
|
||||
this.ResizeBuffers?.InvokeSafely();
|
||||
|
||||
this.scene?.OnPreResize();
|
||||
|
||||
var ret = this.resizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
if (ret.ToInt64() == 0x887A0001)
|
||||
{
|
||||
var ret = this.dxgiSwapChainResizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
if (ret == DXGI.DXGI_ERROR_INVALID_CALL)
|
||||
Log.Error("invalid call to resizeBuffers");
|
||||
}
|
||||
|
||||
this.scene?.OnPostResize((int)width, (int)height);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/// <summary>Represents <c>DXGISwapChain</c> in ReShade.</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ReShadeDxgiSwapChain
|
||||
{
|
||||
// DXGISwapChain only implements IDXGISwapChain4. The only vtable should be that.
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public IDXGISwapChain* AsIDxgiSwapChain() => (IDXGISwapChain*)Unsafe.AsPointer(ref this);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ namespace Dalamud.Interface.Internal;
|
|||
/// <summary>
|
||||
/// This class manages interaction with the ImGui interface.
|
||||
/// </summary>
|
||||
internal partial class InterfaceManager
|
||||
internal unsafe partial class InterfaceManager
|
||||
{
|
||||
private unsafe void ReShadeAddonInterfaceOnDestroySwapChain(ref ReShadeAddonInterface.ApiObject swapChain)
|
||||
private void ReShadeAddonInterfaceOnDestroySwapChain(ref ReShadeAddonInterface.ApiObject swapChain)
|
||||
{
|
||||
var swapChainNative = swapChain.GetNative<IDXGISwapChain>();
|
||||
if (this.scene?.SwapChain.NativePointer != (nint)swapChainNative)
|
||||
|
|
@ -22,7 +22,7 @@ internal partial class InterfaceManager
|
|||
this.scene?.OnPreResize();
|
||||
}
|
||||
|
||||
private unsafe void ReShadeAddonInterfaceOnInitSwapChain(ref ReShadeAddonInterface.ApiObject swapChain)
|
||||
private void ReShadeAddonInterfaceOnInitSwapChain(ref ReShadeAddonInterface.ApiObject swapChain)
|
||||
{
|
||||
var swapChainNative = swapChain.GetNative<IDXGISwapChain>();
|
||||
if (this.scene?.SwapChain.NativePointer != (nint)swapChainNative)
|
||||
|
|
@ -42,52 +42,25 @@ internal partial class InterfaceManager
|
|||
ReadOnlySpan<RECT> destRect,
|
||||
ReadOnlySpan<RECT> dirtyRects)
|
||||
{
|
||||
var swapChainNative = swapChain.GetNative();
|
||||
var swapChainNative = swapChain.GetNative<IDXGISwapChain>();
|
||||
|
||||
if (this.scene == null)
|
||||
this.InitScene(swapChainNative);
|
||||
|
||||
if (this.scene?.SwapChain.NativePointer != swapChainNative)
|
||||
return;
|
||||
|
||||
Debug.Assert(this.dalamudAtlas is not null, "this.dalamudAtlas is not null");
|
||||
|
||||
if (!this.dalamudAtlas!.HasBuiltAtlas)
|
||||
{
|
||||
if (this.dalamudAtlas.BuildTask.Exception != null)
|
||||
{
|
||||
// TODO: Can we do something more user-friendly here? Unload instead?
|
||||
Log.Error(this.dalamudAtlas.BuildTask.Exception, "Failed to initialize Dalamud base fonts");
|
||||
Util.Fatal("Failed to initialize Dalamud base fonts.\nPlease report this error.", "Dalamud");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.CumulativePresentCalls++;
|
||||
this.IsMainThreadInPresent = true;
|
||||
|
||||
while (this.runBeforeImGuiRender.TryDequeue(out var action))
|
||||
action.InvokeSafely();
|
||||
|
||||
RenderImGui(this.scene!);
|
||||
this.PostImGuiRender();
|
||||
this.IsMainThreadInPresent = false;
|
||||
if (this.RenderDalamudCheckAndInitialize(swapChainNative) is { } activeScene)
|
||||
this.RenderDalamudDraw(activeScene);
|
||||
}
|
||||
|
||||
private nint AsReShadeAddonResizeBuffersDetour(
|
||||
nint swapChain,
|
||||
private int AsReShadeAddonDxgiSwapChainResizeBuffersDetour(
|
||||
IDXGISwapChain* swapChain,
|
||||
uint bufferCount,
|
||||
uint width,
|
||||
uint height,
|
||||
uint newFormat,
|
||||
DXGI_FORMAT newFormat,
|
||||
uint swapChainFlags)
|
||||
{
|
||||
// Hooked vtbl instead of registering ReShade event. This check is correct.
|
||||
if (!SwapChainHelper.IsGameDeviceSwapChain(swapChain))
|
||||
return this.resizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
return this.dxgiSwapChainResizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
|
||||
this.ResizeBuffers?.InvokeSafely();
|
||||
return this.resizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
return this.dxgiSwapChainResizeBuffersHook!.Original(swapChain, bufferCount, width, height, newFormat, swapChainFlags);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ using System.Collections.Generic;
|
|||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
|
@ -97,9 +97,10 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
private RawDX11Scene? scene;
|
||||
|
||||
private Hook<SetCursorDelegate>? setCursorHook;
|
||||
private Hook<DxgiPresentDelegate>? dxgiPresentHook;
|
||||
private Hook<ResizeBuffersDelegate>? resizeBuffersHook;
|
||||
private ObjectVTableHook<IDXGISwapChain.Vtbl<IDXGISwapChain>>? swapChainHook;
|
||||
private Hook<ReShadeDxgiSwapChainPresentDelegate>? reShadeDxgiSwapChainPresentHook;
|
||||
private Hook<DxgiSwapChainPresentDelegate>? dxgiSwapChainPresentHook;
|
||||
private Hook<ResizeBuffersDelegate>? dxgiSwapChainResizeBuffersHook;
|
||||
private ObjectVTableHook<IDXGISwapChain4.Vtbl<IDXGISwapChain4>>? dxgiSwapChainHook;
|
||||
private ReShadeAddonInterface? reShadeAddonInterface;
|
||||
|
||||
private IFontAtlas? dalamudAtlas;
|
||||
|
|
@ -115,12 +116,6 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
{
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
|
||||
private delegate IntPtr DxgiPresentDelegate(IntPtr swapChain, uint syncInterval, uint presentFlags);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
|
||||
private delegate IntPtr ResizeBuffersDelegate(IntPtr swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate IntPtr SetCursorDelegate(IntPtr hCursor);
|
||||
|
||||
|
|
@ -231,7 +226,7 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
/// </summary>
|
||||
public bool IsDispatchingEvents { get; set; } = true;
|
||||
|
||||
/// <summary>Gets a value indicating whether the main thread is executing <see cref="PresentDetour"/>.</summary>
|
||||
/// <summary>Gets a value indicating whether the main thread is executing <see cref="DxgiSwapChainPresentDetour"/>.</summary>
|
||||
/// <remarks>This still will be <c>true</c> even when queried off the main thread.</remarks>
|
||||
public bool IsMainThreadInPresent { get; private set; }
|
||||
|
||||
|
|
@ -265,7 +260,7 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
/// </summary>
|
||||
public Task FontBuildTask => WhenFontsReady().dalamudAtlas!.BuildTask;
|
||||
|
||||
/// <summary>Gets the number of calls to <see cref="PresentDetour"/> so far.</summary>
|
||||
/// <summary>Gets the number of calls to <see cref="DxgiSwapChainPresentDetour"/> so far.</summary>
|
||||
/// <remarks>
|
||||
/// The value increases even when Dalamud is hidden via "/xlui hide".
|
||||
/// <see cref="DalamudInterface.FrameCount"/> does not.
|
||||
|
|
@ -312,9 +307,10 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
{
|
||||
this.wndProcHookManager.PreWndProc -= this.WndProcHookManagerOnPreWndProc;
|
||||
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.dxgiSwapChainPresentHook, null)?.Dispose();
|
||||
Interlocked.Exchange(ref this.reShadeDxgiSwapChainPresentHook, null)?.Dispose();
|
||||
Interlocked.Exchange(ref this.dxgiSwapChainResizeBuffersHook, null)?.Dispose();
|
||||
Interlocked.Exchange(ref this.dxgiSwapChainHook, null)?.Dispose();
|
||||
Interlocked.Exchange(ref this.reShadeAddonInterface, null)?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -497,31 +493,72 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
return im;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void RenderImGui(RawDX11Scene scene)
|
||||
/// <summary>Checks if the provided swap chain is the target that Dalamud should draw its interface onto,
|
||||
/// and initializes ImGui for drawing.</summary>
|
||||
/// <param name="swapChain">The swap chain to test and initialize ImGui with if conditions are met.</param>
|
||||
/// <returns>An initialized instance of <see cref="RawDX11Scene"/>, or <c>null</c> if <paramref name="swapChain"/>
|
||||
/// is not the main swap chain.</returns>
|
||||
private unsafe RawDX11Scene? RenderDalamudCheckAndInitialize(IDXGISwapChain* swapChain)
|
||||
{
|
||||
var conf = Service<DalamudConfiguration>.Get();
|
||||
if (!SwapChainHelper.IsGameDeviceSwapChain(swapChain))
|
||||
return null;
|
||||
|
||||
Debug.Assert(this.dalamudAtlas is not null, "dalamudAtlas should have been set already");
|
||||
|
||||
var activeScene = this.scene ?? this.InitScene(swapChain);
|
||||
|
||||
if (!this.dalamudAtlas!.HasBuiltAtlas)
|
||||
{
|
||||
if (this.dalamudAtlas.BuildTask.Exception != null)
|
||||
{
|
||||
// TODO: Can we do something more user-friendly here? Unload instead?
|
||||
Log.Error(this.dalamudAtlas.BuildTask.Exception, "Failed to initialize Dalamud base fonts");
|
||||
Util.Fatal("Failed to initialize Dalamud base fonts.\nPlease report this error.", "Dalamud");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeScene;
|
||||
}
|
||||
|
||||
/// <summary>Draws Dalamud to the given scene representing the ImGui context.</summary>
|
||||
/// <param name="activeScene">The scene to draw to.</param>
|
||||
private void RenderDalamudDraw(RawDX11Scene activeScene)
|
||||
{
|
||||
this.CumulativePresentCalls++;
|
||||
this.IsMainThreadInPresent = true;
|
||||
|
||||
while (this.runBeforeImGuiRender.TryDequeue(out var action))
|
||||
action.InvokeSafely();
|
||||
|
||||
// Process information needed by ImGuiHelpers each frame.
|
||||
ImGuiHelpers.NewFrame();
|
||||
|
||||
// Enable viewports if there are no issues.
|
||||
if (conf.IsDisableViewport || scene.SwapChain.IsFullScreen || ImGui.GetPlatformIO().Monitors.Size == 1)
|
||||
var viewportsEnable = this.dalamudConfiguration.IsDisableViewport ||
|
||||
activeScene.SwapChain.IsFullScreen ||
|
||||
ImGui.GetPlatformIO().Monitors.Size == 1;
|
||||
if (viewportsEnable)
|
||||
ImGui.GetIO().ConfigFlags &= ~ImGuiConfigFlags.ViewportsEnable;
|
||||
else
|
||||
ImGui.GetIO().ConfigFlags |= ImGuiConfigFlags.ViewportsEnable;
|
||||
|
||||
scene.Render();
|
||||
// Call drawing functions, which in turn will call Draw event.
|
||||
activeScene.Render();
|
||||
|
||||
this.PostImGuiRender();
|
||||
this.IsMainThreadInPresent = false;
|
||||
}
|
||||
|
||||
private void InitScene(IntPtr swapChain)
|
||||
private unsafe RawDX11Scene InitScene(IDXGISwapChain* swapChain)
|
||||
{
|
||||
RawDX11Scene newScene;
|
||||
using (Timings.Start("IM Scene Init"))
|
||||
{
|
||||
try
|
||||
{
|
||||
newScene = new RawDX11Scene(swapChain);
|
||||
newScene = new RawDX11Scene((nint)swapChain);
|
||||
}
|
||||
catch (DllNotFoundException ex)
|
||||
{
|
||||
|
|
@ -547,7 +584,7 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
Environment.Exit(-1);
|
||||
|
||||
// Doesn't reach here, but to make the compiler not complain
|
||||
return;
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
var startInfo = Service<Dalamud>.Get().StartInfo;
|
||||
|
|
@ -638,6 +675,7 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
Service<InterfaceManagerWithScene>.Provide(new(this));
|
||||
|
||||
this.wndProcHookManager.PreWndProc += this.WndProcHookManagerOnPreWndProc;
|
||||
return newScene;
|
||||
}
|
||||
|
||||
private unsafe void WndProcHookManagerOnPreWndProc(WndProcEventArgs args)
|
||||
|
|
@ -805,39 +843,84 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
});
|
||||
}
|
||||
|
||||
Log.Verbose("===== S W A P C H A I N =====");
|
||||
if (this.dalamudConfiguration.ReShadeHandlingMode == ReShadeHandlingMode.UnwrapReShade)
|
||||
Log.Information("===== S W A P C H A I N =====");
|
||||
var sb = new StringBuilder();
|
||||
foreach (var m in ReShadeAddonInterface.AllReShadeModules)
|
||||
{
|
||||
if (SwapChainHelper.UnwrapReShade())
|
||||
Log.Information("Unwrapped ReShade");
|
||||
sb.Clear();
|
||||
sb.Append("ReShade detected: ");
|
||||
sb.Append(m.FileName).Append('(');
|
||||
sb.Append(m.FileVersionInfo.OriginalFilename);
|
||||
sb.Append("; ").Append(m.FileVersionInfo.ProductName);
|
||||
sb.Append("; ").Append(m.FileVersionInfo.ProductVersion);
|
||||
sb.Append("; ").Append(m.FileVersionInfo.FileDescription);
|
||||
sb.Append("; ").Append(m.FileVersionInfo.FileVersion);
|
||||
sb.Append($"@ 0x{m.BaseAddress:X}");
|
||||
if (!ReferenceEquals(m, ReShadeAddonInterface.ReShadeModule))
|
||||
sb.Append(" [ignored by Dalamud]");
|
||||
Log.Information(sb.ToString());
|
||||
}
|
||||
|
||||
ResizeBuffersDelegate? resizeBuffersDelegate = null;
|
||||
DxgiPresentDelegate? dxgiPresentDelegate = null;
|
||||
if (this.dalamudConfiguration.ReShadeHandlingMode == ReShadeHandlingMode.ReShadeAddon)
|
||||
{
|
||||
if (ReShadeAddonInterface.TryRegisterAddon(out this.reShadeAddonInterface))
|
||||
{
|
||||
resizeBuffersDelegate = this.AsReShadeAddonResizeBuffersDetour;
|
||||
if (ReShadeAddonInterface.AllReShadeModules.Length > 1)
|
||||
Log.Warning("Multiple ReShade dlls are detected.");
|
||||
|
||||
Log.Information(
|
||||
"Registered as a ReShade({Name}: 0x{Addr:X}) addon",
|
||||
ReShadeAddonInterface.ReShadeModule!.FileName,
|
||||
ReShadeAddonInterface.ReShadeModule!.BaseAddress);
|
||||
ResizeBuffersDelegate dxgiSwapChainResizeBuffersDelegate;
|
||||
ReShadeDxgiSwapChainPresentDelegate? reShadeDxgiSwapChainPresentDelegate = null;
|
||||
DxgiSwapChainPresentDelegate? dxgiSwapChainPresentDelegate = null;
|
||||
nint pfnReShadeDxgiSwapChainPresent = 0;
|
||||
switch (this.dalamudConfiguration.ReShadeHandlingMode)
|
||||
{
|
||||
// This is the only mode honored when SwapChainHookMode is set to VTable.
|
||||
case ReShadeHandlingMode.UnwrapReShade when ReShadeAddonInterface.ReShadeModule is not null:
|
||||
if (SwapChainHelper.UnwrapReShade())
|
||||
Log.Information("Unwrapped ReShade");
|
||||
else
|
||||
Log.Warning("Could not unwrap ReShade");
|
||||
goto default;
|
||||
|
||||
// Do no special ReShade handling.
|
||||
// If ReShade is not found or SwapChainHookMode is set to VTable, also do nothing special.
|
||||
case ReShadeHandlingMode.None:
|
||||
case var _ when ReShadeAddonInterface.ReShadeModule is null:
|
||||
case var _ when this.dalamudConfiguration.SwapChainHookMode == SwapChainHelper.HookMode.VTable:
|
||||
default:
|
||||
dxgiSwapChainResizeBuffersDelegate = this.AsHookDxgiSwapChainResizeBuffersDetour;
|
||||
dxgiSwapChainPresentDelegate = this.DxgiSwapChainPresentDetour;
|
||||
break;
|
||||
|
||||
// Register Dalamud as a ReShade addon.
|
||||
case ReShadeHandlingMode.ReShadeAddon:
|
||||
if (!ReShadeAddonInterface.TryRegisterAddon(out this.reShadeAddonInterface))
|
||||
{
|
||||
Log.Warning("Could not register as ReShade addon");
|
||||
goto default;
|
||||
}
|
||||
|
||||
Log.Information("Registered as a ReShade addon");
|
||||
this.reShadeAddonInterface.InitSwapChain += this.ReShadeAddonInterfaceOnInitSwapChain;
|
||||
this.reShadeAddonInterface.DestroySwapChain += this.ReShadeAddonInterfaceOnDestroySwapChain;
|
||||
this.reShadeAddonInterface.Present += this.ReShadeAddonInterfaceOnPresent;
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Could not register as ReShade addon");
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeBuffersDelegate is null)
|
||||
{
|
||||
resizeBuffersDelegate = this.AsHookResizeBuffersDetour;
|
||||
dxgiPresentDelegate = this.PresentDetour;
|
||||
dxgiSwapChainResizeBuffersDelegate = this.AsReShadeAddonDxgiSwapChainResizeBuffersDetour;
|
||||
break;
|
||||
|
||||
// Hook ReShade's DXGISwapChain::on_present. This is the legacy and the default option.
|
||||
case ReShadeHandlingMode.Default:
|
||||
case ReShadeHandlingMode.HookReShadeDxgiSwapChainOnPresent:
|
||||
pfnReShadeDxgiSwapChainPresent = ReShadeAddonInterface.FindReShadeDxgiSwapChainOnPresent();
|
||||
|
||||
if (pfnReShadeDxgiSwapChainPresent == 0)
|
||||
{
|
||||
Log.Warning("ReShade::DXGISwapChain::on_present could not be found");
|
||||
goto default;
|
||||
}
|
||||
|
||||
Log.Information(
|
||||
"Found ReShade::DXGISwapChain::on_present at {addr}",
|
||||
Util.DescribeAddress(pfnReShadeDxgiSwapChainPresent));
|
||||
reShadeDxgiSwapChainPresentDelegate = this.ReShadeDxgiSwapChainOnPresentDetour;
|
||||
dxgiSwapChainResizeBuffersDelegate = this.AsHookDxgiSwapChainResizeBuffersDetour;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (this.dalamudConfiguration.SwapChainHookMode)
|
||||
|
|
@ -846,16 +929,31 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
default:
|
||||
{
|
||||
Log.Information("Hooking using bytecode...");
|
||||
this.resizeBuffersHook = Hook<ResizeBuffersDelegate>.FromAddress(
|
||||
this.dxgiSwapChainResizeBuffersHook = Hook<ResizeBuffersDelegate>.FromAddress(
|
||||
(nint)SwapChainHelper.GameDeviceSwapChainVtbl->ResizeBuffers,
|
||||
resizeBuffersDelegate);
|
||||
dxgiSwapChainResizeBuffersDelegate);
|
||||
Log.Information(
|
||||
"Hooked IDXGISwapChain::ResizeBuffers using bytecode: {addr}",
|
||||
Util.DescribeAddress(this.dxgiSwapChainResizeBuffersHook.Address));
|
||||
|
||||
if (dxgiPresentDelegate is not null)
|
||||
if (dxgiSwapChainPresentDelegate is not null)
|
||||
{
|
||||
this.dxgiPresentHook = Hook<DxgiPresentDelegate>.FromAddress(
|
||||
this.dxgiSwapChainPresentHook = Hook<DxgiSwapChainPresentDelegate>.FromAddress(
|
||||
(nint)SwapChainHelper.GameDeviceSwapChainVtbl->Present,
|
||||
dxgiPresentDelegate);
|
||||
Log.Information("Hooked present using bytecode");
|
||||
dxgiSwapChainPresentDelegate);
|
||||
Log.Information(
|
||||
"Hooked IDXGISwapChain::Present using bytecode: {addr}",
|
||||
Util.DescribeAddress(this.dxgiSwapChainPresentHook.Address));
|
||||
}
|
||||
|
||||
if (reShadeDxgiSwapChainPresentDelegate is not null && pfnReShadeDxgiSwapChainPresent != 0)
|
||||
{
|
||||
this.reShadeDxgiSwapChainPresentHook = Hook<ReShadeDxgiSwapChainPresentDelegate>.FromAddress(
|
||||
pfnReShadeDxgiSwapChainPresent,
|
||||
reShadeDxgiSwapChainPresentDelegate);
|
||||
Log.Information(
|
||||
"Hooked ReShade::DXGISwapChain::on_present using bytecode: {addr}",
|
||||
Util.DescribeAddress(this.reShadeDxgiSwapChainPresentHook.Address));
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
@ -864,30 +962,38 @@ internal partial class InterfaceManager : IInternalDisposableService
|
|||
case SwapChainHelper.HookMode.VTable:
|
||||
{
|
||||
Log.Information("Hooking using VTable...");
|
||||
this.swapChainHook = new(SwapChainHelper.GameDeviceSwapChain);
|
||||
this.resizeBuffersHook = this.swapChainHook.CreateHook(
|
||||
this.dxgiSwapChainHook = new(SwapChainHelper.GameDeviceSwapChain);
|
||||
this.dxgiSwapChainResizeBuffersHook = this.dxgiSwapChainHook.CreateHook(
|
||||
nameof(IDXGISwapChain.ResizeBuffers),
|
||||
resizeBuffersDelegate);
|
||||
dxgiSwapChainResizeBuffersDelegate);
|
||||
Log.Information(
|
||||
"Hooked IDXGISwapChain::ResizeBuffers using VTable: {addr}",
|
||||
Util.DescribeAddress(this.dxgiSwapChainResizeBuffersHook.Address));
|
||||
|
||||
if (dxgiPresentDelegate is not null)
|
||||
if (dxgiSwapChainPresentDelegate is not null)
|
||||
{
|
||||
this.dxgiPresentHook = this.swapChainHook.CreateHook(
|
||||
this.dxgiSwapChainPresentHook = this.dxgiSwapChainHook.CreateHook(
|
||||
nameof(IDXGISwapChain.Present),
|
||||
dxgiPresentDelegate);
|
||||
Log.Information("Hooked present using VTable");
|
||||
dxgiSwapChainPresentDelegate);
|
||||
Log.Information(
|
||||
"Hooked IDXGISwapChain::Present using VTable: {addr}",
|
||||
Util.DescribeAddress(this.dxgiSwapChainPresentHook.Address));
|
||||
}
|
||||
|
||||
Log.Information(
|
||||
"Detouring vtable at {addr}: {prev} to {new}",
|
||||
Util.DescribeAddress(this.dxgiSwapChainHook.Address),
|
||||
Util.DescribeAddress(this.dxgiSwapChainHook.OriginalVTableAddress),
|
||||
Util.DescribeAddress(this.dxgiSwapChainHook.OverridenVTableAddress));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information($"IDXGISwapChain::ResizeBuffers address: {Util.DescribeAddress(this.resizeBuffersHook.Address)}");
|
||||
Log.Information($"IDXGISwapChain::Present address: {Util.DescribeAddress(this.dxgiPresentHook?.Address ?? 0)}");
|
||||
|
||||
this.setCursorHook.Enable();
|
||||
this.resizeBuffersHook.Enable();
|
||||
this.dxgiPresentHook?.Enable();
|
||||
this.swapChainHook?.Enable();
|
||||
this.reShadeDxgiSwapChainPresentHook?.Enable();
|
||||
this.dxgiSwapChainResizeBuffersHook.Enable();
|
||||
this.dxgiSwapChainPresentHook?.Enable();
|
||||
this.dxgiSwapChainHook?.Enable();
|
||||
}
|
||||
|
||||
private IntPtr SetCursorDetour(IntPtr hCursor)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
|
@ -24,6 +26,7 @@ internal sealed unsafe partial class ReShadeAddonInterface
|
|||
|
||||
static ReShadeAddonInterface()
|
||||
{
|
||||
var modules = new List<ProcessModule>();
|
||||
foreach (var m in Process.GetCurrentProcess().Modules.Cast<ProcessModule>())
|
||||
{
|
||||
ExportsStruct e;
|
||||
|
|
@ -33,27 +36,31 @@ internal sealed unsafe partial class ReShadeAddonInterface
|
|||
!GetProcAddressInto(m, nameof(e.ReShadeUnregisterEvent), &e.ReShadeUnregisterEvent))
|
||||
continue;
|
||||
|
||||
try
|
||||
modules.Add(m);
|
||||
if (modules.Count == 1)
|
||||
{
|
||||
var signerName = GetSignatureSignerNameWithoutVerification(m.FileName);
|
||||
ReShadeIsSignedByReShade = signerName == "ReShade";
|
||||
Log.Information(
|
||||
"ReShade DLL is signed by {signerName}. {vn}={v}",
|
||||
signerName,
|
||||
nameof(ReShadeIsSignedByReShade),
|
||||
ReShadeIsSignedByReShade);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Information(ex, "ReShade DLL did not had a valid signature.");
|
||||
}
|
||||
try
|
||||
{
|
||||
var signerName = GetSignatureSignerNameWithoutVerification(m.FileName);
|
||||
ReShadeIsSignedByReShade = signerName == "ReShade";
|
||||
Log.Information(
|
||||
"ReShade DLL is signed by {signerName}. {vn}={v}",
|
||||
signerName,
|
||||
nameof(ReShadeIsSignedByReShade),
|
||||
ReShadeIsSignedByReShade);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Information(ex, "ReShade DLL did not had a valid signature.");
|
||||
}
|
||||
|
||||
ReShadeModule = m;
|
||||
Exports = e;
|
||||
|
||||
return;
|
||||
ReShadeModule = m;
|
||||
Exports = e;
|
||||
}
|
||||
}
|
||||
|
||||
AllReShadeModules = [..modules];
|
||||
|
||||
return;
|
||||
|
||||
bool GetProcAddressInto(ProcessModule m, ReadOnlySpan<char> name, void* res)
|
||||
|
|
@ -68,10 +75,30 @@ internal sealed unsafe partial class ReShadeAddonInterface
|
|||
/// <summary>Gets the active ReShade module.</summary>
|
||||
public static ProcessModule? ReShadeModule { get; private set; }
|
||||
|
||||
/// <summary>Gets all the detected ReShade modules.</summary>
|
||||
public static ImmutableArray<ProcessModule> AllReShadeModules { get; private set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the loaded ReShade has signatures.</summary>
|
||||
/// <remarks>ReShade without addon support is signed, but may not pass signature verification.</remarks>
|
||||
public static bool ReShadeIsSignedByReShade { get; private set; }
|
||||
|
||||
/// <summary>Finds the address of <c>DXGISwapChain::on_present</c> in <see cref="ReShadeModule"/>.</summary>
|
||||
/// <returns>Address of the function, or <c>0</c> if not found.</returns>
|
||||
public static nint FindReShadeDxgiSwapChainOnPresent()
|
||||
{
|
||||
if (ReShadeModule is not { } rsm)
|
||||
return 0;
|
||||
|
||||
var m = new ReadOnlySpan<byte>((void*)rsm.BaseAddress, rsm.ModuleMemorySize);
|
||||
|
||||
// Signature validated against 5.0.0 to 6.2.0
|
||||
var i = m.IndexOf(new byte[] { 0xCC, 0xF6, 0xC2, 0x01, 0x0F, 0x85 });
|
||||
if (i == -1)
|
||||
return 0;
|
||||
|
||||
return rsm.BaseAddress + i + 1;
|
||||
}
|
||||
|
||||
/// <summary>Gets the name of the signer of a file that has a certificate embedded within, without verifying if the
|
||||
/// file has a valid signature.</summary>
|
||||
/// <param name="path">Path to the file.</param>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@ namespace Dalamud.Interface.Internal.ReShadeHandling;
|
|||
/// <summary>Available handling modes for working with ReShade.</summary>
|
||||
internal enum ReShadeHandlingMode
|
||||
{
|
||||
/// <summary>Use the default method, whatever it is for the current Dalamud version.</summary>
|
||||
Default = 0,
|
||||
|
||||
/// <summary>Unwrap ReShade from the swap chain obtained from the game.</summary>
|
||||
UnwrapReShade,
|
||||
|
||||
/// <summary>Register as a ReShade addon, and draw on reshade_overlay event.</summary>
|
||||
ReShadeAddon,
|
||||
|
||||
/// <summary>Unwraps ReShade from the swap chain obtained from the game.</summary>
|
||||
UnwrapReShade,
|
||||
/// <summary>Hook <c>DXGISwapChain::on_present(UINT flags, const DXGI_PRESENT_PARAMETERS *params)</c> in
|
||||
/// <c>dxgi_swapchain.cpp</c>.</summary>
|
||||
HookReShadeDxgiSwapChainOnPresent,
|
||||
|
||||
/// <summary>Do not do anything special about it. ReShade will process Dalamud rendered stuff.</summary>
|
||||
None = -1,
|
||||
|
|
|
|||
|
|
@ -79,16 +79,28 @@ public class SettingsTabExperimental : SettingsTab
|
|||
"You may try different options to work around problems you may encounter.\nRestart is required for changes to take effect."),
|
||||
c => c.ReShadeHandlingMode,
|
||||
(v, c) => c.ReShadeHandlingMode = v,
|
||||
fallbackValue: ReShadeHandlingMode.ReShadeAddon)
|
||||
fallbackValue: ReShadeHandlingMode.ReShadeAddon,
|
||||
warning: static rshm =>
|
||||
rshm is ReShadeHandlingMode.UnwrapReShade or ReShadeHandlingMode.None
|
||||
? null
|
||||
: Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeIgnoredVTableHookMode",
|
||||
"Current option will be ignored and no special ReShade handling will be done, because SwapChain vtable hook mode is set."))
|
||||
{
|
||||
FriendlyEnumNameGetter = x => x switch
|
||||
{
|
||||
ReShadeHandlingMode.ReShadeAddon => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeReShadeAddon",
|
||||
"ReShade addon"),
|
||||
ReShadeHandlingMode.Default => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeDefault",
|
||||
"Default"),
|
||||
ReShadeHandlingMode.UnwrapReShade => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeUnwrapReShade",
|
||||
"Unwrap ReShade"),
|
||||
ReShadeHandlingMode.ReShadeAddon => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeReShadeAddon",
|
||||
"ReShade addon"),
|
||||
ReShadeHandlingMode.HookReShadeDxgiSwapChainOnPresent => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeHookReShadeDxgiSwapChainOnPresent",
|
||||
"Hook ReShade DXGISwapChain::OnPresent"),
|
||||
ReShadeHandlingMode.None => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeNone",
|
||||
"Do not handle"),
|
||||
|
|
@ -96,12 +108,18 @@ public class SettingsTabExperimental : SettingsTab
|
|||
},
|
||||
FriendlyEnumDescriptionGetter = x => x switch
|
||||
{
|
||||
ReShadeHandlingMode.ReShadeAddon => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeReShadeAddonDescription",
|
||||
"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.Default => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeDefaultDescription",
|
||||
"Dalamud will use the developer-recommend settings. If nothing's wrong, keeping this option is recommended."),
|
||||
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."),
|
||||
ReShadeHandlingMode.ReShadeAddon => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeReShadeAddonDescription",
|
||||
"Dalamud will register itself as a ReShade addon. Multi-monitor window option will require reloading ReShade every time a new window is opened, or even may not work at all."),
|
||||
ReShadeHandlingMode.HookReShadeDxgiSwapChainOnPresent => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeHookReShadeDxgiSwapChainOnPresentDescription",
|
||||
"Dalamud will use an unsupported method of detouring an internal ReShade function. Multi-monitor window option will require reloading ReShade every time a new window is opened, or even may not work at all."),
|
||||
ReShadeHandlingMode.None => Loc.Localize(
|
||||
"DalamudSettingsReShadeHandlingModeNoneDescription",
|
||||
"No special handling will be done for ReShade. Dalamud will be under the effect of ReShade postprocessing."),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue