Merge remote-tracking branch 'upstream/master' into feature/inotificationmanager

This commit is contained in:
Soreepeong 2024-03-14 13:06:04 +09:00
commit 033a57d19d
51 changed files with 3594 additions and 1284 deletions

View file

@ -58,7 +58,7 @@
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<EnableUAC>false</EnableUAC>
<AdditionalDependencies>Version.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>Version.lib;Shlwapi.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalLibraryDirectories>..\lib\CoreCLR;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
@ -137,6 +137,7 @@
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="ntdll.cpp" />
<ClCompile Include="unicode.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">NotUsing</PrecompiledHeader>
@ -176,6 +177,7 @@
<ClInclude Include="DalamudStartInfo.h" />
<ClInclude Include="hooks.h" />
<ClInclude Include="logging.h" />
<ClInclude Include="ntdll.h" />
<ClInclude Include="resource.h" />
<ClInclude Include="unicode.h" />
<ClInclude Include="utils.h" />

View file

@ -73,6 +73,9 @@
<ClCompile Include="DalamudStartInfo.cpp">
<Filter>Dalamud.Boot DLL</Filter>
</ClCompile>
<ClCompile Include="ntdll.cpp">
<Filter>Dalamud.Boot DLL</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\lib\CoreCLR\CoreCLR.h">
@ -140,6 +143,9 @@
</ClInclude>
<ClInclude Include="resource.h" />
<ClInclude Include="crashhandler_shared.h" />
<ClInclude Include="ntdll.h">
<Filter>Dalamud.Boot DLL</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="Dalamud.Boot.rc" />

View file

@ -2,39 +2,9 @@
#include "hooks.h"
#include "ntdll.h"
#include "logging.h"
enum {
LDR_DLL_NOTIFICATION_REASON_LOADED = 1,
LDR_DLL_NOTIFICATION_REASON_UNLOADED = 2,
};
struct LDR_DLL_UNLOADED_NOTIFICATION_DATA {
ULONG Flags; //Reserved.
const UNICODE_STRING* FullDllName; //The full path name of the DLL module.
const UNICODE_STRING* BaseDllName; //The base file name of the DLL module.
PVOID DllBase; //A pointer to the base address for the DLL in memory.
ULONG SizeOfImage; //The size of the DLL image, in bytes.
};
struct LDR_DLL_LOADED_NOTIFICATION_DATA {
ULONG Flags; //Reserved.
const UNICODE_STRING* FullDllName; //The full path name of the DLL module.
const UNICODE_STRING* BaseDllName; //The base file name of the DLL module.
PVOID DllBase; //A pointer to the base address for the DLL in memory.
ULONG SizeOfImage; //The size of the DLL image, in bytes.
};
union LDR_DLL_NOTIFICATION_DATA {
LDR_DLL_LOADED_NOTIFICATION_DATA Loaded;
LDR_DLL_UNLOADED_NOTIFICATION_DATA Unloaded;
};
using PLDR_DLL_NOTIFICATION_FUNCTION = VOID CALLBACK(_In_ ULONG NotificationReason, _In_ const LDR_DLL_NOTIFICATION_DATA* NotificationData, _In_opt_ PVOID Context);
static const auto LdrRegisterDllNotification = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function<NTSTATUS(NTAPI)(ULONG Flags, PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction, PVOID Context, PVOID* Cookie)>("LdrRegisterDllNotification");
static const auto LdrUnregisterDllNotification = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function<NTSTATUS(NTAPI)(PVOID Cookie)>("LdrUnregisterDllNotification");
hooks::getprocaddress_singleton_import_hook::getprocaddress_singleton_import_hook()
: m_pfnGetProcAddress(GetProcAddress)
, m_thunk("kernel32!GetProcAddress(Singleton Import Hook)",

View file

@ -1,6 +1,5 @@
#pragma once
#include <limits>
#include <map>
#include "utils.h"

15
Dalamud.Boot/ntdll.cpp Normal file
View file

@ -0,0 +1,15 @@
#include "pch.h"
#include "ntdll.h"
#include "utils.h"
NTSTATUS LdrRegisterDllNotification(ULONG Flags, PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction, PVOID Context, PVOID* Cookie) {
static const auto pfn = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function<NTSTATUS(NTAPI)(ULONG Flags, PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction, PVOID Context, PVOID* Cookie)>("LdrRegisterDllNotification");
return pfn(Flags, NotificationFunction, Context, Cookie);
}
NTSTATUS LdrUnregisterDllNotification(PVOID Cookie) {
static const auto pfn = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function<NTSTATUS(NTAPI)(PVOID Cookie)>("LdrUnregisterDllNotification");
return pfn(Cookie);
}

33
Dalamud.Boot/ntdll.h Normal file
View file

@ -0,0 +1,33 @@
#pragma once
// ntdll exports
enum {
LDR_DLL_NOTIFICATION_REASON_LOADED = 1,
LDR_DLL_NOTIFICATION_REASON_UNLOADED = 2,
};
struct LDR_DLL_UNLOADED_NOTIFICATION_DATA {
ULONG Flags; //Reserved.
const UNICODE_STRING* FullDllName; //The full path name of the DLL module.
const UNICODE_STRING* BaseDllName; //The base file name of the DLL module.
PVOID DllBase; //A pointer to the base address for the DLL in memory.
ULONG SizeOfImage; //The size of the DLL image, in bytes.
};
struct LDR_DLL_LOADED_NOTIFICATION_DATA {
ULONG Flags; //Reserved.
const UNICODE_STRING* FullDllName; //The full path name of the DLL module.
const UNICODE_STRING* BaseDllName; //The base file name of the DLL module.
PVOID DllBase; //A pointer to the base address for the DLL in memory.
ULONG SizeOfImage; //The size of the DLL image, in bytes.
};
union LDR_DLL_NOTIFICATION_DATA {
LDR_DLL_LOADED_NOTIFICATION_DATA Loaded;
LDR_DLL_UNLOADED_NOTIFICATION_DATA Unloaded;
};
using PLDR_DLL_NOTIFICATION_FUNCTION = VOID CALLBACK(_In_ ULONG NotificationReason, _In_ const LDR_DLL_NOTIFICATION_DATA* NotificationData, _In_opt_ PVOID Context);
NTSTATUS LdrRegisterDllNotification(ULONG Flags, PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction, PVOID Context, PVOID* Cookie);
NTSTATUS LdrUnregisterDllNotification(PVOID Cookie);

View file

@ -15,14 +15,20 @@
#include <Windows.h>
// Windows Header Files (2)
#include <DbgHelp.h>
#include <Dbt.h>
#include <dwmapi.h>
#include <iphlpapi.h>
#include <PathCch.h>
#include <Psapi.h>
#include <ShlObj.h>
#include <Shlwapi.h>
#include <SubAuth.h>
#include <TlHelp32.h>
// Windows Header Files (3)
#include <icmpapi.h> // Must be loaded after iphlpapi.h
// MSVC Compiler Intrinsic
#include <intrin.h>
@ -30,6 +36,7 @@
#include <comdef.h>
// C++ Standard Libraries
#include <algorithm>
#include <cassert>
#include <chrono>
#include <cstdio>

View file

@ -5,9 +5,8 @@
#include "DalamudStartInfo.h"
#include "hooks.h"
#include "logging.h"
#include "ntdll.h"
#include "utils.h"
#include <iphlpapi.h>
#include <icmpapi.h>
template<typename T>
static std::span<T> assume_nonempty_span(std::span<T> t, const char* descr) {
@ -546,6 +545,109 @@ void xivfixes::prevent_icmphandle_crashes(bool bApply) {
}
}
void xivfixes::symbol_load_patches(bool bApply) {
static const char* LogTag = "[xivfixes:symbol_load_patches]";
static std::optional<hooks::import_hook<decltype(SymInitialize)>> s_hookSymInitialize;
static PVOID s_dllNotificationCookie = nullptr;
static const auto RemoveFullPathPdbInfo = [](const utils::loaded_module& mod) {
const auto ddva = mod.data_directory(IMAGE_DIRECTORY_ENTRY_DEBUG).VirtualAddress;
if (!ddva)
return;
const auto& ddir = mod.ref_as<IMAGE_DEBUG_DIRECTORY>(ddva);
if (ddir.Type == IMAGE_DEBUG_TYPE_CODEVIEW) {
// The Visual C++ debug information.
// Ghidra calls it "DotNetPdbInfo".
static constexpr DWORD DotNetPdbInfoSignatureValue = 0x53445352;
struct DotNetPdbInfo {
DWORD Signature; // RSDS
GUID Guid;
DWORD Age;
char PdbPath[1];
};
const auto& pdbref = mod.ref_as<DotNetPdbInfo>(ddir.AddressOfRawData);
if (pdbref.Signature == DotNetPdbInfoSignatureValue) {
const auto pathSpan = std::string_view(pdbref.PdbPath, strlen(pdbref.PdbPath));
const auto pathWide = unicode::convert<std::wstring>(pathSpan);
std::wstring windowsDirectory(GetWindowsDirectoryW(nullptr, 0) + 1, L'\0');
windowsDirectory.resize(
GetWindowsDirectoryW(windowsDirectory.data(), static_cast<UINT>(windowsDirectory.size())));
if (!PathIsRelativeW(pathWide.c_str()) && !PathIsSameRootW(windowsDirectory.c_str(), pathWide.c_str())) {
utils::memory_tenderizer pathOverwrite(&pdbref.PdbPath, pathSpan.size(), PAGE_READWRITE);
auto sep = std::find(pathSpan.rbegin(), pathSpan.rend(), '/');
if (sep == pathSpan.rend())
sep = std::find(pathSpan.rbegin(), pathSpan.rend(), '\\');
if (sep != pathSpan.rend()) {
logging::I(
"{} Stripping pdb path folder: {} to {}",
LogTag,
pathSpan,
&*sep + 1);
memmove(const_cast<char*>(pathSpan.data()), &*sep + 1, sep - pathSpan.rbegin() + 1);
} else {
logging::I("{} Leaving pdb path unchanged: {}", LogTag, pathSpan);
}
} else {
logging::I("{} Leaving pdb path unchanged: {}", LogTag, pathSpan);
}
} else {
logging::I("{} CODEVIEW struct signature mismatch: got {:08X} instead.", LogTag, pdbref.Signature);
}
} else {
logging::I("{} Debug directory: type {} is unsupported.", LogTag, ddir.Type);
}
};
if (bApply) {
if (!g_startInfo.BootEnabledGameFixes.contains("symbol_load_patches")) {
logging::I("{} Turned off via environment variable.", LogTag);
return;
}
for (const auto& mod : utils::loaded_module::all_modules())
RemoveFullPathPdbInfo(mod);
if (!s_dllNotificationCookie) {
const auto res = LdrRegisterDllNotification(
0,
[](ULONG notiReason, const LDR_DLL_NOTIFICATION_DATA* pData, void* /* context */) {
if (notiReason == LDR_DLL_NOTIFICATION_REASON_LOADED)
RemoveFullPathPdbInfo(pData->Loaded.DllBase);
},
nullptr,
&s_dllNotificationCookie);
if (res != STATUS_SUCCESS) {
logging::E("{} LdrRegisterDllNotification failure: 0x{:08X}", LogTag, res);
s_dllNotificationCookie = nullptr;
}
}
s_hookSymInitialize.emplace("dbghelp.dll!SymInitialize (import, symbol_load_patches)", "dbghelp.dll", "SymInitialize", 0);
s_hookSymInitialize->set_detour([](HANDLE hProcess, PCSTR UserSearchPath, BOOL fInvadeProcess) noexcept {
logging::I("{} Suppressed SymInitialize.", LogTag);
SetLastError(ERROR_NOT_SUPPORTED);
return FALSE;
});
logging::I("{} Enable", LogTag);
}
else {
if (s_hookSymInitialize) {
logging::I("{} Disable", LogTag);
s_hookSymInitialize.reset();
}
if (s_dllNotificationCookie) {
(void)LdrUnregisterDllNotification(s_dllNotificationCookie);
s_dllNotificationCookie = nullptr;
}
}
}
void xivfixes::apply_all(bool bApply) {
for (const auto& [taskName, taskFunction] : std::initializer_list<std::pair<const char*, void(*)(bool)>>
{
@ -554,7 +656,8 @@ void xivfixes::apply_all(bool bApply) {
{ "disable_game_openprocess_access_check", &disable_game_openprocess_access_check },
{ "redirect_openprocess", &redirect_openprocess },
{ "backup_userdata_save", &backup_userdata_save },
{ "prevent_icmphandle_crashes", &prevent_icmphandle_crashes }
{ "prevent_icmphandle_crashes", &prevent_icmphandle_crashes },
{ "symbol_load_patches", &symbol_load_patches },
}
) {
try {

View file

@ -7,6 +7,7 @@ namespace xivfixes {
void redirect_openprocess(bool bApply);
void backup_userdata_save(bool bApply);
void prevent_icmphandle_crashes(bool bApply);
void symbol_load_patches(bool bApply);
void apply_all(bool bApply);
}

View file

@ -395,9 +395,15 @@ namespace Dalamud.Injector
startInfo.BootShowConsole = args.Contains("--console");
startInfo.BootEnableEtw = args.Contains("--etw");
startInfo.BootLogPath = GetLogPath(startInfo.LogPath, "dalamud.boot", startInfo.LogName);
startInfo.BootEnabledGameFixes = new List<string> {
"prevent_devicechange_crashes", "disable_game_openprocess_access_check",
"redirect_openprocess", "backup_userdata_save", "prevent_icmphandle_crashes",
startInfo.BootEnabledGameFixes = new()
{
// See: xivfixes.h, xivfixes.cpp
"prevent_devicechange_crashes",
"disable_game_openprocess_access_check",
"redirect_openprocess",
"backup_userdata_save",
"prevent_icmphandle_crashes",
"symbol_load_patches",
};
startInfo.BootDotnetOpenProcessHookMode = 0;
startInfo.BootWaitMessageBox |= args.Contains("--msgbox1") ? 1 : 0;

View file

@ -112,10 +112,6 @@
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="Game\Addon\" />
</ItemGroup>
<Target Name="AddRuntimeDependenciesToContent" BeforeTargets="GetCopyToOutputDirectoryItems" DependsOnTargets="GenerateBuildDependencyFile;GenerateBuildRuntimeConfigurationFiles">
<ItemGroup>
<ContentWithTargetPath Include="$(ProjectDepsFilePath)" CopyToOutputDirectory="PreserveNewest" TargetPath="$(ProjectDepsFileName)" />

View file

@ -0,0 +1,107 @@
using System.Runtime.CompilerServices;
using System.Threading;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
namespace Dalamud.Game.Addon;
/// <summary>Argument pool for Addon Lifecycle services.</summary>
[ServiceManager.EarlyLoadedService]
internal sealed class AddonLifecyclePooledArgs : IServiceType
{
private readonly AddonSetupArgs?[] addonSetupArgPool = new AddonSetupArgs?[64];
private readonly AddonFinalizeArgs?[] addonFinalizeArgPool = new AddonFinalizeArgs?[64];
private readonly AddonDrawArgs?[] addonDrawArgPool = new AddonDrawArgs?[64];
private readonly AddonUpdateArgs?[] addonUpdateArgPool = new AddonUpdateArgs?[64];
private readonly AddonRefreshArgs?[] addonRefreshArgPool = new AddonRefreshArgs?[64];
private readonly AddonRequestedUpdateArgs?[] addonRequestedUpdateArgPool = new AddonRequestedUpdateArgs?[64];
private readonly AddonReceiveEventArgs?[] addonReceiveEventArgPool = new AddonReceiveEventArgs?[64];
[ServiceManager.ServiceConstructor]
private AddonLifecyclePooledArgs()
{
}
/// <summary>Rents an instance of an argument.</summary>
/// <param name="arg">The rented instance.</param>
/// <returns>The returner.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public PooledEntry<AddonSetupArgs> Rent(out AddonSetupArgs arg) => new(out arg, this.addonSetupArgPool);
/// <summary>Rents an instance of an argument.</summary>
/// <param name="arg">The rented instance.</param>
/// <returns>The returner.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public PooledEntry<AddonFinalizeArgs> Rent(out AddonFinalizeArgs arg) => new(out arg, this.addonFinalizeArgPool);
/// <summary>Rents an instance of an argument.</summary>
/// <param name="arg">The rented instance.</param>
/// <returns>The returner.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public PooledEntry<AddonDrawArgs> Rent(out AddonDrawArgs arg) => new(out arg, this.addonDrawArgPool);
/// <summary>Rents an instance of an argument.</summary>
/// <param name="arg">The rented instance.</param>
/// <returns>The returner.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public PooledEntry<AddonUpdateArgs> Rent(out AddonUpdateArgs arg) => new(out arg, this.addonUpdateArgPool);
/// <summary>Rents an instance of an argument.</summary>
/// <param name="arg">The rented instance.</param>
/// <returns>The returner.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public PooledEntry<AddonRefreshArgs> Rent(out AddonRefreshArgs arg) => new(out arg, this.addonRefreshArgPool);
/// <summary>Rents an instance of an argument.</summary>
/// <param name="arg">The rented instance.</param>
/// <returns>The returner.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public PooledEntry<AddonRequestedUpdateArgs> Rent(out AddonRequestedUpdateArgs arg) =>
new(out arg, this.addonRequestedUpdateArgPool);
/// <summary>Rents an instance of an argument.</summary>
/// <param name="arg">The rented instance.</param>
/// <returns>The returner.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public PooledEntry<AddonReceiveEventArgs> Rent(out AddonReceiveEventArgs arg) =>
new(out arg, this.addonReceiveEventArgPool);
/// <summary>Returns the object to the pool on dispose.</summary>
/// <typeparam name="T">The type.</typeparam>
public readonly ref struct PooledEntry<T>
where T : AddonArgs, new()
{
private readonly Span<T> pool;
private readonly T obj;
/// <summary>Initializes a new instance of the <see cref="PooledEntry{T}"/> struct.</summary>
/// <param name="arg">An instance of the argument.</param>
/// <param name="pool">The pool to rent from and return to.</param>
public PooledEntry(out T arg, Span<T> pool)
{
this.pool = pool;
foreach (ref var item in pool)
{
if (Interlocked.Exchange(ref item, null) is { } v)
{
this.obj = arg = v;
return;
}
}
this.obj = arg = new();
}
/// <summary>Returns the item to the pool.</summary>
public void Dispose()
{
var tmp = this.obj;
foreach (ref var item in this.pool)
{
if (Interlocked.Exchange(ref item, tmp) is not { } tmp2)
return;
tmp = tmp2;
}
}
}
}

View file

@ -18,7 +18,7 @@ namespace Dalamud.Game.Addon.Events;
/// Service provider for addon event management.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
internal unsafe class AddonEventManager : IDisposable, IServiceType
{
/// <summary>

View file

@ -44,10 +44,10 @@ public abstract unsafe class AddonArgs
get => this.addon;
set
{
if (this.addon == value)
return;
this.addon = value;
// Note: always clear addonName on updating the addon being pointed.
// Same address may point to a different addon.
this.addonName = null;
}
}

View file

@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
@ -19,7 +18,7 @@ namespace Dalamud.Game.Addon.Lifecycle;
/// This class provides events for in-game addon lifecycles.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
internal unsafe class AddonLifecycle : IDisposable, IServiceType
{
private static readonly ModuleLog Log = new("AddonLifecycle");
@ -27,6 +26,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
[ServiceManager.ServiceDependency]
private readonly Framework framework = Service<Framework>.Get();
[ServiceManager.ServiceDependency]
private readonly AddonLifecyclePooledArgs argsPool = Service<AddonLifecyclePooledArgs>.Get();
private readonly nint disallowedReceiveEventAddress;
private readonly AddonLifecycleAddressResolver address;
@ -38,18 +40,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
private readonly Hook<AddonOnRefreshDelegate> onAddonRefreshHook;
private readonly CallHook<AddonOnRequestedUpdateDelegate> onAddonRequestedUpdateHook;
// Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet
// package, and these events are always called from the main thread, this is fine.
#pragma warning disable CS0618 // Type or member is obsolete
// TODO: turn constructors of these internal
private readonly AddonSetupArgs recyclingSetupArgs = new();
private readonly AddonFinalizeArgs recyclingFinalizeArgs = new();
private readonly AddonDrawArgs recyclingDrawArgs = new();
private readonly AddonUpdateArgs recyclingUpdateArgs = new();
private readonly AddonRefreshArgs recyclingRefreshArgs = new();
private readonly AddonRequestedUpdateArgs recyclingRequestedUpdateArgs = new();
#pragma warning restore CS0618 // Type or member is obsolete
[ServiceManager.ServiceConstructor]
private AddonLifecycle(TargetSigScanner sigScanner)
{
@ -253,12 +243,13 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
Log.Error(e, "Exception in OnAddonSetup ReceiveEvent Registration.");
}
this.recyclingSetupArgs.AddonInternal = (nint)addon;
this.recyclingSetupArgs.AtkValueCount = valueCount;
this.recyclingSetupArgs.AtkValues = (nint)values;
this.InvokeListenersSafely(AddonEvent.PreSetup, this.recyclingSetupArgs);
valueCount = this.recyclingSetupArgs.AtkValueCount;
values = (AtkValue*)this.recyclingSetupArgs.AtkValues;
using var returner = this.argsPool.Rent(out AddonSetupArgs arg);
arg.AddonInternal = (nint)addon;
arg.AtkValueCount = valueCount;
arg.AtkValues = (nint)values;
this.InvokeListenersSafely(AddonEvent.PreSetup, arg);
valueCount = arg.AtkValueCount;
values = (AtkValue*)arg.AtkValues;
try
{
@ -269,7 +260,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
Log.Error(e, "Caught exception when calling original AddonSetup. This may be a bug in the game or another plugin hooking this method.");
}
this.InvokeListenersSafely(AddonEvent.PostSetup, this.recyclingSetupArgs);
this.InvokeListenersSafely(AddonEvent.PostSetup, arg);
}
private void OnAddonFinalize(AtkUnitManager* unitManager, AtkUnitBase** atkUnitBase)
@ -284,8 +275,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
Log.Error(e, "Exception in OnAddonFinalize ReceiveEvent Removal.");
}
this.recyclingFinalizeArgs.AddonInternal = (nint)atkUnitBase[0];
this.InvokeListenersSafely(AddonEvent.PreFinalize, this.recyclingFinalizeArgs);
using var returner = this.argsPool.Rent(out AddonFinalizeArgs arg);
arg.AddonInternal = (nint)atkUnitBase[0];
this.InvokeListenersSafely(AddonEvent.PreFinalize, arg);
try
{
@ -299,8 +291,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
private void OnAddonDraw(AtkUnitBase* addon)
{
this.recyclingDrawArgs.AddonInternal = (nint)addon;
this.InvokeListenersSafely(AddonEvent.PreDraw, this.recyclingDrawArgs);
using var returner = this.argsPool.Rent(out AddonDrawArgs arg);
arg.AddonInternal = (nint)addon;
this.InvokeListenersSafely(AddonEvent.PreDraw, arg);
try
{
@ -311,14 +304,15 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
Log.Error(e, "Caught exception when calling original AddonDraw. This may be a bug in the game or another plugin hooking this method.");
}
this.InvokeListenersSafely(AddonEvent.PostDraw, this.recyclingDrawArgs);
this.InvokeListenersSafely(AddonEvent.PostDraw, arg);
}
private void OnAddonUpdate(AtkUnitBase* addon, float delta)
{
this.recyclingUpdateArgs.AddonInternal = (nint)addon;
this.recyclingUpdateArgs.TimeDeltaInternal = delta;
this.InvokeListenersSafely(AddonEvent.PreUpdate, this.recyclingUpdateArgs);
using var returner = this.argsPool.Rent(out AddonUpdateArgs arg);
arg.AddonInternal = (nint)addon;
arg.TimeDeltaInternal = delta;
this.InvokeListenersSafely(AddonEvent.PreUpdate, arg);
try
{
@ -329,19 +323,20 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
Log.Error(e, "Caught exception when calling original AddonUpdate. This may be a bug in the game or another plugin hooking this method.");
}
this.InvokeListenersSafely(AddonEvent.PostUpdate, this.recyclingUpdateArgs);
this.InvokeListenersSafely(AddonEvent.PostUpdate, arg);
}
private byte OnAddonRefresh(AtkUnitManager* atkUnitManager, AtkUnitBase* addon, uint valueCount, AtkValue* values)
{
byte result = 0;
this.recyclingRefreshArgs.AddonInternal = (nint)addon;
this.recyclingRefreshArgs.AtkValueCount = valueCount;
this.recyclingRefreshArgs.AtkValues = (nint)values;
this.InvokeListenersSafely(AddonEvent.PreRefresh, this.recyclingRefreshArgs);
valueCount = this.recyclingRefreshArgs.AtkValueCount;
values = (AtkValue*)this.recyclingRefreshArgs.AtkValues;
using var returner = this.argsPool.Rent(out AddonRefreshArgs arg);
arg.AddonInternal = (nint)addon;
arg.AtkValueCount = valueCount;
arg.AtkValues = (nint)values;
this.InvokeListenersSafely(AddonEvent.PreRefresh, arg);
valueCount = arg.AtkValueCount;
values = (AtkValue*)arg.AtkValues;
try
{
@ -352,18 +347,19 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
Log.Error(e, "Caught exception when calling original AddonRefresh. This may be a bug in the game or another plugin hooking this method.");
}
this.InvokeListenersSafely(AddonEvent.PostRefresh, this.recyclingRefreshArgs);
this.InvokeListenersSafely(AddonEvent.PostRefresh, arg);
return result;
}
private void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData)
{
this.recyclingRequestedUpdateArgs.AddonInternal = (nint)addon;
this.recyclingRequestedUpdateArgs.NumberArrayData = (nint)numberArrayData;
this.recyclingRequestedUpdateArgs.StringArrayData = (nint)stringArrayData;
this.InvokeListenersSafely(AddonEvent.PreRequestedUpdate, this.recyclingRequestedUpdateArgs);
numberArrayData = (NumberArrayData**)this.recyclingRequestedUpdateArgs.NumberArrayData;
stringArrayData = (StringArrayData**)this.recyclingRequestedUpdateArgs.StringArrayData;
using var returner = this.argsPool.Rent(out AddonRequestedUpdateArgs arg);
arg.AddonInternal = (nint)addon;
arg.NumberArrayData = (nint)numberArrayData;
arg.StringArrayData = (nint)stringArrayData;
this.InvokeListenersSafely(AddonEvent.PreRequestedUpdate, arg);
numberArrayData = (NumberArrayData**)arg.NumberArrayData;
stringArrayData = (StringArrayData**)arg.StringArrayData;
try
{
@ -374,7 +370,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType
Log.Error(e, "Caught exception when calling original AddonRequestedUpdate. This may be a bug in the game or another plugin hooking this method.");
}
this.InvokeListenersSafely(AddonEvent.PostRequestedUpdate, this.recyclingRequestedUpdateArgs);
this.InvokeListenersSafely(AddonEvent.PostRequestedUpdate, arg);
}
}

View file

@ -16,12 +16,8 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable
{
private static readonly ModuleLog Log = new("AddonLifecycle");
// Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet
// package, and these events are always called from the main thread, this is fine.
#pragma warning disable CS0618 // Type or member is obsolete
// TODO: turn constructors of these internal
private readonly AddonReceiveEventArgs recyclingReceiveEventArgs = new();
#pragma warning restore CS0618 // Type or member is obsolete
[ServiceManager.ServiceDependency]
private readonly AddonLifecyclePooledArgs argsPool = Service<AddonLifecyclePooledArgs>.Get();
/// <summary>
/// Initializes a new instance of the <see cref="AddonLifecycleReceiveEventListener"/> class.
@ -82,16 +78,17 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable
return;
}
this.recyclingReceiveEventArgs.AddonInternal = (nint)addon;
this.recyclingReceiveEventArgs.AtkEventType = (byte)eventType;
this.recyclingReceiveEventArgs.EventParam = eventParam;
this.recyclingReceiveEventArgs.AtkEvent = (IntPtr)atkEvent;
this.recyclingReceiveEventArgs.Data = data;
this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PreReceiveEvent, this.recyclingReceiveEventArgs);
eventType = (AtkEventType)this.recyclingReceiveEventArgs.AtkEventType;
eventParam = this.recyclingReceiveEventArgs.EventParam;
atkEvent = (AtkEvent*)this.recyclingReceiveEventArgs.AtkEvent;
data = this.recyclingReceiveEventArgs.Data;
using var returner = this.argsPool.Rent(out AddonReceiveEventArgs arg);
arg.AddonInternal = (nint)addon;
arg.AtkEventType = (byte)eventType;
arg.EventParam = eventParam;
arg.AtkEvent = (IntPtr)atkEvent;
arg.Data = data;
this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PreReceiveEvent, arg);
eventType = (AtkEventType)arg.AtkEventType;
eventParam = arg.EventParam;
atkEvent = (AtkEvent*)arg.AtkEvent;
data = arg.Data;
try
{
@ -102,6 +99,6 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable
Log.Error(e, "Caught exception when calling original AddonReceiveEvent. This may be a bug in the game or another plugin hooking this method.");
}
this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PostReceiveEvent, this.recyclingReceiveEventArgs);
this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PostReceiveEvent, arg);
}
}

