diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4157c5f41..0a6b44eeb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,7 +45,48 @@ jobs: with: name: dalamud-artifact path: bin\Release - + + check_api_compat: + name: "Check API Compatibility" + if: ${{ github.event_name == 'pull_request' }} + needs: build + runs-on: windows-latest + steps: + - name: "Install .NET SDK" + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7 + - name: "Install ApiCompat" + run: | + dotnet tool install -g Microsoft.DotNet.ApiCompat.Tool + - name: "Download Proposed Artifacts" + uses: actions/download-artifact@v2 + with: + name: dalamud-artifact + path: .\right + - name: "Download Live (Stg) Artifacts" + run: | + Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile latest.zip + Expand-Archive -Force latest.zip "left" + - name: "Verify Compatibility" + run: | + $FILES_TO_VALIDATE = "Dalamud.dll","FFXIVClientStructs.dll","Lumina.dll","Lumina.Excel.dll" + + $retcode = 0 + + foreach ($file in $FILES_TO_VALIDATE) { + $testout = "" + Write-Output "::group::=== API COMPATIBILITY CHECK: ${file} ===" + apicompat -l "left\${file}" -r "right\${file}" | Tee-Object -Variable testout + Write-Output "::endgroup::" + if ($testout -ne "APICompat ran successfully without finding any breaking changes.") { + Write-Output "::error::${file} did not pass. Please review it for problems." + $retcode = 1 + } + } + + exit $retcode + deploy_stg: name: Deploy dalamud-distrib staging if: ${{ github.repository_owner == 'goatcorp' && github.event_name == 'push' }} diff --git a/Dalamud/Dalamud.cs b/Dalamud/Dalamud.cs index 9896b87a6..4ab617d0a 100644 --- a/Dalamud/Dalamud.cs +++ b/Dalamud/Dalamud.cs @@ -9,7 +9,6 @@ using System.Threading.Tasks; using Dalamud.Common; using Dalamud.Configuration.Internal; using Dalamud.Game; -using Dalamud.Game.Gui.Internal; using Dalamud.Interface.Internal; using Dalamud.Plugin.Internal; using Dalamud.Storage; @@ -178,7 +177,7 @@ internal sealed class Dalamud : IServiceType // this must be done before unloading interface manager, in order to do rebuild // the correct cascaded WndProc (IME -> RawDX11Scene -> Game). Otherwise the game // will not receive any windows messages - Service.GetNullable()?.Dispose(); + Service.GetNullable()?.Dispose(); // this must be done before unloading plugins, or it can cause a race condition // due to rendering happening on another thread, where a plugin might receive diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 26889ad95..727879c84 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -8,7 +8,7 @@ - 9.0.0.14 + 9.0.0.17 XIV Launcher addon framework $(DalamudVersion) $(DalamudVersion) diff --git a/Dalamud/Game/Addon/Events/AddonEventListener.cs b/Dalamud/Game/Addon/Events/AddonEventListener.cs index ceac38108..a2498d5a7 100644 --- a/Dalamud/Game/Addon/Events/AddonEventListener.cs +++ b/Dalamud/Game/Addon/Events/AddonEventListener.cs @@ -67,7 +67,10 @@ internal unsafe class AddonEventListener : IDisposable { if (node is null) return; - node->AddEvent(eventType, param, this.eventListener, (AtkResNode*)addon, false); + Service.Get().RunOnFrameworkThread(() => + { + node->AddEvent(eventType, param, this.eventListener, (AtkResNode*)addon, false); + }); } /// @@ -80,7 +83,10 @@ internal unsafe class AddonEventListener : IDisposable { if (node is null) return; - node->RemoveEvent(eventType, param, this.eventListener, false); + Service.Get().RunOnFrameworkThread(() => + { + node->RemoveEvent(eventType, param, this.eventListener, false); + }); } [UnmanagedCallersOnly] diff --git a/Dalamud/Game/Addon/Events/AddonEventManager.cs b/Dalamud/Game/Addon/Events/AddonEventManager.cs index af713a771..4231b0d09 100644 --- a/Dalamud/Game/Addon/Events/AddonEventManager.cs +++ b/Dalamud/Game/Addon/Events/AddonEventManager.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Linq; +using System.Collections.Concurrent; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; @@ -9,7 +8,6 @@ using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Services; -using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Component.GUI; @@ -26,22 +24,19 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// /// PluginName for Dalamud Internal use. /// - public const string DalamudInternalKey = "Dalamud.Internal"; + public static readonly Guid DalamudInternalKey = Guid.NewGuid(); private static readonly ModuleLog Log = new("AddonEventManager"); [ServiceManager.ServiceDependency] private readonly AddonLifecycle addonLifecycle = Service.Get(); - [ServiceManager.ServiceDependency] - private readonly Framework framework = Service.Get(); - private readonly AddonLifecycleEventListener finalizeEventListener; private readonly AddonEventManagerAddressResolver address; private readonly Hook onUpdateCursor; - private readonly List pluginEventControllers; + private readonly ConcurrentDictionary pluginEventControllers; private AddonCursorType? cursorOverride; @@ -51,10 +46,8 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType this.address = new AddonEventManagerAddressResolver(); this.address.Setup(sigScanner); - this.pluginEventControllers = new List - { - new(DalamudInternalKey), // Create entry for Dalamud's Internal Use. - }; + this.pluginEventControllers = new ConcurrentDictionary(); + this.pluginEventControllers.TryAdd(DalamudInternalKey, new PluginEventController()); this.cursorOverride = null; @@ -73,7 +66,7 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType { this.onUpdateCursor.Dispose(); - foreach (var pluginEventController in this.pluginEventControllers) + foreach (var (_, pluginEventController) in this.pluginEventControllers) { pluginEventController.Dispose(); } @@ -90,16 +83,17 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// The event type for this event. /// The handler to call when event is triggered. /// IAddonEventHandle used to remove the event. - internal IAddonEventHandle? AddEvent(string pluginId, IntPtr atkUnitBase, IntPtr atkResNode, AddonEventType eventType, IAddonEventManager.AddonEventHandler eventHandler) + internal IAddonEventHandle? AddEvent(Guid pluginId, IntPtr atkUnitBase, IntPtr atkResNode, AddonEventType eventType, IAddonEventManager.AddonEventHandler eventHandler) { - if (!ThreadSafety.IsMainThread) throw new InvalidOperationException("This should be done only from the main thread. Modifying active native code on non-main thread is not supported."); - - if (this.pluginEventControllers.FirstOrDefault(entry => entry.PluginId == pluginId) is { } eventController) + if (this.pluginEventControllers.TryGetValue(pluginId, out var controller)) { - return eventController.AddEvent(atkUnitBase, atkResNode, eventType, eventHandler); + return controller.AddEvent(atkUnitBase, atkResNode, eventType, eventHandler); + } + else + { + Log.Verbose($"Unable to locate controller for {pluginId}. No event was added."); } - Log.Verbose($"Unable to locate controller for {pluginId}. No event was added."); return null; } @@ -108,13 +102,11 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// /// Unique ID for this plugin. /// The Unique Id for this event. - internal void RemoveEvent(string pluginId, IAddonEventHandle eventHandle) + internal void RemoveEvent(Guid pluginId, IAddonEventHandle eventHandle) { - if (!ThreadSafety.IsMainThread) throw new InvalidOperationException("This should be done only from the main thread. Modifying active native code on non-main thread is not supported."); - - if (this.pluginEventControllers.FirstOrDefault(entry => entry.PluginId == pluginId) is { } eventController) + if (this.pluginEventControllers.TryGetValue(pluginId, out var controller)) { - eventController.RemoveEvent(eventHandle); + controller.RemoveEvent(eventHandle); } else { @@ -137,33 +129,28 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// Adds a new managed event controller if one doesn't already exist for this pluginId. /// /// Unique ID for this plugin. - internal void AddPluginEventController(string pluginId) + internal void AddPluginEventController(Guid pluginId) { - this.framework.RunOnFrameworkThread(() => - { - if (this.pluginEventControllers.All(entry => entry.PluginId != pluginId)) + this.pluginEventControllers.GetOrAdd( + pluginId, + key => { - Log.Verbose($"Creating new PluginEventController for: {pluginId}"); - this.pluginEventControllers.Add(new PluginEventController(pluginId)); - } - }); + Log.Verbose($"Creating new PluginEventController for: {key}"); + return new PluginEventController(); + }); } /// /// Removes an existing managed event controller for the specified plugin. /// /// Unique ID for this plugin. - internal void RemovePluginEventController(string pluginId) + internal void RemovePluginEventController(Guid pluginId) { - this.framework.RunOnFrameworkThread(() => + if (this.pluginEventControllers.TryRemove(pluginId, out var controller)) { - if (this.pluginEventControllers.FirstOrDefault(entry => entry.PluginId == pluginId) is { } controller) - { - Log.Verbose($"Removing PluginEventController for: {pluginId}"); - this.pluginEventControllers.Remove(controller); - controller.Dispose(); - } - }); + Log.Verbose($"Removing PluginEventController for: {pluginId}"); + controller.Dispose(); + } } /// @@ -178,7 +165,7 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType foreach (var pluginList in this.pluginEventControllers) { - pluginList.RemoveForAddon(addonInfo.AddonName); + pluginList.Value.RemoveForAddon(addonInfo.AddonName); } } @@ -234,7 +221,7 @@ internal class AddonEventManagerPluginScoped : IDisposable, IServiceType, IAddon { this.plugin = plugin; - this.eventManagerService.AddPluginEventController(plugin.Manifest.WorkingPluginId.ToString()); + this.eventManagerService.AddPluginEventController(plugin.Manifest.WorkingPluginId); } /// @@ -246,16 +233,16 @@ internal class AddonEventManagerPluginScoped : IDisposable, IServiceType, IAddon this.eventManagerService.ResetCursor(); } - this.eventManagerService.RemovePluginEventController(this.plugin.Manifest.WorkingPluginId.ToString()); + this.eventManagerService.RemovePluginEventController(this.plugin.Manifest.WorkingPluginId); } /// public IAddonEventHandle? AddEvent(IntPtr atkUnitBase, IntPtr atkResNode, AddonEventType eventType, IAddonEventManager.AddonEventHandler eventHandler) - => this.eventManagerService.AddEvent(this.plugin.Manifest.WorkingPluginId.ToString(), atkUnitBase, atkResNode, eventType, eventHandler); + => this.eventManagerService.AddEvent(this.plugin.Manifest.WorkingPluginId, atkUnitBase, atkResNode, eventType, eventHandler); /// public void RemoveEvent(IAddonEventHandle eventHandle) - => this.eventManagerService.RemoveEvent(this.plugin.Manifest.WorkingPluginId.ToString(), eventHandle); + => this.eventManagerService.RemoveEvent(this.plugin.Manifest.WorkingPluginId, eventHandle); /// public void SetCursor(AddonCursorType cursor) diff --git a/Dalamud/Game/Addon/Events/PluginEventController.cs b/Dalamud/Game/Addon/Events/PluginEventController.cs index 7847dd482..3ba067a6d 100644 --- a/Dalamud/Game/Addon/Events/PluginEventController.cs +++ b/Dalamud/Game/Addon/Events/PluginEventController.cs @@ -19,19 +19,11 @@ internal unsafe class PluginEventController : IDisposable /// /// Initializes a new instance of the class. /// - /// The Unique ID for this plugin. - public PluginEventController(string pluginId) + public PluginEventController() { - this.PluginId = pluginId; - this.EventListener = new AddonEventListener(this.PluginEventListHandler); } - /// - /// Gets the unique ID for this PluginEventList. - /// - public string PluginId { get; init; } - private AddonEventListener EventListener { get; init; } private List Events { get; } = new(); @@ -125,7 +117,7 @@ internal unsafe class PluginEventController : IDisposable if (this.Events.All(registeredEvent => registeredEvent.ParamKey != i)) return i; } - throw new OverflowException($"uint.MaxValue number of ParamKeys used for {this.PluginId}"); + throw new OverflowException($"uint.MaxValue number of ParamKeys used for this event controller."); } /// diff --git a/Dalamud/Game/ChatHandlers.cs b/Dalamud/Game/ChatHandlers.cs index 90a399d4c..836fb5ec8 100644 --- a/Dalamud/Game/ChatHandlers.cs +++ b/Dalamud/Game/ChatHandlers.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using CheapLoc; @@ -14,9 +15,9 @@ using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Windows; using Dalamud.Interface.Internal.Windows.PluginInstaller; +using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal; using Dalamud.Utility; -using Serilog; namespace Dalamud.Game; @@ -60,6 +61,8 @@ internal class ChatHandlers : IServiceType // { XivChatType.Echo, Color.Gray }, // }; + private static readonly ModuleLog Log = new("CHATHANDLER"); + private readonly Regex rmtRegex = new( @"4KGOLD|We have sufficient stock|VPK\.OM|[Gg]il for free|[Gg]il [Cc]heap|5GOLD|www\.so9\.com|Fast & Convenient|Cheap & Safety Guarantee|【Code|A O A U E|igfans|4KGOLD\.COM|Cheapest Gil with|pvp and bank on google|Selling Cheap GIL|ff14mogstation\.com|Cheap Gil 1000k|gilsforyou|server 1000K =|gils_selling|E A S Y\.C O M|bonus code|mins delivery guarantee|Sell cheap|Salegm\.com|cheap Mog|Off Code:|FF14Mog.com|使用する5%オ|[Oo][Ff][Ff] [Cc]ode( *)[:;]|offers Fantasia", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -110,6 +113,7 @@ internal class ChatHandlers : IServiceType private bool hasSeenLoadingMsg; private bool startedAutoUpdatingPlugins; + private CancellationTokenSource deferredAutoUpdateCts = new(); [ServiceManager.ServiceConstructor] private ChatHandlers(ChatGui chatGui) @@ -165,16 +169,19 @@ internal class ChatHandlers : IServiceType if (clientState == null) return; - if (type == XivChatType.Notice && !this.hasSeenLoadingMsg) - this.PrintWelcomeMessage(); + if (type == XivChatType.Notice) + { + if (!this.hasSeenLoadingMsg) + this.PrintWelcomeMessage(); + + if (!this.startedAutoUpdatingPlugins) + this.AutoUpdatePluginsWithRetry(); + } // For injections while logged in if (clientState.LocalPlayer != null && clientState.TerritoryType == 0 && !this.hasSeenLoadingMsg) this.PrintWelcomeMessage(); - if (!this.startedAutoUpdatingPlugins) - this.AutoUpdatePlugins(); - #if !DEBUG && false if (!this.hasSeenLoadingMsg) return; @@ -264,24 +271,42 @@ internal class ChatHandlers : IServiceType this.hasSeenLoadingMsg = true; } - private void AutoUpdatePlugins() + private void AutoUpdatePluginsWithRetry() + { + var firstAttempt = this.AutoUpdatePlugins(); + if (!firstAttempt) + { + Task.Run(() => + { + Task.Delay(30_000, this.deferredAutoUpdateCts.Token); + this.AutoUpdatePlugins(); + }); + } + } + + private bool AutoUpdatePlugins() { var chatGui = Service.GetNullable(); var pluginManager = Service.GetNullable(); var notifications = Service.GetNullable(); if (chatGui == null || pluginManager == null || notifications == null) - return; + { + Log.Warning("Aborting auto-update because a required service was not loaded."); + return false; + } if (!pluginManager.ReposReady || !pluginManager.InstalledPlugins.Any() || !pluginManager.AvailablePlugins.Any()) { // Plugins aren't ready yet. // TODO: We should retry. This sucks, because it means we won't ever get here again until another notice. - return; + Log.Warning("Aborting auto-update because plugins weren't loaded or ready."); + return false; } this.startedAutoUpdatingPlugins = true; + Log.Debug("Beginning plugin auto-update process..."); Task.Run(() => pluginManager.UpdatePluginsAsync(true, !this.configuration.AutoUpdatePlugins, true)).ContinueWith(task => { this.IsAutoUpdateComplete = true; @@ -320,5 +345,7 @@ internal class ChatHandlers : IServiceType } } }); + + return true; } } diff --git a/Dalamud/Game/Gui/Internal/DalamudIME.cs b/Dalamud/Game/Gui/Internal/DalamudIME.cs deleted file mode 100644 index a9f6991ae..000000000 --- a/Dalamud/Game/Gui/Internal/DalamudIME.cs +++ /dev/null @@ -1,301 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; - -using Dalamud.Hooking; -using Dalamud.Interface.Internal; -using Dalamud.Logging.Internal; -using ImGuiNET; -using PInvoke; - -using static Dalamud.NativeFunctions; - -namespace Dalamud.Game.Gui.Internal; - -/// -/// This class handles IME for non-English users. -/// -[ServiceManager.EarlyLoadedService] -internal unsafe class DalamudIME : IDisposable, IServiceType -{ - private static readonly ModuleLog Log = new("IME"); - - private AsmHook imguiTextInputCursorHook; - private Vector2* cursorPos; - - [ServiceManager.ServiceConstructor] - private DalamudIME() - { - } - - /// - /// Gets a value indicating whether the module is enabled. - /// - internal bool IsEnabled { get; private set; } - - /// - /// Gets the index of the first imm candidate in relation to the full list. - /// - internal CandidateList ImmCandNative { get; private set; } = default; - - /// - /// Gets the imm candidates. - /// - internal List ImmCand { get; private set; } = new(); - - /// - /// Gets the selected imm component. - /// - internal string ImmComp { get; private set; } = string.Empty; - - /// - public void Dispose() - { - this.imguiTextInputCursorHook?.Dispose(); - Marshal.FreeHGlobal((IntPtr)this.cursorPos); - } - - /// - /// Processes window messages. - /// - /// Handle of the window. - /// Type of window message. - /// wParam or the pointer to it. - /// lParam or the pointer to it. - /// Return value, if not doing further processing. - public unsafe IntPtr? ProcessWndProcW(IntPtr hWnd, User32.WindowMessage msg, void* wParamPtr, void* lParamPtr) - { - try - { - if (ImGui.GetCurrentContext() != IntPtr.Zero && ImGui.GetIO().WantTextInput) - { - var io = ImGui.GetIO(); - var wmsg = (WindowsMessage)msg; - long wParam = (long)wParamPtr, lParam = (long)lParamPtr; - try - { - wParam = Marshal.ReadInt32((IntPtr)wParamPtr); - } - catch - { - // ignored - } - - try - { - lParam = Marshal.ReadInt32((IntPtr)lParamPtr); - } - catch - { - // ignored - } - - switch (wmsg) - { - case WindowsMessage.WM_IME_NOTIFY: - switch ((IMECommand)(IntPtr)wParam) - { - case IMECommand.ChangeCandidate: - this.ToggleWindow(true); - this.LoadCand(hWnd); - break; - case IMECommand.OpenCandidate: - this.ToggleWindow(true); - this.ImmCandNative = default; - // this.ImmCand.Clear(); - break; - - case IMECommand.CloseCandidate: - this.ToggleWindow(false); - this.ImmCandNative = default; - // this.ImmCand.Clear(); - break; - - default: - break; - } - - break; - case WindowsMessage.WM_IME_COMPOSITION: - if (((long)(IMEComposition.CompStr | IMEComposition.CompAttr | IMEComposition.CompClause | - IMEComposition.CompReadAttr | IMEComposition.CompReadClause | IMEComposition.CompReadStr) & (long)(IntPtr)lParam) > 0) - { - var hIMC = ImmGetContext(hWnd); - if (hIMC == IntPtr.Zero) - return IntPtr.Zero; - - var dwSize = ImmGetCompositionStringW(hIMC, IMEComposition.CompStr, IntPtr.Zero, 0); - var unmanagedPointer = Marshal.AllocHGlobal((int)dwSize); - ImmGetCompositionStringW(hIMC, IMEComposition.CompStr, unmanagedPointer, (uint)dwSize); - - var bytes = new byte[dwSize]; - Marshal.Copy(unmanagedPointer, bytes, 0, (int)dwSize); - Marshal.FreeHGlobal(unmanagedPointer); - - var lpstr = Encoding.Unicode.GetString(bytes); - this.ImmComp = lpstr; - if (lpstr == string.Empty) - { - this.ToggleWindow(false); - } - else - { - this.LoadCand(hWnd); - } - } - - if (((long)(IntPtr)lParam & (long)IMEComposition.ResultStr) > 0) - { - var hIMC = ImmGetContext(hWnd); - if (hIMC == IntPtr.Zero) - return IntPtr.Zero; - - var dwSize = ImmGetCompositionStringW(hIMC, IMEComposition.ResultStr, IntPtr.Zero, 0); - var unmanagedPointer = Marshal.AllocHGlobal((int)dwSize); - ImmGetCompositionStringW(hIMC, IMEComposition.ResultStr, unmanagedPointer, (uint)dwSize); - - var bytes = new byte[dwSize]; - Marshal.Copy(unmanagedPointer, bytes, 0, (int)dwSize); - Marshal.FreeHGlobal(unmanagedPointer); - - var lpstr = Encoding.Unicode.GetString(bytes); - io.AddInputCharactersUTF8(lpstr); - - this.ImmComp = string.Empty; - this.ImmCandNative = default; - this.ImmCand.Clear(); - this.ToggleWindow(false); - } - - break; - - default: - break; - } - } - } - catch (Exception ex) - { - Log.Error(ex, "Prevented a crash in an IME hook"); - } - - return null; - } - - /// - /// Get the position of the cursor. - /// - /// The position of the cursor. - internal Vector2 GetCursorPos() - { - return new Vector2(this.cursorPos->X, this.cursorPos->Y); - } - - private unsafe void LoadCand(IntPtr hWnd) - { - if (hWnd == IntPtr.Zero) - return; - - var hImc = ImmGetContext(hWnd); - if (hImc == IntPtr.Zero) - return; - - var size = ImmGetCandidateListW(hImc, 0, IntPtr.Zero, 0); - if (size == 0) - return; - - var candlistPtr = Marshal.AllocHGlobal((int)size); - size = ImmGetCandidateListW(hImc, 0, candlistPtr, (uint)size); - - var candlist = this.ImmCandNative = Marshal.PtrToStructure(candlistPtr); - var pageSize = candlist.PageSize; - var candCount = candlist.Count; - - if (pageSize > 0 && candCount > 1) - { - var dwOffsets = new int[candCount]; - for (var i = 0; i < candCount; i++) - { - dwOffsets[i] = Marshal.ReadInt32(candlistPtr + ((i + 6) * sizeof(int))); - } - - var pageStart = candlist.PageStart; - - var cand = new string[pageSize]; - this.ImmCand.Clear(); - - for (var i = 0; i < pageSize; i++) - { - var offStart = dwOffsets[i + pageStart]; - var offEnd = i + pageStart + 1 < candCount ? dwOffsets[i + pageStart + 1] : size; - - var pStrStart = candlistPtr + (int)offStart; - var pStrEnd = candlistPtr + (int)offEnd; - - var len = (int)(pStrEnd.ToInt64() - pStrStart.ToInt64()); - if (len > 0) - { - var candBytes = new byte[len]; - Marshal.Copy(pStrStart, candBytes, 0, len); - - var candStr = Encoding.Unicode.GetString(candBytes); - cand[i] = candStr; - - this.ImmCand.Add(candStr); - } - } - - Marshal.FreeHGlobal(candlistPtr); - } - } - - [ServiceManager.CallWhenServicesReady("Effectively waiting for cimgui.dll to become available.")] - private void ContinueConstruction(InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene) - { - try - { - var module = Process.GetCurrentProcess().Modules.Cast().First(m => m.ModuleName == "cimgui.dll"); - var scanner = new SigScanner(module); - var cursorDrawingPtr = scanner.ScanModule("F3 0F 11 75 ?? 0F 28 CF"); - Log.Debug($"Found cursorDrawingPtr at {cursorDrawingPtr:X}"); - - this.cursorPos = (Vector2*)Marshal.AllocHGlobal(sizeof(Vector2)); - this.cursorPos->X = 0f; - this.cursorPos->Y = 0f; - - var asm = new[] - { - "use64", - $"push rax", - $"mov rax, {(IntPtr)this.cursorPos + sizeof(float)}", - $"movss [rax],xmm7", - $"mov rax, {(IntPtr)this.cursorPos}", - $"movss [rax],xmm6", - $"pop rax", - }; - - Log.Debug($"Asm Code:\n{string.Join("\n", asm)}"); - this.imguiTextInputCursorHook = new AsmHook(cursorDrawingPtr, asm, "ImguiTextInputCursorHook"); - this.imguiTextInputCursorHook?.Enable(); - - this.IsEnabled = true; - Log.Information("Enabled!"); - } - catch (Exception ex) - { - Log.Information(ex, "Enable failed"); - } - } - - private void ToggleWindow(bool visible) - { - if (visible) - Service.GetNullable()?.OpenImeWindow(); - else - Service.GetNullable()?.CloseImeWindow(); - } -} diff --git a/Dalamud/Game/Text/SeIconChar.cs b/Dalamud/Game/Text/SeIconChar.cs index c1be00613..17924c671 100644 --- a/Dalamud/Game/Text/SeIconChar.cs +++ b/Dalamud/Game/Text/SeIconChar.cs @@ -611,29 +611,51 @@ public enum SeIconChar QuestRepeatable = 0xE0BF, /// - /// The IME hiragana icon unicode character. + /// The [あ] character indicating that the Japanese IME is in full-width Hiragana input mode. /// + /// + /// Half-width Hiragana exists as a Windows API constant, but the feature is unused, or at least unexposed to the end user via the IME. + /// ImeHiragana = 0xE020, /// - /// The IME katakana icon unicode character. + /// The [ア] character indicating that the Japanese IME is in full-width Katakana input mode. /// ImeKatakana = 0xE021, /// - /// The IME alphanumeric icon unicode character. + /// The [A] character indicating that Japanese or Korean IME is in full-width Latin character input mode. /// ImeAlphanumeric = 0xE022, /// - /// The IME katakana half-width icon unicode character. + /// The [_ア] character indicating that the Japanese IME is in half-width Katakana input mode. /// ImeKatakanaHalfWidth = 0xE023, /// - /// The IME alphanumeric half-width icon unicode character. + /// The [_A] character indicating that Japanese or Korean IME is in half-width Latin character input mode. /// ImeAlphanumericHalfWidth = 0xE024, + + /// + /// The [가] character indicating that the Korean IME is in Hangul input mode. + /// + /// + /// Use and for alphanumeric input mode, + /// toggled via Alt+=. + /// + ImeKoreanHangul = 0xE025, + + /// + /// The [中] character indicating that the Chinese IME is in Han character input mode. + /// + ImeChineseHan = 0xE026, + + /// + /// The [英] character indicating that the Chinese IME is in Latin character input mode. + /// + ImeChineseLatin = 0xE027, /// /// The instance (1) icon unicode character. diff --git a/Dalamud/Hooking/WndProcHook/WndProcEventArgs.cs b/Dalamud/Hooking/WndProcHook/WndProcEventArgs.cs new file mode 100644 index 000000000..b25df5d14 --- /dev/null +++ b/Dalamud/Hooking/WndProcHook/WndProcEventArgs.cs @@ -0,0 +1,144 @@ +using System.Runtime.InteropServices; + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Hooking.WndProcHook; + +/// +/// Event arguments for , +/// and the manager for individual WndProc hook. +/// +internal sealed unsafe class WndProcEventArgs +{ + private readonly WndProcHookManager owner; + private readonly delegate* unmanaged oldWndProcW; + private readonly WndProcDelegate myWndProc; + + private GCHandle gcHandle; + private bool released; + + /// + /// Initializes a new instance of the class. + /// + /// The owner. + /// The handle of the target window of the message. + /// The viewport ID. + internal WndProcEventArgs(WndProcHookManager owner, HWND hwnd, int viewportId) + { + this.Hwnd = hwnd; + this.owner = owner; + this.ViewportId = viewportId; + this.myWndProc = this.WndProcDetour; + this.oldWndProcW = (delegate* unmanaged)SetWindowLongPtrW( + hwnd, + GWLP.GWLP_WNDPROC, + Marshal.GetFunctionPointerForDelegate(this.myWndProc)); + this.gcHandle = GCHandle.Alloc(this); + } + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate LRESULT WndProcDelegate(HWND hwnd, uint uMsg, WPARAM wParam, LPARAM lParam); + + /// + /// Gets the handle of the target window of the message. + /// + public HWND Hwnd { get; } + + /// + /// Gets the ImGui viewport ID. + /// + public int ViewportId { get; } + + /// + /// Gets or sets the message. + /// + public uint Message { get; set; } + + /// + /// Gets or sets the WPARAM. + /// + public WPARAM WParam { get; set; } + + /// + /// Gets or sets the LPARAM. + /// + public LPARAM LParam { get; set; } + + /// + /// Gets or sets a value indicating whether to suppress calling the next WndProc in the chain.
+ /// Does nothing if changed from . + ///
+ public bool SuppressCall { get; set; } + + /// + /// Gets or sets the return value.
+ /// Has the return value from next window procedure, if accessed from . + ///
+ public LRESULT ReturnValue { get; set; } + + /// + /// Sets to true and sets . + /// + /// The new return value. + public void SuppressWithValue(LRESULT returnValue) + { + this.ReturnValue = returnValue; + this.SuppressCall = true; + } + + /// + /// Sets to true and sets from the result of + /// . + /// + public void SuppressWithDefault() + { + this.ReturnValue = DefWindowProcW(this.Hwnd, this.Message, this.WParam, this.LParam); + this.SuppressCall = true; + } + + /// + internal void InternalRelease() + { + if (this.released) + return; + + this.released = true; + SendMessageW(this.Hwnd, WM.WM_NULL, 0, 0); + this.FinalRelease(); + } + + private void FinalRelease() + { + if (!this.gcHandle.IsAllocated) + return; + + this.gcHandle.Free(); + SetWindowLongPtrW(this.Hwnd, GWLP.GWLP_WNDPROC, (nint)this.oldWndProcW); + this.owner.OnHookedWindowRemoved(this); + } + + private LRESULT WndProcDetour(HWND hwnd, uint uMsg, WPARAM wParam, LPARAM lParam) + { + if (hwnd != this.Hwnd) + return CallWindowProcW(this.oldWndProcW, hwnd, uMsg, wParam, lParam); + + this.SuppressCall = false; + this.ReturnValue = 0; + this.Message = uMsg; + this.WParam = wParam; + this.LParam = lParam; + this.owner.InvokePreWndProc(this); + + if (!this.SuppressCall) + this.ReturnValue = CallWindowProcW(this.oldWndProcW, hwnd, uMsg, wParam, lParam); + + this.owner.InvokePostWndProc(this); + + if (uMsg == WM.WM_NCDESTROY || this.released) + this.FinalRelease(); + + return this.ReturnValue; + } +} diff --git a/Dalamud/Hooking/WndProcHook/WndProcEventDelegate.cs b/Dalamud/Hooking/WndProcHook/WndProcEventDelegate.cs new file mode 100644 index 000000000..f753f16cc --- /dev/null +++ b/Dalamud/Hooking/WndProcHook/WndProcEventDelegate.cs @@ -0,0 +1,7 @@ +namespace Dalamud.Hooking.WndProcHook; + +/// +/// Delegate for overriding WndProc. +/// +/// The arguments. +internal delegate void WndProcEventDelegate(WndProcEventArgs args); diff --git a/Dalamud/Hooking/WndProcHook/WndProcHookManager.cs b/Dalamud/Hooking/WndProcHook/WndProcHookManager.cs new file mode 100644 index 000000000..91020f898 --- /dev/null +++ b/Dalamud/Hooking/WndProcHook/WndProcHookManager.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.Runtime.InteropServices; + +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Hooking.WndProcHook; + +/// +/// Manages WndProc hooks for game main window and extra ImGui viewport windows. +/// +[ServiceManager.BlockingEarlyLoadedService] +internal sealed class WndProcHookManager : IServiceType, IDisposable +{ + private static readonly ModuleLog Log = new(nameof(WndProcHookManager)); + + private readonly Hook dispatchMessageWHook; + private readonly Dictionary wndProcOverrides = new(); + + private HWND mainWindowHwnd; + + [ServiceManager.ServiceConstructor] + private unsafe WndProcHookManager() + { + this.dispatchMessageWHook = Hook.FromImport( + null, + "user32.dll", + "DispatchMessageW", + 0, + this.DispatchMessageWDetour); + this.dispatchMessageWHook.Enable(); + + // Capture the game main window handle, + // so that no guarantees would have to be made on the service dispose order. + Service + .GetAsync() + .ContinueWith(r => this.mainWindowHwnd = (HWND)r.Result.Manager.GameWindowHandle); + } + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private unsafe delegate nint DispatchMessageWDelegate(MSG* msg); + + /// + /// Called before WndProc. + /// + public event WndProcEventDelegate? PreWndProc; + + /// + /// Called after WndProc. + /// + public event WndProcEventDelegate? PostWndProc; + + /// + public void Dispose() + { + if (this.dispatchMessageWHook.IsDisposed) + return; + + this.dispatchMessageWHook.Dispose(); + + // Ensure that either we're on the main thread, or DispatchMessage is executed at least once. + // The game calls DispatchMessageW only from its main thread, so if we're already on one, + // this line does nothing; if not, it will require a cycle of GetMessage ... DispatchMessageW, + // which at the point of returning from DispatchMessageW(=point of returning from SendMessageW), + // the hook would be guaranteed to be fully disabled and detour delegate would be safe to be released. + SendMessageW(this.mainWindowHwnd, WM.WM_NULL, 0, 0); + + // Now this.wndProcOverrides cannot be touched from other thread. + foreach (var v in this.wndProcOverrides.Values) + v.InternalRelease(); + this.wndProcOverrides.Clear(); + } + + /// + /// Invokes . + /// + /// The arguments. + internal void InvokePreWndProc(WndProcEventArgs args) + { + try + { + this.PreWndProc?.Invoke(args); + } + catch (Exception e) + { + Log.Error(e, $"{nameof(this.PreWndProc)} error"); + } + } + + /// + /// Invokes . + /// + /// The arguments. + internal void InvokePostWndProc(WndProcEventArgs args) + { + try + { + this.PostWndProc?.Invoke(args); + } + catch (Exception e) + { + Log.Error(e, $"{nameof(this.PostWndProc)} error"); + } + } + + /// + /// Removes from the list of known WndProc overrides. + /// + /// Object to remove. + internal void OnHookedWindowRemoved(WndProcEventArgs args) + { + if (!this.dispatchMessageWHook.IsDisposed) + this.wndProcOverrides.Remove(args.Hwnd); + } + + /// + /// Detour for . Used to discover new windows to hook. + /// + /// The message. + /// The original return value. + private unsafe nint DispatchMessageWDetour(MSG* msg) + { + if (!this.wndProcOverrides.ContainsKey(msg->hwnd) + && ImGuiHelpers.FindViewportId(msg->hwnd) is var vpid and >= 0) + { + this.wndProcOverrides[msg->hwnd] = new(this, msg->hwnd, vpid); + } + + return this.dispatchMessageWHook.Original(msg); + } +} diff --git a/Dalamud/Interface/Internal/DalamudIme.cs b/Dalamud/Interface/Internal/DalamudIme.cs new file mode 100644 index 000000000..e030b4e50 --- /dev/null +++ b/Dalamud/Interface/Internal/DalamudIme.cs @@ -0,0 +1,708 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Unicode; + +using Dalamud.Game.Text; +using Dalamud.Hooking.WndProcHook; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Utility; + +using ImGuiNET; + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Interface.Internal; + +/// +/// This class handles CJK IME. +/// +[ServiceManager.BlockingEarlyLoadedService] +internal sealed unsafe class DalamudIme : IDisposable, IServiceType +{ + private const int ImGuiContextTextStateOffset = 0x4588; + private const int CImGuiStbTextCreateUndoOffset = 0xB57A0; + private const int CImGuiStbTextUndoOffset = 0xB59C0; + + private static readonly UnicodeRange[] HanRange = + { + UnicodeRanges.CjkRadicalsSupplement, + UnicodeRanges.CjkSymbolsandPunctuation, + UnicodeRanges.CjkUnifiedIdeographsExtensionA, + UnicodeRanges.CjkUnifiedIdeographs, + UnicodeRanges.CjkCompatibilityIdeographs, + UnicodeRanges.CjkCompatibilityForms, + // No more; Extension B~ are outside BMP range + }; + + private static readonly UnicodeRange[] HangulRange = + { + UnicodeRanges.HangulJamo, + UnicodeRanges.HangulSyllables, + UnicodeRanges.HangulCompatibilityJamo, + UnicodeRanges.HangulJamoExtendedA, + UnicodeRanges.HangulJamoExtendedB, + }; + + private static readonly delegate* unmanaged + StbTextMakeUndoReplace; + + private static readonly delegate* unmanaged StbTextUndo; + + private readonly ImGuiSetPlatformImeDataDelegate setPlatformImeDataDelegate; + + private (int Start, int End, int Cursor)? temporaryUndoSelection; + + [SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1003:Symbols should be spaced correctly", Justification = ".")] + static DalamudIme() + { + nint cimgui; + try + { + _ = ImGui.GetCurrentContext(); + + cimgui = Process.GetCurrentProcess().Modules.Cast() + .First(x => x.ModuleName == "cimgui.dll") + .BaseAddress; + } + catch + { + return; + } + + StbTextMakeUndoReplace = + (delegate* unmanaged) + (cimgui + CImGuiStbTextCreateUndoOffset); + StbTextUndo = + (delegate* unmanaged) + (cimgui + CImGuiStbTextUndoOffset); + } + + [ServiceManager.ServiceConstructor] + private DalamudIme() => this.setPlatformImeDataDelegate = this.ImGuiSetPlatformImeData; + + /// + /// Finalizes an instance of the class. + /// + ~DalamudIme() => this.ReleaseUnmanagedResources(); + + private delegate void ImGuiSetPlatformImeDataDelegate(ImGuiViewportPtr viewport, ImGuiPlatformImeDataPtr data); + + /// + /// Gets a value indicating whether Han(Chinese) input has been detected. + /// + public bool EncounteredHan { get; private set; } + + /// + /// Gets a value indicating whether Hangul(Korean) input has been detected. + /// + public bool EncounteredHangul { get; private set; } + + /// + /// Gets a value indicating whether to display the cursor in input text. This also deals with blinking. + /// + internal static bool ShowCursorInInputText + { + get + { + if (!ImGuiHelpers.IsImGuiInitialized) + return true; + if (!ImGui.GetIO().ConfigInputTextCursorBlink) + return true; + var textState = TextState; + if (textState->Id == 0 || (textState->Flags & ImGuiInputTextFlags.ReadOnly) != 0) + return true; + if (textState->CursorAnim <= 0) + return true; + return textState->CursorAnim % 1.2f <= 0.8f; + } + } + + /// + /// Gets the cursor position, in screen coordinates. + /// + internal Vector2 CursorPos { get; private set; } + + /// + /// Gets the associated viewport. + /// + internal ImGuiViewportPtr AssociatedViewport { get; private set; } + + /// + /// Gets the index of the first imm candidate in relation to the full list. + /// + internal CANDIDATELIST ImmCandNative { get; private set; } + + /// + /// Gets the imm candidates. + /// + internal List ImmCand { get; private set; } = new(); + + /// + /// Gets the selected imm component. + /// + internal string ImmComp { get; private set; } = string.Empty; + + /// + /// Gets the partial conversion from-range. + /// + internal int PartialConversionFrom { get; private set; } + + /// + /// Gets the partial conversion to-range. + /// + internal int PartialConversionTo { get; private set; } + + /// + /// Gets the cursor offset in the composition string. + /// + internal int CompositionCursorOffset { get; private set; } + + /// + /// Gets a value indicating whether to display partial conversion status. + /// + internal bool ShowPartialConversion => this.PartialConversionFrom != 0 || + this.PartialConversionTo != this.ImmComp.Length; + + /// + /// Gets the input mode icon from . + /// + internal char InputModeIcon { get; private set; } + + private static ImGuiInputTextState* TextState => + (ImGuiInputTextState*)(ImGui.GetCurrentContext() + ImGuiContextTextStateOffset); + + /// + public void Dispose() + { + this.ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + /// + /// Looks for the characters inside and enables fonts accordingly. + /// + /// The string. + public void ReflectCharacterEncounters(string str) + { + foreach (var chr in str) + { + if (HanRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) + { + if (Service.Get() + .GetFdtReader(GameFontFamilyAndSize.Axis12) + ?.FindGlyph(chr) is null) + { + if (!this.EncounteredHan) + { + this.EncounteredHan = true; + Service.Get().RebuildFonts(); + } + } + } + + if (HangulRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) + { + if (!this.EncounteredHangul) + { + this.EncounteredHangul = true; + Service.Get().RebuildFonts(); + } + } + } + } + + /// + /// Processes window messages. + /// + /// The arguments. + public void ProcessImeMessage(WndProcEventArgs args) + { + if (!ImGuiHelpers.IsImGuiInitialized) + return; + + // Are we not the target of text input? + if (!ImGui.GetIO().WantTextInput) + return; + + var hImc = ImmGetContext(args.Hwnd); + if (hImc == nint.Zero) + return; + + try + { + var invalidTarget = TextState->Id == 0 || (TextState->Flags & ImGuiInputTextFlags.ReadOnly) != 0; + + switch (args.Message) + { + case WM.WM_IME_NOTIFY + when (nint)args.WParam is IMN.IMN_OPENCANDIDATE or IMN.IMN_CLOSECANDIDATE + or IMN.IMN_CHANGECANDIDATE: + this.UpdateImeWindowStatus(hImc); + args.SuppressWithValue(0); + break; + + case WM.WM_IME_STARTCOMPOSITION: + args.SuppressWithValue(0); + break; + + case WM.WM_IME_COMPOSITION: + if (invalidTarget) + ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); + else + this.ReplaceCompositionString(hImc, (uint)args.LParam); + + // Log.Verbose($"{nameof(WM.WM_IME_COMPOSITION)}({(nint)args.LParam:X}): {this.ImmComp}"); + args.SuppressWithValue(0); + break; + + case WM.WM_IME_ENDCOMPOSITION: + // Log.Verbose($"{nameof(WM.WM_IME_ENDCOMPOSITION)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + args.SuppressWithValue(0); + break; + + case WM.WM_IME_CONTROL: + // Log.Verbose($"{nameof(WM.WM_IME_CONTROL)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + args.SuppressWithValue(0); + break; + + case WM.WM_IME_REQUEST: + // Log.Verbose($"{nameof(WM.WM_IME_REQUEST)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + args.SuppressWithValue(0); + break; + + case WM.WM_IME_SETCONTEXT: + // Hide candidate and composition windows. + args.LParam = (LPARAM)((nint)args.LParam & ~(ISC_SHOWUICOMPOSITIONWINDOW | 0xF)); + + // Log.Verbose($"{nameof(WM.WM_IME_SETCONTEXT)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + args.SuppressWithDefault(); + break; + + case WM.WM_IME_NOTIFY: + // Log.Verbose($"{nameof(WM.WM_IME_NOTIFY)}({(nint)args.WParam:X}): {this.ImmComp}"); + break; + + case WM.WM_KEYDOWN when (int)args.WParam is + VK.VK_TAB + or VK.VK_PRIOR + or VK.VK_NEXT + or VK.VK_END + or VK.VK_HOME + or VK.VK_LEFT + or VK.VK_UP + or VK.VK_RIGHT + or VK.VK_DOWN + or VK.VK_RETURN: + if (this.ImmCand.Count != 0) + { + this.ClearState(hImc); + args.WParam = VK.VK_PROCESSKEY; + } + + break; + + case WM.WM_LBUTTONDOWN: + case WM.WM_RBUTTONDOWN: + case WM.WM_MBUTTONDOWN: + case WM.WM_XBUTTONDOWN: + ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_COMPLETE, 0); + break; + } + + this.UpdateInputLanguage(hImc); + } + finally + { + ImmReleaseContext(args.Hwnd, hImc); + } + } + + private static string ImmGetCompositionString(HIMC hImc, uint comp) + { + var numBytes = ImmGetCompositionStringW(hImc, comp, null, 0); + if (numBytes == 0) + return string.Empty; + + var data = stackalloc char[numBytes / 2]; + _ = ImmGetCompositionStringW(hImc, comp, data, (uint)numBytes); + return new(data, 0, numBytes / 2); + } + + private void ReleaseUnmanagedResources() + { + if (ImGuiHelpers.IsImGuiInitialized) + ImGui.GetIO().SetPlatformImeDataFn = nint.Zero; + } + + private void UpdateInputLanguage(HIMC hImc) + { + uint conv, sent; + ImmGetConversionStatus(hImc, &conv, &sent); + var lang = GetKeyboardLayout(0); + var open = ImmGetOpenStatus(hImc) != false; + + // Log.Verbose($"{nameof(this.UpdateInputLanguage)}: conv={conv:X} sent={sent:X} open={open} lang={lang:X}"); + + var native = (conv & 1) != 0; + var katakana = (conv & 2) != 0; + var fullwidth = (conv & 8) != 0; + switch (lang & 0x3F) + { + case LANG.LANG_KOREAN: + if (native) + this.InputModeIcon = (char)SeIconChar.ImeKoreanHangul; + else if (fullwidth) + this.InputModeIcon = (char)SeIconChar.ImeAlphanumeric; + else + this.InputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; + break; + + case LANG.LANG_JAPANESE: + // wtf + // see the function called from: 48 8b 0d ?? ?? ?? ?? e8 ?? ?? ?? ?? 8b d8 e9 ?? 00 00 0 + if (open && native && katakana && fullwidth) + this.InputModeIcon = (char)SeIconChar.ImeKatakana; + else if (open && native && katakana) + this.InputModeIcon = (char)SeIconChar.ImeKatakanaHalfWidth; + else if (open && native) + this.InputModeIcon = (char)SeIconChar.ImeHiragana; + else if (open && fullwidth) + this.InputModeIcon = (char)SeIconChar.ImeAlphanumeric; + else + this.InputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; + break; + + case LANG.LANG_CHINESE: + if (native) + this.InputModeIcon = (char)SeIconChar.ImeChineseHan; + else + this.InputModeIcon = (char)SeIconChar.ImeChineseLatin; + break; + + default: + this.InputModeIcon = default; + break; + } + + this.UpdateImeWindowStatus(hImc); + } + + private void ReplaceCompositionString(HIMC hImc, uint comp) + { + var finalCommit = (comp & GCS.GCS_RESULTSTR) != 0; + var newString = finalCommit + ? ImmGetCompositionString(hImc, GCS.GCS_RESULTSTR) + : ImmGetCompositionString(hImc, GCS.GCS_COMPSTR); + + this.ReflectCharacterEncounters(newString); + + if (this.temporaryUndoSelection is not null) + { + TextState->Undo(); + TextState->SelectionTuple = this.temporaryUndoSelection.Value; + this.temporaryUndoSelection = null; + } + + TextState->SanitizeSelectionRange(); + if (TextState->ReplaceSelectionAndPushUndo(newString)) + this.temporaryUndoSelection = TextState->SelectionTuple; + + // Put the cursor at the beginning, so that the candidate window appears aligned with the text. + TextState->SetSelectionRange(TextState->SelectionTuple.Start, newString.Length, 0); + + if (finalCommit) + { + this.ClearState(hImc); + return; + } + + this.ImmComp = newString; + this.CompositionCursorOffset = ImmGetCompositionStringW(hImc, GCS.GCS_CURSORPOS, null, 0); + + if ((comp & GCS.GCS_COMPATTR) != 0) + { + var attrLength = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, null, 0); + var attrPtr = stackalloc byte[attrLength]; + var attr = new Span(attrPtr, Math.Min(this.ImmComp.Length, attrLength)); + _ = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, attrPtr, (uint)attrLength); + var l = 0; + while (l < attr.Length && attr[l] is not ATTR_TARGET_CONVERTED and not ATTR_TARGET_NOTCONVERTED) + l++; + + var r = l; + while (r < attr.Length && attr[r] is ATTR_TARGET_CONVERTED or ATTR_TARGET_NOTCONVERTED) + r++; + + if (r == 0 || l == this.ImmComp.Length) + (l, r) = (0, this.ImmComp.Length); + + (this.PartialConversionFrom, this.PartialConversionTo) = (l, r); + } + else + { + this.PartialConversionFrom = 0; + this.PartialConversionTo = this.ImmComp.Length; + } + + this.UpdateImeWindowStatus(hImc); + } + + private void ClearState(HIMC hImc) + { + this.ImmComp = string.Empty; + this.PartialConversionFrom = this.PartialConversionTo = 0; + this.CompositionCursorOffset = 0; + this.temporaryUndoSelection = null; + TextState->Stb.SelectStart = TextState->Stb.Cursor = TextState->Stb.SelectEnd; + ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); + this.UpdateImeWindowStatus(default); + + // Log.Information($"{nameof(this.ClearState)}"); + } + + private void LoadCand(HIMC hImc) + { + this.ImmCand.Clear(); + this.ImmCandNative = default; + + if (hImc == default) + return; + + var size = (int)ImmGetCandidateListW(hImc, 0, null, 0); + if (size == 0) + return; + + var pStorage = stackalloc byte[size]; + if (size != ImmGetCandidateListW(hImc, 0, (CANDIDATELIST*)pStorage, (uint)size)) + return; + + ref var candlist = ref *(CANDIDATELIST*)pStorage; + this.ImmCandNative = candlist; + + if (candlist.dwPageSize == 0 || candlist.dwCount == 0) + return; + + foreach (var i in Enumerable.Range( + (int)candlist.dwPageStart, + (int)Math.Min(candlist.dwCount - candlist.dwPageStart, candlist.dwPageSize))) + { + this.ImmCand.Add(new((char*)(pStorage + candlist.dwOffset[i]))); + this.ReflectCharacterEncounters(this.ImmCand[^1]); + } + } + + private void UpdateImeWindowStatus(HIMC hImc) + { + if (Service.GetNullable() is not { } di) + return; + + this.LoadCand(hImc); + if (this.ImmCand.Count != 0 || this.ShowPartialConversion || this.InputModeIcon != default) + di.OpenImeWindow(); + else + di.CloseImeWindow(); + } + + private void ImGuiSetPlatformImeData(ImGuiViewportPtr viewport, ImGuiPlatformImeDataPtr data) + { + this.CursorPos = data.InputPos; + this.AssociatedViewport = data.WantVisible ? viewport : default; + } + + [ServiceManager.CallWhenServicesReady("Effectively waiting for cimgui context initialization.")] + private void ContinueConstruction(InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene) + { + if (!ImGuiHelpers.IsImGuiInitialized) + { + throw new InvalidOperationException( + $"Expected {nameof(InterfaceManager.InterfaceManagerWithScene)} to have initialized ImGui."); + } + + ImGui.GetIO().SetPlatformImeDataFn = Marshal.GetFunctionPointerForDelegate(this.setPlatformImeDataDelegate); + } + + /// + /// Ported from imstb_textedit.h. + /// + [StructLayout(LayoutKind.Sequential, Size = 0xE2C)] + private struct StbTextEditState + { + /// + /// Position of the text cursor within the string. + /// + public int Cursor; + + /// + /// Selection start point. + /// + public int SelectStart; + + /// + /// selection start and end point in characters; if equal, no selection. + /// + /// + /// Note that start may be less than or greater than end (e.g. when dragging the mouse, + /// start is where the initial click was, and you can drag in either direction.) + /// + public int SelectEnd; + + /// + /// Each text field keeps its own insert mode state. + /// To keep an app-wide insert mode, copy this value in/out of the app state. + /// + public byte InsertMode; + + /// + /// Page size in number of row. + /// This value MUST be set to >0 for pageup or pagedown in multilines documents. + /// + public int RowCountPerPage; + + // Remainder is stb-private data. + } + + [StructLayout(LayoutKind.Sequential)] + private struct ImGuiInputTextState + { + public uint Id; + public int CurLenW; + public int CurLenA; + public ImVector TextWRaw; + public ImVector TextARaw; + public ImVector InitialTextARaw; + public bool TextAIsValid; + public int BufCapacityA; + public float ScrollX; + public StbTextEditState Stb; + public float CursorAnim; + public bool CursorFollow; + public bool SelectedAllMouseLock; + public bool Edited; + public ImGuiInputTextFlags Flags; + + public ImVectorWrapper TextW => new((ImVector*)&this.ThisPtr->TextWRaw); + + public (int Start, int End, int Cursor) SelectionTuple + { + get => (this.Stb.SelectStart, this.Stb.SelectEnd, this.Stb.Cursor); + set => (this.Stb.SelectStart, this.Stb.SelectEnd, this.Stb.Cursor) = value; + } + + private ImGuiInputTextState* ThisPtr => (ImGuiInputTextState*)Unsafe.AsPointer(ref this); + + public void SetSelectionRange(int offset, int length, int relativeCursorOffset) + { + this.Stb.SelectStart = offset; + this.Stb.SelectEnd = offset + length; + if (relativeCursorOffset >= 0) + this.Stb.Cursor = this.Stb.SelectStart + relativeCursorOffset; + else + this.Stb.Cursor = this.Stb.SelectEnd + 1 + relativeCursorOffset; + this.SanitizeSelectionRange(); + } + + public void SanitizeSelectionRange() + { + ref var s = ref this.Stb.SelectStart; + ref var e = ref this.Stb.SelectEnd; + ref var c = ref this.Stb.Cursor; + s = Math.Clamp(s, 0, this.CurLenW); + e = Math.Clamp(e, 0, this.CurLenW); + c = Math.Clamp(c, 0, this.CurLenW); + if (s == e) + s = e = c; + if (s > e) + (s, e) = (e, s); + } + + public void Undo() => StbTextUndo(this.ThisPtr, &this.ThisPtr->Stb); + + public bool MakeUndoReplace(int offset, int oldLength, int newLength) + { + if (oldLength == 0 && newLength == 0) + return false; + + StbTextMakeUndoReplace(this.ThisPtr, &this.ThisPtr->Stb, offset, oldLength, newLength); + return true; + } + + public bool ReplaceSelectionAndPushUndo(ReadOnlySpan newText) + { + var off = this.Stb.SelectStart; + var len = this.Stb.SelectEnd - this.Stb.SelectStart; + return this.MakeUndoReplace(off, len, newText.Length) && this.ReplaceChars(off, len, newText); + } + + public bool ReplaceChars(int pos, int len, ReadOnlySpan newText) + { + this.DeleteChars(pos, len); + return this.InsertChars(pos, newText); + } + + // See imgui_widgets.cpp: STB_TEXTEDIT_DELETECHARS + public void DeleteChars(int pos, int n) + { + if (n == 0) + return; + + var dst = this.TextW.Data + pos; + + // We maintain our buffer length in both UTF-8 and wchar formats + this.Edited = true; + this.CurLenA -= Encoding.UTF8.GetByteCount(dst, n); + this.CurLenW -= n; + + // Offset remaining text (FIXME-OPT: Use memmove) + var src = this.TextW.Data + pos + n; + int i; + for (i = 0; src[i] != 0; i++) + dst[i] = src[i]; + dst[i] = '\0'; + } + + // See imgui_widgets.cpp: STB_TEXTEDIT_INSERTCHARS + public bool InsertChars(int pos, ReadOnlySpan newText) + { + if (newText.Length == 0) + return true; + + var isResizable = (this.Flags & ImGuiInputTextFlags.CallbackResize) != 0; + var textLen = this.CurLenW; + Debug.Assert(pos <= textLen, "pos <= text_len"); + + var newTextLenUtf8 = Encoding.UTF8.GetByteCount(newText); + if (!isResizable && newTextLenUtf8 + this.CurLenA + 1 > this.BufCapacityA) + return false; + + // Grow internal buffer if needed + if (newText.Length + textLen + 1 > this.TextW.Length) + { + if (!isResizable) + return false; + + Debug.Assert(textLen < this.TextW.Length, "text_len < this.TextW.Length"); + this.TextW.Resize(textLen + Math.Clamp(newText.Length * 4, 32, Math.Max(256, newText.Length)) + 1); + } + + var text = this.TextW.DataSpan; + if (pos != textLen) + text.Slice(pos, textLen - pos).CopyTo(text[(pos + newText.Length)..]); + newText.CopyTo(text[pos..]); + + this.Edited = true; + this.CurLenW += newText.Length; + this.CurLenA += newTextLenUtf8; + this.TextW[this.CurLenW] = '\0'; + + return true; + } + } +} diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs index 1dcc5c0c7..95415659b 100644 --- a/Dalamud/Interface/Internal/DalamudInterface.cs +++ b/Dalamud/Interface/Internal/DalamudInterface.cs @@ -59,7 +59,7 @@ internal class DalamudInterface : IDisposable, IServiceType private readonly ComponentDemoWindow componentDemoWindow; private readonly DataWindow dataWindow; private readonly GamepadModeNotifierWindow gamepadModeNotifierWindow; - private readonly ImeWindow imeWindow; + private readonly DalamudImeWindow imeWindow; private readonly ConsoleWindow consoleWindow; private readonly PluginStatWindow pluginStatWindow; private readonly PluginInstallerWindow pluginWindow; @@ -111,7 +111,7 @@ internal class DalamudInterface : IDisposable, IServiceType this.componentDemoWindow = new ComponentDemoWindow() { IsOpen = false }; this.dataWindow = new DataWindow() { IsOpen = false }; this.gamepadModeNotifierWindow = new GamepadModeNotifierWindow() { IsOpen = false }; - this.imeWindow = new ImeWindow() { IsOpen = false }; + this.imeWindow = new DalamudImeWindow() { IsOpen = false }; this.consoleWindow = new ConsoleWindow(configuration) { IsOpen = configuration.LogOpenAtStartup }; this.pluginStatWindow = new PluginStatWindow() { IsOpen = false }; this.pluginWindow = new PluginInstallerWindow(pluginImageCache, configuration) { IsOpen = false }; @@ -256,7 +256,7 @@ internal class DalamudInterface : IDisposable, IServiceType public void OpenGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.IsOpen = true; /// - /// Opens the . + /// Opens the . /// public void OpenImeWindow() => this.imeWindow.IsOpen = true; @@ -356,7 +356,7 @@ internal class DalamudInterface : IDisposable, IServiceType #region Close /// - /// Closes the . + /// Closes the . /// public void CloseImeWindow() => this.imeWindow.IsOpen = false; @@ -408,7 +408,7 @@ internal class DalamudInterface : IDisposable, IServiceType public void ToggleGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.Toggle(); /// - /// Toggles the . + /// Toggles the . /// public void ToggleImeWindow() => this.imeWindow.Toggle(); diff --git a/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs b/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs index fd07d824f..1746fb1c4 100644 --- a/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs +++ b/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs @@ -131,6 +131,7 @@ internal sealed unsafe class ImGuiClipboardFunctionProvider : IServiceType, IDis ptr[str.Length] = default; GlobalUnlock(hMem); + EmptyClipboard(); SetClipboardData(CF.CF_UNICODETEXT, hMem); } catch (Exception e) @@ -158,9 +159,9 @@ internal sealed unsafe class ImGuiClipboardFunctionProvider : IServiceType, IDis return this.clipboardData.Data; } + var hMem = (HGLOBAL)GetClipboardData(CF.CF_UNICODETEXT); try { - var hMem = (HGLOBAL)GetClipboardData(CF.CF_UNICODETEXT); if (hMem != default) { var ptr = (char*)GlobalLock(hMem); @@ -191,6 +192,8 @@ internal sealed unsafe class ImGuiClipboardFunctionProvider : IServiceType, IDis } finally { + if (hMem != default) + GlobalUnlock(hMem); CloseClipboard(); } diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index 1b12fd853..48157fa86 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -6,15 +6,16 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using System.Text.Unicode; using System.Threading; using Dalamud.Configuration.Internal; using Dalamud.Game; using Dalamud.Game.ClientState.GamePad; using Dalamud.Game.ClientState.Keys; -using Dalamud.Game.Gui.Internal; using Dalamud.Game.Internal.DXGI; using Dalamud.Hooking; +using Dalamud.Hooking.WndProcHook; using Dalamud.Interface.GameFonts; using Dalamud.Interface.Internal.ManagedAsserts; using Dalamud.Interface.Internal.Notifications; @@ -73,12 +74,16 @@ internal class InterfaceManager : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly Framework framework = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly WndProcHookManager wndProcHookManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly DalamudIme dalamudIme = Service.Get(); private readonly ManualResetEvent fontBuildSignal; private readonly SwapChainVtableResolver address; - private readonly Hook dispatchMessageWHook; private readonly Hook setCursorHook; - private Hook processMessageHook; private RawDX11Scene? scene; private Hook? presentHook; @@ -92,8 +97,6 @@ internal class InterfaceManager : IDisposable, IServiceType [ServiceManager.ServiceConstructor] private InterfaceManager() { - this.dispatchMessageWHook = Hook.FromImport( - null, "user32.dll", "DispatchMessageW", 0, this.DispatchMessageWDetour); this.setCursorHook = Hook.FromImport( null, "user32.dll", "SetCursor", 0, this.SetCursorDetour); @@ -111,12 +114,6 @@ internal class InterfaceManager : IDisposable, IServiceType [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate IntPtr SetCursorDelegate(IntPtr hCursor); - [UnmanagedFunctionPointer(CallingConvention.StdCall)] - private delegate IntPtr DispatchMessageWDelegate(ref User32.MSG msg); - - [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate IntPtr ProcessMessageDelegate(IntPtr hWnd, uint msg, ulong wParam, ulong lParam, IntPtr handeled); - /// /// This event gets called each frame to facilitate ImGui drawing. /// @@ -236,10 +233,9 @@ internal class InterfaceManager : IDisposable, IServiceType this.setCursorHook.Dispose(); this.presentHook?.Dispose(); this.resizeBuffersHook?.Dispose(); - this.dispatchMessageWHook.Dispose(); - this.processMessageHook?.Dispose(); }).Wait(); + this.wndProcHookManager.PreWndProc -= this.WndProcHookManagerOnPreWndProc; this.scene?.Dispose(); } @@ -660,6 +656,17 @@ internal class InterfaceManager : IDisposable, IServiceType this.scene = newScene; Service.Provide(new(this)); + + this.wndProcHookManager.PreWndProc += this.WndProcHookManagerOnPreWndProc; + } + + private unsafe void WndProcHookManagerOnPreWndProc(WndProcEventArgs args) + { + var r = this.scene?.ProcessWndProcW(args.Hwnd, (User32.WindowMessage)args.Message, args.WParam, args.LParam); + if (r is not null) + args.SuppressWithValue(r.Value); + + this.dalamudIme.ProcessImeMessage(args); } /* @@ -778,10 +785,22 @@ internal class InterfaceManager : IDisposable, IServiceType var fontPathKr = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansCJKkr-Regular.otf"); if (!File.Exists(fontPathKr)) fontPathKr = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansKR-Regular.otf"); + if (!File.Exists(fontPathKr)) + fontPathKr = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts", "malgun.ttf"); if (!File.Exists(fontPathKr)) fontPathKr = null; Log.Verbose("[FONT] fontPathKr = {0}", fontPathKr); + var fontPathChs = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts", "msyh.ttc"); + if (!File.Exists(fontPathChs)) + fontPathChs = null; + Log.Verbose("[FONT] fontPathChs = {0}", fontPathChs); + + var fontPathCht = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts", "msjh.ttc"); + if (!File.Exists(fontPathCht)) + fontPathCht = null; + Log.Verbose("[FONT] fontPathChs = {0}", fontPathCht); + // Default font Log.Verbose("[FONT] SetupFonts - Default font"); var fontInfo = new TargetFontModification( @@ -809,7 +828,8 @@ internal class InterfaceManager : IDisposable, IServiceType this.loadedFontInfo[DefaultFont] = fontInfo; } - if (fontPathKr != null && Service.Get().EffectiveLanguage == "ko") + if (fontPathKr != null + && (Service.Get().EffectiveLanguage == "ko" || this.dalamudIme.EncounteredHangul)) { fontConfig.MergeMode = true; fontConfig.GlyphRanges = ioFonts.GetGlyphRangesKorean(); @@ -818,6 +838,46 @@ internal class InterfaceManager : IDisposable, IServiceType fontConfig.MergeMode = false; } + if (fontPathCht != null && Service.Get().EffectiveLanguage == "tw") + { + fontConfig.MergeMode = true; + var rangeHandle = GCHandle.Alloc(new ushort[] + { + (ushort)UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint, + (ushort)(UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint + + (UnicodeRanges.CjkUnifiedIdeographs.Length - 1)), + (ushort)UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint, + (ushort)(UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint + + (UnicodeRanges.CjkUnifiedIdeographsExtensionA.Length - 1)), + 0, + }, GCHandleType.Pinned); + garbageList.Add(rangeHandle); + fontConfig.GlyphRanges = rangeHandle.AddrOfPinnedObject(); + fontConfig.PixelSnapH = true; + ioFonts.AddFontFromFileTTF(fontPathCht, fontConfig.SizePixels, fontConfig); + fontConfig.MergeMode = false; + } + else if (fontPathChs != null && (Service.Get().EffectiveLanguage == "zh" + || this.dalamudIme.EncounteredHan)) + { + fontConfig.MergeMode = true; + var rangeHandle = GCHandle.Alloc(new ushort[] + { + (ushort)UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint, + (ushort)(UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint + + (UnicodeRanges.CjkUnifiedIdeographs.Length - 1)), + (ushort)UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint, + (ushort)(UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint + + (UnicodeRanges.CjkUnifiedIdeographsExtensionA.Length - 1)), + 0, + }, GCHandleType.Pinned); + garbageList.Add(rangeHandle); + fontConfig.GlyphRanges = rangeHandle.AddrOfPinnedObject(); + fontConfig.PixelSnapH = true; + ioFonts.AddFontFromFileTTF(fontPathChs, fontConfig.SizePixels, fontConfig); + fontConfig.MergeMode = false; + } + // FontAwesome icon font Log.Verbose("[FONT] SetupFonts - FontAwesome icon font"); { @@ -1095,15 +1155,9 @@ internal class InterfaceManager : IDisposable, IServiceType Log.Verbose($"Present address 0x{this.presentHook!.Address.ToInt64():X}"); Log.Verbose($"ResizeBuffers address 0x{this.resizeBuffersHook!.Address.ToInt64():X}"); - var wndProcAddress = sigScanner.ScanText("E8 ?? ?? ?? ?? 80 7C 24 ?? ?? 74 ?? B8"); - Log.Verbose($"WndProc address 0x{wndProcAddress.ToInt64():X}"); - this.processMessageHook = Hook.FromAddress(wndProcAddress, this.ProcessMessageDetour); - this.setCursorHook.Enable(); this.presentHook.Enable(); this.resizeBuffersHook.Enable(); - this.dispatchMessageWHook.Enable(); - this.processMessageHook.Enable(); }); } @@ -1124,25 +1178,6 @@ internal class InterfaceManager : IDisposable, IServiceType this.isRebuildingFonts = false; } - private unsafe IntPtr ProcessMessageDetour(IntPtr hWnd, uint msg, ulong wParam, ulong lParam, IntPtr handeled) - { - var ime = Service.GetNullable(); - var res = ime?.ProcessWndProcW(hWnd, (User32.WindowMessage)msg, (void*)wParam, (void*)lParam); - return this.processMessageHook.Original(hWnd, msg, wParam, lParam, handeled); - } - - private unsafe IntPtr DispatchMessageWDetour(ref User32.MSG msg) - { - if (msg.hwnd == this.GameWindowHandle && this.scene != null) - { - var res = this.scene.ProcessWndProcW(msg.hwnd, msg.message, (void*)msg.wParam, (void*)msg.lParam); - if (res != null) - return res.Value; - } - - return this.dispatchMessageWHook.IsDisposed ? User32.DispatchMessage(ref msg) : this.dispatchMessageWHook.Original(ref msg); - } - private IntPtr ResizeBuffersDetour(IntPtr swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags) { #if DEBUG diff --git a/Dalamud/Interface/Internal/TextureManager.cs b/Dalamud/Interface/Internal/TextureManager.cs index 40aa72913..9f90ea1ad 100644 --- a/Dalamud/Interface/Internal/TextureManager.cs +++ b/Dalamud/Interface/Internal/TextureManager.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Numerics; @@ -273,7 +273,10 @@ internal class TextureManager : IDisposable, IServiceType, ITextureProvider, ITe this.fallbackTextureWrap?.Dispose(); this.framework.Update -= this.FrameworkOnUpdate; - Log.Verbose("Disposing {Num} left behind textures."); + if (this.activeTextures.Count == 0) + return; + + Log.Verbose("Disposing {Num} left behind textures.", this.activeTextures.Count); foreach (var activeTexture in this.activeTextures) { diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index 89dd153cc..53821d9df 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -39,6 +39,7 @@ internal class ConsoleWindow : Window, IDisposable private string commandText = string.Empty; private string textFilter = string.Empty; private string selectedSource = "DalamudInternal"; + private string pluginFilter = string.Empty; private bool filterShowUncaughtExceptions; private bool showFilterToolbar; @@ -149,7 +150,7 @@ internal class ConsoleWindow : Window, IDisposable { const string regexErrorString = "Regex Filter Error"; ImGui.SetCursorPosX(ImGui.GetContentRegionMax().X / 2.0f - ImGui.CalcTextSize(regexErrorString).X / 2.0f); - ImGui.TextColored(KnownColor.OrangeRed.Vector(), regexErrorString); + ImGui.TextColored(ImGuiColors.DalamudRed, regexErrorString); } ImGui.BeginChild("scrolling", new Vector2(0, ImGui.GetFrameHeightWithSpacing() - 55 * ImGuiHelpers.GlobalScale), false, ImGuiWindowFlags.AlwaysHorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar); @@ -475,14 +476,24 @@ internal class ConsoleWindow : Window, IDisposable ImGui.TableNextColumn(); ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); - if (ImGui.BeginCombo("##Sources", this.selectedSource)) + if (ImGui.BeginCombo("##Sources", this.selectedSource, ImGuiComboFlags.HeightLarge)) { var sourceNames = Service.Get().InstalledPlugins .Select(p => p.Manifest.InternalName) .OrderBy(s => s) .Prepend("DalamudInternal") + .Where(name => this.pluginFilter is "" || new FuzzyMatcher(this.pluginFilter.ToLowerInvariant(), MatchMode.Fuzzy).Matches(name.ToLowerInvariant()) != 0) .ToList(); + ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); + ImGui.InputTextWithHint("##PluginSearchFilter", "Filter Plugin List", ref this.pluginFilter, 2048); + ImGui.Separator(); + + if (!sourceNames.Any()) + { + ImGui.TextColored(ImGuiColors.DalamudRed, "No Results"); + } + foreach (var selectable in sourceNames) { if (ImGui.Selectable(selectable, this.selectedSource == selectable)) diff --git a/Dalamud/Interface/Internal/Windows/DalamudImeWindow.cs b/Dalamud/Interface/Internal/Windows/DalamudImeWindow.cs new file mode 100644 index 000000000..ecaa522e5 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/DalamudImeWindow.cs @@ -0,0 +1,266 @@ +using System.Numerics; + +using Dalamud.Interface.Windowing; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows; + +/// +/// A window for displaying IME details. +/// +internal unsafe class DalamudImeWindow : Window +{ + private const int ImePageSize = 9; + + /// + /// Initializes a new instance of the class. + /// + public DalamudImeWindow() + : base( + "Dalamud IME", + ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoBackground) + { + this.Size = default(Vector2); + + this.RespectCloseHotkey = false; + } + + /// + public override void Draw() + { + } + + /// + public override void PostDraw() + { + if (Service.GetNullable() is not { } ime) + return; + + var viewport = ime.AssociatedViewport; + if (viewport.NativePtr is null) + return; + + var drawCand = ime.ImmCand.Count != 0; + var drawConv = drawCand || ime.ShowPartialConversion; + var drawIme = ime.InputModeIcon != 0; + var imeIconFont = InterfaceManager.DefaultFont; + + var pad = ImGui.GetStyle().WindowPadding; + var candTextSize = ImGui.CalcTextSize(ime.ImmComp == string.Empty ? " " : ime.ImmComp); + + var native = ime.ImmCandNative; + var totalIndex = native.dwSelection + 1; + var totalSize = native.dwCount; + + var pageStart = native.dwPageStart; + var pageIndex = (pageStart / ImePageSize) + 1; + var pageCount = (totalSize / ImePageSize) + 1; + var pageInfo = $"{totalIndex}/{totalSize} ({pageIndex}/{pageCount})"; + + // Calc the window size. + var maxTextWidth = 0f; + for (var i = 0; i < ime.ImmCand.Count; i++) + { + var textSize = ImGui.CalcTextSize($"{i + 1}. {ime.ImmCand[i]}"); + maxTextWidth = maxTextWidth > textSize.X ? maxTextWidth : textSize.X; + } + + maxTextWidth = maxTextWidth > ImGui.CalcTextSize(pageInfo).X ? maxTextWidth : ImGui.CalcTextSize(pageInfo).X; + maxTextWidth = maxTextWidth > ImGui.CalcTextSize(ime.ImmComp).X + ? maxTextWidth + : ImGui.CalcTextSize(ime.ImmComp).X; + + var numEntries = (drawCand ? ime.ImmCand.Count + 1 : 0) + 1 + (drawIme ? 1 : 0); + var spaceY = ImGui.GetStyle().ItemSpacing.Y; + var imeWindowHeight = (spaceY * (numEntries - 1)) + (candTextSize.Y * numEntries); + var windowSize = new Vector2(maxTextWidth, imeWindowHeight) + (pad * 2); + + // 1. Figure out the expanding direction. + var expandUpward = ime.CursorPos.Y + windowSize.Y > viewport.WorkPos.Y + viewport.WorkSize.Y; + var windowPos = ime.CursorPos - pad; + if (expandUpward) + { + windowPos.Y -= windowSize.Y - candTextSize.Y - (pad.Y * 2); + if (drawIme) + windowPos.Y += candTextSize.Y + spaceY; + } + else + { + if (drawIme) + windowPos.Y -= candTextSize.Y + spaceY; + } + + // 2. Contain within the viewport. Do not use clamp, as the target window might be too small. + if (windowPos.X < viewport.WorkPos.X) + windowPos.X = viewport.WorkPos.X; + else if (windowPos.X + windowSize.X > viewport.WorkPos.X + viewport.WorkSize.X) + windowPos.X = (viewport.WorkPos.X + viewport.WorkSize.X) - windowSize.X; + if (windowPos.Y < viewport.WorkPos.Y) + windowPos.Y = viewport.WorkPos.Y; + else if (windowPos.Y + windowSize.Y > viewport.WorkPos.Y + viewport.WorkSize.Y) + windowPos.Y = (viewport.WorkPos.Y + viewport.WorkSize.Y) - windowSize.Y; + + var cursor = windowPos + pad; + + // Draw the ime window. + var drawList = ImGui.GetForegroundDrawList(viewport); + + // Draw the background rect for candidates. + if (drawCand) + { + Vector2 candRectLt, candRectRb; + if (!expandUpward) + { + candRectLt = windowPos + candTextSize with { X = 0 } + pad with { X = 0 }; + candRectRb = windowPos + windowSize; + if (drawIme) + candRectLt.Y += spaceY + candTextSize.Y; + } + else + { + candRectLt = windowPos; + candRectRb = windowPos + (windowSize - candTextSize with { X = 0 } - pad with { X = 0 }); + if (drawIme) + candRectRb.Y -= spaceY + candTextSize.Y; + } + + drawList.AddRectFilled( + candRectLt, + candRectRb, + ImGui.GetColorU32(ImGuiCol.WindowBg), + ImGui.GetStyle().WindowRounding); + } + + if (!expandUpward && drawIme) + { + for (var dx = -2; dx <= 2; dx++) + { + for (var dy = -2; dy <= 2; dy++) + { + if (dx != 0 || dy != 0) + { + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor + new Vector2(dx, dy), + ImGui.GetColorU32(ImGuiCol.WindowBg), + ime.InputModeIcon); + } + } + } + + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor, + ImGui.GetColorU32(ImGuiCol.Text), + ime.InputModeIcon); + cursor.Y += candTextSize.Y + spaceY; + } + + if (!expandUpward && drawConv) + { + DrawTextBeingConverted(); + cursor.Y += candTextSize.Y + spaceY; + + // Add a separator. + drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); + } + + if (drawCand) + { + // Add the candidate words. + for (var i = 0; i < ime.ImmCand.Count; i++) + { + var selected = i == (native.dwSelection % ImePageSize); + var color = ImGui.GetColorU32(ImGuiCol.Text); + if (selected) + color = ImGui.GetColorU32(ImGuiCol.NavHighlight); + + drawList.AddText(cursor, color, $"{i + 1}. {ime.ImmCand[i]}"); + cursor.Y += candTextSize.Y + spaceY; + } + + // Add a separator + drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); + + // Add the pages infomation. + drawList.AddText(cursor, ImGui.GetColorU32(ImGuiCol.Text), pageInfo); + cursor.Y += candTextSize.Y + spaceY; + } + + if (expandUpward && drawConv) + { + // Add a separator. + drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); + + DrawTextBeingConverted(); + cursor.Y += candTextSize.Y + spaceY; + } + + if (expandUpward && drawIme) + { + for (var dx = -2; dx <= 2; dx++) + { + for (var dy = -2; dy <= 2; dy++) + { + if (dx != 0 || dy != 0) + { + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor + new Vector2(dx, dy), + ImGui.GetColorU32(ImGuiCol.WindowBg), + ime.InputModeIcon); + } + } + } + + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor, + ImGui.GetColorU32(ImGuiCol.Text), + ime.InputModeIcon); + } + + return; + + void DrawTextBeingConverted() + { + // Draw the text background. + drawList.AddRectFilled( + cursor - (pad / 2), + cursor + candTextSize + (pad / 2), + ImGui.GetColorU32(ImGuiCol.WindowBg)); + + // If only a part of the full text is marked for conversion, then draw background for the part being edited. + if (ime.PartialConversionFrom != 0 || ime.PartialConversionTo != ime.ImmComp.Length) + { + var part1 = ime.ImmComp[..ime.PartialConversionFrom]; + var part2 = ime.ImmComp[..ime.PartialConversionTo]; + var size1 = ImGui.CalcTextSize(part1); + var size2 = ImGui.CalcTextSize(part2); + drawList.AddRectFilled( + cursor + size1 with { Y = 0 }, + cursor + size2, + ImGui.GetColorU32(ImGuiCol.TextSelectedBg)); + } + + // Add the text being converted. + drawList.AddText(cursor, ImGui.GetColorU32(ImGuiCol.Text), ime.ImmComp); + + // Draw the caret inside the composition string. + if (DalamudIme.ShowCursorInInputText) + { + var partBeforeCaret = ime.ImmComp[..ime.CompositionCursorOffset]; + var sizeBeforeCaret = ImGui.CalcTextSize(partBeforeCaret); + drawList.AddLine( + cursor + sizeBeforeCaret with { Y = 0 }, + cursor + sizeBeforeCaret, + ImGui.GetColorU32(ImGuiCol.Text)); + } + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs index 0e654d316..5b2855298 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs @@ -3,6 +3,7 @@ using System.Drawing; using System.Linq; using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using ImGuiNET; @@ -130,7 +131,7 @@ public class AddonLifecycleWidget : IDataWindowWidget } else { - var color = receiveEventListener.Hook.IsEnabled ? KnownColor.Green.Vector() : KnownColor.OrangeRed.Vector(); + var color = receiveEventListener.Hook.IsEnabled ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed; var text = receiveEventListener.Hook.IsEnabled ? "Enabled" : "Disabled"; ImGui.TextColored(color, text); } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs index dcae6e689..06c691cc9 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Numerics; using Dalamud.Data; +using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Utility; using ImGuiNET; @@ -154,7 +155,7 @@ public class IconBrowserWidget : IDataWindowWidget this.nullValues.Add(iconId); } - ImGui.GetWindowDrawList().AddRect(cursor, cursor + this.iconSize, ImGui.GetColorU32(KnownColor.White.Vector())); + ImGui.GetWindowDrawList().AddRect(cursor, cursor + this.iconSize, ImGui.GetColorU32(ImGuiColors.DalamudWhite)); } catch (Exception) { diff --git a/Dalamud/Interface/Internal/Windows/IMEWindow.cs b/Dalamud/Interface/Internal/Windows/IMEWindow.cs deleted file mode 100644 index 80e03caf3..000000000 --- a/Dalamud/Interface/Internal/Windows/IMEWindow.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Numerics; - -using Dalamud.Game.ClientState.Keys; -using Dalamud.Game.Gui.Internal; -using Dalamud.Interface.Windowing; -using ImGuiNET; - -namespace Dalamud.Interface.Internal.Windows; - -/// -/// A window for displaying IME details. -/// -internal unsafe class ImeWindow : Window -{ - private const int ImePageSize = 9; - - /// - /// Initializes a new instance of the class. - /// - public ImeWindow() - : base("Dalamud IME", ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoBackground) - { - this.Size = new Vector2(100, 200); - this.SizeCondition = ImGuiCond.FirstUseEver; - - this.RespectCloseHotkey = false; - } - - /// - public override void Draw() - { - if (this.IsOpen && Service.Get()[VirtualKey.SHIFT]) Service.Get().CloseImeWindow(); - var ime = Service.GetNullable(); - - if (ime == null || !ime.IsEnabled) - { - ImGui.Text("IME is unavailable."); - return; - } - - // ImGui.Text($"{ime.GetCursorPos()}"); - // ImGui.Text($"{ImGui.GetWindowViewport().WorkSize}"); - } - - /// - public override void PostDraw() - { - if (this.IsOpen && Service.Get()[VirtualKey.SHIFT]) Service.Get().CloseImeWindow(); - var ime = Service.GetNullable(); - - if (ime == null || !ime.IsEnabled) - return; - - var maxTextWidth = 0f; - var textHeight = ImGui.CalcTextSize(ime.ImmComp).Y; - - var native = ime.ImmCandNative; - var totalIndex = native.Selection + 1; - var totalSize = native.Count; - - var pageStart = native.PageStart; - var pageIndex = (pageStart / ImePageSize) + 1; - var pageCount = (totalSize / ImePageSize) + 1; - var pageInfo = $"{totalIndex}/{totalSize} ({pageIndex}/{pageCount})"; - - // Calc the window size - for (var i = 0; i < ime.ImmCand.Count; i++) - { - var textSize = ImGui.CalcTextSize($"{i + 1}. {ime.ImmCand[i]}"); - maxTextWidth = maxTextWidth > textSize.X ? maxTextWidth : textSize.X; - } - - maxTextWidth = maxTextWidth > ImGui.CalcTextSize(pageInfo).X ? maxTextWidth : ImGui.CalcTextSize(pageInfo).X; - maxTextWidth = maxTextWidth > ImGui.CalcTextSize(ime.ImmComp).X ? maxTextWidth : ImGui.CalcTextSize(ime.ImmComp).X; - - var imeWindowWidth = maxTextWidth + (2 * ImGui.GetStyle().WindowPadding.X); - var imeWindowHeight = (textHeight * (ime.ImmCand.Count + 2)) + (5 * (ime.ImmCand.Count - 1)) + (2 * ImGui.GetStyle().WindowPadding.Y); - - // Calc the window pos - var cursorPos = ime.GetCursorPos(); - var imeWindowMinPos = new Vector2(cursorPos.X, cursorPos.Y); - var imeWindowMaxPos = new Vector2(imeWindowMinPos.X + imeWindowWidth, imeWindowMinPos.Y + imeWindowHeight); - var gameWindowSize = ImGui.GetWindowViewport().WorkSize; - - var offset = new Vector2( - imeWindowMaxPos.X - gameWindowSize.X > 0 ? imeWindowMaxPos.X - gameWindowSize.X : 0, - imeWindowMaxPos.Y - gameWindowSize.Y > 0 ? imeWindowMaxPos.Y - gameWindowSize.Y : 0); - imeWindowMinPos -= offset; - imeWindowMaxPos -= offset; - - var nextDrawPosY = imeWindowMinPos.Y; - var drawAreaPosX = imeWindowMinPos.X + ImGui.GetStyle().WindowPadding.X; - - // Draw the ime window - var drawList = ImGui.GetForegroundDrawList(); - // Draw the background rect - drawList.AddRectFilled(imeWindowMinPos, imeWindowMaxPos, ImGui.GetColorU32(ImGuiCol.WindowBg), ImGui.GetStyle().WindowRounding); - // Add component text - drawList.AddText(new Vector2(drawAreaPosX, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Text), ime.ImmComp); - nextDrawPosY += textHeight + ImGui.GetStyle().ItemSpacing.Y; - // Add separator - drawList.AddLine(new Vector2(drawAreaPosX, nextDrawPosY), new Vector2(drawAreaPosX + maxTextWidth, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Separator)); - // Add candidate words - for (var i = 0; i < ime.ImmCand.Count; i++) - { - var selected = i == (native.Selection % ImePageSize); - var color = ImGui.GetColorU32(ImGuiCol.Text); - if (selected) - color = ImGui.GetColorU32(ImGuiCol.NavHighlight); - - drawList.AddText(new Vector2(drawAreaPosX, nextDrawPosY), color, $"{i + 1}. {ime.ImmCand[i]}"); - nextDrawPosY += textHeight + ImGui.GetStyle().ItemSpacing.Y; - } - - // Add separator - drawList.AddLine(new Vector2(drawAreaPosX, nextDrawPosY), new Vector2(drawAreaPosX + maxTextWidth, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Separator)); - // Add pages infomation - drawList.AddText(new Vector2(drawAreaPosX, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Text), pageInfo); - } -} diff --git a/Dalamud/Interface/Utility/ImGuiHelpers.cs b/Dalamud/Interface/Utility/ImGuiHelpers.cs index 579d93f86..ad151ec4e 100644 --- a/Dalamud/Interface/Utility/ImGuiHelpers.cs +++ b/Dalamud/Interface/Utility/ImGuiHelpers.cs @@ -31,7 +31,8 @@ public static class ImGuiHelpers /// This does not necessarily mean you can call drawing functions. ///
public static unsafe bool IsImGuiInitialized => - ImGui.GetCurrentContext() is not 0 && ImGui.GetIO().NativePtr is not null; + ImGui.GetCurrentContext() is not (nint)0 // KW: IDEs get mad without the cast, despite being unnecessary + && ImGui.GetIO().NativePtr is not null; /// /// Gets the global Dalamud scale; even available before drawing is ready.
@@ -426,6 +427,26 @@ public static class ImGuiHelpers /// The pointer. /// Whether it is empty. public static unsafe bool IsNull(this ImFontAtlasPtr ptr) => ptr.NativePtr == null; + + /// + /// Finds the corresponding ImGui viewport ID for the given window handle. + /// + /// The window handle. + /// The viewport ID, or -1 if not found. + internal static unsafe int FindViewportId(nint hwnd) + { + if (!IsImGuiInitialized) + return -1; + + var viewports = new ImVectorWrapper(&ImGui.GetPlatformIO().NativePtr->Viewports); + for (var i = 0; i < viewports.LengthUnsafe; i++) + { + if (viewports.DataUnsafe[i].PlatformHandle == hwnd) + return i; + } + + return -1; + } /// /// Get data needed for each new frame. diff --git a/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs b/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs index 851e5be33..1119a8c4e 100644 --- a/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs +++ b/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs @@ -3,19 +3,14 @@ namespace Dalamud.Plugin.Internal.Exceptions; /// /// This represents a banned plugin that attempted an operation. /// -internal class BannedPluginException : PluginException +internal class BannedPluginException : PluginPreconditionFailedException { /// /// Initializes a new instance of the class. /// /// The message describing the invalid operation. public BannedPluginException(string message) + : base(message) { - this.Message = message; } - - /// - /// Gets the message describing the invalid operation. - /// - public override string Message { get; } } diff --git a/Dalamud/Plugin/Internal/Exceptions/PluginPreconditionFailedException.cs b/Dalamud/Plugin/Internal/Exceptions/PluginPreconditionFailedException.cs new file mode 100644 index 000000000..c1bb58d0d --- /dev/null +++ b/Dalamud/Plugin/Internal/Exceptions/PluginPreconditionFailedException.cs @@ -0,0 +1,16 @@ +namespace Dalamud.Plugin.Internal.Exceptions; + +/// +/// An exception to be thrown when policy blocks a plugin from loading. +/// +internal class PluginPreconditionFailedException : InvalidPluginOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The message to associate with this exception. + public PluginPreconditionFailedException(string message) + : base(message) + { + } +} diff --git a/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs b/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs index b7a2ffe2e..1a6830a3a 100644 --- a/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs +++ b/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs @@ -131,9 +131,16 @@ internal class AssemblyLoadContextBuilder /// or the default app context. /// /// The name of the assembly. + /// Pull assmeblies recursively. /// The builder. - public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName) + public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName, bool recursive) { + if (!recursive) + { + this.defaultAssemblies.Add(assemblyName.Name); + return this; + } + var names = new Queue(new[] { assemblyName }); while (names.TryDequeue(out var name)) diff --git a/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs b/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs index d3fcdc99e..0b2150069 100644 --- a/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs +++ b/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs @@ -46,7 +46,7 @@ internal class LoaderConfig /// Gets a list of assemblies which should be unified between the host and the plugin. ///
/// what-are-shared-types - public ICollection SharedAssemblies { get; } = new List(); + public ICollection<(AssemblyName Name, bool Recursive)> SharedAssemblies { get; } = new List<(AssemblyName Name, bool Recursive)>(); /// /// Gets or sets a value indicating whether attempt to unify all types from a plugin with the host. diff --git a/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs b/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs index 4bb326ce4..e0629217a 100644 --- a/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs +++ b/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs @@ -194,7 +194,18 @@ internal class ManagedLoadContext : AssemblyLoadContext } } - return null; + // https://learn.microsoft.com/en-us/dotnet/core/dependency-loading/loading-managed#algorithm + // > These assemblies are loaded (load-by-name) as needed by the runtime. + // For load-by-name assembiles, the following will happen in order: + // (1) this.Load will be called. + // (2) AssemblyLoadContext.Default's cache will be referred for lookup. + // (3) Default probing will be done from PLATFORM_RESOURCE_ROOTS and APP_PATHS. + // https://learn.microsoft.com/en-us/dotnet/core/dependency-loading/default-probing#managed-assembly-default-probing + // > TRUSTED_PLATFORM_ASSEMBLIES: List of platform and application assembly file paths. + // > APP_PATHS: is not populated by default and is omitted for most applications. + // If we return null here, if the assembly has not been already loaded, the resolution will fail. + // Therefore as the final attempt, we try loading from the default load context. + return this.defaultLoadContext.LoadFromAssemblyName(assemblyName); } /// diff --git a/Dalamud/Plugin/Internal/Loader/PluginLoader.cs b/Dalamud/Plugin/Internal/Loader/PluginLoader.cs index 5c03c32b8..63b47cf17 100644 --- a/Dalamud/Plugin/Internal/Loader/PluginLoader.cs +++ b/Dalamud/Plugin/Internal/Loader/PluginLoader.cs @@ -1,7 +1,7 @@ // Copyright (c) Nate McMaster, Dalamud team. // Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. -using System; +using System.IO; using System.Reflection; using System.Runtime.Loader; @@ -146,11 +146,15 @@ internal class PluginLoader : IDisposable builder.ShadowCopyNativeLibraries(); } - foreach (var assemblyName in config.SharedAssemblies) + foreach (var (assemblyName, recursive) in config.SharedAssemblies) { - builder.PreferDefaultLoadContextAssembly(assemblyName); + builder.PreferDefaultLoadContextAssembly(assemblyName, recursive); } + // Note: not adding Dalamud path here as a probing path. + // It will be dealt as the last resort from ManagedLoadContext.Load. + // See there for more details. + return builder; } diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index 894fabca6..e88dc3f8e 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -953,7 +953,7 @@ internal partial class PluginManager : IDisposable, IServiceType autoUpdate ? PluginListInvalidationKind.AutoUpdate : PluginListInvalidationKind.Update, updatedList.Select(x => x.InternalName)); - Log.Information("Plugin update OK."); + Log.Information("Plugin update OK. {updateCount} plugins updated.", updatedList.Length); return updatedList; } @@ -1578,6 +1578,8 @@ internal partial class PluginManager : IDisposable, IServiceType private void DetectAvailablePluginUpdates() { + Log.Debug("Starting plugin update check..."); + lock (this.pluginListLock) { this.updatablePluginsList.Clear(); @@ -1612,10 +1614,12 @@ internal partial class PluginManager : IDisposable, IServiceType } } } + + Log.Debug("Update check found {updateCount} available updates.", this.updatablePluginsList.Count); } private void NotifyAvailablePluginsChanged() - { + { this.DetectAvailablePluginUpdates(); this.OnAvailablePluginsChanged?.InvokeSafely(); diff --git a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs index 95dcb9800..20d108a38 100644 --- a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs +++ b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs @@ -315,23 +315,23 @@ internal class LocalPlugin : IDisposable } if (pluginManager.IsManifestBanned(this.manifest) && !this.IsDev) - throw new BannedPluginException($"Unable to load {this.Name}, banned"); + throw new BannedPluginException($"Unable to load {this.Name} as it was banned"); if (this.manifest.ApplicableVersion < dalamud.StartInfo.GameVersion) - throw new InvalidPluginOperationException($"Unable to load {this.Name}, no applicable version"); + throw new PluginPreconditionFailedException($"Unable to load {this.Name}, game is newer than applicable version {this.manifest.ApplicableVersion}"); if (this.manifest.DalamudApiLevel < PluginManager.DalamudApiLevel && !pluginManager.LoadAllApiLevels) - throw new InvalidPluginOperationException($"Unable to load {this.Name}, incompatible API level"); + throw new PluginPreconditionFailedException($"Unable to load {this.Name}, incompatible API level {this.manifest.DalamudApiLevel}"); // We might want to throw here? if (!this.IsWantedByAnyProfile) Log.Warning("{Name} is loading, but isn't wanted by any profile", this.Name); if (this.IsOrphaned) - throw new InvalidPluginOperationException($"Plugin {this.Name} had no associated repo."); + throw new PluginPreconditionFailedException($"Plugin {this.Name} had no associated repo"); if (!this.CheckPolicy()) - throw new InvalidPluginOperationException("Plugin was not loaded as per policy"); + throw new PluginPreconditionFailedException($"Unable to load {this.Name} as a load policy forbids it"); this.State = PluginState.Loading; Log.Information($"Loading {this.DllFile.Name}"); @@ -440,7 +440,10 @@ internal class LocalPlugin : IDisposable { this.State = PluginState.LoadError; - if (ex is not BannedPluginException) + // If a precondition fails, don't record it as an error, as it isn't really. + if (ex is PluginPreconditionFailedException) + Log.Warning(ex.Message); + else Log.Error(ex, $"Error while loading {this.Name}"); throw; @@ -625,8 +628,18 @@ internal class LocalPlugin : IDisposable config.IsUnloadable = true; config.LoadInMemory = true; config.PreferSharedTypes = false; - config.SharedAssemblies.Add(typeof(Lumina.GameData).Assembly.GetName()); - config.SharedAssemblies.Add(typeof(Lumina.Excel.ExcelSheetImpl).Assembly.GetName()); + + // Pin Lumina and its dependencies recursively (compatibility behavior). + // It currently only pulls in System.* anyway. + // TODO(api10): Remove this. We don't want to pin Lumina anymore, plugins should be able to provide their own. + config.SharedAssemblies.Add((typeof(Lumina.GameData).Assembly.GetName(), true)); + config.SharedAssemblies.Add((typeof(Lumina.Excel.ExcelSheetImpl).Assembly.GetName(), true)); + + // Make sure that plugins do not load their own Dalamud assembly. + // We do not pin this recursively; if a plugin loads its own assembly of Dalamud, it is always wrong, + // but plugins may load other versions of assemblies that Dalamud depends on. + config.SharedAssemblies.Add((typeof(EntryPoint).Assembly.GetName(), false)); + config.SharedAssemblies.Add((typeof(Common.DalamudStartInfo).Assembly.GetName(), false)); } private void EnsureLoader() diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index 3364dfea7..bbc4b9942 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit 3364dfea769b79e43aebaa955b6b98ec1d6eb458 +Subproject commit bbc4b994254d6913f51da3a20fad9bf4b8c986e5