View file

@ -1,6 +1,7 @@
using System;
using Dalamud.Game.ClientState.Statuses;
using Dalamud.Utility;
namespace Dalamud.Game.ClientState.Objects.Types;
@ -57,8 +58,22 @@ public unsafe class BattleChara : Character
/// <summary>
/// Gets the total casting time of the spell being cast by the chara.
/// </summary>
/// <remarks>
/// This can only be a portion of the total cast for some actions.
/// Use AdjustedTotalCastTime if you always need the total cast time.
/// </remarks>
[Api10ToDo("Rename so it is not confused with AdjustedTotalCastTime")]
public float TotalCastTime => this.Struct->GetCastInfo->TotalCastTime;
/// <summary>
/// Gets the <see cref="TotalCastTime"/> plus any adjustments from the game, such as Action offset 2B. Used for display purposes.
/// </summary>
/// <remarks>
/// This is the actual total cast time for all actions.
/// </remarks>
[Api10ToDo("Rename so it is not confused with TotalCastTime")]
public float AdjustedTotalCastTime => this.Struct->GetCastInfo->AdjustedTotalCastTime;
/// <summary>
/// Gets the underlying structure.
/// </summary>

View file

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@ -41,11 +42,13 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
[ServiceManager.ServiceDependency]
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
private readonly object runOnNextTickTaskListSync = new();
private List<RunOnNextTickTaskBase> runOnNextTickTaskList = new();
private List<RunOnNextTickTaskBase> runOnNextTickTaskList2 = new();
private readonly CancellationTokenSource frameworkDestroy;
private readonly ThreadBoundTaskScheduler frameworkThreadTaskScheduler;
private Thread? frameworkUpdateThread;
private readonly ConcurrentDictionary<TaskCompletionSource, (ulong Expire, CancellationToken CancellationToken)>
tickDelayedTaskCompletionSources = new();
private ulong tickCounter;
[ServiceManager.ServiceConstructor]
private Framework(TargetSigScanner sigScanner, GameLifecycle lifecycle)
@ -56,6 +59,14 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
this.addressResolver = new FrameworkAddressResolver();
this.addressResolver.Setup(sigScanner);
this.frameworkDestroy = new();
this.frameworkThreadTaskScheduler = new();
this.FrameworkThreadTaskFactory = new(
this.frameworkDestroy.Token,
TaskCreationOptions.None,
TaskContinuationOptions.None,
this.frameworkThreadTaskScheduler);
this.updateHook = Hook<OnUpdateDetour>.FromAddress(this.addressResolver.TickAddress, this.HandleFrameworkUpdate);
this.destroyHook = Hook<OnRealDestroyDelegate>.FromAddress(this.addressResolver.DestroyAddress, this.HandleFrameworkDestroy);
@ -92,14 +103,17 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
/// <inheritdoc/>
public DateTime LastUpdateUTC { get; private set; } = DateTime.MinValue;
/// <inheritdoc/>
public TaskFactory FrameworkThreadTaskFactory { get; }
/// <inheritdoc/>
public TimeSpan UpdateDelta { get; private set; } = TimeSpan.Zero;
/// <inheritdoc/>
public bool IsInFrameworkUpdateThread => Thread.CurrentThread == this.frameworkUpdateThread;
public bool IsInFrameworkUpdateThread => this.frameworkThreadTaskScheduler.IsOnBoundThread;
/// <inheritdoc/>
public bool IsFrameworkUnloading { get; internal set; }
public bool IsFrameworkUnloading => this.frameworkDestroy.IsCancellationRequested;
/// <summary>
/// Gets the list of update sub-delegates that didn't get updated this frame.
@ -111,6 +125,19 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
/// </summary>
internal bool DispatchUpdateEvents { get; set; } = true;
/// <inheritdoc/>
public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default)
{
if (this.frameworkDestroy.IsCancellationRequested)
return Task.FromCanceled(this.frameworkDestroy.Token);
if (numTicks <= 0)
return Task.CompletedTask;
var tcs = new TaskCompletionSource();
this.tickDelayedTaskCompletionSources[tcs] = (this.tickCounter + (ulong)numTicks, cancellationToken);
return tcs.Task;
}
/// <inheritdoc/>
public Task<T> RunOnFrameworkThread<T>(Func<T> func) =>
this.IsInFrameworkUpdateThread || this.IsFrameworkUnloading ? Task.FromResult(func()) : this.RunOnTick(func);
@ -157,20 +184,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled<T>(cts.Token);
}
var tcs = new TaskCompletionSource<T>();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc<T>()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Func = func,
});
}
return tcs.Task;
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => func(),
cancellationToken);
}
/// <inheritdoc/>
@ -186,20 +209,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled(cts.Token);
}
var tcs = new TaskCompletionSource();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskAction()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Action = action,
});
}
return tcs.Task;
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => action(),
cancellationToken);
}
/// <inheritdoc/>
@ -215,20 +234,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled<T>(cts.Token);
}
var tcs = new TaskCompletionSource<Task<T>>();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc<Task<T>>()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Func = func,
});
}
return tcs.Task.ContinueWith(x => x.Result, cancellationToken).Unwrap();
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => func(),
cancellationToken).Unwrap();
}
/// <inheritdoc/>
@ -244,20 +259,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled(cts.Token);
}
var tcs = new TaskCompletionSource<Task>();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc<Task>()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Func = func,
});
}
return tcs.Task.ContinueWith(x => x.Result, cancellationToken).Unwrap();
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => func(),
cancellationToken).Unwrap();
}
/// <summary>
@ -333,23 +344,9 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
}
}
private void RunPendingTickTasks()
{
if (this.runOnNextTickTaskList.Count == 0 && this.runOnNextTickTaskList2.Count == 0)
return;
for (var i = 0; i < 2; i++)
{
lock (this.runOnNextTickTaskListSync)
(this.runOnNextTickTaskList, this.runOnNextTickTaskList2) = (this.runOnNextTickTaskList2, this.runOnNextTickTaskList);
this.runOnNextTickTaskList2.RemoveAll(x => x.Run());
}
}
private bool HandleFrameworkUpdate(IntPtr framework)
{
this.frameworkUpdateThread ??= Thread.CurrentThread;
this.frameworkThreadTaskScheduler.BoundThread ??= Thread.CurrentThread;
ThreadSafety.MarkMainThread();
@ -381,18 +378,30 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
this.LastUpdate = DateTime.Now;
this.LastUpdateUTC = DateTime.UtcNow;
this.tickCounter++;
foreach (var (k, (expiry, ct)) in this.tickDelayedTaskCompletionSources)
{
if (ct.IsCancellationRequested)
k.SetCanceled(ct);
else if (expiry <= this.tickCounter)
k.SetResult();
else
continue;
this.tickDelayedTaskCompletionSources.Remove(k, out _);
}
if (StatsEnabled)
{
StatsStopwatch.Restart();
this.RunPendingTickTasks();
this.frameworkThreadTaskScheduler.Run();
StatsStopwatch.Stop();
AddToStats(nameof(this.RunPendingTickTasks), StatsStopwatch.Elapsed.TotalMilliseconds);
AddToStats(nameof(this.frameworkThreadTaskScheduler), StatsStopwatch.Elapsed.TotalMilliseconds);
}
else
{
this.RunPendingTickTasks();
this.frameworkThreadTaskScheduler.Run();
}
if (StatsEnabled && this.Update != null)
@ -404,7 +413,7 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
// Cleanup handlers that are no longer being called
foreach (var key in this.NonUpdatedSubDelegates)
{
if (key == nameof(this.RunPendingTickTasks))
if (key == nameof(this.FrameworkThreadTaskFactory))
continue;
if (StatsHistory[key].Count > 0)
@ -431,8 +440,11 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
private bool HandleFrameworkDestroy(IntPtr framework)
{
this.IsFrameworkUnloading = true;
this.frameworkDestroy.Cancel();
this.DispatchUpdateEvents = false;
foreach (var k in this.tickDelayedTaskCompletionSources.Keys)
k.SetCanceled(this.frameworkDestroy.Token);
this.tickDelayedTaskCompletionSources.Clear();
// All the same, for now...
this.lifecycle.SetShuttingDown();
@ -440,95 +452,12 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
Log.Information("Framework::Destroy!");
Service<Dalamud>.Get().Unload();
this.RunPendingTickTasks();
this.frameworkThreadTaskScheduler.Run();
ServiceManager.WaitForServiceUnload();
Log.Information("Framework::Destroy OK!");
return this.destroyHook.OriginalDisposeSafe(framework);
}
private abstract class RunOnNextTickTaskBase
{
internal int RemainingTicks { get; set; }
internal long RunAfterTickCount { get; init; }
internal CancellationToken CancellationToken { get; init; }
internal bool Run()
{
if (this.CancellationToken.IsCancellationRequested)
{
this.CancelImpl();
return true;
}
if (this.RemainingTicks > 0)
this.RemainingTicks -= 1;
if (this.RemainingTicks > 0)
return false;
if (this.RunAfterTickCount > Environment.TickCount64)
return false;
this.RunImpl();
return true;
}
protected abstract void RunImpl();
protected abstract void CancelImpl();
}
private class RunOnNextTickTaskFunc<T> : RunOnNextTickTaskBase
{
internal TaskCompletionSource<T> TaskCompletionSource { get; init; }
internal Func<T> Func { get; init; }
protected override void RunImpl()
{
try
{
this.TaskCompletionSource.SetResult(this.Func());
}
catch (Exception ex)
{
this.TaskCompletionSource.SetException(ex);
}
}
protected override void CancelImpl()
{
this.TaskCompletionSource.SetCanceled();
}
}
private class RunOnNextTickTaskAction : RunOnNextTickTaskBase
{
internal TaskCompletionSource TaskCompletionSource { get; init; }
internal Action Action { get; init; }
protected override void RunImpl()
{
try
{
this.Action();
this.TaskCompletionSource.SetResult();
}
catch (Exception ex)
{
this.TaskCompletionSource.SetException(ex);
}
}
protected override void CancelImpl()
{
this.TaskCompletionSource.SetCanceled();
}
}
}
/// <summary>
@ -561,7 +490,10 @@ internal class FrameworkPluginScoped : IDisposable, IServiceType, IFramework
/// <inheritdoc/>
public DateTime LastUpdateUTC => this.frameworkService.LastUpdateUTC;
/// <inheritdoc/>
public TaskFactory FrameworkThreadTaskFactory => this.frameworkService.FrameworkThreadTaskFactory;
/// <inheritdoc/>
public TimeSpan UpdateDelta => this.frameworkService.UpdateDelta;
@ -579,6 +511,10 @@ internal class FrameworkPluginScoped : IDisposable, IServiceType, IFramework
this.Update = null;
}
/// <inheritdoc/>
public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default) =>
this.frameworkService.DelayTicks(numTicks, cancellationToken);
/// <inheritdoc/>
public Task<T> RunOnFrameworkThread<T>(Func<T> func)
=> this.frameworkService.RunOnFrameworkThread(func);

View file

@ -0,0 +1,560 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.System.Memory;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using FFXIVClientStructs.Interop;
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// This class handles interacting with the game's (right-click) context menu.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.EarlyLoadedService]
internal sealed unsafe class ContextMenu : IDisposable, IServiceType, IContextMenu
{
private static readonly ModuleLog Log = new("ContextMenu");
private readonly Hook<RaptureAtkModuleOpenAddonByAgentDelegate> raptureAtkModuleOpenAddonByAgentHook;
private readonly Hook<AddonContextMenuOnMenuSelectedDelegate> addonContextMenuOnMenuSelectedHook;
private readonly RaptureAtkModuleOpenAddonDelegate raptureAtkModuleOpenAddon;
[ServiceManager.ServiceConstructor]
private ContextMenu()
{
this.raptureAtkModuleOpenAddonByAgentHook = Hook<RaptureAtkModuleOpenAddonByAgentDelegate>.FromAddress((nint)RaptureAtkModule.Addresses.OpenAddonByAgent.Value, this.RaptureAtkModuleOpenAddonByAgentDetour);
this.addonContextMenuOnMenuSelectedHook = Hook<AddonContextMenuOnMenuSelectedDelegate>.FromAddress((nint)AddonContextMenu.StaticVTable.OnMenuSelected, this.AddonContextMenuOnMenuSelectedDetour);
this.raptureAtkModuleOpenAddon = Marshal.GetDelegateForFunctionPointer<RaptureAtkModuleOpenAddonDelegate>((nint)RaptureAtkModule.Addresses.OpenAddon.Value);
this.raptureAtkModuleOpenAddonByAgentHook.Enable();
this.addonContextMenuOnMenuSelectedHook.Enable();
}
private unsafe delegate ushort RaptureAtkModuleOpenAddonByAgentDelegate(RaptureAtkModule* module, byte* addonName, AtkUnitBase* addon, int valueCount, AtkValue* values, AgentInterface* agent, nint a7, ushort parentAddonId);
private unsafe delegate bool AddonContextMenuOnMenuSelectedDelegate(AddonContextMenu* addon, int selectedIdx, byte a3);
private unsafe delegate ushort RaptureAtkModuleOpenAddonDelegate(RaptureAtkModule* a1, uint addonNameId, uint valueCount, AtkValue* values, AgentInterface* parentAgent, ulong unk, ushort parentAddonId, int unk2);
/// <inheritdoc/>
public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened;
private Dictionary<ContextMenuType, List<MenuItem>> MenuItems { get; } = new();
private object MenuItemsLock { get; } = new();
private AgentInterface* SelectedAgent { get; set; }
private ContextMenuType? SelectedMenuType { get; set; }
private List<MenuItem>? SelectedItems { get; set; }
private HashSet<nint> SelectedEventInterfaces { get; } = new();
private AtkUnitBase* SelectedParentAddon { get; set; }
// -1 -> -inf: native items
// 0 -> inf: selected items
private List<int> MenuCallbackIds { get; } = new();
private IReadOnlyList<MenuItem>? SubmenuItems { get; set; }
/// <inheritdoc/>
public void Dispose()
{
var manager = RaptureAtkUnitManager.Instance();
var menu = manager->GetAddonByName("ContextMenu");
var submenu = manager->GetAddonByName("AddonContextSub");
if (menu->IsVisible)
menu->FireCallbackInt(-1);
if (submenu->IsVisible)
submenu->FireCallbackInt(-1);
this.raptureAtkModuleOpenAddonByAgentHook.Dispose();
this.addonContextMenuOnMenuSelectedHook.Dispose();
}
/// <inheritdoc/>
public void AddMenuItem(ContextMenuType menuType, MenuItem item)
{
lock (this.MenuItemsLock)
{
if (!this.MenuItems.TryGetValue(menuType, out var items))
this.MenuItems[menuType] = items = new();
items.Add(item);
}
}
/// <inheritdoc/>
public bool RemoveMenuItem(ContextMenuType menuType, MenuItem item)
{
lock (this.MenuItemsLock)
{
if (!this.MenuItems.TryGetValue(menuType, out var items))
return false;
return items.Remove(item);
}
}
private AtkValue* ExpandContextMenuArray(Span<AtkValue> oldValues, int newSize)
{
// if the array has enough room, don't reallocate
if (oldValues.Length >= newSize)
return (AtkValue*)Unsafe.AsPointer(ref oldValues[0]);
var size = (sizeof(AtkValue) * newSize) + 8;
var newArray = (nint)IMemorySpace.GetUISpace()->Malloc((ulong)size, 0);
if (newArray == nint.Zero)
throw new OutOfMemoryException();
NativeMemory.Fill((void*)newArray, (nuint)size, 0);
*(ulong*)newArray = (ulong)newSize;
// copy old memory if existing
if (!oldValues.IsEmpty)
oldValues.CopyTo(new((void*)(newArray + 8), oldValues.Length));
return (AtkValue*)(newArray + 8);
}
private void FreeExpandedContextMenuArray(AtkValue* newValues, int newSize) =>
IMemorySpace.Free((void*)((nint)newValues - 8), (ulong)((newSize * sizeof(AtkValue)) + 8));
private AtkValue* CreateEmptySubmenuContextMenuArray(SeString name, int x, int y, out int valueCount)
{
// 0: UInt = ContextItemCount
// 1: String = Name
// 2: Int = PositionX
// 3: Int = PositionY
// 4: Bool = false
// 5: UInt = ContextItemSubmenuMask
// 6: UInt = ReturnArrowMask (_gap_0x6BC ? 1 << (ContextItemCount - 1) : 0)
// 7: UInt = 1
valueCount = 8;
var values = this.ExpandContextMenuArray(Span<AtkValue>.Empty, valueCount);
values[0].ChangeType(ValueType.UInt);
values[0].UInt = 0;
values[1].ChangeType(ValueType.String);
values[1].SetString(name.Encode().NullTerminate());
values[2].ChangeType(ValueType.Int);
values[2].Int = x;
values[3].ChangeType(ValueType.Int);
values[3].Int = y;
values[4].ChangeType(ValueType.Bool);
values[4].Byte = 0;
values[5].ChangeType(ValueType.UInt);
values[5].UInt = 0;
values[6].ChangeType(ValueType.UInt);
values[6].UInt = 0;
values[7].ChangeType(ValueType.UInt);
values[7].UInt = 1;
return values;
}
private void SetupGenericMenu(int headerCount, int sizeHeaderIdx, int returnHeaderIdx, int submenuHeaderIdx, IReadOnlyList<MenuItem> items, ref int valueCount, ref AtkValue* values)
{
var itemsWithIdx = items.Select((item, idx) => (item, idx)).OrderBy(i => i.item.Priority);
var prefixItems = itemsWithIdx.Where(i => i.item.Priority < 0).ToArray();
var suffixItems = itemsWithIdx.Where(i => i.item.Priority >= 0).ToArray();
var nativeMenuSize = (int)values[sizeHeaderIdx].UInt;
var prefixMenuSize = prefixItems.Length;
var suffixMenuSize = suffixItems.Length;
var hasGameDisabled = valueCount - headerCount - nativeMenuSize > 0;
var hasCustomDisabled = items.Any(item => !item.IsEnabled);
var hasAnyDisabled = hasGameDisabled || hasCustomDisabled;
values = this.ExpandContextMenuArray(
new(values, valueCount),
valueCount = (nativeMenuSize + items.Count) * (hasAnyDisabled ? 2 : 1) + headerCount);
var offsetData = new Span<AtkValue>(values, headerCount);
var nameData = new Span<AtkValue>(values + headerCount, nativeMenuSize + items.Count);
var disabledData = hasAnyDisabled ? new Span<AtkValue>(values + headerCount + nativeMenuSize + items.Count, nativeMenuSize + items.Count) : Span<AtkValue>.Empty;
var returnMask = offsetData[returnHeaderIdx].UInt;
var submenuMask = offsetData[submenuHeaderIdx].UInt;
nameData[..nativeMenuSize].CopyTo(nameData.Slice(prefixMenuSize, nativeMenuSize));
if (hasAnyDisabled)
{
if (hasGameDisabled)
{
// copy old disabled data
var oldDisabledData = new Span<AtkValue>(values + headerCount + nativeMenuSize, nativeMenuSize);
oldDisabledData.CopyTo(disabledData.Slice(prefixMenuSize, nativeMenuSize));
}
else
{
// enable all
for (var i = prefixMenuSize; i < prefixMenuSize + nativeMenuSize; ++i)
{
disabledData[i].ChangeType(ValueType.Int);
disabledData[i].Int = 0;
}
}
}
returnMask <<= prefixMenuSize;
submenuMask <<= prefixMenuSize;
void FillData(Span<AtkValue> disabledData, Span<AtkValue> nameData, int i, MenuItem item, int idx)
{
this.MenuCallbackIds.Add(idx);
if (hasAnyDisabled)
{
disabledData[i].ChangeType(ValueType.Int);
disabledData[i].Int = item.IsEnabled ? 0 : 1;
}
if (item.IsReturn)
returnMask |= 1u << i;
if (item.IsSubmenu)
submenuMask |= 1u << i;
nameData[i].ChangeType(ValueType.String);
nameData[i].SetString(item.PrefixedName.Encode().NullTerminate());
}
for (var i = 0; i < prefixMenuSize; ++i)
{
var (item, idx) = prefixItems[i];
FillData(disabledData, nameData, i, item, idx);
}
this.MenuCallbackIds.AddRange(Enumerable.Range(0, nativeMenuSize).Select(i => -i - 1));
for (var i = prefixMenuSize + nativeMenuSize; i < prefixMenuSize + nativeMenuSize + suffixMenuSize; ++i)
{
var (item, idx) = suffixItems[i - prefixMenuSize - nativeMenuSize];
FillData(disabledData, nameData, i, item, idx);
}
offsetData[returnHeaderIdx].UInt = returnMask;
offsetData[submenuHeaderIdx].UInt = submenuMask;
offsetData[sizeHeaderIdx].UInt += (uint)items.Count;
}
private void SetupContextMenu(IReadOnlyList<MenuItem> items, ref int valueCount, ref AtkValue* values)
{
// 0: UInt = Item Count
// 1: UInt = 0 (probably window name, just unused)
// 2: UInt = Return Mask (?)
// 3: UInt = Submenu Mask
// 4: UInt = OpenAtCursorPosition ? 2 : 1
// 5: UInt = 0
// 6: UInt = 0
foreach (var item in items)
{
if (!item.Prefix.HasValue)
{
item.PrefixChar = 'D';
item.PrefixColor = 539;
Log.Warning($"Menu item \"{item.Name}\" has no prefix, defaulting to Dalamud's. Menu items outside of a submenu must have a prefix.");
}
}
this.SetupGenericMenu(7, 0, 2, 3, items, ref valueCount, ref values);
}
private void SetupContextSubMenu(IReadOnlyList<MenuItem> items, ref int valueCount, ref AtkValue* values)
{
// 0: UInt = ContextItemCount
// 1: skipped?
// 2: Int = PositionX
// 3: Int = PositionY
// 4: Bool = false
// 5: UInt = ContextItemSubmenuMask
// 6: UInt = _gap_0x6BC ? 1 << (ContextItemCount - 1) : 0
// 7: UInt = 1
this.SetupGenericMenu(8, 0, 6, 5, items, ref valueCount, ref values);
}
private ushort RaptureAtkModuleOpenAddonByAgentDetour(RaptureAtkModule* module, byte* addonName, AtkUnitBase* addon, int valueCount, AtkValue* values, AgentInterface* agent, nint a7, ushort parentAddonId)
{
var oldValues = values;
if (MemoryHelper.EqualsZeroTerminatedString("ContextMenu", (nint)addonName))
{
this.MenuCallbackIds.Clear();
this.SelectedAgent = agent;
this.SelectedParentAddon = module->RaptureAtkUnitManager.GetAddonById(parentAddonId);
this.SelectedEventInterfaces.Clear();
if (this.SelectedAgent == AgentInventoryContext.Instance())
{
this.SelectedMenuType = ContextMenuType.Inventory;
}
else if (this.SelectedAgent == AgentContext.Instance())
{
this.SelectedMenuType = ContextMenuType.Default;
var menu = AgentContext.Instance()->CurrentContextMenu;
var handlers = new Span<Pointer<AtkEventInterface>>(menu->EventHandlerArray, 32);
var ids = new Span<byte>(menu->EventIdArray, 32);
var count = (int)values[0].UInt;
handlers = handlers.Slice(7, count);
ids = ids.Slice(7, count);
for (var i = 0; i < count; ++i)
{
if (ids[i] <= 106)
continue;
this.SelectedEventInterfaces.Add((nint)handlers[i].Value);
}
}
else
{
this.SelectedMenuType = null;
}
this.SubmenuItems = null;
if (this.SelectedMenuType is { } menuType)
{
lock (this.MenuItemsLock)
{
if (this.MenuItems.TryGetValue(menuType, out var items))
this.SelectedItems = new(items);
else
this.SelectedItems = new();
}
var args = new MenuOpenedArgs(this.SelectedItems.Add, this.SelectedParentAddon, this.SelectedAgent, this.SelectedMenuType.Value, this.SelectedEventInterfaces);
this.OnMenuOpened?.InvokeSafely(args);
this.SelectedItems = this.FixupMenuList(this.SelectedItems, (int)values[0].UInt);
this.SetupContextMenu(this.SelectedItems, ref valueCount, ref values);
Log.Verbose($"Opening {this.SelectedMenuType} context menu with {this.SelectedItems.Count} custom items.");
}
else
{
this.SelectedItems = null;
}
}
else if (MemoryHelper.EqualsZeroTerminatedString("AddonContextSub", (nint)addonName))
{
this.MenuCallbackIds.Clear();
if (this.SubmenuItems != null)
{
this.SubmenuItems = this.FixupMenuList(this.SubmenuItems.ToList(), (int)values[0].UInt);
this.SetupContextSubMenu(this.SubmenuItems, ref valueCount, ref values);
Log.Verbose($"Opening {this.SelectedMenuType} submenu with {this.SubmenuItems.Count} custom items.");
}
}
var ret = this.raptureAtkModuleOpenAddonByAgentHook.Original(module, addonName, addon, valueCount, values, agent, a7, parentAddonId);
if (values != oldValues)
this.FreeExpandedContextMenuArray(values, valueCount);
return ret;
}
private List<MenuItem> FixupMenuList(List<MenuItem> items, int nativeMenuSize)
{
// The in game menu actually supports 32 items, but the last item can't have a visible submenu arrow.
// As such, we'll only work with 31 items.
const int MaxMenuItems = 31;
if (items.Count + nativeMenuSize > MaxMenuItems)
{
Log.Warning($"Menu size exceeds {MaxMenuItems} items, truncating.");
var orderedItems = items.OrderBy(i => i.Priority).ToArray();
var newItems = orderedItems[..(MaxMenuItems - nativeMenuSize - 1)];
var submenuItems = orderedItems[(MaxMenuItems - nativeMenuSize - 1)..];
return newItems.Append(new MenuItem
{
Prefix = SeIconChar.BoxedLetterD,
PrefixColor = 539,
IsSubmenu = true,
Priority = int.MaxValue,
Name = $"See More ({submenuItems.Length})",
OnClicked = a => a.OpenSubmenu(submenuItems),
}).ToList();
}
return items;
}
private void OpenSubmenu(SeString name, IReadOnlyList<MenuItem> submenuItems, int posX, int posY)
{
if (submenuItems.Count == 0)
throw new ArgumentException("Submenu must not be empty", nameof(submenuItems));
this.SubmenuItems = submenuItems;
var module = RaptureAtkModule.Instance();
var values = this.CreateEmptySubmenuContextMenuArray(name, posX, posY, out var valueCount);
switch (this.SelectedMenuType)
{
case ContextMenuType.Default:
{
var ownerAddonId = ((AgentContext*)this.SelectedAgent)->OwnerAddon;
this.raptureAtkModuleOpenAddon(module, 445, (uint)valueCount, values, this.SelectedAgent, 71, checked((ushort)ownerAddonId), 4);
break;
}
case ContextMenuType.Inventory:
{
var ownerAddonId = ((AgentInventoryContext*)this.SelectedAgent)->OwnerAddonId;
this.raptureAtkModuleOpenAddon(module, 445, (uint)valueCount, values, this.SelectedAgent, 0, checked((ushort)ownerAddonId), 4);
break;
}
default:
Log.Warning($"Unknown context menu type (agent: {(nint)this.SelectedAgent}, cannot open submenu");
break;
}
this.FreeExpandedContextMenuArray(values, valueCount);
}
private bool AddonContextMenuOnMenuSelectedDetour(AddonContextMenu* addon, int selectedIdx, byte a3)
{
var items = this.SubmenuItems ?? this.SelectedItems;
if (items == null)
goto original;
if (this.MenuCallbackIds.Count == 0)
goto original;
if (selectedIdx < 0)
goto original;
if (selectedIdx >= this.MenuCallbackIds.Count)
goto original;
var callbackId = this.MenuCallbackIds[selectedIdx];
if (callbackId < 0)
{
selectedIdx = -callbackId - 1;
goto original;
}
else
{
var item = items[callbackId];
var openedSubmenu = false;
try
{
if (item.OnClicked == null)
throw new InvalidOperationException("Item has no OnClicked handler");
item.OnClicked.InvokeSafely(new(
(name, items) =>
{
short x, y;
addon->AtkUnitBase.GetPosition(&x, &y);
this.OpenSubmenu(name ?? item.Name, items, x, y);
openedSubmenu = true;
},
this.SelectedParentAddon,
this.SelectedAgent,
this.SelectedMenuType.Value,
this.SelectedEventInterfaces));
}
catch (Exception e)
{
Log.Error(e, "Error while handling context menu click");
}
// Close with clicky sound
if (!openedSubmenu)
addon->AtkUnitBase.FireCallbackInt(-2);
return false;
}
original:
// Eventually handled by inventorycontext here: 14022BBD0 (6.51)
return this.addonContextMenuOnMenuSelectedHook.Original(addon, selectedIdx, a3);
}
}
/// <summary>
/// Plugin-scoped version of a <see cref="ContextMenu"/> service.
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<IContextMenu>]
#pragma warning restore SA1015
internal class ContextMenuPluginScoped : IDisposable, IServiceType, IContextMenu
{
[ServiceManager.ServiceDependency]
private readonly ContextMenu parentService = Service<ContextMenu>.Get();
private ContextMenuPluginScoped()
{
this.parentService.OnMenuOpened += this.OnMenuOpenedForward;
}
/// <inheritdoc/>
public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened;
private Dictionary<ContextMenuType, List<MenuItem>> MenuItems { get; } = new();
private object MenuItemsLock { get; } = new();
/// <inheritdoc/>
public void Dispose()
{
this.parentService.OnMenuOpened -= this.OnMenuOpenedForward;
this.OnMenuOpened = null;
lock (this.MenuItemsLock)
{
foreach (var (menuType, items) in this.MenuItems)
{
foreach (var item in items)
this.parentService.RemoveMenuItem(menuType, item);
}
}
}
/// <inheritdoc/>
public void AddMenuItem(ContextMenuType menuType, MenuItem item)
{
lock (this.MenuItemsLock)
{
if (!this.MenuItems.TryGetValue(menuType, out var items))
this.MenuItems[menuType] = items = new();
items.Add(item);
}
this.parentService.AddMenuItem(menuType, item);
}
/// <inheritdoc/>
public bool RemoveMenuItem(ContextMenuType menuType, MenuItem item)
{
lock (this.MenuItemsLock)
{
if (this.MenuItems.TryGetValue(menuType, out var items))
items.Remove(item);
}
return this.parentService.RemoveMenuItem(menuType, item);
}
private void OnMenuOpenedForward(MenuOpenedArgs args) =>
this.OnMenuOpened?.Invoke(args);
}

View file

@ -0,0 +1,18 @@
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// The type of context menu.
/// Each one has a different associated <see cref="MenuTarget"/>.
/// </summary>
public enum ContextMenuType
{
/// <summary>
/// The default context menu.
/// </summary>
Default,
/// <summary>
/// The inventory context menu. Used when right-clicked on an item.
/// </summary>
Inventory,
}

View file

@ -0,0 +1,77 @@
using System.Collections.Generic;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Base class for <see cref="IContextMenu"/> menu args.
/// </summary>
public abstract unsafe class MenuArgs
{
private IReadOnlySet<nint>? eventInterfaces;
/// <summary>
/// Initializes a new instance of the <see cref="MenuArgs"/> class.
/// </summary>
/// <param name="addon">Addon associated with the context menu.</param>
/// <param name="agent">Agent associated with the context menu.</param>
/// <param name="type">The type of context menu.</param>
/// <param name="eventInterfaces">List of AtkEventInterfaces associated with the context menu.</param>
protected internal MenuArgs(AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet<nint>? eventInterfaces)
{
this.AddonName = addon != null ? MemoryHelper.ReadString((nint)addon->Name, 32) : null;
this.AddonPtr = (nint)addon;
this.AgentPtr = (nint)agent;
this.MenuType = type;
this.eventInterfaces = eventInterfaces;
this.Target = type switch
{
ContextMenuType.Default => new MenuTargetDefault((AgentContext*)agent),
ContextMenuType.Inventory => new MenuTargetInventory((AgentInventoryContext*)agent),
_ => throw new ArgumentException("Invalid context menu type", nameof(type)),
};
}
/// <summary>
/// Gets the name of the addon that opened the context menu.
/// </summary>
public string? AddonName { get; }
/// <summary>
/// Gets the memory pointer of the addon that opened the context menu.
/// </summary>
public nint AddonPtr { get; }
/// <summary>
/// Gets the memory pointer of the agent that opened the context menu.
/// </summary>
public nint AgentPtr { get; }
/// <summary>
/// Gets the type of the context menu.
/// </summary>
public ContextMenuType MenuType { get; }
/// <summary>
/// Gets the target info of the context menu. The actual type depends on <see cref="MenuType"/>.
/// <see cref="ContextMenuType.Default"/> signifies a <see cref="MenuTargetDefault"/>.
/// <see cref="ContextMenuType.Inventory"/> signifies a <see cref="MenuTargetInventory"/>.
/// </summary>
public MenuTarget Target { get; }
/// <summary>
/// Gets a list of AtkEventInterface pointers associated with the context menu.
/// Only available with <see cref="ContextMenuType.Default"/>.
/// Almost always an agent pointer. You can use this to find out what type of context menu it is.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the context menu is not a <see cref="ContextMenuType.Default"/>.</exception>
public IReadOnlySet<nint> EventInterfaces =>
this.MenuType != ContextMenuType.Default ?
this.eventInterfaces :
throw new InvalidOperationException("Not a default context menu");
}

View file

@ -0,0 +1,91 @@
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Lumina.Excel.GeneratedSheets;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// A menu item that can be added to a context menu.
/// </summary>
public sealed record MenuItem
{
/// <summary>
/// Gets or sets the display name of the menu item.
/// </summary>
public SeString Name { get; set; } = SeString.Empty;
/// <summary>
/// Gets or sets the prefix attached to the beginning of <see cref="Name"/>.
/// </summary>
public SeIconChar? Prefix { get; set; }
/// <summary>
/// Sets the character to prefix the <see cref="Name"/> with. Will be converted into a fancy boxed letter icon. Must be an uppercase letter.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="value"/> must be an uppercase letter.</exception>
public char? PrefixChar
{
set
{
if (value is { } prefix)
{
if (!char.IsAsciiLetterUpper(prefix))
throw new ArgumentException("Prefix must be an uppercase letter", nameof(value));
this.Prefix = SeIconChar.BoxedLetterA + prefix - 'A';
}
else
{
this.Prefix = null;
}
}
}
/// <summary>
/// Gets or sets the color of the <see cref="Prefix"/>. Specifies a <see cref="UIColor"/> row id.
/// </summary>
public ushort PrefixColor { get; set; }
/// <summary>
/// Gets or sets the callback to be invoked when the menu item is clicked.
/// </summary>
public Action<MenuItemClickedArgs>? OnClicked { get; set; }
/// <summary>
/// Gets or sets the priority (or order) with which the menu item should be displayed in descending order.
/// Priorities below 0 will be displayed above the native menu items.
/// Other priorities will be displayed below the native menu items.
/// </summary>
public int Priority { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the menu item is enabled.
/// Disabled items will be faded and cannot be clicked on.
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the menu item is a submenu.
/// This value is purely visual. Submenu items will have an arrow to its right.
/// </summary>
public bool IsSubmenu { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the menu item is a return item.
/// This value is purely visual. Return items will have a back arrow to its left.
/// If both <see cref="IsSubmenu"/> and <see cref="IsReturn"/> are true, the return arrow will take precedence.
/// </summary>
public bool IsReturn { get; set; }
/// <summary>
/// Gets the name with the given prefix.
/// </summary>
internal SeString PrefixedName =>
this.Prefix is { } prefix
? new SeStringBuilder()
.AddUiForeground($"{prefix.ToIconString()} ", this.PrefixColor)
.Append(this.Name)
.Build()
: this.Name;
}

View file

@ -0,0 +1,44 @@
using System.Collections.Generic;
using Dalamud.Game.Text.SeStringHandling;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Callback args used when a menu item is clicked.
/// </summary>
public sealed unsafe class MenuItemClickedArgs : MenuArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuItemClickedArgs"/> class.
/// </summary>
/// <param name="openSubmenu">Callback for opening a submenu.</param>
/// <param name="addon">Addon associated with the context menu.</param>
/// <param name="agent">Agent associated with the context menu.</param>
/// <param name="type">The type of context menu.</param>
/// <param name="eventInterfaces">List of AtkEventInterfaces associated with the context menu.</param>
internal MenuItemClickedArgs(Action<SeString?, IReadOnlyList<MenuItem>> openSubmenu, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet<nint> eventInterfaces)
: base(addon, agent, type, eventInterfaces)
{
this.OnOpenSubmenu = openSubmenu;
}
private Action<SeString?, IReadOnlyList<MenuItem>> OnOpenSubmenu { get; }
/// <summary>
/// Opens a submenu with the given name and items.
/// </summary>
/// <param name="name">The name of the submenu, displayed at the top.</param>
/// <param name="items">The items to display in the submenu.</param>
public void OpenSubmenu(SeString name, IReadOnlyList<MenuItem> items) =>
this.OnOpenSubmenu(name, items);
/// <summary>
/// Opens a submenu with the given items.
/// </summary>
/// <param name="items">The items to display in the submenu.</param>
public void OpenSubmenu(IReadOnlyList<MenuItem> items) =>
this.OnOpenSubmenu(null, items);
}

View file

@ -0,0 +1,34 @@
using System.Collections.Generic;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Callback args used when a menu item is opened.
/// </summary>
public sealed unsafe class MenuOpenedArgs : MenuArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuOpenedArgs"/> class.
/// </summary>
/// <param name="addMenuItem">Callback for adding a custom menu item.</param>
/// <param name="addon">Addon associated with the context menu.</param>
/// <param name="agent">Agent associated with the context menu.</param>
/// <param name="type">The type of context menu.</param>
/// <param name="eventInterfaces">List of AtkEventInterfaces associated with the context menu.</param>
internal MenuOpenedArgs(Action<MenuItem> addMenuItem, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet<nint> eventInterfaces)
: base(addon, agent, type, eventInterfaces)
{
this.OnAddMenuItem = addMenuItem;
}
private Action<MenuItem> OnAddMenuItem { get; }
/// <summary>
/// Adds a custom menu item to the context menu.
/// </summary>
/// <param name="item">The menu item to add.</param>
public void AddMenuItem(MenuItem item) =>
this.OnAddMenuItem(item);
}

View file

@ -0,0 +1,9 @@
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Base class for <see cref="MenuArgs"/> contexts.
/// Discriminated based on <see cref="ContextMenuType"/>.
/// </summary>
public abstract class MenuTarget
{
}

View file

@ -0,0 +1,67 @@
using Dalamud.Game.ClientState.Objects;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Game.ClientState.Resolvers;
using Dalamud.Game.Network.Structures.InfoProxy;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Lumina.Excel.GeneratedSheets;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Target information on a default context menu.
/// </summary>
public sealed unsafe class MenuTargetDefault : MenuTarget
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuTargetDefault"/> class.
/// </summary>
/// <param name="context">The agent associated with the context menu.</param>
internal MenuTargetDefault(AgentContext* context)
{
this.Context = context;
}
/// <summary>
/// Gets the name of the target.
/// </summary>
public string TargetName => this.Context->TargetName.ToString();
/// <summary>
/// Gets the object id of the target.
/// </summary>
public ulong TargetObjectId => this.Context->TargetObjectId;
/// <summary>
/// Gets the target object.
/// </summary>
public GameObject? TargetObject => Service<ObjectTable>.Get().SearchById(this.TargetObjectId);
/// <summary>
/// Gets the content id of the target.
/// </summary>
public ulong TargetContentId => this.Context->TargetContentId;
/// <summary>
/// Gets the home world id of the target.
/// </summary>
public ExcelResolver<World> TargetHomeWorld => new((uint)this.Context->TargetHomeWorldId);
/// <summary>
/// Gets the currently targeted character. Only shows up for specific targets, like friends, party finder listings, or party members.
/// Just because this is <see langword="null"/> doesn't mean the target isn't a character.
/// </summary>
public CharacterData? TargetCharacter
{
get
{
var target = this.Context->CurrentContextMenuTarget;
if (target != null)
return new(target);
return null;
}
}
private AgentContext* Context { get; }
}

View file

@ -0,0 +1,36 @@
using Dalamud.Game.Inventory;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Target information on an inventory context menu.
/// </summary>
public sealed unsafe class MenuTargetInventory : MenuTarget
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuTargetInventory"/> class.
/// </summary>
/// <param name="context">The agent associated with the context menu.</param>
internal MenuTargetInventory(AgentInventoryContext* context)
{
this.Context = context;
}
/// <summary>
/// Gets the target item.
/// </summary>
public GameInventoryItem? TargetItem
{
get
{
var target = this.Context->TargetInventorySlot;
if (target != null)
return new(*target);
return null;
}
}
private AgentInventoryContext* Context { get; }
}

View file

@ -1,7 +1,10 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace Dalamud.Game.Inventory;
@ -103,8 +106,10 @@ public unsafe struct GameInventoryItem : IEquatable<GameInventoryItem>
/// <summary>
/// Gets the array of materia grades.
/// </summary>
// TODO: Replace with MateriaGradeBytes
[Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)]
public ReadOnlySpan<ushort> MateriaGrade =>
new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5);
this.MateriaGradeBytes.ToArray().Select(g => (ushort)g).ToArray().AsSpan();
/// <summary>
/// Gets the address of native inventory item in the game.<br />
@ -146,6 +151,9 @@ public unsafe struct GameInventoryItem : IEquatable<GameInventoryItem>
/// </summary>
internal ulong CrafterContentId => this.InternalItem.CrafterContentID;
private ReadOnlySpan<byte> MateriaGradeBytes =>
new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5);
public static bool operator ==(in GameInventoryItem l, in GameInventoryItem r) => l.Equals(r);
public static bool operator !=(in GameInventoryItem l, in GameInventoryItem r) => !l.Equals(r);

View file

@ -12,6 +12,7 @@ using Dalamud.Game.Gui;
using Dalamud.Game.Network.Internal.MarketBoardUploaders;
using Dalamud.Game.Network.Internal.MarketBoardUploaders.Universalis;
using Dalamud.Game.Network.Structures;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Networking.Http;
using Dalamud.Utility;
@ -268,8 +269,8 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType
return result;
}
var cfcName = cfCondition.Name.ToString();
if (cfcName.IsNullOrEmpty())
var cfcName = cfCondition.Name.ToDalamudString();
if (cfcName.Payloads.Count == 0)
{
cfcName = "Duty Roulette";
cfCondition.Image = 112324;
@ -279,7 +280,10 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType
{
if (this.configuration.DutyFinderChatMessage)
{
Service<ChatGui>.GetNullable()?.Print($"Duty pop: {cfcName}");
var b = new SeStringBuilder();
b.Append("Duty pop: ");
b.Append(cfcName);
Service<ChatGui>.GetNullable()?.Print(b.Build());
}
this.CfPop.InvokeSafely(cfCondition);

View file

@ -0,0 +1,197 @@
using System.Collections.Generic;
using Dalamud.Game.ClientState.Resolvers;
using Dalamud.Memory;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Lumina.Excel.GeneratedSheets;
namespace Dalamud.Game.Network.Structures.InfoProxy;
/// <summary>
/// Dalamud wrapper around a client structs <see cref="InfoProxyCommonList.CharacterData"/>.
/// </summary>
public unsafe class CharacterData
{
/// <summary>
/// Initializes a new instance of the <see cref="CharacterData"/> class.
/// </summary>
/// <param name="data">Character data to wrap.</param>
internal CharacterData(InfoProxyCommonList.CharacterData* data)
{
this.Address = (nint)data;
}
/// <summary>
/// Gets the address of the <see cref="InfoProxyCommonList.CharacterData"/> in memory.
/// </summary>
public nint Address { get; }
/// <summary>
/// Gets the content id of the character.
/// </summary>
public ulong ContentId => this.Struct->ContentId;
/// <summary>
/// Gets the status mask of the character.
/// </summary>
public ulong StatusMask => (ulong)this.Struct->State;
/// <summary>
/// Gets the applicable statues of the character.
/// </summary>
public IReadOnlyList<ExcelResolver<OnlineStatus>> Statuses
{
get
{
var statuses = new List<ExcelResolver<OnlineStatus>>();
for (var i = 0; i < 64; i++)
{
if ((this.StatusMask & (1UL << i)) != 0)
statuses.Add(new((uint)i));
}
return statuses;
}
}
/// <summary>
/// Gets the display group of the character.
/// </summary>
public DisplayGroup DisplayGroup => (DisplayGroup)this.Struct->Group;
/// <summary>
/// Gets a value indicating whether the character's home world is different from the current world.
/// </summary>
public bool IsFromOtherServer => this.Struct->IsOtherServer;
/// <summary>
/// Gets the sort order of the character.
/// </summary>
public byte Sort => this.Struct->Sort;
/// <summary>
/// Gets the current world of the character.
/// </summary>
public ExcelResolver<World> CurrentWorld => new(this.Struct->CurrentWorld);
/// <summary>
/// Gets the home world of the character.
/// </summary>
public ExcelResolver<World> HomeWorld => new(this.Struct->HomeWorld);
/// <summary>
/// Gets the location of the character.
/// </summary>
public ExcelResolver<TerritoryType> Location => new(this.Struct->Location);
/// <summary>
/// Gets the grand company of the character.
/// </summary>
public ExcelResolver<GrandCompany> GrandCompany => new((uint)this.Struct->GrandCompany);
/// <summary>
/// Gets the primary client language of the character.
/// </summary>
public ClientLanguage ClientLanguage => (ClientLanguage)this.Struct->ClientLanguage;
/// <summary>
/// Gets the supported language mask of the character.
/// </summary>
public byte LanguageMask => (byte)this.Struct->Languages;
/// <summary>
/// Gets the supported languages the character supports.
/// </summary>
public IReadOnlyList<ClientLanguage> Languages
{
get
{
var languages = new List<ClientLanguage>();
for (var i = 0; i < 4; i++)
{
if ((this.LanguageMask & (1 << i)) != 0)
languages.Add((ClientLanguage)i);
}
return languages;
}
}
/// <summary>
/// Gets the gender of the character.
/// </summary>
public byte Gender => this.Struct->Sex;
/// <summary>
/// Gets the job of the character.
/// </summary>
public ExcelResolver<ClassJob> ClassJob => new(this.Struct->Job);
/// <summary>
/// Gets the name of the character.
/// </summary>
public string Name => MemoryHelper.ReadString((nint)this.Struct->Name, 32);
/// <summary>
/// Gets the free company tag of the character.
/// </summary>
public string FCTag => MemoryHelper.ReadString((nint)this.Struct->Name, 6);
/// <summary>
/// Gets the underlying <see cref="InfoProxyCommonList.CharacterData"/> struct.
/// </summary>
internal InfoProxyCommonList.CharacterData* Struct => (InfoProxyCommonList.CharacterData*)this.Address;
}
/// <summary>
/// Display group of a character. Used for friends.
/// </summary>
public enum DisplayGroup : sbyte
{
/// <summary>
/// All display groups.
/// </summary>
All = -1,
/// <summary>
/// No display group.
/// </summary>
None,
/// <summary>
/// Star display group.
/// </summary>
Star,
/// <summary>
/// Circle display group.
/// </summary>
Circle,
/// <summary>
/// Triangle display group.
/// </summary>
Triangle,
/// <summary>
/// Diamond display group.
/// </summary>
Diamond,
/// <summary>
/// Heart display group.
/// </summary>
Heart,
/// <summary>
/// Spade display group.
/// </summary>
Spade,
/// <summary>
/// Club display group.
/// </summary>
Club,
}

View file

@ -9,6 +9,7 @@ using Dalamud.Configuration.Internal;
using Dalamud.Interface.Colors;
using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.ManagedFontAtlas.Internals;
using Dalamud.Interface.Utility;
using Dalamud.Utility;
@ -84,11 +85,22 @@ public sealed class SingleFontChooserDialog : IDisposable
private IFontHandle? fontHandle;
private SingleFontSpec selectedFont;
/// <summary>
/// Initializes a new instance of the <see cref="SingleFontChooserDialog"/> class.
/// </summary>
private bool popupPositionChanged;
private bool popupSizeChanged;
private Vector2 popupPosition = new(float.NaN);
private Vector2 popupSize = new(float.NaN);
/// <summary>Initializes a new instance of the <see cref="SingleFontChooserDialog"/> class.</summary>
/// <param name="newAsyncAtlas">A new instance of <see cref="IFontAtlas"/> created using
/// <see cref="FontAtlasAutoRebuildMode.Async"/> as its auto-rebuild mode.</param>
/// <remarks>The passed instance of <see cref="newAsyncAtlas"/> will be disposed after use. If you pass an atlas
/// that is already being used, then all the font handles under the passed atlas will be invalidated upon disposing
/// this font chooser. Consider using <see cref="SingleFontChooserDialog(UiBuilder, bool, string?)"/> for automatic
/// handling of font atlas derived from a <see cref="UiBuilder"/>, or even <see cref="CreateAuto"/> for automatic
/// registration and unregistration of <see cref="Draw"/> event handler in addition to automatic disposal of this
/// class and the temporary font atlas for this font chooser dialog.</remarks>
[Obsolete("See remarks, and use the other constructor.", false)]
[Api10ToDo("Make private.")]
public SingleFontChooserDialog(IFontAtlas newAsyncAtlas)
{
this.counter = Interlocked.Increment(ref counterStatic);
@ -99,6 +111,39 @@ public sealed class SingleFontChooserDialog : IDisposable
Encoding.UTF8.GetBytes("Font preview.\n0123456789!", this.fontPreviewText);
}
#pragma warning disable CS0618 // Type or member is obsolete
// TODO: Api10ToDo; Remove this pragma warning disable line
/// <summary>Initializes a new instance of the <see cref="SingleFontChooserDialog"/> class.</summary>
/// <param name="uiBuilder">The relevant instance of UiBuilder.</param>
/// <param name="isGlobalScaled">Whether the fonts in the atlas is global scaled.</param>
/// <param name="debugAtlasName">Atlas name for debugging purposes.</param>
/// <remarks>
/// <para>The passed <see cref="UiBuilder"/> is only used for creating a temporary font atlas. It will not
/// automatically register a hander for <see cref="UiBuilder.Draw"/>.</para>
/// <para>Consider using <see cref="CreateAuto"/> for automatic registration and unregistration of
/// <see cref="Draw"/> event handler in addition to automatic disposal of this class and the temporary font atlas
/// for this font chooser dialog.</para>
/// </remarks>
public SingleFontChooserDialog(UiBuilder uiBuilder, bool isGlobalScaled = true, string? debugAtlasName = null)
: this(uiBuilder.CreateFontAtlas(FontAtlasAutoRebuildMode.Async, isGlobalScaled, debugAtlasName))
{
}
/// <summary>Initializes a new instance of the <see cref="SingleFontChooserDialog"/> class.</summary>
/// <param name="factory">An instance of <see cref="FontAtlasFactory"/>.</param>
/// <param name="debugAtlasName">The temporary atlas name.</param>
internal SingleFontChooserDialog(FontAtlasFactory factory, string debugAtlasName)
: this(factory.CreateFontAtlas(debugAtlasName, FontAtlasAutoRebuildMode.Async))
{
}
#pragma warning restore CS0618 // Type or member is obsolete
// TODO: Api10ToDo; Remove this pragma warning restore line
/// <summary>Called when the selected font spec has changed.</summary>
public event Action<SingleFontSpec>? SelectedFontSpecChanged;
/// <summary>
/// Gets or sets the title of this font chooser dialog popup.
/// </summary>
@ -153,6 +198,8 @@ public sealed class SingleFontChooserDialog : IDisposable
this.useAdvancedOptions |= Math.Abs(value.LineHeight - 1f) > 0.000001;
this.useAdvancedOptions |= value.GlyphOffset != default;
this.useAdvancedOptions |= value.LetterSpacing != 0f;
this.SelectedFontSpecChanged?.Invoke(this.selectedFont);
}
}
@ -166,15 +213,55 @@ public sealed class SingleFontChooserDialog : IDisposable
/// </summary>
public bool IgnorePreviewGlobalScale { get; set; }
/// <summary>
/// Creates a new instance of <see cref="SingleFontChooserDialog"/> that will automatically draw and dispose itself as
/// needed.
/// <summary>Gets or sets a value indicating whether this popup should be modal, blocking everything behind from
/// being interacted.</summary>
/// <remarks>If <c>true</c>, then <see cref="ImGui.BeginPopupModal(string, ref bool, ImGuiWindowFlags)"/> will be
/// used. Otherwise, <see cref="ImGui.Begin(string, ref bool, ImGuiWindowFlags)"/> will be used.</remarks>
public bool IsModal { get; set; } = true;
/// <summary>Gets or sets the window flags.</summary>
public ImGuiWindowFlags WindowFlags { get; set; }
/// <summary>Gets or sets the popup window position.</summary>
/// <remarks>
/// <para>Setting the position only works before the first call to <see cref="Draw"/>.</para>
/// <para>If any of the coordinates are <see cref="float.NaN"/>, default position will be used.</para>
/// <para>The position will be clamped into the work area of the selected monitor.</para>
/// </remarks>
public Vector2 PopupPosition
{
get => this.popupPosition;
set
{
this.popupPositionChanged = true;
this.popupPosition = value;
}
}
/// <summary>Gets or sets the popup window size.</summary>
/// <remarks>
/// <para>Setting the size only works before the first call to <see cref="Draw"/>.</para>
/// <para>If any of the coordinates are <see cref="float.NaN"/>, default size will be used.</para>
/// <para>The size will be clamped into the work area of the selected monitor.</para>
/// </remarks>
public Vector2 PopupSize
{
get => this.popupSize;
set
{
this.popupSizeChanged = true;
this.popupSize = value;
}
}
/// <summary>Creates a new instance of <see cref="SingleFontChooserDialog"/> that will automatically draw and
/// dispose itself as needed; calling <see cref="Draw"/> and <see cref="Dispose"/> are handled automatically.
/// </summary>
/// <param name="uiBuilder">An instance of <see cref="UiBuilder"/>.</param>
/// <returns>The new instance of <see cref="SingleFontChooserDialog"/>.</returns>
public static SingleFontChooserDialog CreateAuto(UiBuilder uiBuilder)
{
var fcd = new SingleFontChooserDialog(uiBuilder.CreateFontAtlas(FontAtlasAutoRebuildMode.Async));
var fcd = new SingleFontChooserDialog(uiBuilder);
uiBuilder.Draw += fcd.Draw;
fcd.tcs.Task.ContinueWith(
r =>
@ -187,6 +274,14 @@ public sealed class SingleFontChooserDialog : IDisposable
return fcd;
}
/// <summary>Gets the default popup size before clamping to monitor work area.</summary>
/// <returns>The default popup size.</returns>
public static Vector2 GetDefaultPopupSizeNonClamped()
{
ThreadSafety.AssertMainThread();
return new Vector2(40, 30) * ImGui.GetTextLineHeight();
}
/// <inheritdoc/>
public void Dispose()
{
@ -204,13 +299,28 @@ public sealed class SingleFontChooserDialog : IDisposable
ImGui.GetIO().WantTextInput = false;
}
/// <summary>Sets <see cref="PopupSize"/> and <see cref="PopupPosition"/> to be at the center of the current window
/// being drawn.</summary>
/// <param name="preferredPopupSize">The preferred popup size.</param>
public void SetPopupPositionAndSizeToCurrentWindowCenter(Vector2 preferredPopupSize)
{
ThreadSafety.AssertMainThread();
this.PopupSize = preferredPopupSize;
this.PopupPosition = ImGui.GetWindowPos() + ((ImGui.GetWindowSize() - preferredPopupSize) / 2);
}
/// <summary>Sets <see cref="PopupSize"/> and <see cref="PopupPosition"/> to be at the center of the current window
/// being drawn.</summary>
public void SetPopupPositionAndSizeToCurrentWindowCenter() =>
this.SetPopupPositionAndSizeToCurrentWindowCenter(GetDefaultPopupSizeNonClamped());
/// <summary>
/// Draws this dialog.
/// </summary>
public void Draw()
{
if (this.firstDraw)
ImGui.OpenPopup(this.popupImGuiName);
const float popupMinWidth = 320;
const float popupMinHeight = 240;
ImGui.GetIO().WantCaptureKeyboard = true;
ImGui.GetIO().WantTextInput = true;
@ -220,12 +330,70 @@ public sealed class SingleFontChooserDialog : IDisposable
return;
}
var open = true;
ImGui.SetNextWindowSize(new(640, 480), ImGuiCond.Appearing);
if (!ImGui.BeginPopupModal(this.popupImGuiName, ref open) || !open)
if (this.firstDraw)
{
this.Cancel();
return;
if (this.IsModal)
ImGui.OpenPopup(this.popupImGuiName);
}
if (this.firstDraw || this.popupPositionChanged || this.popupSizeChanged)
{
var preferProvidedSize = !float.IsNaN(this.popupSize.X) && !float.IsNaN(this.popupSize.Y);
var size = preferProvidedSize ? this.popupSize : GetDefaultPopupSizeNonClamped();
size.X = Math.Max(size.X, popupMinWidth);
size.Y = Math.Max(size.Y, popupMinHeight);
var preferProvidedPos = !float.IsNaN(this.popupPosition.X) && !float.IsNaN(this.popupPosition.Y);
var monitorLocatorPos = preferProvidedPos ? this.popupPosition + (size / 2) : ImGui.GetMousePos();
var monitors = ImGui.GetPlatformIO().Monitors;
var preferredMonitor = 0;
var preferredDistance = GetDistanceFromMonitor(monitorLocatorPos, monitors[0]);
for (var i = 1; i < monitors.Size; i++)
{
var distance = GetDistanceFromMonitor(monitorLocatorPos, monitors[i]);
if (distance < preferredDistance)
{
preferredMonitor = i;
preferredDistance = distance;
}
}
var lt = monitors[preferredMonitor].WorkPos;
var workSize = monitors[preferredMonitor].WorkSize;
size.X = Math.Min(size.X, workSize.X);
size.Y = Math.Min(size.Y, workSize.Y);
var rb = (lt + workSize) - size;
var pos =
preferProvidedPos
? new(Math.Clamp(this.PopupPosition.X, lt.X, rb.X), Math.Clamp(this.PopupPosition.Y, lt.Y, rb.Y))
: (lt + rb) / 2;
ImGui.SetNextWindowSize(size, ImGuiCond.Always);
ImGui.SetNextWindowPos(pos, ImGuiCond.Always);
this.popupPositionChanged = this.popupSizeChanged = false;
}
ImGui.SetNextWindowSizeConstraints(new(popupMinWidth, popupMinHeight), new(float.MaxValue));
if (this.IsModal)
{
var open = true;
if (!ImGui.BeginPopupModal(this.popupImGuiName, ref open, this.WindowFlags) || !open)
{
this.Cancel();
return;
}
}
else
{
var open = true;
if (!ImGui.Begin(this.popupImGuiName, ref open, this.WindowFlags) || !open)
{
ImGui.End();
this.Cancel();
return;
}
}
var framePad = ImGui.GetStyle().FramePadding;
@ -261,12 +429,36 @@ public sealed class SingleFontChooserDialog : IDisposable
ImGui.EndChild();
ImGui.EndPopup();
this.popupPosition = ImGui.GetWindowPos();
this.popupSize = ImGui.GetWindowSize();
if (this.IsModal)
ImGui.EndPopup();
else
ImGui.End();
this.firstDraw = false;
this.firstDrawAfterRefresh = false;
}
private static float GetDistanceFromMonitor(Vector2 point, ImGuiPlatformMonitorPtr monitor)
{
var lt = monitor.MainPos;
var rb = monitor.MainPos + monitor.MainSize;
var xoff =
point.X < lt.X
? lt.X - point.X
: point.X > rb.X
? point.X - rb.X
: 0;
var yoff =
point.Y < lt.Y
? lt.Y - point.Y
: point.Y > rb.Y
? point.Y - rb.Y
: 0;
return MathF.Sqrt((xoff * xoff) + (yoff * yoff));
}
private void DrawChoices()
{
var lineHeight = ImGui.GetTextLineHeight();
@ -338,15 +530,20 @@ public sealed class SingleFontChooserDialog : IDisposable
}
}
if (this.IgnorePreviewGlobalScale)
if (this.fontHandle is null)
{
this.fontHandle ??= this.selectedFont.CreateFontHandle(
this.atlas,
tk => tk.OnPreBuild(e => e.SetFontScaleMode(e.Font, FontScaleMode.UndoGlobalScale)));
}
else
{
this.fontHandle ??= this.selectedFont.CreateFontHandle(this.atlas);
if (this.IgnorePreviewGlobalScale)
{
this.fontHandle = this.selectedFont.CreateFontHandle(
this.atlas,
tk => tk.OnPreBuild(e => e.SetFontScaleMode(e.Font, FontScaleMode.UndoGlobalScale)));
}
else
{
this.fontHandle = this.selectedFont.CreateFontHandle(this.atlas);
}
this.SelectedFontSpecChanged?.InvokeSafely(this.selectedFont);
}
if (this.fontHandle is null)

View file

@ -96,12 +96,6 @@ internal class DalamudCommands : IServiceType
ShowInHelp = false,
});
commandManager.AddHandler("/xlime", new CommandInfo(this.OnDebugDrawIMEPanel)
{
HelpMessage = Loc.Localize("DalamudIMEPanelHelp", "Draw IME panel"),
ShowInHelp = false,
});
commandManager.AddHandler("/xllog", new CommandInfo(this.OnOpenLog)
{
HelpMessage = Loc.Localize("DalamudDevLogHelp", "Open dev log DEBUG"),
@ -308,11 +302,6 @@ internal class DalamudCommands : IServiceType
dalamudInterface.ToggleDataWindow(arguments);
}
private void OnDebugDrawIMEPanel(string command, string arguments)
{
Service<DalamudInterface>.Get().OpenImeWindow();
}
private void OnOpenLog(string command, string arguments)
{
Service<DalamudInterface>.Get().ToggleLogWindow();

File diff suppressed because it is too large Load diff

View file

@ -61,7 +61,6 @@ internal class DalamudInterface : IDisposable, IServiceType
private readonly ComponentDemoWindow componentDemoWindow;
private readonly DataWindow dataWindow;
private readonly GamepadModeNotifierWindow gamepadModeNotifierWindow;
private readonly DalamudImeWindow imeWindow;
private readonly ConsoleWindow consoleWindow;
private readonly PluginStatWindow pluginStatWindow;
private readonly PluginInstallerWindow pluginWindow;
@ -114,7 +113,6 @@ 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 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 };
@ -142,7 +140,6 @@ internal class DalamudInterface : IDisposable, IServiceType
this.WindowSystem.AddWindow(this.componentDemoWindow);
this.WindowSystem.AddWindow(this.dataWindow);
this.WindowSystem.AddWindow(this.gamepadModeNotifierWindow);
this.WindowSystem.AddWindow(this.imeWindow);
this.WindowSystem.AddWindow(this.consoleWindow);
this.WindowSystem.AddWindow(this.pluginStatWindow);
this.WindowSystem.AddWindow(this.pluginWindow);
@ -265,11 +262,6 @@ internal class DalamudInterface : IDisposable, IServiceType
/// </summary>
public void OpenGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.IsOpen = true;
/// <summary>
/// Opens the <see cref="DalamudImeWindow"/>.
/// </summary>
public void OpenImeWindow() => this.imeWindow.IsOpen = true;
/// <summary>
/// Opens the <see cref="ConsoleWindow"/>.
/// </summary>
@ -365,11 +357,6 @@ internal class DalamudInterface : IDisposable, IServiceType
#region Close
/// <summary>
/// Closes the <see cref="DalamudImeWindow"/>.
/// </summary>
public void CloseImeWindow() => this.imeWindow.IsOpen = false;
/// <summary>
/// Closes the <see cref="GamepadModeNotifierWindow"/>.
/// </summary>
@ -417,11 +404,6 @@ internal class DalamudInterface : IDisposable, IServiceType
/// </summary>
public void ToggleGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.Toggle();
/// <summary>
/// Toggles the <see cref="DalamudImeWindow"/>.
/// </summary>
public void ToggleImeWindow() => this.imeWindow.Toggle();
/// <summary>
/// Toggles the <see cref="ConsoleWindow"/>.
/// </summary>

View file

@ -68,9 +68,6 @@ internal class InterfaceManager : IDisposable, IServiceType
[ServiceManager.ServiceDependency]
private readonly WndProcHookManager wndProcHookManager = Service<WndProcHookManager>.Get();
[ServiceManager.ServiceDependency]
private readonly DalamudIme dalamudIme = Service<DalamudIme>.Get();
private readonly SwapChainVtableResolver address = new();
private readonly Hook<SetCursorDelegate> setCursorHook;
@ -628,8 +625,6 @@ internal class InterfaceManager : IDisposable, IServiceType
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);
}
/*

File diff suppressed because it is too large Load diff

View file

@ -1,266 +0,0 @@
using System.Numerics;
using Dalamud.Interface.Windowing;
using ImGuiNET;
namespace Dalamud.Interface.Internal.Windows;
/// <summary>
/// A window for displaying IME details.
/// </summary>
internal unsafe class DalamudImeWindow : Window
{
private const int ImePageSize = 9;
/// <summary>
/// Initializes a new instance of the <see cref="DalamudImeWindow"/> class.
/// </summary>
public DalamudImeWindow()
: base(
"Dalamud IME",
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoBackground)
{
this.Size = default(Vector2);
this.RespectCloseHotkey = false;
}
/// <inheritdoc/>
public override void Draw()
{
}
/// <inheritdoc/>
public override void PostDraw()
{
if (Service<DalamudIme>.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));
}
}
}
}

View file

@ -44,6 +44,8 @@ internal class GamePrebakedFontsTestWidget : IDataWindowWidget, IDisposable
private bool useBold;
private bool useMinimumBuild;
private SingleFontChooserDialog? chooserDialog;
/// <inheritdoc/>
public string[]? CommandShortcuts { get; init; }
@ -126,32 +128,75 @@ internal class GamePrebakedFontsTestWidget : IDataWindowWidget, IDisposable
if (ImGui.Button("Test Lock"))
Task.Run(this.TestLock);
ImGui.SameLine();
if (ImGui.Button("Choose Editor Font"))
{
var fcd = new SingleFontChooserDialog(
Service<FontAtlasFactory>.Get().CreateFontAtlas(
$"{nameof(GamePrebakedFontsTestWidget)}:EditorFont",
FontAtlasAutoRebuildMode.Async));
fcd.SelectedFont = this.fontSpec;
fcd.IgnorePreviewGlobalScale = !this.atlasScaleMode;
Service<InterfaceManager>.Get().Draw += fcd.Draw;
fcd.ResultTask.ContinueWith(
r => Service<Framework>.Get().RunOnFrameworkThread(
() =>
{
Service<InterfaceManager>.Get().Draw -= fcd.Draw;
fcd.Dispose();
if (this.chooserDialog is null)
{
DoNext();
}
else
{
this.chooserDialog.Cancel();
this.chooserDialog.ResultTask.ContinueWith(_ => Service<Framework>.Get().RunOnFrameworkThread(DoNext));
this.chooserDialog = null;
}
_ = r.Exception;
if (!r.IsCompletedSuccessfully)
return;
void DoNext()
{
var fcd = new SingleFontChooserDialog(
Service<FontAtlasFactory>.Get(),
$"{nameof(GamePrebakedFontsTestWidget)}:EditorFont");
this.chooserDialog = fcd;
fcd.SelectedFont = this.fontSpec;
fcd.IgnorePreviewGlobalScale = !this.atlasScaleMode;
fcd.IsModal = false;
Service<InterfaceManager>.Get().Draw += fcd.Draw;
var prevSpec = this.fontSpec;
fcd.SelectedFontSpecChanged += spec =>
{
this.fontSpec = spec;
Log.Information("Selected font: {font}", this.fontSpec);
this.fontDialogHandle?.Dispose();
this.fontDialogHandle = null;
};
fcd.ResultTask.ContinueWith(
r => Service<Framework>.Get().RunOnFrameworkThread(
() =>
{
Service<InterfaceManager>.Get().Draw -= fcd.Draw;
fcd.Dispose();
this.fontSpec = r.Result;
Log.Information("Selected font: {font}", this.fontSpec);
this.fontDialogHandle?.Dispose();
this.fontDialogHandle = null;
}));
_ = r.Exception;
var spec = r.IsCompletedSuccessfully ? r.Result : prevSpec;
if (this.fontSpec != spec)
{
this.fontSpec = spec;
this.fontDialogHandle?.Dispose();
this.fontDialogHandle = null;
}
this.chooserDialog = null;
}));
}
}
if (this.chooserDialog is not null)
{
ImGui.SameLine();
ImGui.TextUnformatted($"{this.chooserDialog.PopupPosition}, {this.chooserDialog.PopupSize}");
ImGui.SameLine();
if (ImGui.Button("Random Location"))
{
var monitors = ImGui.GetPlatformIO().Monitors;
var monitor = monitors[Random.Shared.Next() % monitors.Size];
this.chooserDialog.PopupPosition = monitor.WorkPos + (monitor.WorkSize * new Vector2(
Random.Shared.NextSingle(),
Random.Shared.NextSingle()));
this.chooserDialog.PopupSize = monitor.WorkSize * new Vector2(
Random.Shared.NextSingle(),
Random.Shared.NextSingle());
}
}
this.privateAtlas ??=

View file

@ -1,13 +1,22 @@
// ReSharper disable MethodSupportsCancellation // Using alternative method of cancelling tasks by throwing exceptions.
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Game;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Logging.Internal;
using Dalamud.Utility;
using ImGuiNET;
using Serilog;
@ -18,6 +27,12 @@ namespace Dalamud.Interface.Internal.Windows.Data.Widgets;
/// </summary>
internal class TaskSchedulerWidget : IDataWindowWidget
{
private readonly FileDialogManager fileDialogManager = new();
private readonly byte[] urlBytes = new byte[2048];
private readonly byte[] localPathBytes = new byte[2048];
private Task? downloadTask = null;
private (long Downloaded, long Total, float Percentage) downloadState;
private CancellationTokenSource taskSchedulerCancelSource = new();
/// <inheritdoc/>
@ -33,11 +48,16 @@ internal class TaskSchedulerWidget : IDataWindowWidget
public void Load()
{
this.Ready = true;
Encoding.UTF8.GetBytes(
"https://geo.mirror.pkgbuild.com/iso/2024.01.01/archlinux-2024.01.01-x86_64.iso",
this.urlBytes);
}
/// <inheritdoc/>
public void Draw()
{
var framework = Service<Framework>.Get();
if (ImGui.Button("Clear list"))
{
TaskTracker.Clear();
@ -84,8 +104,7 @@ internal class TaskSchedulerWidget : IDataWindowWidget
{
Thread.Sleep(200);
string a = null;
a.Contains("dalamud"); // Intentional null exception.
_ = ((string)null)!.Contains("dalamud"); // Intentional null exception.
});
}
@ -94,36 +113,156 @@ internal class TaskSchedulerWidget : IDataWindowWidget
if (ImGui.Button("ASAP"))
{
Task.Run(async () => await Service<Framework>.Get().RunOnTick(() => { }, cancellationToken: this.taskSchedulerCancelSource.Token));
_ = framework.RunOnTick(() => Log.Information("Framework.Update - ASAP"), cancellationToken: this.taskSchedulerCancelSource.Token);
}
ImGui.SameLine();
if (ImGui.Button("In 1s"))
{
Task.Run(async () => await Service<Framework>.Get().RunOnTick(() => { }, cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1)));
_ = framework.RunOnTick(() => Log.Information("Framework.Update - In 1s"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1));
}
ImGui.SameLine();
if (ImGui.Button("In 60f"))
{
Task.Run(async () => await Service<Framework>.Get().RunOnTick(() => { }, cancellationToken: this.taskSchedulerCancelSource.Token, delayTicks: 60));
_ = framework.RunOnTick(() => Log.Information("Framework.Update - In 60f"), cancellationToken: this.taskSchedulerCancelSource.Token, delayTicks: 60);
}
ImGui.SameLine();
if (ImGui.Button("In 1s+120f"))
{
_ = framework.RunOnTick(() => Log.Information("Framework.Update - In 1s+120f"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1), delayTicks: 120);
}
ImGui.SameLine();
if (ImGui.Button("In 2s+60f"))
{
_ = framework.RunOnTick(() => Log.Information("Framework.Update - In 2s+60f"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(2), delayTicks: 60);
}
ImGui.SameLine();
if (ImGui.Button("Every 60 frames"))
{
_ = framework.RunOnTick(
async () =>
{
for (var i = 0L; ; i++)
{
Log.Information($"Loop #{i}; MainThread={ThreadSafety.IsMainThread}");
await framework.DelayTicks(60, this.taskSchedulerCancelSource.Token);
}
},
cancellationToken: this.taskSchedulerCancelSource.Token);
}
ImGui.SameLine();
if (ImGui.Button("Error in 1s"))
{
Task.Run(async () => await Service<Framework>.Get().RunOnTick(() => throw new Exception("Test Exception"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1)));
_ = framework.RunOnTick(() => throw new Exception("Test Exception"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1));
}
ImGui.SameLine();
if (ImGui.Button("As long as it's in Framework Thread"))
{
Task.Run(async () => await Service<Framework>.Get().RunOnFrameworkThread(() => { Log.Information("Task dispatched from non-framework.update thread"); }));
Service<Framework>.Get().RunOnFrameworkThread(() => { Log.Information("Task dispatched from framework.update thread"); }).Wait();
Task.Run(async () => await framework.RunOnFrameworkThread(() => { Log.Information("Task dispatched from non-framework.update thread"); }));
framework.RunOnFrameworkThread(() => { Log.Information("Task dispatched from framework.update thread"); }).Wait();
}
if (ImGui.CollapsingHeader("Download"))
{
ImGui.InputText("URL", this.urlBytes, (uint)this.urlBytes.Length);
ImGui.InputText("Local Path", this.localPathBytes, (uint)this.localPathBytes.Length);
ImGui.SameLine();
if (ImGuiComponents.IconButton("##localpathpicker", FontAwesomeIcon.File))
{
var defaultFileName = Encoding.UTF8.GetString(this.urlBytes).Split('\0', 2)[0].Split('/').Last();
this.fileDialogManager.SaveFileDialog(
"Choose a local path",
"*",
defaultFileName,
string.Empty,
(accept, newPath) =>
{
if (accept)
{
this.localPathBytes.AsSpan().Clear();
Encoding.UTF8.GetBytes(newPath, this.localPathBytes.AsSpan());
}
});
}
ImGui.TextUnformatted($"{this.downloadState.Downloaded:##,###}/{this.downloadState.Total:##,###} ({this.downloadState.Percentage:0.00}%)");
using var disabled =
ImRaii.Disabled(this.downloadTask?.IsCompleted is false || this.localPathBytes[0] == 0);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Download");
ImGui.SameLine();
var downloadUsingGlobalScheduler = ImGui.Button("using default scheduler");
ImGui.SameLine();
var downloadUsingFramework = ImGui.Button("using Framework.Update");
if (downloadUsingGlobalScheduler || downloadUsingFramework)
{
var url = Encoding.UTF8.GetString(this.urlBytes).Split('\0', 2)[0];
var localPath = Encoding.UTF8.GetString(this.localPathBytes).Split('\0', 2)[0];
var ct = this.taskSchedulerCancelSource.Token;
this.downloadState = default;
var factory = downloadUsingGlobalScheduler
? Task.Factory
: framework.FrameworkThreadTaskFactory;
this.downloadState = default;
this.downloadTask = factory.StartNew(
async () =>
{
try
{
await using var to = File.Create(localPath);
using var client = new HttpClient();
using var conn = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
this.downloadState.Total = conn.Content.Headers.ContentLength ?? -1L;
await using var from = conn.Content.ReadAsStream(ct);
var buffer = new byte[8192];
while (true)
{
if (downloadUsingFramework)
ThreadSafety.AssertMainThread();
if (downloadUsingGlobalScheduler)
ThreadSafety.AssertNotMainThread();
var len = await from.ReadAsync(buffer, ct);
if (len == 0)
break;
await to.WriteAsync(buffer.AsMemory(0, len), ct);
this.downloadState.Downloaded += len;
if (this.downloadState.Total >= 0)
{
this.downloadState.Percentage =
(100f * this.downloadState.Downloaded) / this.downloadState.Total;
}
}
}
catch (Exception e)
{
Log.Error(e, "Failed to download {from} to {to}.", url, localPath);
try
{
File.Delete(localPath);
}
catch
{
// ignore
}
}
},
cancellationToken: ct).Unwrap();
}
}
if (ImGui.Button("Drown in tasks"))
@ -244,6 +383,8 @@ internal class TaskSchedulerWidget : IDataWindowWidget
ImGui.PopStyleColor(1);
}
this.fileDialogManager.Draw();
}
private async Task TestTaskInTaskDelay(CancellationToken token)

View file

@ -1,10 +1,17 @@
/*using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Dalamud.Data;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Utility;
using ImGuiNET;
using Lumina.Excel;
using Lumina.Excel.GeneratedSheets;
using Serilog;*/
using Serilog;
namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps;
@ -13,31 +20,22 @@ namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps;
/// </summary>
internal class ContextMenuAgingStep : IAgingStep
{
/*
private SubStep currentSubStep;
private uint clickedItemId;
private bool clickedItemHq;
private uint clickedItemCount;
private bool? targetInventorySubmenuOpened;
private PlayerCharacter? targetCharacter;
private string? clickedPlayerName;
private ushort? clickedPlayerWorld;
private ulong? clickedPlayerCid;
private uint? clickedPlayerId;
private bool multipleTriggerOne;
private bool multipleTriggerTwo;
private ExcelSheet<Item> itemSheet;
private ExcelSheet<Materia> materiaSheet;
private ExcelSheet<Stain> stainSheet;
private enum SubStep
{
Start,
TestItem,
TestGameObject,
TestSubMenu,
TestMultiple,
TestInventoryAndSubmenu,
TestDefault,
Finish,
}
*/
/// <inheritdoc/>
public string Name => "Test Context Menu";
@ -45,23 +43,24 @@ internal class ContextMenuAgingStep : IAgingStep
/// <inheritdoc/>
public SelfTestStepResult RunStep()
{
/*
var contextMenu = Service<ContextMenu>.Get();
var dataMgr = Service<DataManager>.Get();
this.itemSheet = dataMgr.GetExcelSheet<Item>()!;
this.materiaSheet = dataMgr.GetExcelSheet<Materia>()!;
this.stainSheet = dataMgr.GetExcelSheet<Stain>()!;
ImGui.Text(this.currentSubStep.ToString());
switch (this.currentSubStep)
{
case SubStep.Start:
contextMenu.ContextMenuOpened += this.ContextMenuOnContextMenuOpened;
contextMenu.OnMenuOpened += this.OnMenuOpened;
this.currentSubStep++;
break;
case SubStep.TestItem:
if (this.clickedItemId != 0)
case SubStep.TestInventoryAndSubmenu:
if (this.targetInventorySubmenuOpened == true)
{
var item = dataMgr.GetExcelSheet<Item>()!.GetRow(this.clickedItemId);
ImGui.Text($"Did you click \"{item!.Name.RawString}\", hq:{this.clickedItemHq}, count:{this.clickedItemCount}?");
ImGui.Text($"Is the data in the submenu correct?");
if (ImGui.Button("Yes"))
this.currentSubStep++;
@ -73,7 +72,7 @@ internal class ContextMenuAgingStep : IAgingStep
}
else
{
ImGui.Text("Right-click an item.");
ImGui.Text("Right-click an item and select \"Self Test\".");
if (ImGui.Button("Skip"))
this.currentSubStep++;
@ -81,10 +80,10 @@ internal class ContextMenuAgingStep : IAgingStep
break;
case SubStep.TestGameObject:
if (!this.clickedPlayerName.IsNullOrEmpty())
case SubStep.TestDefault:
if (this.targetCharacter is { } character)
{
ImGui.Text($"Did you click \"{this.clickedPlayerName}\", world:{this.clickedPlayerWorld}, cid:{this.clickedPlayerCid}, id:{this.clickedPlayerId}?");
ImGui.Text($"Did you click \"{character.Name}\" ({character.ClassJob.GameData!.Abbreviation.ToDalamudString()})?");
if (ImGui.Button("Yes"))
this.currentSubStep++;
@ -103,149 +102,195 @@ internal class ContextMenuAgingStep : IAgingStep
}
break;
case SubStep.TestSubMenu:
if (this.multipleTriggerOne && this.multipleTriggerTwo)
{
this.currentSubStep++;
this.multipleTriggerOne = this.multipleTriggerTwo = false;
}
else
{
ImGui.Text("Right-click a character and select both options in the submenu.");
case SubStep.Finish:
return SelfTestStepResult.Pass;
if (ImGui.Button("Skip"))
this.currentSubStep++;
}
break;
case SubStep.TestMultiple:
if (this.multipleTriggerOne && this.multipleTriggerTwo)
{
this.currentSubStep = SubStep.Finish;
return SelfTestStepResult.Pass;
}
ImGui.Text("Select both options on any context menu.");
if (ImGui.Button("Skip"))
this.currentSubStep++;
break;
default:
throw new ArgumentOutOfRangeException();
}
return SelfTestStepResult.Waiting;
*/
return SelfTestStepResult.Pass;
}
/// <inheritdoc/>
public void CleanUp()
{
/*
var contextMenu = Service<ContextMenu>.Get();
contextMenu.ContextMenuOpened -= this.ContextMenuOnContextMenuOpened;
contextMenu.OnMenuOpened -= this.OnMenuOpened;
this.currentSubStep = SubStep.Start;
this.clickedItemId = 0;
this.clickedPlayerName = null;
this.multipleTriggerOne = this.multipleTriggerTwo = false;
*/
this.targetInventorySubmenuOpened = null;
this.targetCharacter = null;
}
/*
private void ContextMenuOnContextMenuOpened(ContextMenuOpenedArgs args)
private void OnMenuOpened(MenuOpenedArgs args)
{
Log.Information("Got context menu with parent addon: {ParentAddonName}, title:{Title}, itemcnt:{ItemCount}", args.ParentAddonName, args.Title, args.Items.Count);
if (args.GameObjectContext != null)
{
Log.Information(" => GameObject:{GameObjectName} world:{World} cid:{Cid} id:{Id}", args.GameObjectContext.Name, args.GameObjectContext.WorldId, args.GameObjectContext.ContentId, args.GameObjectContext.Id);
}
if (args.InventoryItemContext != null)
{
Log.Information(" => Inventory:{ItemId} hq:{Hq} count:{Count}", args.InventoryItemContext.Id, args.InventoryItemContext.IsHighQuality, args.InventoryItemContext.Count);
}
LogMenuOpened(args);
switch (this.currentSubStep)
{
case SubStep.TestSubMenu:
args.AddCustomSubMenu("Aging Submenu", openedArgs =>
case SubStep.TestInventoryAndSubmenu:
if (args.MenuType == ContextMenuType.Inventory)
{
openedArgs.AddCustomItem("Submenu Item 1", _ =>
args.AddMenuItem(new()
{
this.multipleTriggerOne = true;
});
openedArgs.AddCustomItem("Submenu Item 2", _ =>
{
this.multipleTriggerTwo = true;
});
});
return;
case SubStep.TestMultiple:
args.AddCustomItem("Aging Item 1", _ =>
{
this.multipleTriggerOne = true;
});
args.AddCustomItem("Aging Item 2", _ =>
{
this.multipleTriggerTwo = true;
});
return;
case SubStep.Finish:
return;
default:
switch (args.ParentAddonName)
{
case "Inventory":
if (this.currentSubStep != SubStep.TestItem)
return;
args.AddCustomItem("Aging Item", _ =>
Name = "Self Test",
Prefix = SeIconChar.Hyadelyn,
PrefixColor = 56,
Priority = -1,
IsSubmenu = true,
OnClicked = (MenuItemClickedArgs a) =>
{
this.clickedItemId = args.InventoryItemContext!.Id;
this.clickedItemHq = args.InventoryItemContext!.IsHighQuality;
this.clickedItemCount = args.InventoryItemContext!.Count;
Log.Warning("Clicked item: {Id} hq:{Hq} count:{Count}", this.clickedItemId, this.clickedItemHq, this.clickedItemCount);
});
break;
SeString name;
uint count;
var targetItem = (a.Target as MenuTargetInventory).TargetItem;
if (targetItem is { } item)
{
name = (this.itemSheet.GetRow(item.ItemId)?.Name.ToDalamudString() ?? $"Unknown ({item.ItemId})") + (item.IsHq ? $" {SeIconChar.HighQuality.ToIconString()}" : string.Empty);
count = item.Quantity;
}
else
{
name = "None";
count = 0;
}
case null:
case "_PartyList":
case "ChatLog":
case "ContactList":
case "ContentMemberList":
case "CrossWorldLinkshell":
case "FreeCompany":
case "FriendList":
case "LookingForGroup":
case "LinkShell":
case "PartyMemberList":
case "SocialList":
if (this.currentSubStep != SubStep.TestGameObject || args.GameObjectContext == null || args.GameObjectContext.Name.IsNullOrEmpty())
return;
a.OpenSubmenu(new MenuItem[]
{
new()
{
Name = "Name: " + name,
IsEnabled = false,
},
new()
{
Name = $"Count: {count}",
IsEnabled = false,
},
});
args.AddCustomItem("Aging Character", _ =>
{
this.clickedPlayerName = args.GameObjectContext.Name!;
this.clickedPlayerWorld = args.GameObjectContext.WorldId;
this.clickedPlayerCid = args.GameObjectContext.ContentId;
this.clickedPlayerId = args.GameObjectContext.Id;
Log.Warning("Clicked player: {Name} world:{World} cid:{Cid} id:{Id}", this.clickedPlayerName, this.clickedPlayerWorld, this.clickedPlayerCid, this.clickedPlayerId);
});
break;
this.targetInventorySubmenuOpened = true;
},
});
}
break;
case SubStep.TestDefault:
if (args.Target is MenuTargetDefault { TargetObject: PlayerCharacter { } character })
this.targetCharacter = character;
break;
case SubStep.Finish:
return;
}
}
private void LogMenuOpened(MenuOpenedArgs args)
{
Log.Verbose($"Got {args.MenuType} context menu with addon 0x{args.AddonPtr:X8} ({args.AddonName}) and agent 0x{args.AgentPtr:X8}");
if (args.Target is MenuTargetDefault targetDefault)
{
{
var b = new StringBuilder();
b.AppendLine($"Target: {targetDefault.TargetName}");
b.AppendLine($"Home World: {targetDefault.TargetHomeWorld.GameData?.Name.ToDalamudString() ?? "Unknown"} ({targetDefault.TargetHomeWorld.Id})");
b.AppendLine($"Content Id: 0x{targetDefault.TargetContentId:X8}");
b.AppendLine($"Object Id: 0x{targetDefault.TargetObjectId:X8}");
Log.Verbose(b.ToString());
}
if (targetDefault.TargetCharacter is { } character)
{
var b = new StringBuilder();
b.AppendLine($"Character: {character.Name}");
b.AppendLine($"Name: {character.Name}");
b.AppendLine($"Content Id: 0x{character.ContentId:X8}");
b.AppendLine($"FC Tag: {character.FCTag}");
b.AppendLine($"Job: {character.ClassJob.GameData?.Abbreviation.ToDalamudString() ?? "Unknown"} ({character.ClassJob.Id})");
b.AppendLine($"Statuses: {string.Join(", ", character.Statuses.Select(s => s.GameData?.Name.ToDalamudString() ?? s.Id.ToString()))}");
b.AppendLine($"Home World: {character.HomeWorld.GameData?.Name.ToDalamudString() ?? "Unknown"} ({character.HomeWorld.Id})");
b.AppendLine($"Current World: {character.CurrentWorld.GameData?.Name.ToDalamudString() ?? "Unknown"} ({character.CurrentWorld.Id})");
b.AppendLine($"Is From Other Server: {character.IsFromOtherServer}");
b.Append("Location: ");
if (character.Location.GameData is { } location)
b.Append($"{location.PlaceNameRegion.Value?.Name.ToDalamudString() ?? "Unknown"}/{location.PlaceNameZone.Value?.Name.ToDalamudString() ?? "Unknown"}/{location.PlaceName.Value?.Name.ToDalamudString() ?? "Unknown"}");
else
b.Append("Unknown");
b.AppendLine($" ({character.Location.Id})");
b.AppendLine($"Grand Company: {character.GrandCompany.GameData?.Name.ToDalamudString() ?? "Unknown"} ({character.GrandCompany.Id})");
b.AppendLine($"Client Language: {character.ClientLanguage}");
b.AppendLine($"Languages: {string.Join(", ", character.Languages)}");
b.AppendLine($"Gender: {character.Gender}");
b.AppendLine($"Display Group: {character.DisplayGroup}");
b.AppendLine($"Sort: {character.Sort}");
Log.Verbose(b.ToString());
}
else
{
Log.Verbose($"Character: null");
}
}
else if (args.Target is MenuTargetInventory targetInventory)
{
if (targetInventory.TargetItem is { } item)
{
var b = new StringBuilder();
b.AppendLine($"Item: {(item.IsEmpty ? "None" : this.itemSheet.GetRow(item.ItemId)?.Name.ToDalamudString())} ({item.ItemId})");
b.AppendLine($"Container: {item.ContainerType}");
b.AppendLine($"Slot: {item.InventorySlot}");
b.AppendLine($"Quantity: {item.Quantity}");
b.AppendLine($"{(item.IsCollectable ? "Collectability" : "Spiritbond")}: {item.Spiritbond}");
b.AppendLine($"Condition: {item.Condition / 300f:0.00}% ({item.Condition})");
b.AppendLine($"Is HQ: {item.IsHq}");
b.AppendLine($"Is Company Crest Applied: {item.IsCompanyCrestApplied}");
b.AppendLine($"Is Relic: {item.IsRelic}");
b.AppendLine($"Is Collectable: {item.IsCollectable}");
b.Append("Materia: ");
var materias = new List<string>();
foreach (var (materiaId, materiaGrade) in item.Materia.ToArray().Zip(item.MateriaGrade.ToArray()).Where(m => m.First != 0))
{
Log.Verbose($"{materiaId} {materiaGrade}");
if (this.materiaSheet.GetRow(materiaId) is { } materia &&
materia.Item[materiaGrade].Value is { } materiaItem)
materias.Add($"{materiaItem.Name.ToDalamudString()}");
else
materias.Add($"Unknown (Id: {materiaId}, Grade: {materiaGrade})");
}
if (materias.Count == 0)
b.AppendLine("None");
else
b.AppendLine(string.Join(", ", materias));
b.Append($"Dye/Stain: ");
if (item.Stain != 0)
b.AppendLine($"{this.stainSheet.GetRow(item.Stain)?.Name.ToDalamudString() ?? "Unknown"} ({item.Stain})");
else
b.AppendLine("None");
b.Append("Glamoured Item: ");
if (item.GlamourId != 0)
b.AppendLine($"{this.itemSheet.GetRow(item.GlamourId)?.Name.ToDalamudString() ?? "Unknown"} ({item.GlamourId})");
else
b.AppendLine("None");
Log.Verbose(b.ToString());
}
else
{
Log.Verbose("Item: null");
}
}
else
{
Log.Verbose($"Target: Unknown ({args.Target?.GetType().Name ?? "null"})");
}
}
*/
}

View file

@ -12,7 +12,6 @@ using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ImGuiFontChooserDialog;
using Dalamud.Interface.Internal.Windows.PluginInstaller;
using Dalamud.Interface.Internal.Windows.Settings.Widgets;
using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.ManagedFontAtlas.Internals;
using Dalamud.Interface.Utility;
using Dalamud.Utility;
@ -199,10 +198,10 @@ public class SettingsTabLook : SettingsTab
if (ImGui.Button(Loc.Localize("DalamudSettingChooseDefaultFont", "Choose Default Font")))
{
var faf = Service<FontAtlasFactory>.Get();
var fcd = new SingleFontChooserDialog(
faf.CreateFontAtlas($"{nameof(SettingsTabLook)}:Default", FontAtlasAutoRebuildMode.Async));
var fcd = new SingleFontChooserDialog(faf, $"{nameof(SettingsTabLook)}:Default");
fcd.SelectedFont = (SingleFontSpec)this.defaultFontSpec;
fcd.FontFamilyExcludeFilter = x => x is DalamudDefaultFontAndFamilyId;
fcd.SetPopupPositionAndSizeToCurrentWindowCenter();
interfaceManager.Draw += fcd.Draw;
fcd.ResultTask.ContinueWith(
r => Service<Framework>.Get().RunOnFrameworkThread(

View file

@ -82,21 +82,25 @@ public interface IFontAtlas : IDisposable
/// </example>
public IDisposable SuppressAutoRebuild();
/// <summary>
/// Creates a new <see cref="IFontHandle"/> from game's built-in fonts.
/// </summary>
/// <summary>Creates a new <see cref="IFontHandle"/> from game's built-in fonts.</summary>
/// <param name="style">Font to use.</param>
/// <returns>Handle to a font that may or may not be ready yet.</returns>
/// <remarks>This function does not throw. <see cref="IFontHandle.LoadException"/> will be populated instead, if
/// the build procedure has failed. <see cref="IFontHandle.Push"/> can be used regardless of the state of the font
/// handle.</remarks>
public IFontHandle NewGameFontHandle(GameFontStyle style);
/// <summary>
/// Creates a new IFontHandle using your own callbacks.
/// </summary>
/// <summary>Creates a new IFontHandle using your own callbacks.</summary>
/// <param name="buildStepDelegate">Callback for <see cref="IFontAtlas.BuildStepChange"/>.</param>
/// <returns>Handle to a font that may or may not be ready yet.</returns>
/// <remarks>
/// Consider calling <see cref="IFontAtlasBuildToolkitPreBuild.AttachExtraGlyphsForDalamudLanguage"/> to support
/// glyphs that are not supplied by the game by default; this mostly affects Chinese and Korean language users.
/// <para>Consider calling <see cref="IFontAtlasBuildToolkitPreBuild.AttachExtraGlyphsForDalamudLanguage"/> to
/// support glyphs that are not supplied by the game by default; this mostly affects Chinese and Korean language
/// users.</para>
/// <para>This function does not throw, even if <paramref name="buildStepDelegate"/> would throw exceptions.
/// Instead, if it fails, the returned handle will contain an <see cref="IFontHandle.LoadException"/> property
/// containing the exception happened during the build process. <see cref="IFontHandle.Push"/> can be used even if
/// the build process has not been completed yet or failed.</para>
/// </remarks>
/// <example>
/// <b>On initialization</b>:

View file

@ -58,10 +58,27 @@ public interface IFontHandle : IDisposable
/// <returns>A disposable object that will pop the font on dispose.</returns>
/// <exception cref="InvalidOperationException">If called outside of the main thread.</exception>
/// <remarks>
/// This function uses <see cref="ImGui.PushFont"/>, and may do extra things.
/// <para>This function uses <see cref="ImGui.PushFont"/>, and may do extra things.
/// Use <see cref="IDisposable.Dispose"/> or <see cref="Pop"/> to undo this operation.
/// Do not use <see cref="ImGui.PopFont"/>.
/// Do not use <see cref="ImGui.PopFont"/>.</para>
/// </remarks>
/// <example>
/// <b>Push a font with `using` clause.</b>
/// <code>
/// using (fontHandle.Push())
/// ImGui.TextUnformatted("Test");
/// </code>
/// <b>Push a font with a matching call to <see cref="Pop"/>.</b>
/// <code>
/// fontHandle.Push();
/// ImGui.TextUnformatted("Test 2");
/// </code>
/// <b>Push a font between two choices.</b>
/// <code>
/// using ((someCondition ? myFontHandle : dalamudPluginInterface.UiBuilder.MonoFontHandle).Push())
/// ImGui.TextUnformatted("Test 3");
/// </code>
/// </example>
IDisposable Push();
/// <summary>

View file

@ -138,13 +138,18 @@ internal abstract class FontHandle : IFontHandle
/// An instance of <see cref="ILockedImFont"/> that <b>must</b> be disposed after use on success;
/// <c>null</c> with <paramref name="errorMessage"/> populated on failure.
/// </returns>
/// <exception cref="ObjectDisposedException">Still may be thrown.</exception>
public ILockedImFont? TryLock(out string? errorMessage)
{
IFontHandleSubstance? prevSubstance = default;
while (true)
{
var substance = this.Manager.Substance;
if (this.manager is not { } nonDisposedManager)
{
errorMessage = "The font handle has been disposed.";
return null;
}
var substance = nonDisposedManager.Substance;
// Does the associated IFontAtlas have a built substance?
if (substance is null)

View file

@ -0,0 +1,37 @@
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
namespace Dalamud.Plugin.Services;
/// <summary>
/// This class provides methods for interacting with the game's context menu.
/// </summary>
public interface IContextMenu
{
/// <summary>
/// A delegate type used for the <see cref="OnMenuOpened"/> event.
/// </summary>
/// <param name="args">Information about the currently opening menu.</param>
public delegate void OnMenuOpenedDelegate(MenuOpenedArgs args);
/// <summary>
/// Event that gets fired every time the game framework updates.
/// </summary>
event OnMenuOpenedDelegate OnMenuOpened;
/// <summary>
/// Adds a menu item to a context menu.
/// </summary>
/// <param name="menuType">The type of context menu to add the item to.</param>
/// <param name="item">The item to add.</param>
void AddMenuItem(ContextMenuType menuType, MenuItem item);
/// <summary>
/// Removes a menu item from a context menu.
/// </summary>
/// <param name="menuType">The type of context menu to remove the item from.</param>
/// <param name="item">The item to add.</param>
/// <returns><see langword="true"/> if the item was removed, <see langword="false"/> if it was not found.</returns>
bool RemoveMenuItem(ContextMenuType menuType, MenuItem item);
}

View file

@ -29,6 +29,11 @@ public interface IFramework
/// </summary>
public DateTime LastUpdateUTC { get; }
/// <summary>
/// Gets a <see cref="TaskFactory"/> that runs tasks during Framework Update event.
/// </summary>
public TaskFactory FrameworkThreadTaskFactory { get; }
/// <summary>
/// Gets the delta between the last Framework Update and the currently executing one.
/// </summary>
@ -44,6 +49,14 @@ public interface IFramework
/// </summary>
public bool IsFrameworkUnloading { get; }
/// <summary>
/// Returns a task that completes after the given number of ticks.
/// </summary>
/// <param name="numTicks">Number of ticks to delay.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A new <see cref="Task"/> that gets resolved after specified number of ticks happen.</returns>
public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default);
/// <summary>
/// Run given function right away if this function has been called from game's Framework.Update thread, or otherwise run on next Framework.Update call.
/// </summary>
@ -65,6 +78,7 @@ public interface IFramework
/// <typeparam name="T">Return type.</typeparam>
/// <param name="func">Function to call.</param>
/// <returns>Task representing the pending or already completed function.</returns>
[Obsolete($"Use {nameof(RunOnTick)} instead.")]
public Task<T> RunOnFrameworkThread<T>(Func<Task<T>> func);
/// <summary>
@ -72,6 +86,7 @@ public interface IFramework
/// </summary>
/// <param name="func">Function to call.</param>
/// <returns>Task representing the pending or already completed function.</returns>
[Obsolete($"Use {nameof(RunOnTick)} instead.")]
public Task RunOnFrameworkThread(Func<Task> func);
/// <summary>

View file

@ -165,6 +165,7 @@ internal static class ServiceManager
var earlyLoadingServices = new HashSet<Type>();
var blockingEarlyLoadingServices = new HashSet<Type>();
var providedServices = new HashSet<Type>();
var dependencyServicesMap = new Dictionary<Type, List<Type>>();
var getAsyncTaskMap = new Dictionary<Type, Task>();
@ -197,7 +198,10 @@ internal static class ServiceManager
// We don't actually need to load provided services, something else does
if (serviceKind.HasFlag(ServiceKind.ProvidedService))
{
providedServices.Add(serviceType);
continue;
}
Debug.Assert(
serviceKind.HasFlag(ServiceKind.EarlyLoadedService) ||
@ -340,7 +344,16 @@ internal static class ServiceManager
}
if (!tasks.Any())
throw new InvalidOperationException("Unresolvable dependency cycle detected");
{
// No more services we can start loading for now.
// Either we're waiting for provided services, or there's a dependency cycle.
providedServices.RemoveWhere(x => getAsyncTaskMap[x].IsCompleted);
if (providedServices.Any())
await Task.WhenAny(providedServices.Select(x => getAsyncTaskMap[x]));
else
throw new InvalidOperationException("Unresolvable dependency cycle detected");
continue;
}
if (servicesToLoad.Any())
{

View file

@ -75,7 +75,7 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA
.Where(x => x is not DalamudAsset.Empty4X4)
.Where(x => x.GetAttribute<DalamudAssetAttribute>()?.Required is false)
.Select(this.CreateStreamAsync)
.Select(x => x.ToContentDisposedTask()))
.Select(x => x.ToContentDisposedTask(true)))
.ContinueWith(r => Log.Verbose($"Optional assets load state: {r}"));
}
@ -99,6 +99,7 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA
.Concat(this.fileStreams.Values)
.Concat(this.textureWraps.Values)
.Where(x => x is not null)
.Select(x => x.ContinueWith(r => { _ = r.Exception; }))
.ToArray());
this.scopedFinalizer.Dispose();
}

View file

@ -1,6 +1,7 @@
using System.Linq;
using Dalamud.Game;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Plugin.Services;
using Serilog;
@ -99,6 +100,23 @@ internal static class EventHandlerExtensions
}
}
/// <summary>
/// Replacement for Invoke() on OnMenuOpenedDelegate to catch exceptions that stop event propagation in case
/// of a thrown Exception inside of an invocation.
/// </summary>
/// <param name="openedDelegate">The OnMenuOpenedDelegate in question.</param>
/// <param name="argument">Templated argument for Action.</param>
public static void InvokeSafely(this IContextMenu.OnMenuOpenedDelegate? openedDelegate, MenuOpenedArgs argument)
{
if (openedDelegate == null)
return;
foreach (var action in openedDelegate.GetInvocationList().Cast<IContextMenu.OnMenuOpenedDelegate>())
{
HandleInvoke(() => action(argument));
}
}
private static void HandleInvoke(Action act)
{
try

View file

@ -0,0 +1,90 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Dalamud.Utility;
/// <summary>
/// A task scheduler that runs tasks on a specific thread.
/// </summary>
internal class ThreadBoundTaskScheduler : TaskScheduler
{
private const byte Scheduled = 0;
private const byte Running = 1;
private readonly ConcurrentDictionary<Task, byte> scheduledTasks = new();
/// <summary>
/// Initializes a new instance of the <see cref="ThreadBoundTaskScheduler"/> class.
/// </summary>
/// <param name="boundThread">The thread to bind this task scheduelr to.</param>
public ThreadBoundTaskScheduler(Thread? boundThread = null)
{
this.BoundThread = boundThread;
}
/// <summary>
/// Gets or sets the thread this task scheduler is bound to.
/// </summary>
public Thread? BoundThread { get; set; }
/// <summary>
/// Gets a value indicating whether we're on the bound thread.
/// </summary>
public bool IsOnBoundThread => Thread.CurrentThread == this.BoundThread;
/// <summary>
/// Runs queued tasks.
/// </summary>
public void Run()
{
foreach (var task in this.scheduledTasks.Keys)
{
if (!this.scheduledTasks.TryUpdate(task, Running, Scheduled))
continue;
_ = this.TryExecuteTask(task);
}
}
/// <inheritdoc/>
protected override IEnumerable<Task> GetScheduledTasks()
{
return this.scheduledTasks.Keys;
}
/// <inheritdoc/>
protected override void QueueTask(Task task)
{
this.scheduledTasks[task] = Scheduled;
}
/// <inheritdoc/>
protected override bool TryDequeue(Task task)
{
if (!this.scheduledTasks.TryRemove(task, out _))
return false;
return true;
}
/// <inheritdoc/>
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
if (!this.IsOnBoundThread)
return false;
if (taskWasPreviouslyQueued && !this.scheduledTasks.TryUpdate(task, Running, Scheduled))
return false;
_ = this.TryExecuteTask(task);
return true;
}
private new bool TryExecuteTask(Task task)
{
var r = base.TryExecuteTask(task);
this.scheduledTasks.Remove(task, out _);
return r;
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Runtime.CompilerServices;
namespace Dalamud.Utility;
@ -19,6 +20,7 @@ public static class ThreadSafety
/// Throws an exception when the current thread is not the main thread.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the current thread is not the main thread.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void AssertMainThread()
{
if (!threadStaticIsMainThread)
@ -31,6 +33,7 @@ public static class ThreadSafety
/// Throws an exception when the current thread is the main thread.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the current thread is the main thread.</exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void AssertNotMainThread()
{
if (threadStaticIsMainThread)
@ -39,6 +42,15 @@ public static class ThreadSafety
}
}
/// <summary><see cref="AssertMainThread"/>, but only on debug compilation mode.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void DebugAssertMainThread()
{
#if DEBUG
AssertMainThread();
#endif
}
/// <summary>
/// Marks a thread as the main thread.
/// </summary>