diff --git a/.editorconfig b/.editorconfig index d88c7ce7a..141e8c9c9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -35,7 +35,7 @@ dotnet_naming_rule.private_instance_fields_rule.severity = warning dotnet_naming_rule.private_instance_fields_rule.style = lower_camel_case_style dotnet_naming_rule.private_instance_fields_rule.symbols = private_instance_fields_symbols dotnet_naming_rule.private_static_fields_rule.severity = warning -dotnet_naming_rule.private_static_fields_rule.style = upper_camel_case_style +dotnet_naming_rule.private_static_fields_rule.style = lower_camel_case_style dotnet_naming_rule.private_static_fields_rule.symbols = private_static_fields_symbols dotnet_naming_rule.private_static_readonly_rule.severity = warning dotnet_naming_rule.private_static_readonly_rule.style = upper_camel_case_style @@ -57,6 +57,7 @@ dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static,readonly +dotnet_separate_import_directive_groups = true dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion @@ -97,22 +98,32 @@ resharper_apply_on_completion = true resharper_auto_property_can_be_made_get_only_global_highlighting = none resharper_auto_property_can_be_made_get_only_local_highlighting = none resharper_autodetect_indent_settings = true +resharper_blank_lines_around_single_line_auto_property = 1 resharper_braces_for_ifelse = required_for_multiline resharper_can_use_global_alias = false resharper_csharp_align_multiline_parameter = true resharper_csharp_align_multiple_declaration = true resharper_csharp_empty_block_style = multiline -resharper_csharp_int_align_comments = true +resharper_csharp_int_align_comments = false resharper_csharp_new_line_before_while = true resharper_csharp_wrap_after_declaration_lpar = true +resharper_csharp_wrap_after_invocation_lpar = true +resharper_csharp_wrap_arguments_style = chop_if_long resharper_enforce_line_ending_style = true +resharper_instance_members_qualify_declared_in = this_class, base_class +resharper_int_align = false resharper_member_can_be_private_global_highlighting = none resharper_member_can_be_private_local_highlighting = none -resharper_new_line_before_finally = false +resharper_new_line_before_finally = true +resharper_parentheses_non_obvious_operations = none, multiplicative, additive, arithmetic, shift, bitwise_and, bitwise_exclusive_or, bitwise_inclusive_or, bitwise +resharper_parentheses_redundancy_style = remove_if_not_clarifies_precedence resharper_place_accessorholder_attribute_on_same_line = false resharper_place_field_attribute_on_same_line = false +resharper_place_simple_initializer_on_single_line = true resharper_show_autodetect_configure_formatting_tip = false +resharper_space_within_single_line_array_initializer_braces = true resharper_use_indent_from_vs = false +resharper_wrap_array_initializer_style = chop_if_long # ReSharper inspection severities resharper_arrange_missing_parentheses_highlighting = hint diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7ada48e50..8a4fdf2e3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,7 +42,48 @@ jobs: with: name: dalamud-artifact path: bin\Release - + + check_api_compat: + name: "Check API Compatibility" + if: ${{ github.event_name == 'pull_request' }} + needs: build + runs-on: windows-latest + steps: + - name: "Install .NET SDK" + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7 + - name: "Install ApiCompat" + run: | + dotnet tool install -g Microsoft.DotNet.ApiCompat.Tool + - name: "Download Proposed Artifacts" + uses: actions/download-artifact@v2 + with: + name: dalamud-artifact + path: .\right + - name: "Download Live (Stg) Artifacts" + run: | + Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile latest.zip + Expand-Archive -Force latest.zip "left" + - name: "Verify Compatibility" + run: | + $FILES_TO_VALIDATE = "Dalamud.dll","FFXIVClientStructs.dll","Lumina.dll","Lumina.Excel.dll" + + $retcode = 0 + + foreach ($file in $FILES_TO_VALIDATE) { + $testout = "" + Write-Output "::group::=== API COMPATIBILITY CHECK: ${file} ===" + apicompat -l "left\${file}" -r "right\${file}" | Tee-Object -Variable testout + Write-Output "::endgroup::" + if ($testout -ne "APICompat ran successfully without finding any breaking changes.") { + Write-Output "::error::${file} did not pass. Please review it for problems." + $retcode = 1 + } + } + + exit $retcode + deploy_stg: name: Deploy dalamud-distrib staging if: ${{ github.repository_owner == 'goatcorp' && github.event_name == 'push' }} diff --git a/.github/workflows/rollup.yml b/.github/workflows/rollup.yml index 25b558711..44116e7b2 100644 --- a/.github/workflows/rollup.yml +++ b/.github/workflows/rollup.yml @@ -11,7 +11,8 @@ jobs: strategy: matrix: branches: - - v9 + - net8 + #- new_im_hooks # Unmergeable defaults: run: diff --git a/Dalamud.Boot/Dalamud.Boot.vcxproj b/Dalamud.Boot/Dalamud.Boot.vcxproj index 07c034b8f..e75b31f19 100644 --- a/Dalamud.Boot/Dalamud.Boot.vcxproj +++ b/Dalamud.Boot/Dalamud.Boot.vcxproj @@ -32,6 +32,9 @@ obj\$(Configuration)\ + + + true $(SolutionDir)bin\lib\$(Configuration)\libMinHook\;$(VC_LibraryPath_x64);$(WindowsSDK_LibraryPath_x64) @@ -56,7 +59,7 @@ Windows true false - Version.lib;%(AdditionalDependencies) + Version.lib;Shlwapi.lib;%(AdditionalDependencies) ..\lib\CoreCLR;%(AdditionalLibraryDirectories) @@ -72,6 +75,7 @@ false false + module.def @@ -85,9 +89,13 @@ true true + module.def + + + nethost.dll @@ -131,6 +139,7 @@ NotUsing NotUsing + NotUsing NotUsing @@ -178,6 +187,7 @@ + @@ -191,8 +201,14 @@ + + + + + + - \ No newline at end of file + diff --git a/Dalamud.Boot/Dalamud.Boot.vcxproj.filters b/Dalamud.Boot/Dalamud.Boot.vcxproj.filters index eeb4c8ab2..6a9d14a58 100644 --- a/Dalamud.Boot/Dalamud.Boot.vcxproj.filters +++ b/Dalamud.Boot/Dalamud.Boot.vcxproj.filters @@ -76,6 +76,9 @@ Dalamud.Boot DLL + + Dalamud.Boot DLL + @@ -143,6 +146,9 @@ + + Dalamud.Boot DLL + ReshadePlugin @@ -174,4 +180,14 @@ - \ No newline at end of file + + + Dalamud.Boot DLL + + + + + Dalamud.Boot DLL + + + diff --git a/Dalamud.Boot/DalamudStartInfo.cpp b/Dalamud.Boot/DalamudStartInfo.cpp index 15faf82ad..f5632a2ea 100644 --- a/Dalamud.Boot/DalamudStartInfo.cpp +++ b/Dalamud.Boot/DalamudStartInfo.cpp @@ -68,19 +68,37 @@ void from_json(const nlohmann::json& json, DalamudStartInfo::ClientLanguage& val } } +void from_json(const nlohmann::json& json, DalamudStartInfo::LoadMethod& value) { + if (json.is_number_integer()) { + value = static_cast(json.get()); + + } + else if (json.is_string()) { + const auto langstr = unicode::convert(json.get(), &unicode::lower); + if (langstr == "entrypoint") + value = DalamudStartInfo::LoadMethod::Entrypoint; + else if (langstr == "inject") + value = DalamudStartInfo::LoadMethod::DllInject; + } +} + void from_json(const nlohmann::json& json, DalamudStartInfo& config) { if (!json.is_object()) return; + config.DalamudLoadMethod = json.value("LoadMethod", config.DalamudLoadMethod); config.WorkingDirectory = json.value("WorkingDirectory", config.WorkingDirectory); config.ConfigurationPath = json.value("ConfigurationPath", config.ConfigurationPath); + config.LogPath = json.value("LogPath", config.LogPath); + config.LogName = json.value("LogName", config.LogName); config.PluginDirectory = json.value("PluginDirectory", config.PluginDirectory); - config.DefaultPluginDirectory = json.value("DefaultPluginDirectory", config.DefaultPluginDirectory); config.AssetDirectory = json.value("AssetDirectory", config.AssetDirectory); config.Language = json.value("Language", config.Language); config.GameVersion = json.value("GameVersion", config.GameVersion); - config.DelayInitializeMs = json.value("DelayInitializeMs", config.DelayInitializeMs); config.TroubleshootingPackData = json.value("TroubleshootingPackData", std::string{}); + config.DelayInitializeMs = json.value("DelayInitializeMs", config.DelayInitializeMs); + config.NoLoadPlugins = json.value("NoLoadPlugins", config.NoLoadPlugins); + config.NoLoadThirdPartyPlugins = json.value("NoLoadThirdPartyPlugins", config.NoLoadThirdPartyPlugins); config.BootLogPath = json.value("BootLogPath", config.BootLogPath); config.BootShowConsole = json.value("BootShowConsole", config.BootShowConsole); @@ -103,6 +121,7 @@ void from_json(const nlohmann::json& json, DalamudStartInfo& config) { } config.CrashHandlerShow = json.value("CrashHandlerShow", config.CrashHandlerShow); + config.NoExceptionHandlers = json.value("NoExceptionHandlers", config.NoExceptionHandlers); } void DalamudStartInfo::from_envvars() { diff --git a/Dalamud.Boot/DalamudStartInfo.h b/Dalamud.Boot/DalamudStartInfo.h index 66109abf7..e6cc54ab0 100644 --- a/Dalamud.Boot/DalamudStartInfo.h +++ b/Dalamud.Boot/DalamudStartInfo.h @@ -26,15 +26,25 @@ struct DalamudStartInfo { }; friend void from_json(const nlohmann::json&, ClientLanguage&); + enum class LoadMethod : int { + Entrypoint, + DllInject, + }; + friend void from_json(const nlohmann::json&, LoadMethod&); + + LoadMethod DalamudLoadMethod = LoadMethod::Entrypoint; std::string WorkingDirectory; std::string ConfigurationPath; + std::string LogPath; + std::string LogName; std::string PluginDirectory; - std::string DefaultPluginDirectory; std::string AssetDirectory; ClientLanguage Language = ClientLanguage::English; std::string GameVersion; - int DelayInitializeMs = 0; std::string TroubleshootingPackData; + int DelayInitializeMs = 0; + bool NoLoadPlugins; + bool NoLoadThirdPartyPlugins; std::string BootLogPath; bool BootShowConsole = false; @@ -49,6 +59,7 @@ struct DalamudStartInfo { std::set BootUnhookDlls{}; bool CrashHandlerShow = false; + bool NoExceptionHandlers = false; friend void from_json(const nlohmann::json&, DalamudStartInfo&); void from_envvars(); diff --git a/Dalamud.Boot/crashhandler_shared.h b/Dalamud.Boot/crashhandler_shared.h index 4e8cbb520..8d93e4460 100644 --- a/Dalamud.Boot/crashhandler_shared.h +++ b/Dalamud.Boot/crashhandler_shared.h @@ -14,6 +14,7 @@ struct exception_info CONTEXT ContextRecord; uint64_t nLifetime; HANDLE hThreadHandle; + HANDLE hEventHandle; DWORD dwStackTraceLength; DWORD dwTroubleshootingPackDataLength; }; diff --git a/Dalamud.Boot/dllmain.cpp b/Dalamud.Boot/dllmain.cpp index dd4fadd25..7d16f6e85 100644 --- a/Dalamud.Boot/dllmain.cpp +++ b/Dalamud.Boot/dllmain.cpp @@ -17,7 +17,7 @@ static void OnReshadeOverlay(reshade::api::effect_runtime *runtime) { s_pfnReshadeOverlayCallback(reinterpret_cast(runtime->get_native())); } -DWORD WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) { +HRESULT WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) { g_startInfo.from_envvars(); std::string jsonParseError; @@ -122,7 +122,7 @@ DWORD WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) { logging::I("Calling InitializeClrAndGetEntryPoint"); void* entrypoint_vfn; - int result = InitializeClrAndGetEntryPoint( + const auto result = InitializeClrAndGetEntryPoint( g_hModule, g_startInfo.BootEnableEtw, runtimeconfig_path, @@ -132,7 +132,7 @@ DWORD WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) { L"Dalamud.EntryPoint+InitDelegate, Dalamud", &entrypoint_vfn); - if (result != 0) + if (FAILED(result)) return result; using custom_component_entry_point_fn = void (CORECLR_DELEGATE_CALLTYPE*)(LPVOID, HANDLE, LPVOID); @@ -141,8 +141,8 @@ DWORD WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) { // ============================== VEH ======================================== // logging::I("Initializing VEH..."); - if (utils::is_running_on_linux()) { - logging::I("=> VEH was disabled, running on linux"); + if (g_startInfo.NoExceptionHandlers) { + logging::W("=> Exception handlers are disabled from DalamudStartInfo."); } else if (g_startInfo.BootVehEnabled) { if (veh::add_handler(g_startInfo.BootVehFull, g_startInfo.WorkingDirectory)) logging::I("=> Done!"); @@ -164,10 +164,10 @@ DWORD WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) { entrypoint_fn(lpParam, hMainThreadContinue, g_bReshadeAvailable ? &s_pfnReshadeOverlayCallback : nullptr); logging::I("Done!"); - return 0; + return S_OK; } -DllExport DWORD WINAPI Initialize(LPVOID lpParam) { +extern "C" DWORD WINAPI Initialize(LPVOID lpParam) { return InitializeImpl(lpParam, CreateEvent(nullptr, TRUE, FALSE, nullptr)); } diff --git a/Dalamud.Boot/hooks.cpp b/Dalamud.Boot/hooks.cpp index 7cf489195..1b1280cf0 100644 --- a/Dalamud.Boot/hooks.cpp +++ b/Dalamud.Boot/hooks.cpp @@ -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("LdrRegisterDllNotification"); -static const auto LdrUnregisterDllNotification = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function("LdrUnregisterDllNotification"); - hooks::getprocaddress_singleton_import_hook::getprocaddress_singleton_import_hook() : m_pfnGetProcAddress(GetProcAddress) , m_thunk("kernel32!GetProcAddress(Singleton Import Hook)", diff --git a/Dalamud.Boot/hooks.h b/Dalamud.Boot/hooks.h index ad3b2cc6c..f6ad370d1 100644 --- a/Dalamud.Boot/hooks.h +++ b/Dalamud.Boot/hooks.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include "utils.h" diff --git a/Dalamud.Boot/module.def b/Dalamud.Boot/module.def new file mode 100644 index 000000000..047d825e5 --- /dev/null +++ b/Dalamud.Boot/module.def @@ -0,0 +1,5 @@ +LIBRARY Dalamud.Boot +EXPORTS + Initialize @1 + RewriteRemoteEntryPointW @2 + RewrittenEntryPoint @3 diff --git a/Dalamud.Boot/ntdll.cpp b/Dalamud.Boot/ntdll.cpp new file mode 100644 index 000000000..9bda0e1c4 --- /dev/null +++ b/Dalamud.Boot/ntdll.cpp @@ -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("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("LdrUnregisterDllNotification"); + return pfn(Cookie); +} diff --git a/Dalamud.Boot/ntdll.h b/Dalamud.Boot/ntdll.h new file mode 100644 index 000000000..c631475d1 --- /dev/null +++ b/Dalamud.Boot/ntdll.h @@ -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); diff --git a/Dalamud.Boot/pch.h b/Dalamud.Boot/pch.h index ae2fab9c6..7d0860089 100644 --- a/Dalamud.Boot/pch.h +++ b/Dalamud.Boot/pch.h @@ -15,18 +15,28 @@ #include // Windows Header Files (2) +#include #include #include +#include #include #include #include +#include #include #include +// Windows Header Files (3) +#include // Must be loaded after iphlpapi.h + // MSVC Compiler Intrinsic #include +// COM +#include + // C++ Standard Libraries +#include #include #include #include @@ -64,9 +74,6 @@ #include "unicode.h" -// Commonly used macros -#define DllExport extern "C" __declspec(dllexport) - // Global variables extern HMODULE g_hModule; extern HINSTANCE g_hGameInstance; diff --git a/Dalamud.Boot/rewrite_entrypoint.cpp b/Dalamud.Boot/rewrite_entrypoint.cpp index 04f68ec51..3a1672af7 100644 --- a/Dalamud.Boot/rewrite_entrypoint.cpp +++ b/Dalamud.Boot/rewrite_entrypoint.cpp @@ -1,115 +1,92 @@ #include "pch.h" #include "logging.h" +#include "utils.h" -DWORD WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue); +HRESULT WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue); struct RewrittenEntryPointParameters { - void* pAllocation; char* pEntrypoint; - char* pEntrypointBytes; size_t entrypointLength; - char* pLoadInfo; - HANDLE hMainThread; - HANDLE hMainThreadContinue; }; -#pragma pack(push, 1) -struct EntryPointThunkTemplate { - struct DUMMYSTRUCTNAME { - struct { - const uint8_t op_mov_rdi[2]{ 0x48, 0xbf }; - void* ptr = nullptr; - } fn; +namespace thunks { + constexpr uint64_t Terminator = 0xCCCCCCCCCCCCCCCCu; + constexpr uint64_t Placeholder = 0x0606060606060606u; + + extern "C" void EntryPointReplacement(); + extern "C" void RewrittenEntryPoint_Standalone(); - const uint8_t op_call_rdi[2]{ 0xff, 0xd7 }; - } CallTrampoline; -}; + void* resolve_thunk_address(void (*pfn)()) { + const auto ptr = reinterpret_cast(pfn); + if (*ptr == 0xe9) + return ptr + 5 + *reinterpret_cast(ptr + 1); + return ptr; + } -struct TrampolineTemplate { - const struct { - const uint8_t op_sub_rsp_imm[3]{ 0x48, 0x81, 0xec }; - const uint32_t length = 0x80; - } stack_alloc; + size_t get_thunk_length(void (*pfn)()) { + size_t length = 0; + for (auto ptr = reinterpret_cast(resolve_thunk_address(pfn)); *reinterpret_cast(ptr) != Terminator; ptr++) + length++; + return length; + } - struct DUMMYSTRUCTNAME { - struct { - const uint8_t op_mov_rcx_imm[2]{ 0x48, 0xb9 }; - void* val = nullptr; - } lpLibFileName; + template + void* fill_placeholders(void* pfn, const T& value) { + auto ptr = static_cast(pfn); - struct { - const uint8_t op_mov_rdi_imm[2]{ 0x48, 0xbf }; - decltype(&LoadLibraryW) ptr = nullptr; - } fn; + while (*reinterpret_cast(ptr) != Placeholder) + ptr++; - const uint8_t op_call_rdi[2]{ 0xff, 0xd7 }; - } CallLoadLibrary_nethost; + *reinterpret_cast(ptr) = 0; + *reinterpret_cast(ptr) = value; + return ptr + sizeof(value); + } - struct DUMMYSTRUCTNAME { - struct { - const uint8_t op_mov_rcx_imm[2]{ 0x48, 0xb9 }; - void* val = nullptr; - } lpLibFileName; + template + void* fill_placeholders(void* ptr, const T& value, TArgs&&...more_values) { + return fill_placeholders(fill_placeholders(ptr, value), std::forward(more_values)...); + } - struct { - const uint8_t op_mov_rdi_imm[2]{ 0x48, 0xbf }; - decltype(&LoadLibraryW) ptr = nullptr; - } fn; + std::vector create_entrypointreplacement() { + std::vector buf(get_thunk_length(&EntryPointReplacement)); + memcpy(buf.data(), resolve_thunk_address(&EntryPointReplacement), buf.size()); + return buf; + } - const uint8_t op_call_rdi[2]{ 0xff, 0xd7 }; - } CallLoadLibrary_DalamudBoot; + std::vector create_standalone_rewrittenentrypoint(const std::filesystem::path& dalamud_path) { + const auto nethost_path = std::filesystem::path(dalamud_path).replace_filename(L"nethost.dll"); - struct { - const uint8_t hModule_op_mov_rcx_rax[3]{ 0x48, 0x89, 0xc1 }; + // These are null terminated, since pointers are returned from .c_str() + const auto dalamud_path_wview = std::wstring_view(dalamud_path.c_str()); + const auto nethost_path_wview = std::wstring_view(nethost_path.c_str()); - struct { - const uint8_t op_mov_rdx_imm[2]{ 0x48, 0xba }; - void* val = nullptr; - } lpProcName; + // +2 is for null terminator + const auto dalamud_path_view = std::span(reinterpret_cast(dalamud_path_wview.data()), dalamud_path_wview.size() * 2 + 2); + const auto nethost_path_view = std::span(reinterpret_cast(nethost_path_wview.data()), nethost_path_wview.size() * 2 + 2); - struct { - const uint8_t op_mov_rdi_imm[2]{ 0x48, 0xbf }; - decltype(&GetProcAddress) ptr = nullptr; - } fn; + std::vector buffer; + const auto thunk_template_length = thunks::get_thunk_length(&thunks::RewrittenEntryPoint_Standalone); + buffer.reserve(thunk_template_length + dalamud_path_view.size() + nethost_path_view.size()); + buffer.resize(thunk_template_length); + memcpy(buffer.data(), resolve_thunk_address(&thunks::RewrittenEntryPoint_Standalone), thunk_template_length); - const uint8_t op_call_rdi[2]{ 0xff, 0xd7 }; - } CallGetProcAddress; + // &::GetProcAddress will return Dalamud.dll's import table entry. + // GetProcAddress(..., "GetProcAddress") returns the address inside kernel32.dll. + const auto kernel32 = GetModuleHandleA("kernel32.dll"); - struct { - const uint8_t op_add_rsp_imm[3]{ 0x48, 0x81, 0xc4 }; - const uint32_t length = 0x80; - } stack_release; - - struct DUMMYSTRUCTNAME2 { - // rdi := returned value from GetProcAddress - const uint8_t op_mov_rdi_rax[3]{ 0x48, 0x89, 0xc7 }; - // rax := return address - const uint8_t op_pop_rax[1]{ 0x58 }; - - // rax := rax - sizeof thunk (last instruction must be call) - struct { - const uint8_t op_sub_rax_imm4[2]{ 0x48, 0x2d }; - const uint32_t displacement = static_cast(sizeof EntryPointThunkTemplate); - } op_sub_rax_to_entry_point; - - struct { - const uint8_t op_mov_rcx_imm[2]{ 0x48, 0xb9 }; - void* val = nullptr; - } param; - - const uint8_t op_push_rax[1]{ 0x50 }; - const uint8_t op_jmp_rdi[2]{ 0xff, 0xe7 }; - } CallInjectEntryPoint; - - const char buf_CallGetProcAddress_lpProcName[20] = "RewrittenEntryPoint"; - uint8_t buf_EntryPointBackup[sizeof EntryPointThunkTemplate]{}; - -#pragma pack(push, 8) - RewrittenEntryPointParameters parameters{}; -#pragma pack(pop) -}; -#pragma pack(pop) + thunks::fill_placeholders(buffer.data(), + /* pfnLoadLibraryW = */ GetProcAddress(kernel32, "LoadLibraryW"), + /* pfnGetProcAddress = */ GetProcAddress(kernel32, "GetProcAddress"), + /* pRewrittenEntryPointParameters = */ Placeholder, + /* nNethostOffset = */ 0, + /* nDalamudOffset = */ nethost_path_view.size_bytes() + ); + buffer.insert(buffer.end(), nethost_path_view.begin(), nethost_path_view.end()); + buffer.insert(buffer.end(), dalamud_path_view.begin(), dalamud_path_view.end()); + return buffer; + } +} void read_process_memory_or_throw(HANDLE hProcess, void* pAddress, void* data, size_t len) { SIZE_T read = 0; @@ -126,6 +103,7 @@ void read_process_memory_or_throw(HANDLE hProcess, void* pAddress, T& data) { void write_process_memory_or_throw(HANDLE hProcess, void* pAddress, const void* data, size_t len) { SIZE_T written = 0; + const utils::memory_tenderizer tenderizer(hProcess, pAddress, len, PAGE_EXECUTE_READWRITE); if (!WriteProcessMemory(hProcess, pAddress, data, len, &written)) throw std::runtime_error("WriteProcessMemory failure"); if (written != len) @@ -170,10 +148,17 @@ void* get_mapped_image_base_address(HANDLE hProcess, const std::filesystem::path exe.read(reinterpret_cast(&exe_section_headers[0]), sizeof IMAGE_SECTION_HEADER * exe_section_headers.size()); if (!exe) throw std::runtime_error("Game executable is corrupt (Truncated section header)."); + + SYSTEM_INFO sysinfo; + GetSystemInfo(&sysinfo); for (MEMORY_BASIC_INFORMATION mbi{}; VirtualQueryEx(hProcess, mbi.BaseAddress, &mbi, sizeof mbi); mbi.BaseAddress = static_cast(mbi.BaseAddress) + mbi.RegionSize) { + + // wine: apparently there exists a RegionSize of 0xFFF + mbi.RegionSize = (mbi.RegionSize + sysinfo.dwPageSize - 1) / sysinfo.dwPageSize * sysinfo.dwPageSize; + if (!(mbi.State & MEM_COMMIT) || mbi.Type != MEM_IMAGE) continue; @@ -241,31 +226,22 @@ void* get_mapped_image_base_address(HANDLE hProcess, const std::filesystem::path throw std::runtime_error("corresponding base address not found"); } -std::string from_utf16(const std::wstring& wstr, UINT codePage = CP_UTF8) { - std::string str(WideCharToMultiByte(codePage, 0, &wstr[0], static_cast(wstr.size()), nullptr, 0, nullptr, nullptr), 0); - WideCharToMultiByte(codePage, 0, &wstr[0], static_cast(wstr.size()), &str[0], static_cast(str.size()), nullptr, nullptr); - return str; -} - -std::wstring to_utf16(const std::string& str, UINT codePage = CP_UTF8, bool errorOnInvalidChars = false) { - std::wstring wstr(MultiByteToWideChar(codePage, 0, &str[0], static_cast(str.size()), nullptr, 0), 0); - MultiByteToWideChar(codePage, errorOnInvalidChars ? MB_ERR_INVALID_CHARS : 0, &str[0], static_cast(str.size()), &wstr[0], static_cast(wstr.size())); - return wstr; -} - /// @brief Rewrite target process' entry point so that this DLL can be loaded and executed first. /// @param hProcess Process handle. /// @param pcwzPath Path to target process. -/// @param pcszLoadInfo JSON string to be passed to Initialize. -/// @return 0 if successful; nonzero if unsuccessful +/// @param pcwzLoadInfo JSON string to be passed to Initialize. +/// @return null if successful; memory containing wide string allocated via GlobalAlloc if unsuccessful /// /// When the process has just been started up via CreateProcess (CREATE_SUSPENDED), GetModuleFileName and alikes result in an error. /// Instead, we have to enumerate through all the files mapped into target process' virtual address space and find the base address /// of memory region corresponding to the path given. /// -DllExport DWORD WINAPI RewriteRemoteEntryPointW(HANDLE hProcess, const wchar_t* pcwzPath, const wchar_t* pcwzLoadInfo) { +extern "C" HRESULT WINAPI RewriteRemoteEntryPointW(HANDLE hProcess, const wchar_t* pcwzPath, const wchar_t* pcwzLoadInfo) { + std::wstring last_operation; + SetLastError(ERROR_SUCCESS); try { - const auto base_address = reinterpret_cast(get_mapped_image_base_address(hProcess, pcwzPath)); + last_operation = L"get_mapped_image_base_address"; + const auto base_address = static_cast(get_mapped_image_base_address(hProcess, pcwzPath)); IMAGE_DOS_HEADER dos_header{}; union { @@ -273,113 +249,150 @@ DllExport DWORD WINAPI RewriteRemoteEntryPointW(HANDLE hProcess, const wchar_t* IMAGE_NT_HEADERS64 nt_header64{}; }; + last_operation = L"read_process_memory_or_throw(base_address)"; read_process_memory_or_throw(hProcess, base_address, dos_header); + + last_operation = L"read_process_memory_or_throw(base_address + dos_header.e_lfanew)"; read_process_memory_or_throw(hProcess, base_address + dos_header.e_lfanew, nt_header64); const auto entrypoint = base_address + (nt_header32.OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC ? nt_header32.OptionalHeader.AddressOfEntryPoint : nt_header64.OptionalHeader.AddressOfEntryPoint); - auto path = get_path_from_local_module(g_hModule).wstring(); - path.resize(path.size() + 1); // ensure null termination - auto path_bytes = std::span(reinterpret_cast(&path[0]), std::span(path).size_bytes()); + last_operation = L"get_path_from_local_module(g_hModule)"; + auto local_module_path = get_path_from_local_module(g_hModule); + + last_operation = L"thunks::create_standalone_rewrittenentrypoint(local_module_path)"; + auto standalone_rewrittenentrypoint = thunks::create_standalone_rewrittenentrypoint(local_module_path); - auto nethost_path = (get_path_from_local_module(g_hModule).parent_path() / L"nethost.dll").wstring(); - nethost_path.resize(nethost_path.size() + 1); // ensure null termination - auto nethost_path_bytes = std::span(reinterpret_cast(&nethost_path[0]), std::span(nethost_path).size_bytes()); + last_operation = L"thunks::create_entrypointreplacement()"; + auto entrypoint_replacement = thunks::create_entrypointreplacement(); - auto load_info = from_utf16(pcwzLoadInfo); + last_operation = L"unicode::convert(pcwzLoadInfo)"; + auto load_info = unicode::convert(pcwzLoadInfo); load_info.resize(load_info.size() + 1); //ensure null termination - // Allocate full buffer in advance to keep reference to trampoline valid. - std::vector buffer(sizeof TrampolineTemplate + load_info.size() + nethost_path_bytes.size() + path_bytes.size()); - auto& trampoline = *reinterpret_cast(&buffer[0]); - const auto load_info_buffer = std::span(buffer).subspan(sizeof trampoline, load_info.size()); - const auto nethost_path_buffer = std::span(buffer).subspan(sizeof trampoline + load_info.size(), nethost_path_bytes.size()); - const auto dalamud_path_buffer = std::span(buffer).subspan(sizeof trampoline + load_info.size() + nethost_path_bytes.size(), path_bytes.size()); - - new(&trampoline)TrampolineTemplate(); // this line initializes given buffer instead of allocating memory - memcpy(&load_info_buffer[0], &load_info[0], load_info_buffer.size()); - memcpy(&nethost_path_buffer[0], &nethost_path_bytes[0], nethost_path_buffer.size()); - memcpy(&dalamud_path_buffer[0], &path_bytes[0], dalamud_path_buffer.size()); - - // Backup remote process' original entry point. - read_process_memory_or_throw(hProcess, entrypoint, trampoline.buf_EntryPointBackup); + const auto bufferSize = sizeof(RewrittenEntryPointParameters) + entrypoint_replacement.size() + load_info.size() + standalone_rewrittenentrypoint.size(); + last_operation = std::format(L"std::vector alloc({}b)", bufferSize); + std::vector buffer(bufferSize); // Allocate buffer in remote process, which will be used to fill addresses in the local buffer. - const auto remote_buffer = reinterpret_cast(VirtualAllocEx(hProcess, nullptr, buffer.size(), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)); - - // Fill the values to be used in RewrittenEntryPoint - trampoline.parameters = { - .pAllocation = remote_buffer, - .pEntrypoint = entrypoint, - .pEntrypointBytes = remote_buffer + offsetof(TrampolineTemplate, buf_EntryPointBackup), - .entrypointLength = sizeof trampoline.buf_EntryPointBackup, - .pLoadInfo = remote_buffer + (&load_info_buffer[0] - &buffer[0]), - }; + last_operation = std::format(L"VirtualAllocEx({}b)", bufferSize); + const auto remote_buffer = static_cast(VirtualAllocEx(hProcess, nullptr, buffer.size(), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)); + + auto& params = *reinterpret_cast(buffer.data()); + params.entrypointLength = entrypoint_replacement.size(); + params.pEntrypoint = entrypoint; - // Fill the addresses referred in machine code. - trampoline.CallLoadLibrary_nethost.lpLibFileName.val = remote_buffer + (&nethost_path_buffer[0] - &buffer[0]); - trampoline.CallLoadLibrary_nethost.fn.ptr = LoadLibraryW; - trampoline.CallLoadLibrary_DalamudBoot.lpLibFileName.val = remote_buffer + (&dalamud_path_buffer[0] - &buffer[0]); - trampoline.CallLoadLibrary_DalamudBoot.fn.ptr = LoadLibraryW; - trampoline.CallGetProcAddress.lpProcName.val = remote_buffer + offsetof(TrampolineTemplate, buf_CallGetProcAddress_lpProcName); - trampoline.CallGetProcAddress.fn.ptr = GetProcAddress; - trampoline.CallInjectEntryPoint.param.val = remote_buffer + offsetof(TrampolineTemplate, parameters); + // Backup original entry point. + last_operation = std::format(L"read_process_memory_or_throw(entrypoint, {}b)", entrypoint_replacement.size()); + read_process_memory_or_throw(hProcess, entrypoint, &buffer[sizeof params], entrypoint_replacement.size()); + + memcpy(&buffer[sizeof params + entrypoint_replacement.size()], load_info.data(), load_info.size()); + + last_operation = L"thunks::fill_placeholders(EntryPointReplacement)"; + thunks::fill_placeholders(standalone_rewrittenentrypoint.data(), remote_buffer); + memcpy(&buffer[sizeof params + entrypoint_replacement.size() + load_info.size()], standalone_rewrittenentrypoint.data(), standalone_rewrittenentrypoint.size()); // Write the local buffer into the buffer in remote process. + last_operation = std::format(L"write_process_memory_or_throw(remote_buffer, {}b)", buffer.size()); write_process_memory_or_throw(hProcess, remote_buffer, buffer.data(), buffer.size()); - // Overwrite remote process' entry point with a thunk that immediately calls our trampoline function. - EntryPointThunkTemplate thunk{}; - thunk.CallTrampoline.fn.ptr = remote_buffer; - write_process_memory_or_throw(hProcess, entrypoint, thunk); + last_operation = L"thunks::fill_placeholders(RewrittenEntryPoint_Standalone::pRewrittenEntryPointParameters)"; + thunks::fill_placeholders(entrypoint_replacement.data(), remote_buffer + sizeof params + entrypoint_replacement.size() + load_info.size()); - return 0; + // Overwrite remote process' entry point with a thunk that will load our DLLs and call our trampoline function. + last_operation = std::format(L"write_process_memory_or_throw(entrypoint={:X}, {}b)", reinterpret_cast(entrypoint), buffer.size()); + write_process_memory_or_throw(hProcess, entrypoint, entrypoint_replacement.data(), entrypoint_replacement.size()); + FlushInstructionCache(hProcess, entrypoint, entrypoint_replacement.size()); + + return S_OK; } catch (const std::exception& e) { - OutputDebugStringA(std::format("RewriteRemoteEntryPoint failure: {} (GetLastError: {})\n", e.what(), GetLastError()).c_str()); - return 1; - } -} + const auto err = GetLastError(); + const auto hr = err == ERROR_SUCCESS ? E_FAIL : HRESULT_FROM_WIN32(err); + auto formatted = std::format( + L"{}: {} ({})", + last_operation, + unicode::convert(e.what()), + utils::format_win32_error(err)); + OutputDebugStringW((formatted + L"\r\n").c_str()); -/// @deprecated -DllExport DWORD WINAPI RewriteRemoteEntryPoint(HANDLE hProcess, const wchar_t* pcwzPath, const char* pcszLoadInfo) { - return RewriteRemoteEntryPointW(hProcess, pcwzPath, to_utf16(pcszLoadInfo).c_str()); + ICreateErrorInfoPtr cei; + if (FAILED(CreateErrorInfo(&cei))) + return hr; + if (FAILED(cei->SetSource(const_cast(L"Dalamud.Boot")))) + return hr; + if (FAILED(cei->SetDescription(const_cast(formatted.c_str())))) + return hr; + + IErrorInfoPtr ei; + if (FAILED(cei.QueryInterface(IID_PPV_ARGS(&ei)))) + return hr; + + (void)SetErrorInfo(0, ei); + return hr; + } } /// @brief Entry point function "called" instead of game's original main entry point. /// @param params Parameters set up from RewriteRemoteEntryPoint. -DllExport void WINAPI RewrittenEntryPoint(RewrittenEntryPointParameters& params) { - params.hMainThreadContinue = CreateEventW(nullptr, true, false, nullptr); - if (!params.hMainThreadContinue) - ExitProcess(-1); +extern "C" void WINAPI RewrittenEntryPoint_AdjustedStack(RewrittenEntryPointParameters & params) { + HANDLE hMainThreadContinue = nullptr; + auto hr = S_OK; + std::wstring last_operation; + std::wstring exc_msg; + SetLastError(ERROR_SUCCESS); - // Do whatever the work in a separate thread to minimize the stack usage at this context, - // as this function really should have been a naked procedure but __declspec(naked) isn't supported in x64 version of msvc. - params.hMainThread = CreateThread(nullptr, 0, [](void* p) -> DWORD { - try { - std::string loadInfo; - auto& params = *reinterpret_cast(p); - { - // Restore original entry point. - // Use WriteProcessMemory instead of memcpy to avoid having to fiddle with VirtualProtect. - write_process_memory_or_throw(GetCurrentProcess(), params.pEntrypoint, params.pEntrypointBytes, params.entrypointLength); + try { + const auto pOriginalEntryPointBytes = reinterpret_cast(¶ms) + sizeof(params); + const auto pLoadInfo = pOriginalEntryPointBytes + params.entrypointLength; - // Make a copy of load info, as the whole params will be freed after this code block. - loadInfo = params.pLoadInfo; - } + // Restore original entry point. + // Use WriteProcessMemory instead of memcpy to avoid having to fiddle with VirtualProtect. + last_operation = L"restore original entry point"; + write_process_memory_or_throw(GetCurrentProcess(), params.pEntrypoint, pOriginalEntryPointBytes, params.entrypointLength); + FlushInstructionCache(GetCurrentProcess(), params.pEntrypoint, params.entrypointLength); - if (const auto err = InitializeImpl(&loadInfo[0], params.hMainThreadContinue)) - throw std::exception(std::format("{:08X}", err).c_str()); - return 0; - } catch (const std::exception& e) { - MessageBoxA(nullptr, std::format("Failed to load Dalamud.\n\nError: {}", e.what()).c_str(), "Dalamud.Boot", MB_OK | MB_ICONERROR); - ExitProcess(-1); + hMainThreadContinue = CreateEventW(nullptr, true, false, nullptr); + last_operation = L"hMainThreadContinue = CreateEventW"; + if (!hMainThreadContinue) + throw std::runtime_error("CreateEventW"); + + last_operation = L"InitializeImpl"; + hr = InitializeImpl(pLoadInfo, hMainThreadContinue); + } catch (const std::exception& e) { + if (hr == S_OK) { + const auto err = GetLastError(); + hr = err == ERROR_SUCCESS ? E_FAIL : HRESULT_FROM_WIN32(err); } - }, ¶ms, 0, nullptr); - if (!params.hMainThread) - ExitProcess(-1); - CloseHandle(params.hMainThread); - WaitForSingleObject(params.hMainThreadContinue, INFINITE); - VirtualFree(params.pAllocation, 0, MEM_RELEASE); + ICreateErrorInfoPtr cei; + IErrorInfoPtr ei; + if (SUCCEEDED(CreateErrorInfo(&cei)) + && SUCCEEDED(cei->SetDescription(const_cast(unicode::convert(e.what()).c_str()))) + && SUCCEEDED(cei.QueryInterface(IID_PPV_ARGS(&ei)))) { + (void)SetErrorInfo(0, ei); + } + } + + if (FAILED(hr)) { + const _com_error err(hr); + auto desc = err.Description(); + if (desc.length() == 0) + desc = err.ErrorMessage(); + if (MessageBoxW(nullptr, std::format( + L"Failed to load Dalamud. Load game without Dalamud(yes) or abort(no)?\n\n{}\n{}", + last_operation, + desc.GetBSTR()).c_str(), + L"Dalamud.Boot", MB_OK | MB_YESNO) == IDNO) + ExitProcess(-1); + if (hMainThreadContinue) { + CloseHandle(hMainThreadContinue); + hMainThreadContinue = nullptr; + } + } + + if (hMainThreadContinue) + WaitForSingleObject(hMainThreadContinue, INFINITE); + + VirtualFree(¶ms, 0, MEM_RELEASE); } diff --git a/Dalamud.Boot/rewrite_entrypoint_thunks.asm b/Dalamud.Boot/rewrite_entrypoint_thunks.asm new file mode 100644 index 000000000..af7be8287 --- /dev/null +++ b/Dalamud.Boot/rewrite_entrypoint_thunks.asm @@ -0,0 +1,82 @@ +PUBLIC EntryPointReplacement +PUBLIC RewrittenEntryPoint_Standalone +PUBLIC RewrittenEntryPoint + +; 06 and 07 are invalid opcodes +; CC is int3 = bp +; using 0CCCCCCCCCCCCCCCCh as function terminator +; using 00606060606060606h as placeholders + +TERMINATOR = 0CCCCCCCCCCCCCCCCh +PLACEHOLDER = 00606060606060606h + +.code + +EntryPointReplacement PROC + start: + ; rsp % 0x10 = 0x08 + lea rax, [start] + push rax + + ; rsp % 0x10 = 0x00 + mov rax, PLACEHOLDER + + ; this calls RewrittenEntryPoint_Standalone + jmp rax + + dq TERMINATOR +EntryPointReplacement ENDP + +RewrittenEntryPoint_Standalone PROC + start: + ; stack is aligned to 0x10; see above + sub rsp, 20h + lea rcx, [embeddedData] + add rcx, qword ptr [nNethostOffset] + call qword ptr [pfnLoadLibraryW] + + lea rcx, [embeddedData] + add rcx, qword ptr [nDalamudOffset] + call qword ptr [pfnLoadLibraryW] + + mov rcx, rax + lea rdx, [pcszEntryPointName] + call qword ptr [pfnGetProcAddress] + + mov rcx, qword ptr [pRewrittenEntryPointParameters] + ; this calls RewrittenEntryPoint + jmp rax + + pfnLoadLibraryW: + dq PLACEHOLDER + + pfnGetProcAddress: + dq PLACEHOLDER + + pRewrittenEntryPointParameters: + dq PLACEHOLDER + + nNethostOffset: + dq PLACEHOLDER + + nDalamudOffset: + dq PLACEHOLDER + + pcszEntryPointName: + db "RewrittenEntryPoint", 0 + + embeddedData: + + dq TERMINATOR +RewrittenEntryPoint_Standalone ENDP + +EXTERN RewrittenEntryPoint_AdjustedStack :PROC + +RewrittenEntryPoint PROC + ; stack is aligned to 0x10; see above + call RewrittenEntryPoint_AdjustedStack + add rsp, 20h + ret +RewrittenEntryPoint ENDP + +END diff --git a/Dalamud.Boot/utils.cpp b/Dalamud.Boot/utils.cpp index 79205eb8d..65018add4 100644 --- a/Dalamud.Boot/utils.cpp +++ b/Dalamud.Boot/utils.cpp @@ -408,14 +408,20 @@ utils::signature_finder::result utils::signature_finder::find_one() const { return find(1, 1, false).front(); } -utils::memory_tenderizer::memory_tenderizer(const void* pAddress, size_t length, DWORD dwNewProtect) : m_data(reinterpret_cast(const_cast(pAddress)), length) { +utils::memory_tenderizer::memory_tenderizer(const void* pAddress, size_t length, DWORD dwNewProtect) + : memory_tenderizer(GetCurrentProcess(), pAddress, length, dwNewProtect) { +} + +utils::memory_tenderizer::memory_tenderizer(HANDLE hProcess, const void* pAddress, size_t length, DWORD dwNewProtect) +: m_process(hProcess) +, m_data(static_cast(const_cast(pAddress)), length) { try { - for (auto pCoveredAddress = &m_data[0]; - pCoveredAddress < &m_data[0] + m_data.size(); - pCoveredAddress = reinterpret_cast(m_regions.back().BaseAddress) + m_regions.back().RegionSize) { + for (auto pCoveredAddress = m_data.data(); + pCoveredAddress < m_data.data() + m_data.size(); + pCoveredAddress = static_cast(m_regions.back().BaseAddress) + m_regions.back().RegionSize) { MEMORY_BASIC_INFORMATION region{}; - if (!VirtualQuery(pCoveredAddress, ®ion, sizeof region)) { + if (!VirtualQueryEx(hProcess, pCoveredAddress, ®ion, sizeof region)) { throw std::runtime_error(std::format( "VirtualQuery(addr=0x{:X}, ..., cb={}) failed with Win32 code 0x{:X}", reinterpret_cast(pCoveredAddress), @@ -423,7 +429,7 @@ utils::memory_tenderizer::memory_tenderizer(const void* pAddress, size_t length, GetLastError())); } - if (!VirtualProtect(region.BaseAddress, region.RegionSize, dwNewProtect, ®ion.Protect)) { + if (!VirtualProtectEx(hProcess, region.BaseAddress, region.RegionSize, dwNewProtect, ®ion.Protect)) { throw std::runtime_error(std::format( "(Change)VirtualProtect(addr=0x{:X}, size=0x{:X}, ..., ...) failed with Win32 code 0x{:X}", reinterpret_cast(region.BaseAddress), @@ -436,7 +442,7 @@ utils::memory_tenderizer::memory_tenderizer(const void* pAddress, size_t length, } catch (...) { for (auto& region : std::ranges::reverse_view(m_regions)) { - if (!VirtualProtect(region.BaseAddress, region.RegionSize, region.Protect, ®ion.Protect)) { + if (!VirtualProtectEx(hProcess, region.BaseAddress, region.RegionSize, region.Protect, ®ion.Protect)) { // Could not restore; fast fail __fastfail(GetLastError()); } @@ -448,7 +454,7 @@ utils::memory_tenderizer::memory_tenderizer(const void* pAddress, size_t length, utils::memory_tenderizer::~memory_tenderizer() { for (auto& region : std::ranges::reverse_view(m_regions)) { - if (!VirtualProtect(region.BaseAddress, region.RegionSize, region.Protect, ®ion.Protect)) { + if (!VirtualProtectEx(m_process, region.BaseAddress, region.RegionSize, region.Protect, ®ion.Protect)) { // Could not restore; fast fail __fastfail(GetLastError()); } @@ -578,19 +584,6 @@ std::vector utils::get_env_list(const wchar_t* pcszName) { return res; } -bool utils::is_running_on_linux() { - if (get_env(L"XL_WINEONLINUX")) - return true; - HMODULE hntdll = GetModuleHandleW(L"ntdll.dll"); - if (!hntdll) - return true; - if (GetProcAddress(hntdll, "wine_get_version")) - return true; - if (GetProcAddress(hntdll, "wine_get_host_version")) - return true; - return false; -} - std::filesystem::path utils::get_module_path(HMODULE hModule) { std::wstring buf(MAX_PATH, L'\0'); while (true) { @@ -657,3 +650,25 @@ std::wstring utils::escape_shell_arg(const std::wstring& arg) { } return res; } + +std::wstring utils::format_win32_error(DWORD err) { + wchar_t* pwszMsg = nullptr; + FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + err, + MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US), + reinterpret_cast(&pwszMsg), + 0, + nullptr); + if (pwszMsg) { + std::wstring result = std::format(L"Win32 error ({}=0x{:X}): {}", err, err, pwszMsg); + while (!result.empty() && std::isspace(result.back())) + result.pop_back(); + LocalFree(pwszMsg); + return result; + } + + return std::format(L"Win32 error ({}=0x{:X})", err, err); +} diff --git a/Dalamud.Boot/utils.h b/Dalamud.Boot/utils.h index 5d5c90dde..f10e277c0 100644 --- a/Dalamud.Boot/utils.h +++ b/Dalamud.Boot/utils.h @@ -111,10 +111,13 @@ namespace utils { }; class memory_tenderizer { + HANDLE m_process; std::span m_data; std::vector m_regions; public: + memory_tenderizer(HANDLE hProcess, const void* pAddress, size_t length, DWORD dwNewProtect); + memory_tenderizer(const void* pAddress, size_t length, DWORD dwNewProtect); template&& std::is_standard_layout_v>> @@ -264,8 +267,6 @@ namespace utils { return get_env_list(unicode::convert(pcszName).c_str()); } - bool is_running_on_linux(); - std::filesystem::path get_module_path(HMODULE hModule); /// @brief Find the game main window. @@ -275,4 +276,6 @@ namespace utils { void wait_for_game_window(); std::wstring escape_shell_arg(const std::wstring& arg); + + std::wstring format_win32_error(DWORD err); } diff --git a/Dalamud.Boot/veh.cpp b/Dalamud.Boot/veh.cpp index 0898441ff..58234783a 100644 --- a/Dalamud.Boot/veh.cpp +++ b/Dalamud.Boot/veh.cpp @@ -6,6 +6,7 @@ #include "logging.h" #include "utils.h" +#include "hooks.h" #include "crashhandler_shared.h" #include "DalamudStartInfo.h" @@ -24,8 +25,10 @@ PVOID g_veh_handle = nullptr; bool g_veh_do_full_dump = false; +std::optional> g_HookSetUnhandledExceptionFilter; HANDLE g_crashhandler_process = nullptr; +HANDLE g_crashhandler_event = nullptr; HANDLE g_crashhandler_pipe_write = nullptr; std::recursive_mutex g_exception_handler_mutex; @@ -101,8 +104,24 @@ bool is_ffxiv_address(const wchar_t* module_name, const DWORD64 address) static void append_injector_launch_args(std::vector& args) { - args.emplace_back(L"-g"); - args.emplace_back(utils::loaded_module::current_process().path().wstring()); + args.emplace_back(L"--game=\"" + utils::loaded_module::current_process().path().wstring() + L"\""); + switch (g_startInfo.DalamudLoadMethod) { + case DalamudStartInfo::LoadMethod::Entrypoint: + args.emplace_back(L"--mode=entrypoint"); + break; + case DalamudStartInfo::LoadMethod::DllInject: + args.emplace_back(L"--mode=inject"); + } + args.emplace_back(L"--dalamud-working-directory=\"" + unicode::convert(g_startInfo.WorkingDirectory) + L"\""); + args.emplace_back(L"--dalamud-configuration-path=\"" + unicode::convert(g_startInfo.ConfigurationPath) + L"\""); + args.emplace_back(L"--logpath=\"" + unicode::convert(g_startInfo.LogPath) + L"\""); + args.emplace_back(L"--logname=\"" + unicode::convert(g_startInfo.LogName) + L"\""); + args.emplace_back(L"--dalamud-plugin-directory=\"" + unicode::convert(g_startInfo.PluginDirectory) + L"\""); + args.emplace_back(L"--dalamud-asset-directory=\"" + unicode::convert(g_startInfo.AssetDirectory) + L"\""); + args.emplace_back(std::format(L"--dalamud-client-language={}", static_cast(g_startInfo.Language))); + args.emplace_back(std::format(L"--dalamud-delay-initialize={}", g_startInfo.DelayInitializeMs)); + // NoLoadPlugins/NoLoadThirdPartyPlugins: supplied from DalamudCrashHandler + if (g_startInfo.BootShowConsole) args.emplace_back(L"--console"); if (g_startInfo.BootEnableEtw) @@ -128,6 +147,85 @@ static void append_injector_launch_args(std::vector& args) } LONG exception_handler(EXCEPTION_POINTERS* ex) +{ + // block any other exceptions hitting the handler while the messagebox is open + const auto lock = std::lock_guard(g_exception_handler_mutex); + + exception_info exinfo{}; + exinfo.pExceptionPointers = ex; + exinfo.ExceptionPointers = *ex; + exinfo.ContextRecord = *ex->ContextRecord; + exinfo.ExceptionRecord = ex->ExceptionRecord ? *ex->ExceptionRecord : EXCEPTION_RECORD{}; + const auto time_now = std::chrono::system_clock::now(); + auto lifetime = std::chrono::duration_cast( + time_now.time_since_epoch()).count() + - std::chrono::duration_cast( + g_time_start.time_since_epoch()).count(); + exinfo.nLifetime = lifetime; + DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), g_crashhandler_process, &exinfo.hThreadHandle, 0, TRUE, DUPLICATE_SAME_ACCESS); + DuplicateHandle(GetCurrentProcess(), g_crashhandler_event, g_crashhandler_process, &exinfo.hEventHandle, 0, TRUE, DUPLICATE_SAME_ACCESS); + + std::wstring stackTrace; + if (!g_clr) + { + stackTrace = L"(no CLR stack trace available)"; + } + else if (void* fn; const auto err = static_cast(g_clr->get_function_pointer( + L"Dalamud.EntryPoint, Dalamud", + L"VehCallback", + L"Dalamud.EntryPoint+VehDelegate, Dalamud", + nullptr, nullptr, &fn))) + { + stackTrace = std::format(L"Failed to read stack trace: 0x{:08x}", err); + } + else + { + stackTrace = static_cast(fn)(); + // Don't free it, as the program's going to be quit anyway + } + + exinfo.dwStackTraceLength = static_cast(stackTrace.size()); + exinfo.dwTroubleshootingPackDataLength = static_cast(g_startInfo.TroubleshootingPackData.size()); + if (DWORD written; !WriteFile(g_crashhandler_pipe_write, &exinfo, static_cast(sizeof exinfo), &written, nullptr) || sizeof exinfo != written) + return EXCEPTION_CONTINUE_SEARCH; + + if (const auto nb = static_cast(std::span(stackTrace).size_bytes())) + { + if (DWORD written; !WriteFile(g_crashhandler_pipe_write, stackTrace.data(), nb, &written, nullptr) || nb != written) + return EXCEPTION_CONTINUE_SEARCH; + } + + if (const auto nb = static_cast(std::span(g_startInfo.TroubleshootingPackData).size_bytes())) + { + if (DWORD written; !WriteFile(g_crashhandler_pipe_write, g_startInfo.TroubleshootingPackData.data(), nb, &written, nullptr) || nb != written) + return EXCEPTION_CONTINUE_SEARCH; + } + + AllowSetForegroundWindow(GetProcessId(g_crashhandler_process)); + + HANDLE waitHandles[] = { g_crashhandler_process, g_crashhandler_event }; + DWORD waitResult = WaitForMultipleObjects(2, waitHandles, FALSE, INFINITE); + + switch (waitResult) { + case WAIT_OBJECT_0: + logging::E("DalamudCrashHandler.exe exited unexpectedly"); + break; + case WAIT_OBJECT_0 + 1: + logging::I("Crashing thread was resumed"); + break; + default: + logging::E("Unexpected WaitForMultipleObjects return code 0x{:x}", waitResult); + } + + return EXCEPTION_CONTINUE_SEARCH; +} + +LONG WINAPI structured_exception_handler(EXCEPTION_POINTERS* ex) +{ + return exception_handler(ex); +} + +LONG WINAPI vectored_exception_handler(EXCEPTION_POINTERS* ex) { if (ex->ExceptionRecord->ExceptionCode == 0x12345678) { @@ -143,50 +241,7 @@ LONG exception_handler(EXCEPTION_POINTERS* ex) return EXCEPTION_CONTINUE_SEARCH; } - // block any other exceptions hitting the veh while the messagebox is open - const auto lock = std::lock_guard(g_exception_handler_mutex); - - exception_info exinfo{}; - exinfo.pExceptionPointers = ex; - exinfo.ExceptionPointers = *ex; - exinfo.ContextRecord = *ex->ContextRecord; - exinfo.ExceptionRecord = ex->ExceptionRecord ? *ex->ExceptionRecord : EXCEPTION_RECORD{}; - const auto time_now = std::chrono::system_clock::now(); - auto lifetime = std::chrono::duration_cast( - time_now.time_since_epoch()).count() - - std::chrono::duration_cast( - g_time_start.time_since_epoch()).count(); - exinfo.nLifetime = lifetime; - DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), g_crashhandler_process, &exinfo.hThreadHandle, 0, TRUE, DUPLICATE_SAME_ACCESS); - - std::wstring stackTrace; - if (void* fn; const auto err = static_cast(g_clr->get_function_pointer( - L"Dalamud.EntryPoint, Dalamud", - L"VehCallback", - L"Dalamud.EntryPoint+VehDelegate, Dalamud", - nullptr, nullptr, &fn))) - { - stackTrace = std::format(L"Failed to read stack trace: 0x{:08x}", err); - } - else - { - stackTrace = static_cast(fn)(); - // Don't free it, as the program's going to be quit anyway - } - - exinfo.dwStackTraceLength = static_cast(stackTrace.size()); - exinfo.dwTroubleshootingPackDataLength = static_cast(g_startInfo.TroubleshootingPackData.size()); - if (DWORD written; !WriteFile(g_crashhandler_pipe_write, &exinfo, static_cast(sizeof exinfo), &written, nullptr) || sizeof exinfo != written) - return EXCEPTION_CONTINUE_SEARCH; - - if (DWORD written; !WriteFile(g_crashhandler_pipe_write, &stackTrace[0], static_cast(std::span(stackTrace).size_bytes()), &written, nullptr) || std::span(stackTrace).size_bytes() != written) - return EXCEPTION_CONTINUE_SEARCH; - - if (DWORD written; !WriteFile(g_crashhandler_pipe_write, &g_startInfo.TroubleshootingPackData[0], static_cast(std::span(g_startInfo.TroubleshootingPackData).size_bytes()), &written, nullptr) || std::span(g_startInfo.TroubleshootingPackData).size_bytes() != written) - return EXCEPTION_CONTINUE_SEARCH; - - SuspendThread(GetCurrentThread()); - return EXCEPTION_CONTINUE_SEARCH; + return exception_handler(ex); } bool veh::add_handler(bool doFullDump, const std::string& workingDirectory) @@ -194,8 +249,15 @@ bool veh::add_handler(bool doFullDump, const std::string& workingDirectory) if (g_veh_handle) return false; - g_veh_handle = AddVectoredExceptionHandler(1, exception_handler); - SetUnhandledExceptionFilter(nullptr); + g_veh_handle = AddVectoredExceptionHandler(TRUE, vectored_exception_handler); + + g_HookSetUnhandledExceptionFilter.emplace("kernel32.dll!SetUnhandledExceptionFilter (lpTopLevelExceptionFilter)", "kernel32.dll", "SetUnhandledExceptionFilter", 0); + g_HookSetUnhandledExceptionFilter->set_detour([](LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter) -> LPTOP_LEVEL_EXCEPTION_FILTER + { + logging::I("Overwriting UnhandledExceptionFilter from {} to {}", reinterpret_cast(lpTopLevelExceptionFilter), reinterpret_cast(structured_exception_handler)); + return g_HookSetUnhandledExceptionFilter->call_original(structured_exception_handler); + }); + SetUnhandledExceptionFilter(structured_exception_handler); g_veh_do_full_dump = doFullDump; g_time_start = std::chrono::system_clock::now(); @@ -308,6 +370,12 @@ bool veh::add_handler(bool doFullDump, const std::string& workingDirectory) return false; } + if (!(g_crashhandler_event = CreateEventW(NULL, FALSE, FALSE, NULL))) + { + logging::W("Failed to create crash synchronization event: CreateEventW error 0x{:x}", GetLastError()); + return false; + } + CloseHandle(pi.hThread); g_crashhandler_process = pi.hProcess; @@ -321,6 +389,8 @@ bool veh::remove_handler() if (g_veh_handle && RemoveVectoredExceptionHandler(g_veh_handle) != 0) { g_veh_handle = nullptr; + g_HookSetUnhandledExceptionFilter.reset(); + SetUnhandledExceptionFilter(nullptr); return true; } return false; diff --git a/Dalamud.Boot/xivfixes.cpp b/Dalamud.Boot/xivfixes.cpp index e16dd6e5a..f3b6aaa2c 100644 --- a/Dalamud.Boot/xivfixes.cpp +++ b/Dalamud.Boot/xivfixes.cpp @@ -5,9 +5,8 @@ #include "DalamudStartInfo.h" #include "hooks.h" #include "logging.h" +#include "ntdll.h" #include "utils.h" -#include -#include template static std::span assume_nonempty_span(std::span t, const char* descr) { @@ -513,50 +512,6 @@ void xivfixes::backup_userdata_save(bool bApply) { } } -void xivfixes::clr_failfast_hijack(bool bApply) -{ - static const char* LogTag = "[xivfixes:clr_failfast_hijack]"; - static std::optional> s_HookClrFatalError; - static std::optional> s_HookSetUnhandledExceptionFilter; - - if (bApply) - { - if (!g_startInfo.BootEnabledGameFixes.contains("clr_failfast_hijack")) { - logging::I("{} Turned off via environment variable.", LogTag); - return; - } - - s_HookClrFatalError.emplace("kernel32.dll!RaiseFailFastException (import, backup_userdata_save)", "kernel32.dll", "RaiseFailFastException", 0); - s_HookSetUnhandledExceptionFilter.emplace("kernel32.dll!SetUnhandledExceptionFilter (lpTopLevelExceptionFilter)", "kernel32.dll", "SetUnhandledExceptionFilter", 0); - - s_HookClrFatalError->set_detour([](PEXCEPTION_RECORD pExceptionRecord, - _In_opt_ PCONTEXT pContextRecord, - _In_ DWORD dwFlags) - { - MessageBoxW(nullptr, L"An error in a Dalamud plugin was detected and the game cannot continue.\n\nPlease take a screenshot of this error message and let us know about it.", L"Dalamud", MB_OK | MB_ICONERROR); - - return s_HookClrFatalError->call_original(pExceptionRecord, pContextRecord, dwFlags); - }); - - s_HookSetUnhandledExceptionFilter->set_detour([](LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter) -> LPTOP_LEVEL_EXCEPTION_FILTER - { - logging::I("{} SetUnhandledExceptionFilter", LogTag); - return nullptr; - }); - - logging::I("{} Enable", LogTag); - } - else - { - if (s_HookClrFatalError) { - logging::I("{} Disable ClrFatalError", LogTag); - s_HookClrFatalError.reset(); - s_HookSetUnhandledExceptionFilter.reset(); - } - } -} - - void xivfixes::prevent_icmphandle_crashes(bool bApply) { static const char* LogTag = "[xivfixes:prevent_icmphandle_crashes]"; @@ -590,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> 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(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(ddir.AddressOfRawData); + if (pdbref.Signature == DotNetPdbInfoSignatureValue) { + const auto pathSpan = std::string_view(pdbref.PdbPath, strlen(pdbref.PdbPath)); + const auto pathWide = unicode::convert(pathSpan); + std::wstring windowsDirectory(GetWindowsDirectoryW(nullptr, 0) + 1, L'\0'); + windowsDirectory.resize( + GetWindowsDirectoryW(windowsDirectory.data(), static_cast(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(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> { @@ -598,8 +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 }, - { "clr_failfast_hijack", &clr_failfast_hijack }, - { "prevent_icmphandle_crashes", &prevent_icmphandle_crashes } + { "prevent_icmphandle_crashes", &prevent_icmphandle_crashes }, + { "symbol_load_patches", &symbol_load_patches }, } ) { try { diff --git a/Dalamud.Boot/xivfixes.h b/Dalamud.Boot/xivfixes.h index 701913c88..afe2edb45 100644 --- a/Dalamud.Boot/xivfixes.h +++ b/Dalamud.Boot/xivfixes.h @@ -6,8 +6,8 @@ namespace xivfixes { void disable_game_openprocess_access_check(bool bApply); void redirect_openprocess(bool bApply); void backup_userdata_save(bool bApply); - void clr_failfast_hijack(bool bApply); void prevent_icmphandle_crashes(bool bApply); + void symbol_load_patches(bool bApply); void apply_all(bool bApply); } diff --git a/Dalamud.Common/ClientLanguage.cs b/Dalamud.Common/ClientLanguage.cs new file mode 100644 index 000000000..1fd58dce1 --- /dev/null +++ b/Dalamud.Common/ClientLanguage.cs @@ -0,0 +1,27 @@ +namespace Dalamud.Common; + +/// +/// Enum describing the language the game loads in. +/// +public enum ClientLanguage +{ + /// + /// Indicating a Japanese game client. + /// + Japanese, + + /// + /// Indicating an English game client. + /// + English, + + /// + /// Indicating a German game client. + /// + German, + + /// + /// Indicating a French game client. + /// + French, +} diff --git a/Dalamud.Common/Dalamud.Common.csproj b/Dalamud.Common/Dalamud.Common.csproj new file mode 100644 index 000000000..ac5d3fdba --- /dev/null +++ b/Dalamud.Common/Dalamud.Common.csproj @@ -0,0 +1,13 @@ + + + + net7.0 + enable + enable + + + + + + + diff --git a/Dalamud/DalamudStartInfo.cs b/Dalamud.Common/DalamudStartInfo.cs similarity index 71% rename from Dalamud/DalamudStartInfo.cs rename to Dalamud.Common/DalamudStartInfo.cs index 63a61c97e..a84d3b68f 100644 --- a/Dalamud/DalamudStartInfo.cs +++ b/Dalamud.Common/DalamudStartInfo.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; - -using Dalamud.Game; +using Dalamud.Common.Game; using Newtonsoft.Json; -namespace Dalamud; +namespace Dalamud.Common; /// /// Struct containing information needed to initialize Dalamud. +/// Modify DalamudStartInfo.h and DalamudStartInfo.cpp along with this record. /// [Serializable] -public record DalamudStartInfo : IServiceType +public record DalamudStartInfo { /// /// Initializes a new instance of the class. @@ -21,36 +19,9 @@ public record DalamudStartInfo : IServiceType } /// - /// Initializes a new instance of the class. + /// Gets or sets the Dalamud load method. /// - /// Object to copy values from. - public DalamudStartInfo(DalamudStartInfo other) - { - this.WorkingDirectory = other.WorkingDirectory; - this.ConfigurationPath = other.ConfigurationPath; - this.LogPath = other.LogPath; - this.LogName = other.LogName; - this.PluginDirectory = other.PluginDirectory; - this.AssetDirectory = other.AssetDirectory; - this.Language = other.Language; - this.GameVersion = other.GameVersion; - this.DelayInitializeMs = other.DelayInitializeMs; - this.TroubleshootingPackData = other.TroubleshootingPackData; - this.NoLoadPlugins = other.NoLoadPlugins; - this.NoLoadThirdPartyPlugins = other.NoLoadThirdPartyPlugins; - this.BootLogPath = other.BootLogPath; - this.BootShowConsole = other.BootShowConsole; - this.BootDisableFallbackConsole = other.BootDisableFallbackConsole; - this.BootWaitMessageBox = other.BootWaitMessageBox; - this.BootWaitDebugger = other.BootWaitDebugger; - this.BootVehEnabled = other.BootVehEnabled; - this.BootVehFull = other.BootVehFull; - this.BootEnableEtw = other.BootEnableEtw; - this.BootDotnetOpenProcessHookMode = other.BootDotnetOpenProcessHookMode; - this.BootEnabledGameFixes = other.BootEnabledGameFixes; - this.BootUnhookDlls = other.BootUnhookDlls; - this.CrashHandlerShow = other.CrashHandlerShow; - } + public LoadMethod LoadMethod { get; set; } /// /// Gets or sets the working directory of the XIVLauncher installations. @@ -96,7 +67,7 @@ public record DalamudStartInfo : IServiceType /// /// Gets or sets troubleshooting information to attach when generating a tspack file. /// - public string TroubleshootingPackData { get; set; } + public string? TroubleshootingPackData { get; set; } /// /// Gets or sets a value that specifies how much to wait before a new Dalamud session. @@ -172,4 +143,9 @@ public record DalamudStartInfo : IServiceType /// Gets or sets a value indicating whether to show crash handler console window. /// public bool CrashHandlerShow { get; set; } + + /// + /// Gets or sets a value indicating whether to disable all kinds of global exception handlers. + /// + public bool NoExceptionHandlers { get; set; } } diff --git a/Dalamud/Game/GameVersion.cs b/Dalamud.Common/Game/GameVersion.cs similarity index 97% rename from Dalamud/Game/GameVersion.cs rename to Dalamud.Common/Game/GameVersion.cs index 2b2021e60..26ff0e48f 100644 --- a/Dalamud/Game/GameVersion.cs +++ b/Dalamud.Common/Game/GameVersion.cs @@ -1,11 +1,9 @@ -using System; using System.Globalization; -using System.Linq; using System.Text; using Newtonsoft.Json; -namespace Dalamud.Game; +namespace Dalamud.Common.Game; /// /// A GameVersion object contains give hierarchical numeric components: year, month, @@ -168,14 +166,14 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable new GameVersion(this.Year, this.Month, this.Day, this.Major, this.Minor); /// - public int CompareTo(object obj) + public int CompareTo(object? obj) { if (obj == null) return 1; @@ -315,7 +313,7 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable - public int CompareTo(GameVersion value) + public int CompareTo(GameVersion? value) { if (value == null) return 1; @@ -348,7 +346,7 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (obj is not GameVersion value) return false; @@ -357,7 +355,7 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable - public bool Equals(GameVersion value) + public bool Equals(GameVersion? value) { if (value == null) { diff --git a/Dalamud/Game/GameVersionConverter.cs b/Dalamud.Common/Game/GameVersionConverter.cs similarity index 98% rename from Dalamud/Game/GameVersionConverter.cs rename to Dalamud.Common/Game/GameVersionConverter.cs index f307b6fb9..a1876869a 100644 --- a/Dalamud/Game/GameVersionConverter.cs +++ b/Dalamud.Common/Game/GameVersionConverter.cs @@ -1,8 +1,6 @@ -using System; - using Newtonsoft.Json; -namespace Dalamud.Game; +namespace Dalamud.Common.Game; /// /// Converts a to and from a string (e.g. "2010.01.01.1234.5678"). diff --git a/Dalamud.Common/LoadMethod.cs b/Dalamud.Common/LoadMethod.cs new file mode 100644 index 000000000..ca50098e2 --- /dev/null +++ b/Dalamud.Common/LoadMethod.cs @@ -0,0 +1,17 @@ +namespace Dalamud.Common; + +/// +/// Enum describing the method Dalamud has been loaded. +/// +public enum LoadMethod +{ + /// + /// Load Dalamud by rewriting the games entrypoint. + /// + Entrypoint, + + /// + /// Load Dalamud via DLL-injection. + /// + DllInject, +} diff --git a/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj b/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj index 25919af07..bf315d99e 100644 --- a/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj +++ b/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj @@ -27,8 +27,8 @@ - - + + all @@ -50,4 +50,10 @@ false + + + + Always + + diff --git a/Dalamud.CorePlugin/Dalamud.CorePlugin.json b/Dalamud.CorePlugin/Dalamud.CorePlugin.json new file mode 100644 index 000000000..7db669a73 --- /dev/null +++ b/Dalamud.CorePlugin/Dalamud.CorePlugin.json @@ -0,0 +1,9 @@ +{ + "Author": "Dalamud Maintainers", + "Name": "CorePlugin", + "Punchline": "Testbed for developing Dalamud features.", + "Description": "Develop and debug internal Dalamud features using CorePlugin. You have full access to all types in Dalamud assembly.", + "InternalName": "CorePlugin", + "ApplicableVersion": "any", + "Tags": [] +} diff --git a/Dalamud.CorePlugin/PluginImpl.cs b/Dalamud.CorePlugin/PluginImpl.cs index 9026ea0dd..7c9adc6a8 100644 --- a/Dalamud.CorePlugin/PluginImpl.cs +++ b/Dalamud.CorePlugin/PluginImpl.cs @@ -2,11 +2,13 @@ using System; using System.IO; using Dalamud.Configuration.Internal; +using Dalamud.Game; using Dalamud.Game.Command; using Dalamud.Interface.Windowing; -using Dalamud.Logging; using Dalamud.Plugin; +using Dalamud.Plugin.Services; using Dalamud.Utility; +using Serilog; namespace Dalamud.CorePlugin { @@ -37,9 +39,6 @@ namespace Dalamud.CorePlugin { } - /// - public string Name => "Dalamud.CorePlugin"; - /// public void Dispose() { @@ -50,36 +49,41 @@ namespace Dalamud.CorePlugin private readonly WindowSystem windowSystem = new("Dalamud.CorePlugin"); private Localization localization; + private IPluginLog pluginLog; + /// /// Initializes a new instance of the class. /// /// Dalamud plugin interface. /// Logging service. - public PluginImpl(DalamudPluginInterface pluginInterface, PluginLog log) + public PluginImpl(DalamudPluginInterface pluginInterface, IPluginLog log) { try { // this.InitLoc(); this.Interface = pluginInterface; + this.pluginLog = log; this.windowSystem.AddWindow(new PluginWindow()); this.Interface.UiBuilder.Draw += this.OnDraw; this.Interface.UiBuilder.OpenConfigUi += this.OnOpenConfigUi; + this.Interface.UiBuilder.OpenMainUi += this.OnOpenMainUi; + this.Interface.UiBuilder.DefaultFontHandle.ImFontChanged += (fc, _) => + { + Log.Information($"CorePlugin : DefaultFontHandle.ImFontChanged called {fc}"); + }; - Service.Get().AddHandler("/coreplug", new(this.OnCommand) { HelpMessage = $"Access the {this.Name} plugin." }); + Service.Get().AddHandler("/coreplug", new(this.OnCommand) { HelpMessage = "Access the plugin." }); log.Information("CorePlugin ctor!"); } catch (Exception ex) { - PluginLog.Error(ex, "kaboom"); + log.Error(ex, "kaboom"); } } - /// - public string Name => "Dalamud.CorePlugin"; - /// /// Gets the plugin interface. /// @@ -93,8 +97,6 @@ namespace Dalamud.CorePlugin this.Interface.UiBuilder.Draw -= this.OnDraw; this.windowSystem.RemoveAllWindows(); - - this.Interface.ExplicitDispose(); } /// @@ -127,13 +129,13 @@ namespace Dalamud.CorePlugin } catch (Exception ex) { - PluginLog.Error(ex, "Boom"); + this.pluginLog.Error(ex, "Boom"); } } private void OnCommand(string command, string args) { - PluginLog.Information("Command called!"); + this.pluginLog.Information("Command called!"); // this.window.IsOpen = true; } @@ -143,6 +145,11 @@ namespace Dalamud.CorePlugin // this.window.IsOpen = true; } + private void OnOpenMainUi() + { + Log.Verbose("Opened main UI"); + } + #endif } } diff --git a/Dalamud.Injector.Boot/main.cpp b/Dalamud.Injector.Boot/main.cpp index 741505d08..7fc44f5e1 100644 --- a/Dalamud.Injector.Boot/main.cpp +++ b/Dalamud.Injector.Boot/main.cpp @@ -23,7 +23,7 @@ int wmain(int argc, wchar_t** argv) // =========================================================================== // void* entrypoint_vfn; - int result = InitializeClrAndGetEntryPoint( + const auto result = InitializeClrAndGetEntryPoint( GetModuleHandleW(nullptr), false, runtimeconfig_path, @@ -33,15 +33,15 @@ int wmain(int argc, wchar_t** argv) L"Dalamud.Injector.EntryPoint+MainDelegate, Dalamud.Injector", &entrypoint_vfn); - if (result != 0) + if (FAILED(result)) return result; - typedef void (CORECLR_DELEGATE_CALLTYPE* custom_component_entry_point_fn)(int, wchar_t**); + typedef int (CORECLR_DELEGATE_CALLTYPE* custom_component_entry_point_fn)(int, wchar_t**); custom_component_entry_point_fn entrypoint_fn = reinterpret_cast(entrypoint_vfn); logging::I("Running Dalamud Injector..."); - entrypoint_fn(argc, argv); + const auto ret = entrypoint_fn(argc, argv); logging::I("Done!"); - return 0; + return ret; } diff --git a/Dalamud.Injector/Dalamud.Injector.csproj b/Dalamud.Injector/Dalamud.Injector.csproj index ea9e4f0a3..d8a74e58d 100644 --- a/Dalamud.Injector/Dalamud.Injector.csproj +++ b/Dalamud.Injector/Dalamud.Injector.csproj @@ -81,12 +81,6 @@ - - - - - - - + diff --git a/Dalamud.Injector/EntryPoint.cs b/Dalamud.Injector/EntryPoint.cs index a35248062..9085eae04 100644 --- a/Dalamud.Injector/EntryPoint.cs +++ b/Dalamud.Injector/EntryPoint.cs @@ -9,7 +9,8 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; -using Dalamud.Game; +using Dalamud.Common; +using Dalamud.Common.Game; using Newtonsoft.Json; using Reloaded.Memory.Buffers; using Serilog; @@ -30,88 +31,100 @@ namespace Dalamud.Injector /// /// Count of arguments. /// char** string arguments. - public delegate void MainDelegate(int argc, IntPtr argvPtr); + /// Return value (HRESULT). + public delegate int MainDelegate(int argc, IntPtr argvPtr); /// /// Start the Dalamud injector. /// /// Count of arguments. /// byte** string arguments. - public static void Main(int argc, IntPtr argvPtr) + /// Return value (HRESULT). + public static int Main(int argc, IntPtr argvPtr) { - List args = new(argc); - - unsafe + try { - var argv = (IntPtr*)argvPtr; - for (var i = 0; i < argc; i++) - args.Add(Marshal.PtrToStringUni(argv[i])); - } + List args = new(argc); - Init(args); - args.Remove("-v"); // Remove "verbose" flag - - if (args.Count >= 2 && args[1].ToLowerInvariant() == "launch-test") - { - Environment.Exit(ProcessLaunchTestCommand(args)); - return; - } - - DalamudStartInfo startInfo = null; - if (args.Count == 1) - { - // No command defaults to inject - args.Add("inject"); - args.Add("--all"); - -#if !DEBUG - args.Add("--warn"); -#endif - - } - else if (int.TryParse(args[1], out var _)) - { - // Assume that PID has been passed. - args.Insert(1, "inject"); - - // If originally second parameter exists, then assume that it's a base64 encoded start info. - // Dalamud.Injector.exe inject [pid] [base64] - if (args.Count == 4) + unsafe { - startInfo = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(Convert.FromBase64String(args[3]))); - args.RemoveAt(3); + var argv = (IntPtr*)argvPtr; + for (var i = 0; i < argc; i++) + args.Add(Marshal.PtrToStringUni(argv[i])); + } + + Init(args); + args.Remove("-v"); // Remove "verbose" flag + + if (args.Count >= 2 && args[1].ToLowerInvariant() == "launch-test") + { + return ProcessLaunchTestCommand(args); + } + + DalamudStartInfo startInfo = null; + if (args.Count == 1) + { + // No command defaults to inject + args.Add("inject"); + args.Add("--all"); + + #if !DEBUG + args.Add("--warn"); + #endif + + } + else if (int.TryParse(args[1], out var _)) + { + // Assume that PID has been passed. + args.Insert(1, "inject"); + + // If originally second parameter exists, then assume that it's a base64 encoded start info. + // Dalamud.Injector.exe inject [pid] [base64] + if (args.Count == 4) + { + startInfo = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(Convert.FromBase64String(args[3]))); + args.RemoveAt(3); + } + } + + startInfo = ExtractAndInitializeStartInfoFromArguments(startInfo, args); + // Remove already handled arguments + args.Remove("--console"); + args.Remove("--msgbox1"); + args.Remove("--msgbox2"); + args.Remove("--msgbox3"); + args.Remove("--etw"); + args.Remove("--veh"); + args.Remove("--veh-full"); + args.Remove("--no-plugin"); + args.Remove("--no-3rd-plugin"); + args.Remove("--crash-handler-console"); + args.Remove("--no-exception-handlers"); + + var mainCommand = args[1].ToLowerInvariant(); + if (mainCommand.Length > 0 && mainCommand.Length <= 6 && "inject"[..mainCommand.Length] == mainCommand) + { + return ProcessInjectCommand(args, startInfo); + } + else if (mainCommand.Length > 0 && mainCommand.Length <= 6 && + "launch"[..mainCommand.Length] == mainCommand) + { + return ProcessLaunchCommand(args, startInfo); + } + else if (mainCommand.Length > 0 && mainCommand.Length <= 4 && + "help"[..mainCommand.Length] == mainCommand) + { + return ProcessHelpCommand(args, args.Count >= 3 ? args[2] : null); + } + else + { + throw new CommandLineException($"\"{mainCommand}\" is not a valid command."); } } - - startInfo = ExtractAndInitializeStartInfoFromArguments(startInfo, args); - // Remove already handled arguments - args.Remove("--console"); - args.Remove("--msgbox1"); - args.Remove("--msgbox2"); - args.Remove("--msgbox3"); - args.Remove("--etw"); - args.Remove("--veh"); - args.Remove("--veh-full"); - args.Remove("--no-plugin"); - args.Remove("--no-3rd-plugin"); - args.Remove("--crash-handler-console"); - - var mainCommand = args[1].ToLowerInvariant(); - if (mainCommand.Length > 0 && mainCommand.Length <= 6 && "inject"[..mainCommand.Length] == mainCommand) + catch (Exception e) { - Environment.Exit(ProcessInjectCommand(args, startInfo)); - } - else if (mainCommand.Length > 0 && mainCommand.Length <= 6 && "launch"[..mainCommand.Length] == mainCommand) - { - Environment.Exit(ProcessLaunchCommand(args, startInfo)); - } - else if (mainCommand.Length > 0 && mainCommand.Length <= 4 && "help"[..mainCommand.Length] == mainCommand) - { - Environment.Exit(ProcessHelpCommand(args, args.Count >= 3 ? args[2] : null)); - } - else - { - throw new CommandLineException($"\"{mainCommand}\" is not a valid command."); + Log.Error(e, "Operation failed."); + return e.HResult; } } @@ -187,6 +200,7 @@ namespace Dalamud.Injector CullLogFile(logPath, 1 * 1024 * 1024); Log.Logger = new LoggerConfiguration() + .WriteTo.Console(standardErrorFromLevel: LogEventLevel.Debug) .WriteTo.File(logPath, fileSizeLimitBytes: null) .MinimumLevel.ControlledBy(levelSwitch) .CreateLogger(); @@ -375,12 +389,22 @@ namespace Dalamud.Injector #else startInfo.LogPath ??= xivlauncherDir; #endif + startInfo.LogName ??= string.Empty; // Set boot defaults startInfo.BootShowConsole = args.Contains("--console"); startInfo.BootEnableEtw = args.Contains("--etw"); startInfo.BootLogPath = GetLogPath(startInfo.LogPath, "dalamud.boot", startInfo.LogName); - startInfo.BootEnabledGameFixes = new List { "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; startInfo.BootWaitMessageBox |= args.Contains("--msgbox2") ? 2 : 0; @@ -392,6 +416,7 @@ namespace Dalamud.Injector startInfo.NoLoadThirdPartyPlugins = args.Contains("--no-3rd-plugin"); // startInfo.BootUnhookDlls = new List() { "kernel32.dll", "ntdll.dll", "user32.dll" }; startInfo.CrashHandlerShow = args.Contains("--crash-handler-console"); + startInfo.NoExceptionHandlers = args.Contains("--no-exception-handlers"); return startInfo; } @@ -433,7 +458,7 @@ namespace Dalamud.Injector Console.WriteLine("Verbose logging:\t[-v]"); Console.WriteLine("Show Console:\t[--console] [--crash-handler-console]"); Console.WriteLine("Enable ETW:\t[--etw]"); - Console.WriteLine("Enable VEH:\t[--veh], [--veh-full]"); + Console.WriteLine("Enable VEH:\t[--veh], [--veh-full], [--no-exception-handlers]"); Console.WriteLine("Show messagebox:\t[--msgbox1], [--msgbox2], [--msgbox3]"); Console.WriteLine("No plugins:\t[--no-plugin] [--no-3rd-plugin]"); Console.WriteLine("Logging:\t[--logname=] [--logpath=]"); @@ -677,11 +702,11 @@ namespace Dalamud.Injector mode = mode == null ? "entrypoint" : mode.ToLowerInvariant(); if (mode.Length > 0 && mode.Length <= 10 && "entrypoint"[0..mode.Length] == mode) { - mode = "entrypoint"; + dalamudStartInfo.LoadMethod = LoadMethod.Entrypoint; } else if (mode.Length > 0 && mode.Length <= 6 && "inject"[0..mode.Length] == mode) { - mode = "inject"; + dalamudStartInfo.LoadMethod = LoadMethod.DllInject; } else { @@ -793,16 +818,12 @@ namespace Dalamud.Injector noFixAcl, p => { - if (!withoutDalamud && mode == "entrypoint") + if (!withoutDalamud && dalamudStartInfo.LoadMethod == LoadMethod.Entrypoint) { var startInfo = AdjustStartInfo(dalamudStartInfo, gamePath); Log.Information("Using start info: {0}", JsonConvert.SerializeObject(startInfo)); - if (RewriteRemoteEntryPointW(p.Handle, gamePath, JsonConvert.SerializeObject(startInfo)) != 0) - { - Log.Error("[HOOKS] RewriteRemoteEntryPointW failed"); - throw new Exception("RewriteRemoteEntryPointW failed"); - } - + Marshal.ThrowExceptionForHR( + RewriteRemoteEntryPointW(p.Handle, gamePath, JsonConvert.SerializeObject(startInfo))); Log.Verbose("RewriteRemoteEntryPointW called!"); } }, @@ -810,7 +831,7 @@ namespace Dalamud.Injector Log.Verbose("Game process started with PID {0}", process.Id); - if (!withoutDalamud && mode == "inject") + if (!withoutDalamud && dalamudStartInfo.LoadMethod == LoadMethod.DllInject) { var startInfo = AdjustStartInfo(dalamudStartInfo, gamePath); Log.Information("Using start info: {0}", JsonConvert.SerializeObject(startInfo)); @@ -888,7 +909,7 @@ namespace Dalamud.Injector var gameVerStr = File.ReadAllText(Path.Combine(ffxivDir, "ffxivgame.ver")); var gameVer = GameVersion.Parse(gameVerStr); - return new DalamudStartInfo(startInfo) + return startInfo with { GameVersion = gameVer, }; diff --git a/Dalamud.Interface/ArrayExtensions.cs b/Dalamud.Interface/ArrayExtensions.cs deleted file mode 100644 index 68bf52a29..000000000 --- a/Dalamud.Interface/ArrayExtensions.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Dalamud.Interface; - -internal static class ArrayExtensions -{ - /// Iterate over enumerables with additional index. - public static IEnumerable<(T Value, int Index)> WithIndex(this IEnumerable list) - => list.Select((x, i) => (x, i)); - - /// Remove an added index from an indexed enumerable. - public static IEnumerable WithoutIndex(this IEnumerable<(T Value, int Index)> list) - => list.Select(x => x.Value); - - /// Remove the value and only keep the index from an indexed enumerable. - public static IEnumerable WithoutValue(this IEnumerable<(T Value, int Index)> list) - => list.Select(x => x.Index); - - - // Find the index of the first object fulfilling predicate's criteria in the given list. - // Returns -1 if no such object is found. - public static int IndexOf(this IEnumerable array, Predicate predicate) - { - var i = 0; - foreach (var obj in array) - { - if (predicate(obj)) - return i; - - ++i; - } - - return -1; - } - - // Find the index of the first occurrence of needle in the given list. - // Returns -1 if needle is not contained in the list. - public static int IndexOf(this IEnumerable array, T needle) where T : notnull - { - var i = 0; - foreach (var obj in array) - { - if (needle.Equals(obj)) - return i; - - ++i; - } - - return -1; - } - - // Find the first object fulfilling predicate's criteria in the given list, if one exists. - // Returns true if an object is found, false otherwise. - public static bool FindFirst(this IEnumerable array, Predicate predicate, [NotNullWhen(true)] out T? result) - { - foreach (var obj in array) - { - if (predicate(obj)) - { - result = obj!; - return true; - } - } - - result = default; - return false; - } - - // Find the first occurrence of needle in the given list and return the value contained in the list in result. - // Returns true if an object is found, false otherwise. - public static bool FindFirst(this IEnumerable array, T needle, [NotNullWhen(true)] out T? result) where T : notnull - { - foreach (var obj in array) - { - if (obj.Equals(needle)) - { - result = obj; - return true; - } - } - - result = default; - return false; - } -} diff --git a/Dalamud.Interface/Dalamud.Interface.csproj b/Dalamud.Interface/Dalamud.Interface.csproj deleted file mode 100644 index 1dd8468be..000000000 --- a/Dalamud.Interface/Dalamud.Interface.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net7.0-windows - x64 - x64;AnyCPU - enable - enable - true - Dalamud.Interface - - - - - - - diff --git a/Dalamud.Interface/ImGuiTable.cs b/Dalamud.Interface/ImGuiTable.cs deleted file mode 100644 index 5ea6a2c9a..000000000 --- a/Dalamud.Interface/ImGuiTable.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Dalamud.Interface.Raii; -using ImGuiNET; - -namespace Dalamud.Interface; - -public static class ImGuiTable -{ - // Draw a simple table with the given data using the drawRow action. - // Headers and thus columns and column count are defined by columnTitles. - public static void DrawTable(string label, IEnumerable data, Action drawRow, ImGuiTableFlags flags = ImGuiTableFlags.None, - params string[] columnTitles) - { - if (columnTitles.Length == 0) - return; - - using var table = ImRaii.Table(label, columnTitles.Length, flags); - if (!table) - return; - - foreach (var title in columnTitles) - { - ImGui.TableNextColumn(); - ImGui.TableHeader(title); - } - - foreach (var datum in data) - { - ImGui.TableNextRow(); - drawRow(datum); - } - } - - // Draw a simple table with the given data using the drawRow action inside a collapsing header. - // Headers and thus columns and column count are defined by columnTitles. - public static void DrawTabbedTable(string label, IEnumerable data, Action drawRow, ImGuiTableFlags flags = ImGuiTableFlags.None, - params string[] columnTitles) - { - if (ImGui.CollapsingHeader(label)) - DrawTable($"{label}##Table", data, drawRow, flags, columnTitles); - } -} diff --git a/Dalamud.Interface/InterfaceHelpers.cs b/Dalamud.Interface/InterfaceHelpers.cs deleted file mode 100644 index 26f09bedb..000000000 --- a/Dalamud.Interface/InterfaceHelpers.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Dalamud.Interface; - -public static class InterfaceHelpers -{ - public static float GlobalScale = 1.0f; -} diff --git a/Dalamud.Test/Game/GameVersionTests.cs b/Dalamud.Test/Game/GameVersionTests.cs index 44a5813c8..dcace4279 100644 --- a/Dalamud.Test/Game/GameVersionTests.cs +++ b/Dalamud.Test/Game/GameVersionTests.cs @@ -1,4 +1,4 @@ -using Dalamud.Game; +using Dalamud.Common.Game; using Xunit; namespace Dalamud.Test.Game diff --git a/Dalamud.sln b/Dalamud.sln index 20442e52d..93089b9a6 100644 --- a/Dalamud.sln +++ b/Dalamud.sln @@ -6,8 +6,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig .gitignore = .gitignore - targets\Dalamud.Plugin.targets = targets\Dalamud.Plugin.targets targets\Dalamud.Plugin.Bootstrap.targets = targets\Dalamud.Plugin.Bootstrap.targets + targets\Dalamud.Plugin.targets = targets\Dalamud.Plugin.targets EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "build", "build\build.csproj", "{94E5B016-02B1-459B-97D9-E783F28764B2}" @@ -38,184 +38,70 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FFXIVClientStructs.InteropS EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "DalamudCrashHandler", "DalamudCrashHandler\DalamudCrashHandler.vcxproj", "{317A264C-920B-44A1-8A34-F3A6827B0705}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.Interface", "Dalamud.Interface\Dalamud.Interface.csproj", "{757C997D-AA58-4241-8299-243C56514917}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dalamud.Common", "Dalamud.Common\Dalamud.Common.csproj", "{F21B13D2-D7D0-4456-B70F-3F8D695064E2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {94E5B016-02B1-459B-97D9-E783F28764B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {94E5B016-02B1-459B-97D9-E783F28764B2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {94E5B016-02B1-459B-97D9-E783F28764B2}.Debug|x64.ActiveCfg = Debug|Any CPU - {94E5B016-02B1-459B-97D9-E783F28764B2}.Debug|x64.Build.0 = Debug|Any CPU - {94E5B016-02B1-459B-97D9-E783F28764B2}.Debug|x86.ActiveCfg = Debug|Any CPU {94E5B016-02B1-459B-97D9-E783F28764B2}.Release|Any CPU.ActiveCfg = Release|Any CPU {94E5B016-02B1-459B-97D9-E783F28764B2}.Release|Any CPU.Build.0 = Release|Any CPU - {94E5B016-02B1-459B-97D9-E783F28764B2}.Release|x64.ActiveCfg = Release|Any CPU - {94E5B016-02B1-459B-97D9-E783F28764B2}.Release|x64.Build.0 = Release|Any CPU - {94E5B016-02B1-459B-97D9-E783F28764B2}.Release|x86.ActiveCfg = Release|Any CPU {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Debug|Any CPU.ActiveCfg = Debug|x64 {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Debug|Any CPU.Build.0 = Debug|x64 - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Debug|x64.ActiveCfg = Debug|x64 - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Debug|x64.Build.0 = Debug|x64 - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Debug|x86.ActiveCfg = Debug|Any CPU - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Debug|x86.Build.0 = Debug|Any CPU {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Release|Any CPU.ActiveCfg = Release|x64 {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Release|Any CPU.Build.0 = Release|x64 - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Release|x64.ActiveCfg = Release|x64 - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Release|x64.Build.0 = Release|x64 - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Release|x86.ActiveCfg = Release|Any CPU - {B92DAB43-2279-4A2C-96E3-D9D5910EDBEA}.Release|x86.Build.0 = Release|Any CPU {55198DC3-A03D-408E-A8EB-2077780C8576}.Debug|Any CPU.ActiveCfg = Debug|x64 {55198DC3-A03D-408E-A8EB-2077780C8576}.Debug|Any CPU.Build.0 = Debug|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Debug|x64.ActiveCfg = Debug|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Debug|x64.Build.0 = Debug|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Debug|x86.ActiveCfg = Debug|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Debug|x86.Build.0 = Debug|x64 {55198DC3-A03D-408E-A8EB-2077780C8576}.Release|Any CPU.ActiveCfg = Release|x64 {55198DC3-A03D-408E-A8EB-2077780C8576}.Release|Any CPU.Build.0 = Release|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Release|x64.ActiveCfg = Release|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Release|x64.Build.0 = Release|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Release|x86.ActiveCfg = Release|x64 - {55198DC3-A03D-408E-A8EB-2077780C8576}.Release|x86.Build.0 = Release|x64 {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Debug|Any CPU.ActiveCfg = Debug|x64 {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Debug|Any CPU.Build.0 = Debug|x64 - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Debug|x64.ActiveCfg = Debug|x64 - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Debug|x64.Build.0 = Debug|x64 - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Debug|x86.ActiveCfg = Debug|Any CPU - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Debug|x86.Build.0 = Debug|Any CPU {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Release|Any CPU.ActiveCfg = Release|x64 {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Release|Any CPU.Build.0 = Release|x64 - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Release|x64.ActiveCfg = Release|x64 - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Release|x64.Build.0 = Release|x64 - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Release|x86.ActiveCfg = Release|Any CPU - {5B832F73-5F54-4ADC-870F-D0095EF72C9A}.Release|x86.Build.0 = Release|Any CPU {8874326B-E755-4D13-90B4-59AB263A3E6B}.Debug|Any CPU.ActiveCfg = Debug|x64 {8874326B-E755-4D13-90B4-59AB263A3E6B}.Debug|Any CPU.Build.0 = Debug|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Debug|x64.ActiveCfg = Debug|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Debug|x64.Build.0 = Debug|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Debug|x86.ActiveCfg = Debug|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Debug|x86.Build.0 = Debug|x64 {8874326B-E755-4D13-90B4-59AB263A3E6B}.Release|Any CPU.ActiveCfg = Release|x64 {8874326B-E755-4D13-90B4-59AB263A3E6B}.Release|Any CPU.Build.0 = Release|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Release|x64.ActiveCfg = Release|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Release|x64.Build.0 = Release|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Release|x86.ActiveCfg = Release|x64 - {8874326B-E755-4D13-90B4-59AB263A3E6B}.Release|x86.Build.0 = Release|x64 {C8004563-1806-4329-844F-0EF6274291FC}.Debug|Any CPU.ActiveCfg = Debug|x64 {C8004563-1806-4329-844F-0EF6274291FC}.Debug|Any CPU.Build.0 = Debug|x64 - {C8004563-1806-4329-844F-0EF6274291FC}.Debug|x64.ActiveCfg = Debug|x64 - {C8004563-1806-4329-844F-0EF6274291FC}.Debug|x64.Build.0 = Debug|x64 - {C8004563-1806-4329-844F-0EF6274291FC}.Debug|x86.ActiveCfg = Debug|Any CPU - {C8004563-1806-4329-844F-0EF6274291FC}.Debug|x86.Build.0 = Debug|Any CPU {C8004563-1806-4329-844F-0EF6274291FC}.Release|Any CPU.ActiveCfg = Release|x64 {C8004563-1806-4329-844F-0EF6274291FC}.Release|Any CPU.Build.0 = Release|x64 - {C8004563-1806-4329-844F-0EF6274291FC}.Release|x64.ActiveCfg = Release|x64 - {C8004563-1806-4329-844F-0EF6274291FC}.Release|x64.Build.0 = Release|x64 - {C8004563-1806-4329-844F-0EF6274291FC}.Release|x86.ActiveCfg = Release|Any CPU - {C8004563-1806-4329-844F-0EF6274291FC}.Release|x86.Build.0 = Release|Any CPU {0483026E-C6CE-4B1A-AA68-46544C08140B}.Debug|Any CPU.ActiveCfg = Debug|x64 {0483026E-C6CE-4B1A-AA68-46544C08140B}.Debug|Any CPU.Build.0 = Debug|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Debug|x64.ActiveCfg = Debug|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Debug|x64.Build.0 = Debug|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Debug|x86.ActiveCfg = Debug|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Debug|x86.Build.0 = Debug|x64 {0483026E-C6CE-4B1A-AA68-46544C08140B}.Release|Any CPU.ActiveCfg = Release|x64 {0483026E-C6CE-4B1A-AA68-46544C08140B}.Release|Any CPU.Build.0 = Release|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Release|x64.ActiveCfg = Release|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Release|x64.Build.0 = Release|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Release|x86.ActiveCfg = Release|x64 - {0483026E-C6CE-4B1A-AA68-46544C08140B}.Release|x86.Build.0 = Release|x64 {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Debug|Any CPU.ActiveCfg = Debug|x64 {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Debug|Any CPU.Build.0 = Debug|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Debug|x64.ActiveCfg = Debug|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Debug|x64.Build.0 = Debug|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Debug|x86.ActiveCfg = Debug|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Debug|x86.Build.0 = Debug|x64 {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Release|Any CPU.ActiveCfg = Release|x64 {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Release|Any CPU.Build.0 = Release|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Release|x64.ActiveCfg = Release|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Release|x64.Build.0 = Release|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Release|x86.ActiveCfg = Release|x64 - {C0E7E797-4FBF-4F46-BC57-463F3719BA7A}.Release|x86.Build.0 = Release|x64 {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Debug|Any CPU.ActiveCfg = Debug|x64 {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Debug|Any CPU.Build.0 = Debug|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Debug|x64.ActiveCfg = Debug|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Debug|x64.Build.0 = Debug|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Debug|x86.ActiveCfg = Debug|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Debug|x86.Build.0 = Debug|x64 {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Release|Any CPU.ActiveCfg = Release|x64 {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Release|Any CPU.Build.0 = Release|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Release|x64.ActiveCfg = Release|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Release|x64.Build.0 = Release|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Release|x86.ActiveCfg = Release|x64 - {2F7FF0A8-B619-4572-86C7-71E46FE22FB8}.Release|x86.Build.0 = Release|x64 {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Debug|Any CPU.ActiveCfg = Debug|x64 {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Debug|Any CPU.Build.0 = Debug|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Debug|x64.ActiveCfg = Debug|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Debug|x64.Build.0 = Debug|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Debug|x86.ActiveCfg = Debug|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Debug|x86.Build.0 = Debug|x64 {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Release|Any CPU.ActiveCfg = Release|x64 {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Release|Any CPU.Build.0 = Release|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Release|x64.ActiveCfg = Release|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Release|x64.Build.0 = Release|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Release|x86.ActiveCfg = Release|x64 - {4AFDB34A-7467-4D41-B067-53BC4101D9D0}.Release|x86.Build.0 = Release|x64 {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Debug|x64.ActiveCfg = Debug|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Debug|x64.Build.0 = Debug|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Debug|x86.ActiveCfg = Debug|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Debug|x86.Build.0 = Debug|Any CPU {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Release|Any CPU.ActiveCfg = Release|Any CPU {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Release|Any CPU.Build.0 = Release|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Release|x64.ActiveCfg = Release|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Release|x64.Build.0 = Release|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Release|x86.ActiveCfg = Release|Any CPU - {C9B87BD7-AF49-41C3-91F1-D550ADEB7833}.Release|x86.Build.0 = Release|Any CPU {05AB2F46-268B-4915-806F-DDF813E2D59D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {05AB2F46-268B-4915-806F-DDF813E2D59D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Debug|x64.ActiveCfg = Debug|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Debug|x64.Build.0 = Debug|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Debug|x86.ActiveCfg = Debug|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Debug|x86.Build.0 = Debug|Any CPU {05AB2F46-268B-4915-806F-DDF813E2D59D}.Release|Any CPU.ActiveCfg = Release|Any CPU {05AB2F46-268B-4915-806F-DDF813E2D59D}.Release|Any CPU.Build.0 = Release|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Release|x64.ActiveCfg = Release|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Release|x64.Build.0 = Release|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Release|x86.ActiveCfg = Release|Any CPU - {05AB2F46-268B-4915-806F-DDF813E2D59D}.Release|x86.Build.0 = Release|Any CPU {317A264C-920B-44A1-8A34-F3A6827B0705}.Debug|Any CPU.ActiveCfg = Debug|x64 {317A264C-920B-44A1-8A34-F3A6827B0705}.Debug|Any CPU.Build.0 = Debug|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Debug|x64.ActiveCfg = Debug|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Debug|x64.Build.0 = Debug|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Debug|x86.ActiveCfg = Debug|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Debug|x86.Build.0 = Debug|x64 {317A264C-920B-44A1-8A34-F3A6827B0705}.Release|Any CPU.ActiveCfg = Release|x64 {317A264C-920B-44A1-8A34-F3A6827B0705}.Release|Any CPU.Build.0 = Release|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Release|x64.ActiveCfg = Release|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Release|x64.Build.0 = Release|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Release|x86.ActiveCfg = Release|x64 - {317A264C-920B-44A1-8A34-F3A6827B0705}.Release|x86.Build.0 = Release|x64 - {757C997D-AA58-4241-8299-243C56514917}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Debug|Any CPU.Build.0 = Debug|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Debug|x64.ActiveCfg = Debug|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Debug|x64.Build.0 = Debug|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Debug|x86.ActiveCfg = Debug|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Debug|x86.Build.0 = Debug|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Release|Any CPU.ActiveCfg = Release|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Release|Any CPU.Build.0 = Release|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Release|x64.ActiveCfg = Release|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Release|x64.Build.0 = Release|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Release|x86.ActiveCfg = Release|Any CPU - {757C997D-AA58-4241-8299-243C56514917}.Release|x86.Build.0 = Release|Any CPU + {F21B13D2-D7D0-4456-B70F-3F8D695064E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F21B13D2-D7D0-4456-B70F-3F8D695064E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F21B13D2-D7D0-4456-B70F-3F8D695064E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F21B13D2-D7D0-4456-B70F-3F8D695064E2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Dalamud/ClientLanguage.cs b/Dalamud/ClientLanguage.cs index 4e04d4a54..8f2c52456 100644 --- a/Dalamud/ClientLanguage.cs +++ b/Dalamud/ClientLanguage.cs @@ -1,5 +1,7 @@ namespace Dalamud; +// TODO(v10): Delete this, and use Dalamud.Common.ClientLanguage instead for everything. + /// /// Enum describing the language the game loads in. /// diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index ac410527c..70ed5dfde 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -1,12 +1,16 @@ -using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using Dalamud.Game.Text; +using Dalamud.Interface.FontIdentifier; +using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Style; +using Dalamud.IoC.Internal; using Dalamud.Plugin.Internal.Profiles; +using Dalamud.Storage; using Dalamud.Utility; using Newtonsoft.Json; using Serilog; @@ -18,7 +22,11 @@ namespace Dalamud.Configuration.Internal; /// Class containing Dalamud settings. /// [Serializable] -internal sealed class DalamudConfiguration : IServiceType +[ServiceManager.ProvidedService] +#pragma warning disable SA1015 +[InherentDependency] // We must still have this when unloading +#pragma warning restore SA1015 +internal sealed class DalamudConfiguration : IInternalDisposableService { private static readonly JsonSerializerSettings SerializerSettings = new() { @@ -28,7 +36,7 @@ internal sealed class DalamudConfiguration : IServiceType }; [JsonIgnore] - private string configPath; + private string? configPath; [JsonIgnore] private bool isSaveQueued; @@ -42,12 +50,12 @@ internal sealed class DalamudConfiguration : IServiceType /// /// Event that occurs when dalamud configuration is saved. /// - public event DalamudConfigurationSavedDelegate DalamudConfigurationSaved; + public event DalamudConfigurationSavedDelegate? DalamudConfigurationSaved; /// /// Gets or sets a list of muted works. /// - public List BadWords { get; set; } + public List? BadWords { get; set; } /// /// Gets or sets a value indicating whether or not the taskbar should flash once a duty is found. @@ -62,12 +70,12 @@ internal sealed class DalamudConfiguration : IServiceType /// /// Gets or sets the language code to load Dalamud localization with. /// - public string LanguageOverride { get; set; } = null; + public string? LanguageOverride { get; set; } = null; /// /// Gets or sets the last loaded Dalamud version. /// - public string LastVersion { get; set; } = null; + public string? LastVersion { get; set; } = null; /// /// Gets or sets a value indicating the last seen FTUE version. @@ -78,7 +86,7 @@ internal sealed class DalamudConfiguration : IServiceType /// /// Gets or sets the last loaded Dalamud version. /// - public string LastChangelogMajorMinor { get; set; } = null; + public string? LastChangelogMajorMinor { get; set; } = null; /// /// Gets or sets the chat type used by default for plugin messages. @@ -100,6 +108,11 @@ internal sealed class DalamudConfiguration : IServiceType /// public List ThirdRepoList { get; set; } = new(); + /// + /// Gets or sets a value indicating whether or not a disclaimer regarding third-party repos has been dismissed. + /// + public bool? ThirdRepoSpeedbumpDismissed { get; set; } = null; + /// /// Gets or sets a list of hidden plugins. /// @@ -133,15 +146,18 @@ internal sealed class DalamudConfiguration : IServiceType /// /// Gets or sets a value indicating whether to use AXIS fonts from the game. /// - public bool UseAxisFontsFromGame { get; set; } = false; + [Obsolete($"See {nameof(DefaultFontSpec)}")] + public bool UseAxisFontsFromGame { get; set; } = true; /// - /// Gets or sets the gamma value to apply for Dalamud fonts. Effects text thickness. - /// - /// Before gamma is applied... - /// * ...TTF fonts loaded with stb or FreeType are in linear space. - /// * ...the game's prebaked AXIS fonts are in gamma space with gamma value of 1.4. + /// Gets or sets the default font spec. /// + public IFontSpec? DefaultFontSpec { get; set; } + + /// + /// Gets or sets the gamma value to apply for Dalamud fonts. Do not use. + /// + [Obsolete("It happens that nobody touched this setting", true)] public float FontGammaLevel { get; set; } = 1.4f; /// @@ -199,6 +215,11 @@ internal sealed class DalamudConfiguration : IServiceType /// public bool LogOpenAtStartup { get; set; } + /// + /// Gets or sets the number of lines to keep for the Dalamud Console window. + /// + public int LogLinesLimit { get; set; } = 10000; + /// /// Gets or sets a value indicating whether or not the dev bar should open at startup. /// @@ -218,8 +239,16 @@ internal sealed class DalamudConfiguration : IServiceType /// Gets or sets a value indicating whether or not plugin user interfaces should trigger sound effects. /// This setting is effected by the in-game "System Sounds" option and volume. /// + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "ABI")] public bool EnablePluginUISoundEffects { get; set; } + /// + /// Gets or sets a value indicating whether or not an additional button allowing pinning and clickthrough options should be shown + /// on plugin title bars when using the Window System. + /// + [JsonProperty("EnablePluginUiAdditionalOptionsExperimental")] + public bool EnablePluginUiAdditionalOptions { get; set; } = false; + /// /// Gets or sets a value indicating whether viewports should always be disabled. /// @@ -248,7 +277,7 @@ internal sealed class DalamudConfiguration : IServiceType /// /// Gets or sets the kind of beta to download when matches the server value. /// - public string DalamudBetaKind { get; set; } + public string? DalamudBetaKind { get; set; } /// /// Gets or sets a value indicating whether or not any plugin should be loaded when the game is started. @@ -414,26 +443,45 @@ internal sealed class DalamudConfiguration : IServiceType /// public double UiBuilderHitch { get; set; } = 100; + /// + /// Gets or sets the page of the plugin installer that is shown by default when opened. + /// + public PluginInstallerWindow.PluginInstallerOpenKind PluginInstallerOpen { get; set; } = PluginInstallerWindow.PluginInstallerOpenKind.AllPlugins; + /// /// Load a configuration from the provided path. /// - /// The path to load the configuration file from. + /// Path to read from. + /// File storage. /// The deserialized configuration file. - public static DalamudConfiguration Load(string path) + public static DalamudConfiguration Load(string path, ReliableFileStorage fs) { DalamudConfiguration deserialized = null; + try { - deserialized = JsonConvert.DeserializeObject(File.ReadAllText(path), SerializerSettings); + fs.ReadAllText(path, text => + { + deserialized = + JsonConvert.DeserializeObject(text, SerializerSettings); + + // If this reads as null, the file was empty, that's no good + if (deserialized == null) + throw new Exception("Read config was null."); + }); } - catch (Exception ex) + catch (FileNotFoundException) { - Log.Warning(ex, "Failed to load DalamudConfiguration at {0}", path); + // ignored + } + catch (Exception e) + { + Log.Error(e, "Could not load DalamudConfiguration at {Path}, creating new", path); } deserialized ??= new DalamudConfiguration(); deserialized.configPath = path; - + return deserialized; } @@ -452,6 +500,13 @@ internal sealed class DalamudConfiguration : IServiceType { this.Save(); } + + /// + void IInternalDisposableService.DisposeService() + { + // Make sure that we save, if a save is queued while we are shutting down + this.Update(); + } /// /// Save the file, if needed. Only needs to be done once a frame. @@ -470,8 +525,11 @@ internal sealed class DalamudConfiguration : IServiceType private void Save() { ThreadSafety.AssertMainThread(); + if (this.configPath is null) + throw new InvalidOperationException("configPath is not set."); - Util.WriteAllTextSafe(this.configPath, JsonConvert.SerializeObject(this, SerializerSettings)); + Service.Get().WriteAllText( + this.configPath, JsonConvert.SerializeObject(this, SerializerSettings)); this.DalamudConfigurationSaved?.Invoke(this); } } diff --git a/Dalamud/Configuration/Internal/DevPluginSettings.cs b/Dalamud/Configuration/Internal/DevPluginSettings.cs index 939b03eca..cfe8ba411 100644 --- a/Dalamud/Configuration/Internal/DevPluginSettings.cs +++ b/Dalamud/Configuration/Internal/DevPluginSettings.cs @@ -1,3 +1,5 @@ +using System; + namespace Dalamud.Configuration.Internal; /// @@ -14,4 +16,9 @@ internal sealed class DevPluginSettings /// Gets or sets a value indicating whether this plugin should automatically reload on file change. /// public bool AutomaticReloading { get; set; } = false; + + /// + /// Gets or sets an ID uniquely identifying this specific instance of a devPlugin. + /// + public Guid WorkingPluginId { get; set; } = Guid.Empty; } diff --git a/Dalamud/Configuration/PluginConfigurations.cs b/Dalamud/Configuration/PluginConfigurations.cs index 957a7c99e..de5e071c1 100644 --- a/Dalamud/Configuration/PluginConfigurations.cs +++ b/Dalamud/Configuration/PluginConfigurations.cs @@ -1,6 +1,6 @@ using System.IO; -using Dalamud.Utility; +using Dalamud.Storage; using Newtonsoft.Json; namespace Dalamud.Configuration; @@ -31,24 +31,39 @@ public sealed class PluginConfigurations /// /// Plugin configuration. /// Plugin name. - public void Save(IPluginConfiguration config, string pluginName) + /// WorkingPluginId of the plugin. + public void Save(IPluginConfiguration config, string pluginName, Guid workingPluginId) { - Util.WriteAllTextSafe(this.GetConfigFile(pluginName).FullName, SerializeConfig(config)); + Service.Get() + .WriteAllText(this.GetConfigFile(pluginName).FullName, SerializeConfig(config), workingPluginId); } /// /// Load plugin configuration. /// /// Plugin name. + /// WorkingPluginID of the plugin. /// Plugin configuration. - public IPluginConfiguration? Load(string pluginName) + public IPluginConfiguration? Load(string pluginName, Guid workingPluginId) { var path = this.GetConfigFile(pluginName); - if (!path.Exists) - return null; + IPluginConfiguration? config = null; + try + { + Service.Get().ReadAllText(path.FullName, text => + { + config = DeserializeConfig(text); + if (config == null) + throw new Exception("Read config was null."); + }, workingPluginId); + } + catch (FileNotFoundException) + { + // ignored + } - return DeserializeConfig(File.ReadAllText(path.FullName)); + return config; } /// diff --git a/Dalamud/Dalamud.cs b/Dalamud/Dalamud.cs index c38594771..f9d2aff3c 100644 --- a/Dalamud/Dalamud.cs +++ b/Dalamud/Dalamud.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics; using System.IO; using System.Linq; @@ -7,12 +6,13 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Dalamud.Common; using Dalamud.Configuration.Internal; using Dalamud.Game; -using Dalamud.Game.Gui.Internal; -using Dalamud.Interface.Internal; using Dalamud.Plugin.Internal; +using Dalamud.Storage; using Dalamud.Utility; +using Dalamud.Utility.Timing; using PInvoke; using Serilog; @@ -28,6 +28,7 @@ namespace Dalamud; /// /// The main Dalamud class containing all subsystems. /// +[ServiceManager.ProvidedService] internal sealed class Dalamud : IServiceType { #region Internals @@ -40,26 +41,48 @@ internal sealed class Dalamud : IServiceType /// Initializes a new instance of the class. /// /// DalamudStartInfo instance. + /// ReliableFileStorage instance. /// The Dalamud configuration. /// Event used to signal the main thread to continue. - public Dalamud(DalamudStartInfo info, DalamudConfiguration configuration, IntPtr mainThreadContinueEvent) + public Dalamud(DalamudStartInfo info, ReliableFileStorage fs, DalamudConfiguration configuration, IntPtr mainThreadContinueEvent) { + this.StartInfo = info; + this.unloadSignal = new ManualResetEvent(false); this.unloadSignal.Reset(); + + // Directory resolved signatures(CS, our own) will be cached in + var cacheDir = new DirectoryInfo(Path.Combine(this.StartInfo.WorkingDirectory!, "cachedSigs")); + if (!cacheDir.Exists) + cacheDir.Create(); + + // Set up the SigScanner for our target module + TargetSigScanner scanner; + using (Timings.Start("SigScanner Init")) + { + scanner = new TargetSigScanner( + true, new FileInfo(Path.Combine(cacheDir.FullName, $"{this.StartInfo.GameVersion}.json"))); + } - ServiceManager.InitializeProvidedServicesAndClientStructs(this, info, configuration); + ServiceManager.InitializeProvidedServices(this, fs, configuration, scanner); + + // Set up FFXIVClientStructs + this.SetupClientStructsResolver(cacheDir); if (!configuration.IsResumeGameAfterPluginLoad) { NativeFunctions.SetEvent(mainThreadContinueEvent); - try - { - _ = ServiceManager.InitializeEarlyLoadableServices(); - } - catch (Exception e) - { - Log.Error(e, "Service initialization failure"); - } + ServiceManager.InitializeEarlyLoadableServices() + .ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + return; + + Log.Error(t.Exception!, "Service initialization failure"); + Util.Fatal( + "Dalamud failed to load all necessary services.\n\nThe game will continue, but you may not be able to use plugins.", + "Dalamud", false); + }); } else { @@ -93,13 +116,36 @@ internal sealed class Dalamud : IServiceType } }); } + + this.DefaultExceptionFilter = NativeFunctions.SetUnhandledExceptionFilter(nint.Zero); + NativeFunctions.SetUnhandledExceptionFilter(this.DefaultExceptionFilter); + Log.Debug($"SE default exception filter at {this.DefaultExceptionFilter.ToInt64():X}"); + + var debugSig = "40 55 53 56 48 8D AC 24 ?? ?? ?? ?? B8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 2B E0 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 85 ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ??"; + this.DebugExceptionFilter = Service.Get().ScanText(debugSig); + Log.Debug($"SE debug exception filter at {this.DebugExceptionFilter.ToInt64():X}"); } + + /// + /// Gets the start information for this Dalamud instance. + /// + internal DalamudStartInfo StartInfo { get; private set; } /// /// Gets location of stored assets. /// - internal DirectoryInfo AssetDirectory => new(Service.Get().AssetDirectory!); - + internal DirectoryInfo AssetDirectory => new(this.StartInfo.AssetDirectory!); + + /// + /// Gets the in-game default exception filter. + /// + private nint DefaultExceptionFilter { get; } + + /// + /// Gets the in-game debug exception filter. + /// + private nint DebugExceptionFilter { get; } + /// /// Signal to the crash handler process that we should restart the game. /// @@ -141,36 +187,38 @@ internal sealed class Dalamud : IServiceType } /// - /// Dispose subsystems related to plugin handling. + /// Replace the current exception handler with the default one. /// - public void DisposePlugins() - { - // this must be done before unloading interface manager, in order to do rebuild - // the correct cascaded WndProc (IME -> RawDX11Scene -> Game). Otherwise the game - // will not receive any windows messages - Service.GetNullable()?.Dispose(); - - // this must be done before unloading plugins, or it can cause a race condition - // due to rendering happening on another thread, where a plugin might receive - // a render call after it has been disposed, which can crash if it attempts to - // use any resources that it freed in its own Dispose method - Service.GetNullable()?.Dispose(); - - Service.GetNullable()?.Dispose(); - - Service.GetNullable()?.Dispose(); - } + internal void UseDefaultExceptionHandler() => + this.SetExceptionHandler(this.DefaultExceptionFilter); /// - /// Replace the built-in exception handler with a debug one. + /// Replace the current exception handler with a debug one. /// - internal void ReplaceExceptionHandler() - { - var releaseSig = "40 55 53 56 48 8D AC 24 ?? ?? ?? ?? B8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 2B E0 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 85 ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ??"; - var releaseFilter = Service.Get().ScanText(releaseSig); - Log.Debug($"SE debug filter at {releaseFilter.ToInt64():X}"); + internal void UseDebugExceptionHandler() => + this.SetExceptionHandler(this.DebugExceptionFilter); - var oldFilter = NativeFunctions.SetUnhandledExceptionFilter(releaseFilter); - Log.Debug("Reset ExceptionFilter, old: {0}", oldFilter); + /// + /// Disable the current exception handler. + /// + internal void UseNoExceptionHandler() => + this.SetExceptionHandler(nint.Zero); + + /// + /// Helper function to set the exception handler. + /// + private void SetExceptionHandler(nint newFilter) + { + var oldFilter = NativeFunctions.SetUnhandledExceptionFilter(newFilter); + Log.Debug("Set ExceptionFilter to {0}, old: {1}", newFilter, oldFilter); + } + + private void SetupClientStructsResolver(DirectoryInfo cacheDir) + { + using (Timings.Start("CS Resolver Init")) + { + FFXIVClientStructs.Interop.Resolver.GetInstance.SetupSearchSpace(Service.Get().SearchBase, new FileInfo(Path.Combine(cacheDir.FullName, $"{this.StartInfo.GameVersion}_cs.json"))); + FFXIVClientStructs.Interop.Resolver.GetInstance.Resolve(); + } } } diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index e2da1a057..7e166d8b3 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -8,11 +8,12 @@ - 7.10.1.0 + 9.0.0.21 XIV Launcher addon framework $(DalamudVersion) $(DalamudVersion) $(DalamudVersion) + AGPL-3.0-or-later @@ -67,8 +68,12 @@ - - + + + + + all + @@ -76,6 +81,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -85,9 +91,10 @@ + - + diff --git a/Dalamud/DalamudAsset.cs b/Dalamud/DalamudAsset.cs new file mode 100644 index 000000000..a7b35b196 --- /dev/null +++ b/Dalamud/DalamudAsset.cs @@ -0,0 +1,153 @@ +using Dalamud.Storage.Assets; + +namespace Dalamud; + +/// +/// Specifies an asset that has been shipped as Dalamud Asset.
+/// Any asset can cease to exist at any point, even if the enum value exists.
+/// Either ship your own assets, or be prepared for errors. +///
+public enum DalamudAsset +{ + /// + /// Nothing. + /// + [DalamudAsset(DalamudAssetPurpose.Empty, data: new byte[0])] + Unspecified = 0, + + /// + /// : The fallback empty texture. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromRaw, data: new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 })] + [DalamudAssetRawTexture(4, 8, 4, SharpDX.DXGI.Format.BC1_UNorm)] + Empty4X4 = 1000, + + /// + /// : The Dalamud logo. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "logo.png")] + Logo = 1001, + + /// + /// : The Dalamud logo, but smaller. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "tsmLogo.png")] + LogoSmall = 1002, + + /// + /// : The default plugin icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "defaultIcon.png")] + DefaultIcon = 1003, + + /// + /// : The disabled plugin icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "disabledIcon.png")] + DisabledIcon = 1004, + + /// + /// : The outdated installable plugin icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "outdatedInstallableIcon.png")] + OutdatedInstallableIcon = 1005, + + /// + /// : The plugin trouble icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "troubleIcon.png")] + TroubleIcon = 1006, + + /// + /// : The plugin trouble icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "devPluginIcon.png")] + DevPluginIcon = 1007, + + /// + /// : The plugin update icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "updateIcon.png")] + UpdateIcon = 1008, + + /// + /// : The plugin installed icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "installedIcon.png")] + InstalledIcon = 1009, + + /// + /// : The third party plugin icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "thirdIcon.png")] + ThirdIcon = 1010, + + /// + /// : The installed third party plugin icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "thirdInstalledIcon.png")] + ThirdInstalledIcon = 1011, + + /// + /// : The API bump explainer icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "changelogApiBump.png")] + ChangelogApiBumpIcon = 1012, + + /// + /// : The background shade for + /// . + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "tsmShade.png")] + TitleScreenMenuShade = 1013, + + /// + /// : Noto Sans CJK JP Medium. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "NotoSansCJKjp-Regular.otf")] + [DalamudAssetPath("UIRes", "NotoSansCJKjp-Medium.otf")] + NotoSansJpMedium = 2000, + + /// + /// : Noto Sans CJK KR Regular. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "NotoSansCJKkr-Regular.otf")] + [DalamudAssetPath("UIRes", "NotoSansKR-Regular.otf")] + NotoSansKrRegular = 2001, + + /// + /// : Inconsolata Regular. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "Inconsolata-Regular.ttf")] + InconsolataRegular = 2002, + + /// + /// : FontAwesome Free Solid. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "FontAwesomeFreeSolid.otf")] + FontAwesomeFreeSolid = 2003, + + /// + /// : Game symbol fonts being used as webfonts at Lodestone. + /// + [DalamudAsset(DalamudAssetPurpose.Font, required: false)] + // [DalamudAssetOnlineSource("https://img.finalfantasyxiv.com/lds/pc/global/fonts/FFXIV_Lodestone_SSF.ttf")] + LodestoneGameSymbol = 2004, +} diff --git a/Dalamud/Data/DataManager.cs b/Dalamud/Data/DataManager.cs index a4c81c7a7..da93f57c4 100644 --- a/Dalamud/Data/DataManager.cs +++ b/Dalamud/Data/DataManager.cs @@ -1,27 +1,20 @@ -using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; -using Dalamud.Interface.Internal; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Plugin.Services; using Dalamud.Utility; using Dalamud.Utility.Timing; -using ImGuiScene; using JetBrains.Annotations; using Lumina; using Lumina.Data; -using Lumina.Data.Files; -using Lumina.Data.Parsing.Tex.Buffers; using Lumina.Excel; using Newtonsoft.Json; using Serilog; -using SharpDX.DXGI; namespace Dalamud.Data; @@ -34,39 +27,20 @@ namespace Dalamud.Data; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed class DataManager : IDisposable, IServiceType, IDataManager +internal sealed class DataManager : IInternalDisposableService, IDataManager { - private const string IconFileFormat = "ui/icon/{0:D3}000/{1}{2:D6}.tex"; - private const string HighResolutionIconFileFormat = "ui/icon/{0:D3}000/{1}{2:D6}_hr1.tex"; - private readonly Thread luminaResourceThread; private readonly CancellationTokenSource luminaCancellationTokenSource; [ServiceManager.ServiceConstructor] - private DataManager(DalamudStartInfo dalamudStartInfo, Dalamud dalamud) + private DataManager(Dalamud dalamud) { - this.Language = dalamudStartInfo.Language; + this.Language = (ClientLanguage)dalamud.StartInfo.Language; - // Set up default values so plugins do not null-reference when data is being loaded. - this.ClientOpCodes = this.ServerOpCodes = new ReadOnlyDictionary(new Dictionary()); - - var baseDir = dalamud.AssetDirectory.FullName; try { Log.Verbose("Starting data load..."); - - var zoneOpCodeDict = JsonConvert.DeserializeObject>( - File.ReadAllText(Path.Combine(baseDir, "UIRes", "serveropcode.json")))!; - this.ServerOpCodes = new ReadOnlyDictionary(zoneOpCodeDict); - - Log.Verbose("Loaded {0} ServerOpCodes.", zoneOpCodeDict.Count); - - var clientOpCodeDict = JsonConvert.DeserializeObject>( - File.ReadAllText(Path.Combine(baseDir, "UIRes", "clientopcode.json")))!; - this.ClientOpCodes = new ReadOnlyDictionary(clientOpCodeDict); - - Log.Verbose("Loaded {0} ClientOpCodes.", clientOpCodeDict.Count); - + using (Timings.Start("Lumina Init")) { var luminaOptions = new LuminaOptions @@ -93,17 +67,20 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager Log.Information("Lumina is ready: {0}", this.GameData.DataPath); - try + if (!dalamud.StartInfo.TroubleshootingPackData.IsNullOrEmpty()) { - var tsInfo = - JsonConvert.DeserializeObject( - dalamudStartInfo.TroubleshootingPackData); - this.HasModifiedGameDataFiles = - tsInfo?.IndexIntegrity is LauncherTroubleshootingInfo.IndexIntegrityResult.Failed or LauncherTroubleshootingInfo.IndexIntegrityResult.Exception; - } - catch - { - // ignored + try + { + var tsInfo = + JsonConvert.DeserializeObject( + dalamud.StartInfo.TroubleshootingPackData); + this.HasModifiedGameDataFiles = + tsInfo?.IndexIntegrity is LauncherTroubleshootingInfo.IndexIntegrityResult.Failed or LauncherTroubleshootingInfo.IndexIntegrityResult.Exception; + } + catch + { + // ignored + } } } @@ -137,25 +114,20 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager /// public ClientLanguage Language { get; private set; } - /// - public ReadOnlyDictionary ServerOpCodes { get; private set; } - - /// - [UsedImplicitly] - public ReadOnlyDictionary ClientOpCodes { get; private set; } - /// public GameData GameData { get; private set; } /// public ExcelModule Excel => this.GameData.Excel; - /// - public bool IsDataReady { get; private set; } - /// public bool HasModifiedGameDataFiles { get; private set; } + /// + /// Gets a value indicating whether Game Data is ready to be read. + /// + internal bool IsDataReady { get; private set; } + #region Lumina Wrappers /// @@ -183,162 +155,10 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager public bool FileExists(string path) => this.GameData.FileExists(path); - /// - /// Get a containing the icon with the given ID. - /// - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(uint iconId) - => this.GetIcon(this.Language, iconId, false); - - /// - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(uint iconId, bool highResolution) - => this.GetIcon(this.Language, iconId, highResolution); - - /// - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(bool isHq, uint iconId) - { - var type = isHq ? "hq/" : string.Empty; - return this.GetIcon(type, iconId); - } - - /// - /// Get a containing the icon with the given ID, of the given language. - /// - /// The requested language. - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(ClientLanguage iconLanguage, uint iconId) - => this.GetIcon(iconLanguage, iconId, false); - - /// - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(ClientLanguage iconLanguage, uint iconId, bool highResolution) - { - var type = iconLanguage switch - { - ClientLanguage.Japanese => "ja/", - ClientLanguage.English => "en/", - ClientLanguage.German => "de/", - ClientLanguage.French => "fr/", - _ => throw new ArgumentOutOfRangeException(nameof(iconLanguage), $"Unknown Language: {iconLanguage}"), - }; - - return this.GetIcon(type, iconId, highResolution); - } - - /// - /// Get a containing the icon with the given ID, of the given type. - /// - /// The type of the icon (e.g. 'hq' to get the HQ variant of an item icon). - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(string? type, uint iconId) - => this.GetIcon(type, iconId, false); - - /// - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(string? type, uint iconId, bool highResolution) - { - var format = highResolution ? HighResolutionIconFileFormat : IconFileFormat; - - type ??= string.Empty; - if (type.Length > 0 && !type.EndsWith("/")) - type += "/"; - - var filePath = string.Format(format, iconId / 1000, type, iconId); - var file = this.GetFile(filePath); - - if (type == string.Empty || file != default) - return file; - - // Couldn't get specific type, try for generic version. - filePath = string.Format(format, iconId / 1000, string.Empty, iconId); - file = this.GetFile(filePath); - return file; - } - - /// - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetHqIcon(uint iconId) - => this.GetIcon(true, iconId); - - /// - [Obsolete("Use ITextureProvider instead")] - [return: NotNullIfNotNull(nameof(tex))] - public TextureWrap? GetImGuiTexture(TexFile? tex) - { - if (tex is null) - return null; - - var im = Service.Get(); - var buffer = tex.TextureBuffer; - var bpp = 1 << (((int)tex.Header.Format & (int)TexFile.TextureFormat.BppMask) >> - (int)TexFile.TextureFormat.BppShift); - - var (dxgiFormat, conversion) = TexFile.GetDxgiFormatFromTextureFormat(tex.Header.Format, false); - if (conversion != TexFile.DxgiFormatConversion.NoConversion || !im.SupportsDxgiFormat((Format)dxgiFormat)) - { - dxgiFormat = (int)Format.B8G8R8A8_UNorm; - buffer = buffer.Filter(0, 0, TexFile.TextureFormat.B8G8R8A8); - bpp = 32; - } - - var pitch = buffer is BlockCompressionTextureBuffer - ? Math.Max(1, (buffer.Width + 3) / 4) * 2 * bpp - : ((buffer.Width * bpp) + 7) / 8; - return im.LoadImageFromDxgiFormat(buffer.RawData, pitch, buffer.Width, buffer.Height, (Format)dxgiFormat); - } - - /// - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTexture(string path) - => this.GetImGuiTexture(this.GetFile(path)); - - /// - /// Get a containing the icon with the given ID. - /// - /// The icon ID. - /// The containing the icon. - /// TODO(v9): remove in api9 in favor of GetImGuiTextureIcon(uint iconId, bool highResolution) - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(uint iconId) - => this.GetImGuiTexture(this.GetIcon(iconId, false)); - - /// - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(uint iconId, bool highResolution) - => this.GetImGuiTexture(this.GetIcon(iconId, highResolution)); - - /// - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(bool isHq, uint iconId) - => this.GetImGuiTexture(this.GetIcon(isHq, iconId)); - - /// - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(ClientLanguage iconLanguage, uint iconId) - => this.GetImGuiTexture(this.GetIcon(iconLanguage, iconId)); - - /// - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(string type, uint iconId) - => this.GetImGuiTexture(this.GetIcon(type, iconId)); - - /// - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureHqIcon(uint iconId) - => this.GetImGuiTexture(this.GetHqIcon(iconId)); - #endregion /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.luminaCancellationTokenSource.Cancel(); } diff --git a/Dalamud/EntryPoint.cs b/Dalamud/EntryPoint.cs index 6b4f7a8d1..0763b9d91 100644 --- a/Dalamud/EntryPoint.cs +++ b/Dalamud/EntryPoint.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics; using System.IO; using System.Net; @@ -6,10 +5,12 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Dalamud.Common; using Dalamud.Configuration.Internal; using Dalamud.Logging.Internal; using Dalamud.Logging.Retention; using Dalamud.Plugin.Internal; +using Dalamud.Storage; using Dalamud.Support; using Dalamud.Utility; using Newtonsoft.Json; @@ -162,7 +163,10 @@ public static class EntryPoint SerilogEventSink.Instance.LogLine += SerilogOnLogLine; // Load configuration first to get some early persistent state, like log level - var configuration = DalamudConfiguration.Load(info.ConfigurationPath!); +#pragma warning disable CS0618 // Type or member is obsolete + var fs = new ReliableFileStorage(Path.GetDirectoryName(info.ConfigurationPath)!); +#pragma warning restore CS0618 // Type or member is obsolete + var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs); // Set the appropriate logging level from the configuration if (!configuration.LogSynchronously) @@ -170,7 +174,8 @@ public static class EntryPoint LogLevelSwitch.MinimumLevel = configuration.LogLevel; // Log any unhandled exception. - AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + if (!info.NoExceptionHandlers) + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; var unloadFailed = false; @@ -186,15 +191,18 @@ public static class EntryPoint Log.Information(new string('-', 80)); Log.Information("Initializing a session.."); + if (string.IsNullOrEmpty(info.WorkingDirectory)) + throw new Exception("Working directory was invalid"); + Reloaded.Hooks.Tools.Utilities.FasmBasePath = new DirectoryInfo(info.WorkingDirectory); // This is due to GitHub not supporting TLS 1.0, so we enable all TLS versions globally ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12 | SecurityProtocolType.Tls; - if (!Util.IsLinux()) + if (!Util.IsWine()) InitSymbolHandler(info); - var dalamud = new Dalamud(info, configuration, mainThreadContinueEvent); + var dalamud = new Dalamud(info, fs, configuration, mainThreadContinueEvent); Log.Information("This is Dalamud - Core: {GitHash}, CS: {CsGitHash} [{CsVersion}]", Util.GetGitHash(), Util.GetGitHashClientStructs(), FFXIVClientStructs.Interop.Resolver.Version); dalamud.WaitForUnload(); @@ -216,7 +224,8 @@ public static class EntryPoint finally { TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException; - AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; + if (!info.NoExceptionHandlers) + AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; Log.Information("Session has ended."); Log.CloseAndFlush(); diff --git a/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs b/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs new file mode 100644 index 000000000..14def2036 --- /dev/null +++ b/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs @@ -0,0 +1,107 @@ +using System.Runtime.CompilerServices; +using System.Threading; + +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +namespace Dalamud.Game.Addon; + +/// Argument pool for Addon Lifecycle services. +[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() + { + } + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonSetupArgs arg) => new(out arg, this.addonSetupArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonFinalizeArgs arg) => new(out arg, this.addonFinalizeArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonDrawArgs arg) => new(out arg, this.addonDrawArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonUpdateArgs arg) => new(out arg, this.addonUpdateArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonRefreshArgs arg) => new(out arg, this.addonRefreshArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonRequestedUpdateArgs arg) => + new(out arg, this.addonRequestedUpdateArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonReceiveEventArgs arg) => + new(out arg, this.addonReceiveEventArgPool); + + /// Returns the object to the pool on dispose. + /// The type. + public readonly ref struct PooledEntry + where T : AddonArgs, new() + { + private readonly Span pool; + private readonly T obj; + + /// Initializes a new instance of the struct. + /// An instance of the argument. + /// The pool to rent from and return to. + public PooledEntry(out T arg, Span 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(); + } + + /// Returns the item to the pool. + 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; + } + } + } +} diff --git a/Dalamud/Game/Addon/Events/AddonCursorType.cs b/Dalamud/Game/Addon/Events/AddonCursorType.cs new file mode 100644 index 000000000..83a81582c --- /dev/null +++ b/Dalamud/Game/Addon/Events/AddonCursorType.cs @@ -0,0 +1,97 @@ +namespace Dalamud.Game.Addon.Events; + +/// +/// Reimplementation of CursorType. +/// +public enum AddonCursorType +{ + /// + /// Arrow. + /// + Arrow, + + /// + /// Boot. + /// + Boot, + + /// + /// Search. + /// + Search, + + /// + /// Chat Pointer. + /// + ChatPointer, + + /// + /// Interact. + /// + Interact, + + /// + /// Attack. + /// + Attack, + + /// + /// Hand. + /// + Hand, + + /// + /// Resizeable Left-Right. + /// + ResizeWE, + + /// + /// Resizeable Up-Down. + /// + ResizeNS, + + /// + /// Resizeable. + /// + ResizeNWSR, + + /// + /// Resizeable 4-way. + /// + ResizeNESW, + + /// + /// Clickable. + /// + Clickable, + + /// + /// Text Input. + /// + TextInput, + + /// + /// Text Click. + /// + TextClick, + + /// + /// Grab. + /// + Grab, + + /// + /// Chat Bubble. + /// + ChatBubble, + + /// + /// No Access. + /// + NoAccess, + + /// + /// Hidden. + /// + Hidden, +} diff --git a/Dalamud/Game/Addon/Events/AddonEventEntry.cs b/Dalamud/Game/Addon/Events/AddonEventEntry.cs new file mode 100644 index 000000000..a7430acf0 --- /dev/null +++ b/Dalamud/Game/Addon/Events/AddonEventEntry.cs @@ -0,0 +1,59 @@ +using Dalamud.Memory; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Events; + +/// +/// This class represents a registered event that a plugin registers with a native ui node. +/// Contains all necessary information to track and clean up events automatically. +/// +internal unsafe class AddonEventEntry +{ + /// + /// Name of an invalid addon. + /// + public const string InvalidAddonName = "NullAddon"; + + private string? addonName; + + /// + /// Gets the pointer to the addons AtkUnitBase. + /// + required public nint Addon { get; init; } + + /// + /// Gets the name of the addon this args referrers to. + /// + public string AddonName => this.Addon == nint.Zero ? InvalidAddonName : this.addonName ??= MemoryHelper.ReadString((nint)((AtkUnitBase*)this.Addon)->Name, 0x20); + + /// + /// Gets the pointer to the event source. + /// + required public nint Node { get; init; } + + /// + /// Gets the handler that gets called when this event is triggered. + /// + required public IAddonEventManager.AddonEventHandler Handler { get; init; } + + /// + /// Gets the unique id for this event. + /// + required public uint ParamKey { get; init; } + + /// + /// Gets the event type for this event. + /// + required public AddonEventType EventType { get; init; } + + /// + /// Gets the event handle for this event. + /// + required internal IAddonEventHandle Handle { get; init; } + + /// + /// Gets the formatted log string for this AddonEventEntry. + /// + internal string LogString => $"ParamKey: {this.ParamKey}, Addon: {this.AddonName}, Event: {this.EventType}, GUID: {this.Handle.EventGuid}"; +} diff --git a/Dalamud/Game/Addon/Events/AddonEventHandle.cs b/Dalamud/Game/Addon/Events/AddonEventHandle.cs new file mode 100644 index 000000000..fb0e2886c --- /dev/null +++ b/Dalamud/Game/Addon/Events/AddonEventHandle.cs @@ -0,0 +1,19 @@ +namespace Dalamud.Game.Addon.Events; + +/// +/// Class that represents a addon event handle. +/// +public class AddonEventHandle : IAddonEventHandle +{ + /// + public uint ParamKey { get; init; } + + /// + public string AddonName { get; init; } = "NullAddon"; + + /// + public AddonEventType EventType { get; init; } + + /// + public Guid EventGuid { get; init; } +} diff --git a/Dalamud/Game/Addon/Events/AddonEventListener.cs b/Dalamud/Game/Addon/Events/AddonEventListener.cs new file mode 100644 index 000000000..a2498d5a7 --- /dev/null +++ b/Dalamud/Game/Addon/Events/AddonEventListener.cs @@ -0,0 +1,97 @@ +using System.Runtime.InteropServices; + +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Events; + +/// +/// Event listener class for managing custom events. +/// +// Custom event handler tech provided by Pohky, implemented by MidoriKami +internal unsafe class AddonEventListener : IDisposable +{ + private ReceiveEventDelegate? receiveEventDelegate; + + private AtkEventListener* eventListener; + + /// + /// Initializes a new instance of the class. + /// + /// The managed handler to send events to. + public AddonEventListener(ReceiveEventDelegate eventHandler) + { + this.receiveEventDelegate = eventHandler; + + this.eventListener = (AtkEventListener*)Marshal.AllocHGlobal(sizeof(AtkEventListener)); + this.eventListener->vtbl = (void*)Marshal.AllocHGlobal(sizeof(void*) * 3); + this.eventListener->vfunc[0] = (delegate* unmanaged)&NullSub; + this.eventListener->vfunc[1] = (delegate* unmanaged)&NullSub; + this.eventListener->vfunc[2] = (void*)Marshal.GetFunctionPointerForDelegate(this.receiveEventDelegate); + } + + /// + /// Delegate for receiving custom events. + /// + /// Pointer to the event listener. + /// Event type. + /// Unique Id for this event. + /// Event Data. + /// Unknown Parameter. + public delegate void ReceiveEventDelegate(AtkEventListener* self, AtkEventType eventType, uint eventParam, AtkEvent* eventData, nint unknown); + + /// + /// Gets the address of this listener. + /// + public nint Address => (nint)this.eventListener; + + /// + public void Dispose() + { + if (this.eventListener is null) return; + + Marshal.FreeHGlobal((nint)this.eventListener->vtbl); + Marshal.FreeHGlobal((nint)this.eventListener); + + this.eventListener = null; + this.receiveEventDelegate = null; + } + + /// + /// Register an event to this event handler. + /// + /// Addon that triggers this event. + /// Node to attach event to. + /// Event type to trigger this event. + /// Unique id for this event. + public void RegisterEvent(AtkUnitBase* addon, AtkResNode* node, AtkEventType eventType, uint param) + { + if (node is null) return; + + Service.Get().RunOnFrameworkThread(() => + { + node->AddEvent(eventType, param, this.eventListener, (AtkResNode*)addon, false); + }); + } + + /// + /// Unregister an event from this event handler. + /// + /// Node to remove the event from. + /// Event type that this event is for. + /// Unique id for this event. + public void UnregisterEvent(AtkResNode* node, AtkEventType eventType, uint param) + { + if (node is null) return; + + Service.Get().RunOnFrameworkThread(() => + { + node->RemoveEvent(eventType, param, this.eventListener, false); + }); + } + + [UnmanagedCallersOnly] + private static void NullSub() + { + /* do nothing */ + } +} diff --git a/Dalamud/Game/Addon/Events/AddonEventManager.cs b/Dalamud/Game/Addon/Events/AddonEventManager.cs new file mode 100644 index 000000000..a9b9ef5fa --- /dev/null +++ b/Dalamud/Game/Addon/Events/AddonEventManager.cs @@ -0,0 +1,262 @@ +using System.Collections.Concurrent; + +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Hooking; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; + +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Events; + +/// +/// Service provider for addon event management. +/// +[InterfaceVersion("1.0")] +[ServiceManager.EarlyLoadedService] +internal unsafe class AddonEventManager : IInternalDisposableService +{ + /// + /// PluginName for Dalamud Internal use. + /// + public static readonly Guid DalamudInternalKey = Guid.NewGuid(); + + private static readonly ModuleLog Log = new("AddonEventManager"); + + [ServiceManager.ServiceDependency] + private readonly AddonLifecycle addonLifecycle = Service.Get(); + + private readonly AddonLifecycleEventListener finalizeEventListener; + + private readonly AddonEventManagerAddressResolver address; + private readonly Hook onUpdateCursor; + + private readonly ConcurrentDictionary pluginEventControllers; + + private AddonCursorType? cursorOverride; + + [ServiceManager.ServiceConstructor] + private AddonEventManager(TargetSigScanner sigScanner) + { + this.address = new AddonEventManagerAddressResolver(); + this.address.Setup(sigScanner); + + this.pluginEventControllers = new ConcurrentDictionary(); + this.pluginEventControllers.TryAdd(DalamudInternalKey, new PluginEventController()); + + this.cursorOverride = null; + + this.onUpdateCursor = Hook.FromAddress(this.address.UpdateCursor, this.UpdateCursorDetour); + + this.finalizeEventListener = new AddonLifecycleEventListener(AddonEvent.PreFinalize, string.Empty, this.OnAddonFinalize); + this.addonLifecycle.RegisterListener(this.finalizeEventListener); + + this.onUpdateCursor.Enable(); + } + + private delegate nint UpdateCursorDelegate(RaptureAtkModule* module); + + /// + void IInternalDisposableService.DisposeService() + { + this.onUpdateCursor.Dispose(); + + foreach (var (_, pluginEventController) in this.pluginEventControllers) + { + pluginEventController.Dispose(); + } + + this.addonLifecycle.UnregisterListener(this.finalizeEventListener); + } + + /// + /// Registers an event handler for the specified addon, node, and type. + /// + /// Unique ID for this plugin. + /// The parent addon for this event. + /// The node that will trigger this event. + /// The event type for this event. + /// The handler to call when event is triggered. + /// IAddonEventHandle used to remove the event. + internal IAddonEventHandle? AddEvent(Guid pluginId, IntPtr atkUnitBase, IntPtr atkResNode, AddonEventType eventType, IAddonEventManager.AddonEventHandler eventHandler) + { + if (this.pluginEventControllers.TryGetValue(pluginId, out var controller)) + { + return controller.AddEvent(atkUnitBase, atkResNode, eventType, eventHandler); + } + else + { + Log.Verbose($"Unable to locate controller for {pluginId}. No event was added."); + } + + return null; + } + + /// + /// Unregisters an event handler with the specified event id and event type. + /// + /// Unique ID for this plugin. + /// The Unique Id for this event. + internal void RemoveEvent(Guid pluginId, IAddonEventHandle eventHandle) + { + if (this.pluginEventControllers.TryGetValue(pluginId, out var controller)) + { + controller.RemoveEvent(eventHandle); + } + else + { + Log.Verbose($"Unable to locate controller for {pluginId}. No event was removed."); + } + } + + /// + /// Force the game cursor to be the specified cursor. + /// + /// Which cursor to use. + internal void SetCursor(AddonCursorType cursor) => this.cursorOverride = cursor; + + /// + /// Un-forces the game cursor. + /// + internal void ResetCursor() => this.cursorOverride = null; + + /// + /// Adds a new managed event controller if one doesn't already exist for this pluginId. + /// + /// Unique ID for this plugin. + internal void AddPluginEventController(Guid pluginId) + { + this.pluginEventControllers.GetOrAdd( + pluginId, + key => + { + Log.Verbose($"Creating new PluginEventController for: {key}"); + return new PluginEventController(); + }); + } + + /// + /// Removes an existing managed event controller for the specified plugin. + /// + /// Unique ID for this plugin. + internal void RemovePluginEventController(Guid pluginId) + { + if (this.pluginEventControllers.TryRemove(pluginId, out var controller)) + { + Log.Verbose($"Removing PluginEventController for: {pluginId}"); + controller.Dispose(); + } + } + + /// + /// When an addon finalizes, check it for any registered events, and unregister them. + /// + /// Event type that triggered this call. + /// Addon that triggered this call. + private void OnAddonFinalize(AddonEvent eventType, AddonArgs addonInfo) + { + // It shouldn't be possible for this event to be anything other than PreFinalize. + if (eventType != AddonEvent.PreFinalize) return; + + foreach (var pluginList in this.pluginEventControllers) + { + pluginList.Value.RemoveForAddon(addonInfo.AddonName); + } + } + + private nint UpdateCursorDetour(RaptureAtkModule* module) + { + try + { + var atkStage = AtkStage.GetSingleton(); + + if (this.cursorOverride is not null && atkStage is not null) + { + var cursor = (AddonCursorType)atkStage->AtkCursor.Type; + if (cursor != this.cursorOverride) + { + AtkStage.GetSingleton()->AtkCursor.SetCursorType((AtkCursor.CursorType)this.cursorOverride, 1); + } + + return nint.Zero; + } + } + catch (Exception e) + { + Log.Error(e, "Exception in UpdateCursorDetour."); + } + + return this.onUpdateCursor!.Original(module); + } +} + +/// +/// Plugin-scoped version of a AddonEventManager service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class AddonEventManagerPluginScoped : IInternalDisposableService, IAddonEventManager +{ + [ServiceManager.ServiceDependency] + private readonly AddonEventManager eventManagerService = Service.Get(); + + private readonly LocalPlugin plugin; + + private bool isForcingCursor; + + /// + /// Initializes a new instance of the class. + /// + /// Plugin info for the plugin that requested this service. + public AddonEventManagerPluginScoped(LocalPlugin plugin) + { + this.plugin = plugin; + + this.eventManagerService.AddPluginEventController(plugin.Manifest.WorkingPluginId); + } + + /// + void IInternalDisposableService.DisposeService() + { + // if multiple plugins force cursors and dispose without un-forcing them then all forces will be cleared. + if (this.isForcingCursor) + { + this.eventManagerService.ResetCursor(); + } + + this.eventManagerService.RemovePluginEventController(this.plugin.Manifest.WorkingPluginId); + } + + /// + public IAddonEventHandle? AddEvent(IntPtr atkUnitBase, IntPtr atkResNode, AddonEventType eventType, IAddonEventManager.AddonEventHandler eventHandler) + => this.eventManagerService.AddEvent(this.plugin.Manifest.WorkingPluginId, atkUnitBase, atkResNode, eventType, eventHandler); + + /// + public void RemoveEvent(IAddonEventHandle eventHandle) + => this.eventManagerService.RemoveEvent(this.plugin.Manifest.WorkingPluginId, eventHandle); + + /// + public void SetCursor(AddonCursorType cursor) + { + this.isForcingCursor = true; + + this.eventManagerService.SetCursor(cursor); + } + + /// + public void ResetCursor() + { + this.isForcingCursor = false; + + this.eventManagerService.ResetCursor(); + } +} diff --git a/Dalamud/Game/Addon/Events/AddonEventManagerAddressResolver.cs b/Dalamud/Game/Addon/Events/AddonEventManagerAddressResolver.cs new file mode 100644 index 000000000..927ed87ab --- /dev/null +++ b/Dalamud/Game/Addon/Events/AddonEventManagerAddressResolver.cs @@ -0,0 +1,21 @@ +namespace Dalamud.Game.Addon.Events; + +/// +/// AddonEventManager memory address resolver. +/// +internal class AddonEventManagerAddressResolver : BaseAddressResolver +{ + /// + /// Gets the address of the AtkModule UpdateCursor method. + /// + public nint UpdateCursor { get; private set; } + + /// + /// Scan for and setup any configured address pointers. + /// + /// The signature scanner to facilitate setup. + protected override void Setup64Bit(ISigScanner scanner) + { + this.UpdateCursor = scanner.ScanText("48 89 74 24 ?? 48 89 7C 24 ?? 41 56 48 83 EC 20 4C 8B F1 E8 ?? ?? ?? ?? 49 8B CE"); + } +} diff --git a/Dalamud/Game/Addon/Events/AddonEventType.cs b/Dalamud/Game/Addon/Events/AddonEventType.cs new file mode 100644 index 000000000..100168e22 --- /dev/null +++ b/Dalamud/Game/Addon/Events/AddonEventType.cs @@ -0,0 +1,158 @@ +namespace Dalamud.Game.Addon.Events; + +/// +/// Reimplementation of AtkEventType. +/// +public enum AddonEventType : byte +{ + /// + /// Mouse Down. + /// + MouseDown = 3, + + /// + /// Mouse Up. + /// + MouseUp = 4, + + /// + /// Mouse Move. + /// + MouseMove = 5, + + /// + /// Mouse Over. + /// + MouseOver = 6, + + /// + /// Mouse Out. + /// + MouseOut = 7, + + /// + /// Mouse Click. + /// + MouseClick = 9, + + /// + /// Input Received. + /// + InputReceived = 12, + + /// + /// Focus Start. + /// + FocusStart = 18, + + /// + /// Focus Stop. + /// + FocusStop = 19, + + /// + /// Button Press, sent on MouseDown on Button. + /// + ButtonPress = 23, + + /// + /// Button Release, sent on MouseUp and MouseOut. + /// + ButtonRelease = 24, + + /// + /// Button Click, sent on MouseUp and MouseClick on button. + /// + ButtonClick = 25, + + /// + /// List Item RollOver. + /// + ListItemRollOver = 33, + + /// + /// List Item Roll Out. + /// + ListItemRollOut = 34, + + /// + /// List Item Toggle. + /// + ListItemToggle = 35, + + /// + /// Drag Drop Begin. + /// Sent on MouseDown over a draggable icon (will NOT send for a locked icon). + /// + DragDropBegin = 47, + + /// + /// Drag Drop Insert. + /// Sent when dropping an icon into a hotbar/inventory slot or similar. + /// + DragDropInsert = 50, + + /// + /// Drag Drop Roll Over. + /// + DragDropRollOver = 52, + + /// + /// Drag Drop Roll Out. + /// + DragDropRollOut = 53, + + /// + /// Drag Drop Discard. + /// Sent when dropping an icon into empty screenspace, eg to remove an action from a hotBar. + /// + DragDropDiscard = 54, + + /// + /// Drag Drop Unknown. + /// + [Obsolete("Use DragDropDiscard")] + DragDropUnk54 = 54, + + /// + /// Drag Drop Cancel. + /// Sent on MouseUp if the cursor has not moved since DragDropBegin, OR on MouseDown over a locked icon. + /// + DragDropCancel = 55, + + /// + /// Drag Drop Unknown. + /// + [Obsolete("Use DragDropCancel")] + DragDropUnk55 = 55, + + /// + /// Icon Text Roll Over. + /// + IconTextRollOver = 56, + + /// + /// Icon Text Roll Out. + /// + IconTextRollOut = 57, + + /// + /// Icon Text Click. + /// + IconTextClick = 58, + + /// + /// Window Roll Over. + /// + WindowRollOver = 67, + + /// + /// Window Roll Out. + /// + WindowRollOut = 68, + + /// + /// Window Change Scale. + /// + WindowChangeScale = 69, +} diff --git a/Dalamud/Game/Addon/Events/IAddonEventHandle.cs b/Dalamud/Game/Addon/Events/IAddonEventHandle.cs new file mode 100644 index 000000000..f9272c92a --- /dev/null +++ b/Dalamud/Game/Addon/Events/IAddonEventHandle.cs @@ -0,0 +1,27 @@ +namespace Dalamud.Game.Addon.Events; + +/// +/// Interface representing the data used for managing AddonEvents. +/// +public interface IAddonEventHandle +{ + /// + /// Gets the param key associated with this event. + /// + public uint ParamKey { get; init; } + + /// + /// Gets the name of the addon that this event was attached to. + /// + public string AddonName { get; init; } + + /// + /// Gets the event type associated with this handle. + /// + public AddonEventType EventType { get; init; } + + /// + /// Gets the unique ID for this handle. + /// + public Guid EventGuid { get; init; } +} diff --git a/Dalamud/Game/Addon/Events/PluginEventController.cs b/Dalamud/Game/Addon/Events/PluginEventController.cs new file mode 100644 index 000000000..3ba067a6d --- /dev/null +++ b/Dalamud/Game/Addon/Events/PluginEventController.cs @@ -0,0 +1,201 @@ +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Game.Gui; +using Dalamud.Logging.Internal; +using Dalamud.Memory; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Events; + +/// +/// Class to manage creating and cleaning up events per-plugin. +/// +internal unsafe class PluginEventController : IDisposable +{ + private static readonly ModuleLog Log = new("AddonEventManager"); + + /// + /// Initializes a new instance of the class. + /// + public PluginEventController() + { + this.EventListener = new AddonEventListener(this.PluginEventListHandler); + } + + private AddonEventListener EventListener { get; init; } + + private List Events { get; } = new(); + + /// + /// Adds a tracked event. + /// + /// The Parent addon for the event. + /// The Node for the event. + /// The Event Type. + /// The delegate to call when invoking this event. + /// IAddonEventHandle used to remove the event. + public IAddonEventHandle AddEvent(nint atkUnitBase, nint atkResNode, AddonEventType atkEventType, IAddonEventManager.AddonEventHandler handler) + { + var node = (AtkResNode*)atkResNode; + var addon = (AtkUnitBase*)atkUnitBase; + var eventType = (AtkEventType)atkEventType; + var eventId = this.GetNextParamKey(); + var eventGuid = Guid.NewGuid(); + + var eventHandle = new AddonEventHandle + { + AddonName = MemoryHelper.ReadStringNullTerminated((nint)addon->Name), + ParamKey = eventId, + EventType = atkEventType, + EventGuid = eventGuid, + }; + + var eventEntry = new AddonEventEntry + { + Addon = atkUnitBase, + Handler = handler, + Node = atkResNode, + EventType = atkEventType, + ParamKey = eventId, + Handle = eventHandle, + }; + + Log.Verbose($"Adding Event. {eventEntry.LogString}"); + this.EventListener.RegisterEvent(addon, node, eventType, eventId); + this.Events.Add(eventEntry); + + return eventHandle; + } + + /// + /// Removes a tracked event, also attempts to un-attach the event from native. + /// + /// Unique ID of the event to remove. + public void RemoveEvent(IAddonEventHandle handle) + { + if (this.Events.FirstOrDefault(registeredEvent => registeredEvent.Handle == handle) is not { } targetEvent) return; + + Log.Verbose($"Removing Event. {targetEvent.LogString}"); + this.TryRemoveEventFromNative(targetEvent); + this.Events.Remove(targetEvent); + } + + /// + /// Removes all events attached to the specified addon. + /// + /// Addon name to remove events from. + public void RemoveForAddon(string addonName) + { + if (this.Events.Where(entry => entry.AddonName == addonName).ToList() is { Count: not 0 } events) + { + Log.Verbose($"Addon: {addonName} is Finalizing, removing {events.Count} events."); + + foreach (var registeredEvent in events) + { + this.RemoveEvent(registeredEvent.Handle); + } + } + } + + /// + public void Dispose() + { + foreach (var registeredEvent in this.Events.ToList()) + { + this.RemoveEvent(registeredEvent.Handle); + } + + this.EventListener.Dispose(); + } + + private uint GetNextParamKey() + { + for (var i = 0u; i < uint.MaxValue; ++i) + { + if (this.Events.All(registeredEvent => registeredEvent.ParamKey != i)) return i; + } + + throw new OverflowException($"uint.MaxValue number of ParamKeys used for this event controller."); + } + + /// + /// Attempts to remove a tracked event from native UI. + /// This method performs several safety checks to only remove events from a still active addon. + /// If any of these checks fail, it likely means the native UI already cleaned up the event, and we don't have to worry about them. + /// + /// Event entry to remove. + private void TryRemoveEventFromNative(AddonEventEntry eventEntry) + { + // Is the eventEntry addon valid? + if (eventEntry.AddonName is AddonEventEntry.InvalidAddonName) return; + + // Is an addon with the same name active? + var currentAddonPointer = Service.Get().GetAddonByName(eventEntry.AddonName); + if (currentAddonPointer == nint.Zero) return; + + // Is our stored addon pointer the same as the active addon pointer? + if (currentAddonPointer != eventEntry.Addon) return; + + // Does this addon contain the node this event is for? (by address) + var atkUnitBase = (AtkUnitBase*)currentAddonPointer; + var nodeFound = false; + foreach (var index in Enumerable.Range(0, atkUnitBase->UldManager.NodeListCount)) + { + var node = atkUnitBase->UldManager.NodeList[index]; + + // If this node matches our node, then we know our node is still valid. + if (node is not null && (nint)node == eventEntry.Node) + { + nodeFound = true; + } + } + + // If we didn't find the node, we can't remove the event. + if (!nodeFound) return; + + // Does the node have a registered event matching the parameters we have? + var atkResNode = (AtkResNode*)eventEntry.Node; + var eventType = (AtkEventType)eventEntry.EventType; + var currentEvent = atkResNode->AtkEventManager.Event; + var eventFound = false; + while (currentEvent is not null) + { + var paramKeyMatches = currentEvent->Param == eventEntry.ParamKey; + var eventListenerAddressMatches = (nint)currentEvent->Listener == this.EventListener.Address; + var eventTypeMatches = currentEvent->Type == eventType; + + if (paramKeyMatches && eventListenerAddressMatches && eventTypeMatches) + { + eventFound = true; + break; + } + + // Move to the next event. + currentEvent = currentEvent->NextEvent; + } + + // If we didn't find the event, we can't remove the event. + if (!eventFound) return; + + // We have a valid addon, valid node, valid event, and valid key. + this.EventListener.UnregisterEvent(atkResNode, eventType, eventEntry.ParamKey); + } + + private void PluginEventListHandler(AtkEventListener* self, AtkEventType eventType, uint eventParam, AtkEvent* eventData, IntPtr unknown) + { + try + { + if (eventData is null) return; + if (this.Events.FirstOrDefault(handler => handler.ParamKey == eventParam) is not { } eventInfo) return; + + // We stored the AtkUnitBase* in EventData->Node, and EventData->Target contains the node that triggered the event. + eventInfo.Handler.Invoke((AddonEventType)eventType, (nint)eventData->Node, (nint)eventData->Target); + } + catch (Exception exception) + { + Log.Error(exception, "Exception in PluginEventList custom event invoke."); + } + } +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs new file mode 100644 index 000000000..1095202cc --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs @@ -0,0 +1,85 @@ +using Dalamud.Memory; + +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Base class for AddonLifecycle AddonArgTypes. +/// +public abstract unsafe class AddonArgs +{ + /// + /// Constant string representing the name of an addon that is invalid. + /// + public const string InvalidAddon = "NullAddon"; + + private string? addonName; + private IntPtr addon; + + /// + /// Gets the name of the addon this args referrers to. + /// + public string AddonName => this.GetAddonName(); + + /// + /// Gets the pointer to the addons AtkUnitBase. + /// + public nint Addon + { + get => this.AddonInternal; + init => this.AddonInternal = value; + } + + /// + /// Gets the type of these args. + /// + public abstract AddonArgsType Type { get; } + + /// + /// Gets or sets the pointer to the addons AtkUnitBase. + /// + internal nint AddonInternal + { + get => this.addon; + set + { + this.addon = value; + + // Note: always clear addonName on updating the addon being pointed. + // Same address may point to a different addon. + this.addonName = null; + } + } + + /// + /// Checks if addon name matches the given span of char. + /// + /// The name to check. + /// Whether it is the case. + internal bool IsAddon(ReadOnlySpan name) + { + if (this.Addon == nint.Zero) return false; + if (name.Length is 0 or > 0x20) + return false; + + var addonPointer = (AtkUnitBase*)this.Addon; + if (addonPointer->Name is null) return false; + + return MemoryHelper.EqualsZeroTerminatedString(name, (nint)addonPointer->Name, null, 0x20); + } + + /// + /// Helper method for ensuring the name of the addon is valid. + /// + /// The name of the addon for this object. when invalid. + private string GetAddonName() + { + if (this.Addon == nint.Zero) return InvalidAddon; + + var addonPointer = (AtkUnitBase*)this.Addon; + if (addonPointer->Name is null) return InvalidAddon; + + return this.addonName ??= MemoryHelper.ReadString((nint)addonPointer->Name, 0x20); + } +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonDrawArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonDrawArgs.cs new file mode 100644 index 000000000..989e11912 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonDrawArgs.cs @@ -0,0 +1,24 @@ +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Addon argument data for Draw events. +/// +public class AddonDrawArgs : AddonArgs, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonDrawArgs() + { + } + + /// + public override AddonArgsType Type => AddonArgsType.Draw; + + /// + public AddonDrawArgs Clone() => (AddonDrawArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonFinalizeArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonFinalizeArgs.cs new file mode 100644 index 000000000..d9401b414 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonFinalizeArgs.cs @@ -0,0 +1,24 @@ +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Addon argument data for ReceiveEvent events. +/// +public class AddonFinalizeArgs : AddonArgs, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonFinalizeArgs() + { + } + + /// + public override AddonArgsType Type => AddonArgsType.Finalize; + + /// + public AddonFinalizeArgs Clone() => (AddonFinalizeArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonReceiveEventArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonReceiveEventArgs.cs new file mode 100644 index 000000000..a557b0cb3 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonReceiveEventArgs.cs @@ -0,0 +1,44 @@ +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Addon argument data for ReceiveEvent events. +/// +public class AddonReceiveEventArgs : AddonArgs, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonReceiveEventArgs() + { + } + + /// + public override AddonArgsType Type => AddonArgsType.ReceiveEvent; + + /// + /// Gets or sets the AtkEventType for this event message. + /// + public byte AtkEventType { get; set; } + + /// + /// Gets or sets the event id for this event message. + /// + public int EventParam { get; set; } + + /// + /// Gets or sets the pointer to an AtkEvent for this event message. + /// + public nint AtkEvent { get; set; } + + /// + /// Gets or sets the pointer to a block of data for this event message. + /// + public nint Data { get; set; } + + /// + public AddonReceiveEventArgs Clone() => (AddonReceiveEventArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRefreshArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRefreshArgs.cs new file mode 100644 index 000000000..6e1b11ead --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRefreshArgs.cs @@ -0,0 +1,41 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Addon argument data for Refresh events. +/// +public class AddonRefreshArgs : AddonArgs, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonRefreshArgs() + { + } + + /// + public override AddonArgsType Type => AddonArgsType.Refresh; + + /// + /// Gets or sets the number of AtkValues. + /// + public uint AtkValueCount { get; set; } + + /// + /// Gets or sets the address of the AtkValue array. + /// + public nint AtkValues { get; set; } + + /// + /// Gets the AtkValues in the form of a span. + /// + public unsafe Span AtkValueSpan => new(this.AtkValues.ToPointer(), (int)this.AtkValueCount); + + /// + public AddonRefreshArgs Clone() => (AddonRefreshArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRequestedUpdateArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRequestedUpdateArgs.cs new file mode 100644 index 000000000..26357abb0 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRequestedUpdateArgs.cs @@ -0,0 +1,34 @@ +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Addon argument data for OnRequestedUpdate events. +/// +public class AddonRequestedUpdateArgs : AddonArgs, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonRequestedUpdateArgs() + { + } + + /// + public override AddonArgsType Type => AddonArgsType.RequestedUpdate; + + /// + /// Gets or sets the NumberArrayData** for this event. + /// + public nint NumberArrayData { get; set; } + + /// + /// Gets or sets the StringArrayData** for this event. + /// + public nint StringArrayData { get; set; } + + /// + public AddonRequestedUpdateArgs Clone() => (AddonRequestedUpdateArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonSetupArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonSetupArgs.cs new file mode 100644 index 000000000..19c93ce25 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonSetupArgs.cs @@ -0,0 +1,41 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Addon argument data for Setup events. +/// +public class AddonSetupArgs : AddonArgs, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonSetupArgs() + { + } + + /// + public override AddonArgsType Type => AddonArgsType.Setup; + + /// + /// Gets or sets the number of AtkValues. + /// + public uint AtkValueCount { get; set; } + + /// + /// Gets or sets the address of the AtkValue array. + /// + public nint AtkValues { get; set; } + + /// + /// Gets the AtkValues in the form of a span. + /// + public unsafe Span AtkValueSpan => new(this.AtkValues.ToPointer(), (int)this.AtkValueCount); + + /// + public AddonSetupArgs Clone() => (AddonSetupArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonUpdateArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonUpdateArgs.cs new file mode 100644 index 000000000..cc34a7531 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonUpdateArgs.cs @@ -0,0 +1,38 @@ +namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +/// +/// Addon argument data for Update events. +/// +public class AddonUpdateArgs : AddonArgs, ICloneable +{ + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonUpdateArgs() + { + } + + /// + public override AddonArgsType Type => AddonArgsType.Update; + + /// + /// Gets the time since the last update. + /// + public float TimeDelta + { + get => this.TimeDeltaInternal; + init => this.TimeDeltaInternal = value; + } + + /// + /// Gets or sets the time since the last update. + /// + internal float TimeDeltaInternal { get; set; } + + /// + public AddonUpdateArgs Clone() => (AddonUpdateArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgsType.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgsType.cs new file mode 100644 index 000000000..b58b5f4c7 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgsType.cs @@ -0,0 +1,42 @@ +namespace Dalamud.Game.Addon.Lifecycle; + +/// +/// Enumeration for available AddonLifecycle arg data. +/// +public enum AddonArgsType +{ + /// + /// Contains argument data for Setup. + /// + Setup, + + /// + /// Contains argument data for Update. + /// + Update, + + /// + /// Contains argument data for Draw. + /// + Draw, + + /// + /// Contains argument data for Finalize. + /// + Finalize, + + /// + /// Contains argument data for RequestedUpdate. + /// + RequestedUpdate, + + /// + /// Contains argument data for Refresh. + /// + Refresh, + + /// + /// Contains argument data for ReceiveEvent. + /// + ReceiveEvent, +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonEvent.cs b/Dalamud/Game/Addon/Lifecycle/AddonEvent.cs new file mode 100644 index 000000000..7cbc93eb2 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonEvent.cs @@ -0,0 +1,72 @@ +namespace Dalamud.Game.Addon.Lifecycle; + +/// +/// Enumeration for available AddonLifecycle events. +/// +public enum AddonEvent +{ + /// + /// Event that is fired before an addon begins it's setup process. + /// + PreSetup, + + /// + /// Event that is fired after an addon has completed it's setup process. + /// + PostSetup, + + /// + /// Event that is fired before an addon begins update. + /// + PreUpdate, + + /// + /// Event that is fired after an addon has completed update. + /// + PostUpdate, + + /// + /// Event that is fired before an addon begins draw. + /// + PreDraw, + + /// + /// Event that is fired after an addon has completed draw. + /// + PostDraw, + + /// + /// Event that is fired before an addon is finalized. + /// + PreFinalize, + + /// + /// Event that is fired before an addon begins a requested update. + /// + PreRequestedUpdate, + + /// + /// Event that is fired after an addon finishes a requested update. + /// + PostRequestedUpdate, + + /// + /// Event that is fired before an addon begins a refresh. + /// + PreRefresh, + + /// + /// Event that is fired after an addon has finished a refresh. + /// + PostRefresh, + + /// + /// Event that is fired before an addon begins processing an event. + /// + PreReceiveEvent, + + /// + /// Event that is fired after an addon has processed an event. + /// + PostReceiveEvent, +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs new file mode 100644 index 000000000..eefb3b5e9 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs @@ -0,0 +1,468 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Hooking; +using Dalamud.Hooking.Internal; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Memory; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Lifecycle; + +/// +/// This class provides events for in-game addon lifecycles. +/// +[InterfaceVersion("1.0")] +[ServiceManager.EarlyLoadedService] +internal unsafe class AddonLifecycle : IInternalDisposableService +{ + private static readonly ModuleLog Log = new("AddonLifecycle"); + + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly AddonLifecyclePooledArgs argsPool = Service.Get(); + + private readonly nint disallowedReceiveEventAddress; + + private readonly AddonLifecycleAddressResolver address; + private readonly CallHook onAddonSetupHook; + private readonly CallHook onAddonSetup2Hook; + private readonly Hook onAddonFinalizeHook; + private readonly CallHook onAddonDrawHook; + private readonly CallHook onAddonUpdateHook; + private readonly Hook onAddonRefreshHook; + private readonly CallHook onAddonRequestedUpdateHook; + + [ServiceManager.ServiceConstructor] + private AddonLifecycle(TargetSigScanner sigScanner) + { + this.address = new AddonLifecycleAddressResolver(); + this.address.Setup(sigScanner); + + // We want value of the function pointer at vFunc[2] + this.disallowedReceiveEventAddress = ((nint*)this.address.AtkEventListener)![2]; + + this.onAddonSetupHook = new CallHook(this.address.AddonSetup, this.OnAddonSetup); + this.onAddonSetup2Hook = new CallHook(this.address.AddonSetup2, this.OnAddonSetup); + this.onAddonFinalizeHook = Hook.FromAddress(this.address.AddonFinalize, this.OnAddonFinalize); + this.onAddonDrawHook = new CallHook(this.address.AddonDraw, this.OnAddonDraw); + this.onAddonUpdateHook = new CallHook(this.address.AddonUpdate, this.OnAddonUpdate); + this.onAddonRefreshHook = Hook.FromAddress(this.address.AddonOnRefresh, this.OnAddonRefresh); + this.onAddonRequestedUpdateHook = new CallHook(this.address.AddonOnRequestedUpdate, this.OnRequestedUpdate); + + this.onAddonSetupHook.Enable(); + this.onAddonSetup2Hook.Enable(); + this.onAddonFinalizeHook.Enable(); + this.onAddonDrawHook.Enable(); + this.onAddonUpdateHook.Enable(); + this.onAddonRefreshHook.Enable(); + this.onAddonRequestedUpdateHook.Enable(); + } + + private delegate void AddonSetupDelegate(AtkUnitBase* addon, uint valueCount, AtkValue* values); + + private delegate void AddonFinalizeDelegate(AtkUnitManager* unitManager, AtkUnitBase** atkUnitBase); + + private delegate void AddonDrawDelegate(AtkUnitBase* addon); + + private delegate void AddonUpdateDelegate(AtkUnitBase* addon, float delta); + + private delegate void AddonOnRequestedUpdateDelegate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData); + + private delegate byte AddonOnRefreshDelegate(AtkUnitManager* unitManager, AtkUnitBase* addon, uint valueCount, AtkValue* values); + + /// + /// Gets a list of all AddonLifecycle ReceiveEvent Listener Hooks. + /// + internal List ReceiveEventListeners { get; } = new(); + + /// + /// Gets a list of all AddonLifecycle Event Listeners. + /// + internal List EventListeners { get; } = new(); + + /// + void IInternalDisposableService.DisposeService() + { + this.onAddonSetupHook.Dispose(); + this.onAddonSetup2Hook.Dispose(); + this.onAddonFinalizeHook.Dispose(); + this.onAddonDrawHook.Dispose(); + this.onAddonUpdateHook.Dispose(); + this.onAddonRefreshHook.Dispose(); + this.onAddonRequestedUpdateHook.Dispose(); + + foreach (var receiveEventListener in this.ReceiveEventListeners) + { + receiveEventListener.Dispose(); + } + } + + /// + /// Register a listener for the target event and addon. + /// + /// The listener to register. + internal void RegisterListener(AddonLifecycleEventListener listener) + { + this.framework.RunOnTick(() => + { + this.EventListeners.Add(listener); + + // If we want receive event messages have an already active addon, enable the receive event hook. + // If the addon isn't active yet, we'll grab the hook when it sets up. + if (listener is { EventType: AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent }) + { + if (this.ReceiveEventListeners.FirstOrDefault(listeners => listeners.AddonNames.Contains(listener.AddonName)) is { } receiveEventListener) + { + receiveEventListener.Hook?.Enable(); + } + } + }); + } + + /// + /// Unregisters the listener from events. + /// + /// The listener to unregister. + internal void UnregisterListener(AddonLifecycleEventListener listener) + { + this.framework.RunOnTick(() => + { + this.EventListeners.Remove(listener); + + // If we are disabling an ReceiveEvent listener, check if we should disable the hook. + if (listener is { EventType: AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent }) + { + // Get the ReceiveEvent Listener for this addon + if (this.ReceiveEventListeners.FirstOrDefault(listeners => listeners.AddonNames.Contains(listener.AddonName)) is { } receiveEventListener) + { + // If there are no other listeners listening for this event, disable the hook. + if (!this.EventListeners.Any(listeners => listeners.AddonName.Contains(listener.AddonName) && listener.EventType is AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent)) + { + receiveEventListener.Hook?.Disable(); + } + } + } + }); + } + + /// + /// Invoke listeners for the specified event type. + /// + /// Event Type. + /// AddonArgs. + /// What to blame on errors. + internal void InvokeListenersSafely(AddonEvent eventType, AddonArgs args, [CallerMemberName] string blame = "") + { + // Do not use linq; this is a high-traffic function, and more heap allocations avoided, the better. + foreach (var listener in this.EventListeners) + { + if (listener.EventType != eventType) + continue; + + // Match on string.empty for listeners that want events for all addons. + if (!string.IsNullOrWhiteSpace(listener.AddonName) && !args.IsAddon(listener.AddonName)) + continue; + + try + { + listener.FunctionDelegate.Invoke(eventType, args); + } + catch (Exception e) + { + Log.Error(e, $"Exception in {blame} during {eventType} invoke."); + } + } + } + + private void RegisterReceiveEventHook(AtkUnitBase* addon) + { + // Hook the addon's ReceiveEvent function here, but only enable the hook if we have an active listener. + // Disallows hooking the core internal event handler. + var addonName = MemoryHelper.ReadStringNullTerminated((nint)addon->Name); + var receiveEventAddress = (nint)addon->VTable->ReceiveEvent; + if (receiveEventAddress != this.disallowedReceiveEventAddress) + { + // If we have a ReceiveEvent listener already made for this hook address, add this addon's name to that handler. + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.HookAddress == receiveEventAddress) is { } existingListener) + { + if (!existingListener.AddonNames.Contains(addonName)) + { + existingListener.AddonNames.Add(addonName); + } + } + + // Else, we have an addon that we don't have the ReceiveEvent for yet, make it. + else + { + this.ReceiveEventListeners.Add(new AddonLifecycleReceiveEventListener(this, addonName, receiveEventAddress)); + } + + // If we have an active listener for this addon already, we need to activate this hook. + if (this.EventListeners.Any(listener => (listener.EventType is AddonEvent.PostReceiveEvent or AddonEvent.PreReceiveEvent) && listener.AddonName == addonName)) + { + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.AddonNames.Contains(addonName)) is { } receiveEventListener) + { + receiveEventListener.Hook?.Enable(); + } + } + } + } + + private void UnregisterReceiveEventHook(string addonName) + { + // Remove this addons ReceiveEvent Registration + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.AddonNames.Contains(addonName)) is { } eventListener) + { + eventListener.AddonNames.Remove(addonName); + + // If there are no more listeners let's remove and dispose. + if (eventListener.AddonNames.Count is 0) + { + this.ReceiveEventListeners.Remove(eventListener); + eventListener.Dispose(); + } + } + } + + private void OnAddonSetup(AtkUnitBase* addon, uint valueCount, AtkValue* values) + { + try + { + this.RegisterReceiveEventHook(addon); + } + catch (Exception e) + { + Log.Error(e, "Exception in OnAddonSetup ReceiveEvent Registration."); + } + + 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 + { + addon->OnSetup(valueCount, values); + } + catch (Exception e) + { + 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, arg); + } + + private void OnAddonFinalize(AtkUnitManager* unitManager, AtkUnitBase** atkUnitBase) + { + try + { + var addonName = MemoryHelper.ReadStringNullTerminated((nint)atkUnitBase[0]->Name); + this.UnregisterReceiveEventHook(addonName); + } + catch (Exception e) + { + Log.Error(e, "Exception in OnAddonFinalize ReceiveEvent Removal."); + } + + using var returner = this.argsPool.Rent(out AddonFinalizeArgs arg); + arg.AddonInternal = (nint)atkUnitBase[0]; + this.InvokeListenersSafely(AddonEvent.PreFinalize, arg); + + try + { + this.onAddonFinalizeHook.Original(unitManager, atkUnitBase); + } + catch (Exception e) + { + Log.Error(e, "Caught exception when calling original AddonFinalize. This may be a bug in the game or another plugin hooking this method."); + } + } + + private void OnAddonDraw(AtkUnitBase* addon) + { + using var returner = this.argsPool.Rent(out AddonDrawArgs arg); + arg.AddonInternal = (nint)addon; + this.InvokeListenersSafely(AddonEvent.PreDraw, arg); + + try + { + addon->Draw(); + } + catch (Exception e) + { + 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, arg); + } + + private void OnAddonUpdate(AtkUnitBase* addon, float delta) + { + using var returner = this.argsPool.Rent(out AddonUpdateArgs arg); + arg.AddonInternal = (nint)addon; + arg.TimeDeltaInternal = delta; + this.InvokeListenersSafely(AddonEvent.PreUpdate, arg); + + try + { + addon->Update(delta); + } + catch (Exception e) + { + 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, arg); + } + + private byte OnAddonRefresh(AtkUnitManager* atkUnitManager, AtkUnitBase* addon, uint valueCount, AtkValue* values) + { + byte result = 0; + + 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 + { + result = this.onAddonRefreshHook.Original(atkUnitManager, addon, valueCount, values); + } + catch (Exception e) + { + 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, arg); + return result; + } + + private void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** 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 + { + addon->OnUpdate(numberArrayData, stringArrayData); + } + catch (Exception e) + { + 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, arg); + } +} + +/// +/// Plugin-scoped version of a AddonLifecycle service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class AddonLifecyclePluginScoped : IInternalDisposableService, IAddonLifecycle +{ + [ServiceManager.ServiceDependency] + private readonly AddonLifecycle addonLifecycleService = Service.Get(); + + private readonly List eventListeners = new(); + + /// + void IInternalDisposableService.DisposeService() + { + foreach (var listener in this.eventListeners) + { + this.addonLifecycleService.UnregisterListener(listener); + } + } + + /// + public void RegisterListener(AddonEvent eventType, IEnumerable addonNames, IAddonLifecycle.AddonEventDelegate handler) + { + foreach (var addonName in addonNames) + { + this.RegisterListener(eventType, addonName, handler); + } + } + + /// + public void RegisterListener(AddonEvent eventType, string addonName, IAddonLifecycle.AddonEventDelegate handler) + { + var listener = new AddonLifecycleEventListener(eventType, addonName, handler); + this.eventListeners.Add(listener); + this.addonLifecycleService.RegisterListener(listener); + } + + /// + public void RegisterListener(AddonEvent eventType, IAddonLifecycle.AddonEventDelegate handler) + { + this.RegisterListener(eventType, string.Empty, handler); + } + + /// + public void UnregisterListener(AddonEvent eventType, IEnumerable addonNames, IAddonLifecycle.AddonEventDelegate? handler = null) + { + foreach (var addonName in addonNames) + { + this.UnregisterListener(eventType, addonName, handler); + } + } + + /// + public void UnregisterListener(AddonEvent eventType, string addonName, IAddonLifecycle.AddonEventDelegate? handler = null) + { + this.eventListeners.RemoveAll(entry => + { + if (entry.EventType != eventType) return false; + if (entry.AddonName != addonName) return false; + if (handler is not null && entry.FunctionDelegate != handler) return false; + + this.addonLifecycleService.UnregisterListener(entry); + return true; + }); + } + + /// + public void UnregisterListener(AddonEvent eventType, IAddonLifecycle.AddonEventDelegate? handler = null) + { + this.UnregisterListener(eventType, string.Empty, handler); + } + + /// + public void UnregisterListener(params IAddonLifecycle.AddonEventDelegate[] handlers) + { + foreach (var handler in handlers) + { + this.eventListeners.RemoveAll(entry => + { + if (entry.FunctionDelegate != handler) return false; + + this.addonLifecycleService.UnregisterListener(entry); + return true; + }); + } + } +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleAddressResolver.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleAddressResolver.cs new file mode 100644 index 000000000..df25d0a46 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleAddressResolver.cs @@ -0,0 +1,68 @@ +namespace Dalamud.Game.Addon.Lifecycle; + +/// +/// AddonLifecycleService memory address resolver. +/// +internal class AddonLifecycleAddressResolver : BaseAddressResolver +{ + /// + /// Gets the address of the addon setup hook invoked by the AtkUnitManager. + /// There are two callsites for this vFunc, we need to hook both of them to catch both normal UI and special UI cases like dialogue. + /// This is called for a majority of all addon OnSetup's. + /// + public nint AddonSetup { get; private set; } + + /// + /// Gets the address of the other addon setup hook invoked by the AtkUnitManager. + /// There are two callsites for this vFunc, we need to hook both of them to catch both normal UI and special UI cases like dialogue. + /// This seems to be called rarely for specific addons. + /// + public nint AddonSetup2 { get; private set; } + + /// + /// Gets the address of the addon finalize hook invoked by the AtkUnitManager. + /// + public nint AddonFinalize { get; private set; } + + /// + /// Gets the address of the addon draw hook invoked by virtual function call. + /// + public nint AddonDraw { get; private set; } + + /// + /// Gets the address of the addon update hook invoked by virtual function call. + /// + public nint AddonUpdate { get; private set; } + + /// + /// Gets the address of the addon onRequestedUpdate hook invoked by virtual function call. + /// + public nint AddonOnRequestedUpdate { get; private set; } + + /// + /// Gets the address of AtkUnitManager_vf10 which triggers addon onRefresh. + /// + public nint AddonOnRefresh { get; private set; } + + /// + /// Gets the address of AtkEventListener base vTable. + /// This is used to ensure that we do not hook ReceiveEvents that resolve back to the internal handler. + /// + public nint AtkEventListener { get; private set; } + + /// + /// Scan for and setup any configured address pointers. + /// + /// The signature scanner to facilitate setup. + protected override void Setup64Bit(ISigScanner sig) + { + this.AddonSetup = sig.ScanText("FF 90 ?? ?? ?? ?? 48 8B 93 ?? ?? ?? ?? 80 8B"); + this.AddonSetup2 = sig.ScanText("FF 90 ?? ?? ?? ?? 48 8B 03 48 8B CB 80 8B"); + this.AddonFinalize = sig.ScanText("E8 ?? ?? ?? ?? 48 8B 7C 24 ?? 41 8B C6"); + this.AddonDraw = sig.ScanText("FF 90 ?? ?? ?? ?? 83 EB 01 79 C1"); + this.AddonUpdate = sig.ScanText("FF 90 ?? ?? ?? ?? 40 88 AF"); + this.AddonOnRequestedUpdate = sig.ScanText("FF 90 98 01 00 00 48 8B 5C 24 30 48 83 C4 20"); + this.AddonOnRefresh = sig.ScanText("48 89 5C 24 08 57 48 83 EC 20 41 8B F8 48 8B DA"); + this.AtkEventListener = sig.GetStaticAddressFromSig("4C 8D 3D ?? ?? ?? ?? 49 8D 8E"); + } +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleEventListener.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleEventListener.cs new file mode 100644 index 000000000..6464a1edd --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleEventListener.cs @@ -0,0 +1,38 @@ +using Dalamud.Plugin.Services; + +namespace Dalamud.Game.Addon.Lifecycle; + +/// +/// This class is a helper for tracking and invoking listener delegates. +/// +internal class AddonLifecycleEventListener +{ + /// + /// Initializes a new instance of the class. + /// + /// Event type to listen for. + /// Addon name to listen for. + /// Delegate to invoke. + internal AddonLifecycleEventListener(AddonEvent eventType, string addonName, IAddonLifecycle.AddonEventDelegate functionDelegate) + { + this.EventType = eventType; + this.AddonName = addonName; + this.FunctionDelegate = functionDelegate; + } + + /// + /// Gets the name of the addon this listener is looking for. + /// string.Empty if it wants to be called for any addon. + /// + public string AddonName { get; init; } + + /// + /// Gets the event type this listener is looking for. + /// + public AddonEvent EventType { get; init; } + + /// + /// Gets the delegate this listener invokes. + /// + public IAddonLifecycle.AddonEventDelegate FunctionDelegate { get; init; } +} diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs new file mode 100644 index 000000000..fd3b5d79d --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; + +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Hooking; +using Dalamud.Logging.Internal; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Lifecycle; + +/// +/// This class is a helper for tracking and invoking listener delegates for Addon_OnReceiveEvent. +/// Multiple addons may use the same ReceiveEvent function, this helper makes sure that those addon events are handled properly. +/// +internal unsafe class AddonLifecycleReceiveEventListener : IDisposable +{ + private static readonly ModuleLog Log = new("AddonLifecycle"); + + [ServiceManager.ServiceDependency] + private readonly AddonLifecyclePooledArgs argsPool = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + /// AddonLifecycle service instance. + /// Initial Addon Requesting this listener. + /// Address of Addon's ReceiveEvent function. + internal AddonLifecycleReceiveEventListener(AddonLifecycle service, string addonName, nint receiveEventAddress) + { + this.AddonLifecycle = service; + this.AddonNames = new List { addonName }; + this.Hook = Hook.FromAddress(receiveEventAddress, this.OnReceiveEvent); + } + + /// + /// Addon Receive Event Function delegate. + /// + /// Addon Pointer. + /// Event Type. + /// Unique Event ID. + /// Event Data. + /// Unknown. + public delegate void AddonReceiveEventDelegate(AtkUnitBase* addon, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, nint a5); + + /// + /// Gets the list of addons that use this receive event hook. + /// + public List AddonNames { get; init; } + + /// + /// Gets the address of the registered hook. + /// + public nint HookAddress => this.Hook?.Address ?? nint.Zero; + + /// + /// Gets the contained hook for these addons. + /// + public Hook? Hook { get; init; } + + /// + /// Gets or sets the Reference to AddonLifecycle service instance. + /// + private AddonLifecycle AddonLifecycle { get; set; } + + /// + public void Dispose() + { + this.Hook?.Dispose(); + } + + private void OnReceiveEvent(AtkUnitBase* addon, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, nint data) + { + // Check that we didn't get here through a call to another addons handler. + var addonName = MemoryHelper.ReadString((nint)addon->Name, 0x20); + if (!this.AddonNames.Contains(addonName)) + { + this.Hook!.Original(addon, eventType, eventParam, atkEvent, data); + return; + } + + 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 + { + this.Hook!.Original(addon, eventType, eventParam, atkEvent, data); + } + catch (Exception e) + { + 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, arg); + } +} diff --git a/Dalamud/Game/BaseAddressResolver.cs b/Dalamud/Game/BaseAddressResolver.cs index 24e7dffe8..7a455aea0 100644 --- a/Dalamud/Game/BaseAddressResolver.cs +++ b/Dalamud/Game/BaseAddressResolver.cs @@ -18,24 +18,15 @@ public abstract class BaseAddressResolver public static Dictionary> DebugScannedValues { get; } = new(); /// - /// Gets or sets a value indicating whether the resolver has successfully run or . + /// Gets or sets a value indicating whether the resolver has successfully run or . /// protected bool IsResolved { get; set; } - /// - /// Setup the resolver, calling the appropriate method based on the process architecture, - /// using the default SigScanner. - /// - /// For plugins. Not intended to be called from Dalamud Service{T} constructors. - /// - [UsedImplicitly] - public void Setup() => this.Setup(Service.Get()); - /// /// Setup the resolver, calling the appropriate method based on the process architecture. /// /// The SigScanner instance. - public void Setup(SigScanner scanner) + public void Setup(ISigScanner scanner) { // Because C# don't allow to call virtual function while in ctor // we have to do this shit :\ @@ -92,7 +83,7 @@ public abstract class BaseAddressResolver /// Setup the resolver by finding any necessary memory addresses. ///
/// The SigScanner instance. - protected virtual void Setup32Bit(SigScanner scanner) + protected virtual void Setup32Bit(ISigScanner scanner) { throw new NotSupportedException("32 bit version is not supported."); } @@ -101,7 +92,7 @@ public abstract class BaseAddressResolver /// Setup the resolver by finding any necessary memory addresses. ///
/// The SigScanner instance. - protected virtual void Setup64Bit(SigScanner scanner) + protected virtual void Setup64Bit(ISigScanner scanner) { throw new NotSupportedException("64 bit version is not supported."); } @@ -110,7 +101,7 @@ public abstract class BaseAddressResolver /// Setup the resolver by finding any necessary memory addresses. ///
/// The SigScanner instance. - protected virtual void SetupInternal(SigScanner scanner) + protected virtual void SetupInternal(ISigScanner scanner) { // Do nothing } diff --git a/Dalamud/Game/ChatHandlers.cs b/Dalamud/Game/ChatHandlers.cs index ed69b7bbe..5dd6ed3ba 100644 --- a/Dalamud/Game/ChatHandlers.cs +++ b/Dalamud/Game/ChatHandlers.cs @@ -1,8 +1,8 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using CheapLoc; @@ -11,24 +11,22 @@ using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Internal.Windows; -using Dalamud.IoC; -using Dalamud.IoC.Internal; +using Dalamud.Interface.Internal.Windows.PluginInstaller; +using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal; using Dalamud.Utility; -using Serilog; namespace Dalamud.Game; /// /// Chat events and public helper functions. /// -[PluginInterface] -[InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public class ChatHandlers : IServiceType +internal class ChatHandlers : IServiceType { // private static readonly Dictionary UnicodeToDiscordEmojiDict = new() // { @@ -64,6 +62,8 @@ public class ChatHandlers : IServiceType // { XivChatType.Echo, Color.Gray }, // }; + private static readonly ModuleLog Log = new("CHATHANDLER"); + private readonly Regex rmtRegex = new( @"4KGOLD|We have sufficient stock|VPK\.OM|[Gg]il for free|[Gg]il [Cc]heap|5GOLD|www\.so9\.com|Fast & Convenient|Cheap & Safety Guarantee|【Code|A O A U E|igfans|4KGOLD\.COM|Cheapest Gil with|pvp and bank on google|Selling Cheap GIL|ff14mogstation\.com|Cheap Gil 1000k|gilsforyou|server 1000K =|gils_selling|E A S Y\.C O M|bonus code|mins delivery guarantee|Sell cheap|Salegm\.com|cheap Mog|Off Code:|FF14Mog.com|使用する5%オ|[Oo][Ff][Ff] [Cc]ode( *)[:;]|offers Fantasia", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -106,11 +106,15 @@ public class ChatHandlers : IServiceType private readonly DalamudLinkPayload openInstallerWindowLink; + [ServiceManager.ServiceDependency] + private readonly Dalamud dalamud = Service.Get(); + [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); private bool hasSeenLoadingMsg; private bool startedAutoUpdatingPlugins; + private CancellationTokenSource deferredAutoUpdateCts = new(); [ServiceManager.ServiceConstructor] private ChatHandlers(ChatGui chatGui) @@ -120,7 +124,7 @@ public class ChatHandlers : IServiceType this.openInstallerWindowLink = chatGui.AddChatLinkHandler("Dalamud", 1001, (i, m) => { - Service.GetNullable()?.OpenPluginInstaller(); + Service.GetNullable()?.OpenPluginInstallerTo(PluginInstallerWindow.PluginInstallerOpenKind.InstalledPlugins); }); } @@ -134,22 +138,6 @@ public class ChatHandlers : IServiceType ///
public bool IsAutoUpdateComplete { get; private set; } - /// - /// Convert a TextPayload to SeString and wrap in italics payloads. - /// - /// Text to convert. - /// SeString payload of italicized text. - public static SeString MakeItalics(string text) - => MakeItalics(new TextPayload(text)); - - /// - /// Convert a TextPayload to SeString and wrap in italics payloads. - /// - /// Text to convert. - /// SeString payload of italicized text. - public static SeString MakeItalics(TextPayload text) - => new(EmphasisItalicPayload.ItalicsOn, text, EmphasisItalicPayload.ItalicsOff); - private void OnCheckMessageHandled(XivChatType type, uint senderid, ref SeString sender, ref SeString message, ref bool isHandled) { var textVal = message.TextValue; @@ -178,21 +166,23 @@ public class ChatHandlers : IServiceType private void OnChatMessage(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) { - var startInfo = Service.Get(); var clientState = Service.GetNullable(); if (clientState == null) return; - if (type == XivChatType.Notice && !this.hasSeenLoadingMsg) - this.PrintWelcomeMessage(); + if (type == XivChatType.Notice) + { + if (!this.hasSeenLoadingMsg) + this.PrintWelcomeMessage(); + + if (!this.startedAutoUpdatingPlugins) + this.AutoUpdatePluginsWithRetry(); + } // For injections while logged in if (clientState.LocalPlayer != null && clientState.TerritoryType == 0 && !this.hasSeenLoadingMsg) this.PrintWelcomeMessage(); - if (!this.startedAutoUpdatingPlugins) - this.AutoUpdatePlugins(); - #if !DEBUG && false if (!this.hasSeenLoadingMsg) return; @@ -200,7 +190,7 @@ public class ChatHandlers : IServiceType if (type == XivChatType.RetainerSale) { - foreach (var regex in this.retainerSaleRegexes[startInfo.Language]) + foreach (var regex in this.retainerSaleRegexes[(ClientLanguage)this.dalamud.StartInfo.Language]) { var matchInfo = regex.Match(message.TextValue); @@ -258,22 +248,21 @@ public class ChatHandlers : IServiceType { foreach (var plugin in pluginManager.InstalledPlugins.OrderBy(plugin => plugin.Name).Where(x => x.IsLoaded)) { - chatGui.Print(string.Format(Loc.Localize("DalamudPluginLoaded", " 》 {0} v{1} loaded."), plugin.Name, plugin.Manifest.AssemblyVersion)); + chatGui.Print(string.Format(Loc.Localize("DalamudPluginLoaded", " 》 {0} v{1} loaded."), plugin.Name, plugin.EffectiveVersion)); } } if (string.IsNullOrEmpty(this.configuration.LastVersion) || !assemblyVersion.StartsWith(this.configuration.LastVersion)) { - chatGui.PrintChat(new XivChatEntry + chatGui.Print(new XivChatEntry { Message = Loc.Localize("DalamudUpdated", "Dalamud has been updated successfully! Please check the discord for a full changelog."), Type = XivChatType.Notice, }); - if (string.IsNullOrEmpty(this.configuration.LastChangelogMajorMinor) || (!ChangelogWindow.WarrantsChangelogForMajorMinor.StartsWith(this.configuration.LastChangelogMajorMinor) && assemblyVersion.StartsWith(ChangelogWindow.WarrantsChangelogForMajorMinor))) + if (ChangelogWindow.WarrantsChangelog()) { dalamudInterface.OpenChangelogWindow(); - this.configuration.LastChangelogMajorMinor = ChangelogWindow.WarrantsChangelogForMajorMinor; } this.configuration.LastVersion = assemblyVersion; @@ -283,24 +272,42 @@ public class ChatHandlers : IServiceType this.hasSeenLoadingMsg = true; } - private void AutoUpdatePlugins() + private void AutoUpdatePluginsWithRetry() + { + var firstAttempt = this.AutoUpdatePlugins(); + if (!firstAttempt) + { + Task.Run(() => + { + Task.Delay(30_000, this.deferredAutoUpdateCts.Token); + this.AutoUpdatePlugins(); + }); + } + } + + private bool AutoUpdatePlugins() { var chatGui = Service.GetNullable(); var pluginManager = Service.GetNullable(); var notifications = Service.GetNullable(); if (chatGui == null || pluginManager == null || notifications == null) - return; + { + Log.Warning("Aborting auto-update because a required service was not loaded."); + return false; + } if (!pluginManager.ReposReady || !pluginManager.InstalledPlugins.Any() || !pluginManager.AvailablePlugins.Any()) { // Plugins aren't ready yet. // TODO: We should retry. This sucks, because it means we won't ever get here again until another notice. - return; + Log.Warning("Aborting auto-update because plugins weren't loaded or ready."); + return false; } this.startedAutoUpdatingPlugins = true; + Log.Debug("Beginning plugin auto-update process..."); Task.Run(() => pluginManager.UpdatePluginsAsync(true, !this.configuration.AutoUpdatePlugins, true)).ContinueWith(task => { this.IsAutoUpdateComplete = true; @@ -321,7 +328,7 @@ public class ChatHandlers : IServiceType } else { - chatGui.PrintChat(new XivChatEntry + chatGui.Print(new XivChatEntry { Message = new SeString(new List() { @@ -339,5 +346,7 @@ public class ChatHandlers : IServiceType } } }); + + return true; } } diff --git a/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs b/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs index 17b468d70..e6af6e1df 100644 --- a/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs +++ b/Dalamud/Game/ClientState/Aetherytes/AetheryteList.cs @@ -18,7 +18,7 @@ namespace Dalamud.Game.ClientState.Aetherytes; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed unsafe partial class AetheryteList : IServiceType, IAetheryteList +internal sealed unsafe partial class AetheryteList : IServiceType, IAetheryteList { [ServiceManager.ServiceDependency] private readonly ClientState clientState = Service.Get(); @@ -78,7 +78,7 @@ public sealed unsafe partial class AetheryteList : IServiceType, IAetheryteList /// /// This collection represents the list of available Aetherytes in the Teleport window. /// -public sealed partial class AetheryteList +internal sealed partial class AetheryteList { /// public int Count => this.Length; diff --git a/Dalamud/Game/ClientState/Buddy/BuddyList.cs b/Dalamud/Game/ClientState/Buddy/BuddyList.cs index dc2cb9fae..5d0098187 100644 --- a/Dalamud/Game/ClientState/Buddy/BuddyList.cs +++ b/Dalamud/Game/ClientState/Buddy/BuddyList.cs @@ -20,7 +20,7 @@ namespace Dalamud.Game.ClientState.Buddy; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed partial class BuddyList : IServiceType, IBuddyList +internal sealed partial class BuddyList : IServiceType, IBuddyList { private const uint InvalidObjectID = 0xE0000000; @@ -55,18 +55,6 @@ public sealed partial class BuddyList : IServiceType, IBuddyList } } - /// - /// Gets a value indicating whether the local player's companion is present. - /// - [Obsolete("Use CompanionBuddy != null", false)] - public bool CompanionBuddyPresent => this.CompanionBuddy != null; - - /// - /// Gets a value indicating whether the local player's pet is present. - /// - [Obsolete("Use PetBuddy != null", false)] - public bool PetBuddyPresent => this.PetBuddy != null; - /// public BuddyMember? CompanionBuddy { @@ -147,7 +135,7 @@ public sealed partial class BuddyList : IServiceType, IBuddyList /// /// This collection represents the buddies present in your squadron or trust party. /// -public sealed partial class BuddyList +internal sealed partial class BuddyList { /// int IReadOnlyCollection.Count => this.Length; diff --git a/Dalamud/Game/ClientState/ClientState.cs b/Dalamud/Game/ClientState/ClientState.cs index fed0ec3c4..bd4259f5a 100644 --- a/Dalamud/Game/ClientState/ClientState.cs +++ b/Dalamud/Game/ClientState/ClientState.cs @@ -1,4 +1,3 @@ -using System; using System.Runtime.InteropServices; using Dalamud.Data; @@ -9,24 +8,25 @@ using Dalamud.Game.Network.Internal; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; using Dalamud.Plugin.Services; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game; -using Serilog; +using Lumina.Excel.GeneratedSheets; + +using Action = System.Action; namespace Dalamud.Game.ClientState; /// /// This class represents the state of the game client at the time of access. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -public sealed class ClientState : IDisposable, IServiceType, IClientState +internal sealed class ClientState : IInternalDisposableService, IClientState { + private static readonly ModuleLog Log = new("ClientState"); + private readonly GameLifecycle lifecycle; private readonly ClientStateAddressResolver address; private readonly Hook setupTerritoryTypeHook; @@ -38,10 +38,10 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState private readonly NetworkHandlers networkHandlers = Service.Get(); private bool lastConditionNone = true; - private bool lastFramePvP = false; + private bool lastFramePvP; [ServiceManager.ServiceConstructor] - private ClientState(SigScanner sigScanner, DalamudStartInfo startInfo, GameLifecycle lifecycle) + private ClientState(TargetSigScanner sigScanner, Dalamud dalamud, GameLifecycle lifecycle) { this.lifecycle = lifecycle; this.address = new ClientStateAddressResolver(); @@ -49,7 +49,7 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState Log.Verbose("===== C L I E N T S T A T E ====="); - this.ClientLanguage = startInfo.Language; + this.ClientLanguage = (ClientLanguage)dalamud.StartInfo.Language; Log.Verbose($"SetupTerritoryType address 0x{this.address.SetupTerritoryType.ToInt64():X}"); @@ -58,28 +58,30 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState this.framework.Update += this.FrameworkOnOnUpdateEvent; this.networkHandlers.CfPop += this.NetworkHandlersOnCfPop; + + this.setupTerritoryTypeHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr SetupTerritoryTypeDelegate(IntPtr manager, ushort terriType); /// - public event EventHandler TerritoryChanged; + public event Action? TerritoryChanged; /// - public event EventHandler Login; + public event Action? Login; /// - public event EventHandler Logout; + public event Action? Logout; /// - public event Action EnterPvP; + public event Action? EnterPvP; /// - public event Action LeavePvP; + public event Action? LeavePvP; /// - public event EventHandler CfPop; + public event Action? CfPop; /// public ClientLanguage ClientLanguage { get; } @@ -102,6 +104,9 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState /// public bool IsPvPExcludingDen { get; private set; } + /// + public bool IsGPosing => GameMain.IsInGPose(); + /// /// Gets client state address resolver. /// @@ -110,35 +115,29 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState /// /// Dispose of managed and unmanaged resources. /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.setupTerritoryTypeHook.Dispose(); this.framework.Update -= this.FrameworkOnOnUpdateEvent; this.networkHandlers.CfPop -= this.NetworkHandlersOnCfPop; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.setupTerritoryTypeHook.Enable(); - } - private IntPtr SetupTerritoryTypeDetour(IntPtr manager, ushort terriType) { this.TerritoryType = terriType; - this.TerritoryChanged?.InvokeSafely(this, terriType); + this.TerritoryChanged?.InvokeSafely(terriType); Log.Debug("TerritoryType changed: {0}", terriType); return this.setupTerritoryTypeHook.Original(manager, terriType); } - private void NetworkHandlersOnCfPop(object sender, Lumina.Excel.GeneratedSheets.ContentFinderCondition e) + private void NetworkHandlersOnCfPop(ContentFinderCondition e) { - this.CfPop?.InvokeSafely(this, e); + this.CfPop?.InvokeSafely(e); } - private void FrameworkOnOnUpdateEvent(Framework framework1) + private void FrameworkOnOnUpdateEvent(IFramework framework1) { var condition = Service.GetNullable(); var gameGui = Service.GetNullable(); @@ -147,12 +146,12 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState if (condition == null || gameGui == null || data == null) return; - if (condition.Any() && this.lastConditionNone == true && this.LocalPlayer != null) + if (condition.Any() && this.lastConditionNone && this.LocalPlayer != null) { Log.Debug("Is login"); this.lastConditionNone = false; this.IsLoggedIn = true; - this.Login?.InvokeSafely(this, null); + this.Login?.InvokeSafely(); gameGui.ResetUiHideState(); this.lifecycle.ResetLogout(); @@ -163,7 +162,7 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState Log.Debug("Is logout"); this.lastConditionNone = true; this.IsLoggedIn = false; - this.Logout?.InvokeSafely(this, null); + this.Logout?.InvokeSafely(); gameGui.ResetUiHideState(); this.lifecycle.SetLogout(); @@ -187,3 +186,103 @@ public sealed class ClientState : IDisposable, IServiceType, IClientState } } } + +/// +/// Plugin-scoped version of a GameConfig service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class ClientStatePluginScoped : IInternalDisposableService, IClientState +{ + [ServiceManager.ServiceDependency] + private readonly ClientState clientStateService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal ClientStatePluginScoped() + { + this.clientStateService.TerritoryChanged += this.TerritoryChangedForward; + this.clientStateService.Login += this.LoginForward; + this.clientStateService.Logout += this.LogoutForward; + this.clientStateService.EnterPvP += this.EnterPvPForward; + this.clientStateService.LeavePvP += this.ExitPvPForward; + this.clientStateService.CfPop += this.ContentFinderPopForward; + } + + /// + public event Action? TerritoryChanged; + + /// + public event Action? Login; + + /// + public event Action? Logout; + + /// + public event Action? EnterPvP; + + /// + public event Action? LeavePvP; + + /// + public event Action? CfPop; + + /// + public ClientLanguage ClientLanguage => this.clientStateService.ClientLanguage; + + /// + public ushort TerritoryType => this.clientStateService.TerritoryType; + + /// + public PlayerCharacter? LocalPlayer => this.clientStateService.LocalPlayer; + + /// + public ulong LocalContentId => this.clientStateService.LocalContentId; + + /// + public bool IsLoggedIn => this.clientStateService.IsLoggedIn; + + /// + public bool IsPvP => this.clientStateService.IsPvP; + + /// + public bool IsPvPExcludingDen => this.clientStateService.IsPvPExcludingDen; + + /// + public bool IsGPosing => this.clientStateService.IsGPosing; + + /// + void IInternalDisposableService.DisposeService() + { + this.clientStateService.TerritoryChanged -= this.TerritoryChangedForward; + this.clientStateService.Login -= this.LoginForward; + this.clientStateService.Logout -= this.LogoutForward; + this.clientStateService.EnterPvP -= this.EnterPvPForward; + this.clientStateService.LeavePvP -= this.ExitPvPForward; + this.clientStateService.CfPop -= this.ContentFinderPopForward; + + this.TerritoryChanged = null; + this.Login = null; + this.Logout = null; + this.EnterPvP = null; + this.LeavePvP = null; + this.CfPop = null; + } + + private void TerritoryChangedForward(ushort territoryId) => this.TerritoryChanged?.Invoke(territoryId); + + private void LoginForward() => this.Login?.Invoke(); + + private void LogoutForward() => this.Logout?.Invoke(); + + private void EnterPvPForward() => this.EnterPvP?.Invoke(); + + private void ExitPvPForward() => this.LeavePvP?.Invoke(); + + private void ContentFinderPopForward(ContentFinderCondition cfc) => this.CfPop?.Invoke(cfc); +} diff --git a/Dalamud/Game/ClientState/ClientStateAddressResolver.cs b/Dalamud/Game/ClientState/ClientStateAddressResolver.cs index 369e620be..73ed24e95 100644 --- a/Dalamud/Game/ClientState/ClientStateAddressResolver.cs +++ b/Dalamud/Game/ClientState/ClientStateAddressResolver.cs @@ -5,7 +5,7 @@ namespace Dalamud.Game.ClientState; /// /// Client state memory address resolver. /// -public sealed class ClientStateAddressResolver : BaseAddressResolver +internal sealed class ClientStateAddressResolver : BaseAddressResolver { // Static offsets @@ -79,7 +79,7 @@ public sealed class ClientStateAddressResolver : BaseAddressResolver /// Scan for and setup any configured address pointers. /// /// The signature scanner to facilitate setup. - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.ObjectTable = sig.GetStaticAddressFromSig("48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 44 0F B6 83 ?? ?? ?? ?? C6 83 ?? ?? ?? ?? ??"); diff --git a/Dalamud/Game/ClientState/Conditions/Condition.cs b/Dalamud/Game/ClientState/Conditions/Condition.cs index f611a01c6..dc8b28494 100644 --- a/Dalamud/Game/ClientState/Conditions/Condition.cs +++ b/Dalamud/Game/ClientState/Conditions/Condition.cs @@ -1,7 +1,6 @@ -using System; - using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Plugin.Services; using Serilog; namespace Dalamud.Game.ClientState.Conditions; @@ -9,47 +8,48 @@ namespace Dalamud.Game.ClientState.Conditions; /// /// Provides access to conditions (generally player state). You can check whether a player is in combat, mounted, etc. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public sealed partial class Condition : IServiceType +internal sealed partial class Condition : IInternalDisposableService, ICondition { /// - /// The current max number of conditions. You can get this just by looking at the condition sheet and how many rows it has. + /// Gets the current max number of conditions. You can get this just by looking at the condition sheet and how many rows it has. /// - public const int MaxConditionEntries = 104; + internal const int MaxConditionEntries = 104; + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + private readonly bool[] cache = new bool[MaxConditionEntries]; + private bool isDisposed; + [ServiceManager.ServiceConstructor] private Condition(ClientState clientState) { var resolver = clientState.AddressResolver; this.Address = resolver.ConditionFlags; + + // Initialization + for (var i = 0; i < MaxConditionEntries; i++) + this.cache[i] = this[i]; + + this.framework.Update += this.FrameworkUpdate; } + + /// Finalizes an instance of the class. + ~Condition() => this.Dispose(false); - /// - /// A delegate type used with the event. - /// - /// The changed condition. - /// The value the condition is set to. - public delegate void ConditionChangeDelegate(ConditionFlag flag, bool value); + /// + public event ICondition.ConditionChangeDelegate? ConditionChange; - /// - /// Event that gets fired when a condition is set. - /// Should only get fired for actual changes, so the previous value will always be !value. - /// - public event ConditionChangeDelegate? ConditionChange; + /// + public int MaxEntries => MaxConditionEntries; - /// - /// Gets the condition array base pointer. - /// + /// public IntPtr Address { get; private set; } - /// - /// Check the value of a specific condition/state flag. - /// - /// The condition flag to check. + /// public unsafe bool this[int flag] { get @@ -61,14 +61,14 @@ public sealed partial class Condition : IServiceType } } - /// - public unsafe bool this[ConditionFlag flag] + /// + public bool this[ConditionFlag flag] => this[(int)flag]; - /// - /// Check if any condition flags are set. - /// - /// Whether any single flag is set. + /// + void IInternalDisposableService.DisposeService() => this.Dispose(true); + + /// public bool Any() { for (var i = 0; i < MaxConditionEntries; i++) @@ -81,18 +81,36 @@ public sealed partial class Condition : IServiceType return false; } - - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(Framework framework) + + /// + public bool Any(params ConditionFlag[] flags) { - // Initialization - for (var i = 0; i < MaxConditionEntries; i++) - this.cache[i] = this[i]; + foreach (var flag in flags) + { + // this[i] performs range checking, so no need to check here + if (this[flag]) + { + return true; + } + } - framework.Update += this.FrameworkUpdate; + return false; } - private void FrameworkUpdate(Framework framework) + private void Dispose(bool disposing) + { + if (this.isDisposed) + return; + + if (disposing) + { + this.framework.Update -= this.FrameworkUpdate; + } + + this.isDisposed = true; + } + + private void FrameworkUpdate(IFramework unused) { for (var i = 0; i < MaxConditionEntries; i++) { @@ -116,39 +134,52 @@ public sealed partial class Condition : IServiceType } /// -/// Provides access to conditions (generally player state). You can check whether a player is in combat, mounted, etc. +/// Plugin-scoped version of a Condition service. /// -public sealed partial class Condition : IDisposable +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class ConditionPluginScoped : IInternalDisposableService, ICondition { - private bool isDisposed; + [ServiceManager.ServiceDependency] + private readonly Condition conditionService = Service.Get(); /// - /// Finalizes an instance of the class. + /// Initializes a new instance of the class. /// - ~Condition() + internal ConditionPluginScoped() { - this.Dispose(false); + this.conditionService.ConditionChange += this.ConditionChangedForward; + } + + /// + public event ICondition.ConditionChangeDelegate? ConditionChange; + + /// + public int MaxEntries => this.conditionService.MaxEntries; + + /// + public IntPtr Address => this.conditionService.Address; + + /// + public bool this[int flag] => this.conditionService[flag]; + + /// + void IInternalDisposableService.DisposeService() + { + this.conditionService.ConditionChange -= this.ConditionChangedForward; + + this.ConditionChange = null; } - /// - /// Disposes this instance, alongside its hooks. - /// - void IDisposable.Dispose() - { - GC.SuppressFinalize(this); - this.Dispose(true); - } + /// + public bool Any() => this.conditionService.Any(); - private void Dispose(bool disposing) - { - if (this.isDisposed) - return; + /// + public bool Any(params ConditionFlag[] flags) => this.conditionService.Any(flags); - if (disposing) - { - Service.Get().Update -= this.FrameworkUpdate; - } - - this.isDisposed = true; - } + private void ConditionChangedForward(ConditionFlag flag, bool value) => this.ConditionChange?.Invoke(flag, value); } diff --git a/Dalamud/Game/ClientState/Fates/FateTable.cs b/Dalamud/Game/ClientState/Fates/FateTable.cs index 53196d5df..e9400842f 100644 --- a/Dalamud/Game/ClientState/Fates/FateTable.cs +++ b/Dalamud/Game/ClientState/Fates/FateTable.cs @@ -18,7 +18,7 @@ namespace Dalamud.Game.ClientState.Fates; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed partial class FateTable : IServiceType, IFateTable +internal sealed partial class FateTable : IServiceType, IFateTable { private readonly ClientStateAddressResolver address; @@ -110,7 +110,7 @@ public sealed partial class FateTable : IServiceType, IFateTable /// /// This collection represents the currently available Fate events. /// -public sealed partial class FateTable +internal sealed partial class FateTable { /// int IReadOnlyCollection.Count => this.Length; diff --git a/Dalamud/Game/ClientState/GamePad/GamepadState.cs b/Dalamud/Game/ClientState/GamePad/GamepadState.cs index bc5744047..a0e16f0e2 100644 --- a/Dalamud/Game/ClientState/GamePad/GamepadState.cs +++ b/Dalamud/Game/ClientState/GamePad/GamepadState.cs @@ -21,7 +21,7 @@ namespace Dalamud.Game.ClientState.GamePad; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public unsafe class GamepadState : IDisposable, IServiceType, IGamepadState +internal unsafe class GamepadState : IInternalDisposableService, IGamepadState { private readonly Hook? gamepadPoll; @@ -38,6 +38,7 @@ public unsafe class GamepadState : IDisposable, IServiceType, IGamepadState var resolver = clientState.AddressResolver; Log.Verbose($"GamepadPoll address 0x{resolver.GamepadPoll.ToInt64():X}"); this.gamepadPoll = Hook.FromAddress(resolver.GamepadPoll, this.GamepadPollDetour); + this.gamepadPoll?.Enable(); } private delegate int ControllerPoll(IntPtr controllerInput); @@ -55,54 +56,6 @@ public unsafe class GamepadState : IDisposable, IServiceType, IGamepadState public Vector2 RightStick => new(this.rightStickX, this.rightStickY); - /// - /// Gets the state of the left analogue stick in the left direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.LeftStick.X", false)] - public float LeftStickLeft => this.leftStickX < 0 ? -this.leftStickX / 100f : 0; - - /// - /// Gets the state of the left analogue stick in the right direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.LeftStick.X", false)] - public float LeftStickRight => this.leftStickX > 0 ? this.leftStickX / 100f : 0; - - /// - /// Gets the state of the left analogue stick in the up direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.LeftStick.Y", false)] - public float LeftStickUp => this.leftStickY > 0 ? this.leftStickY / 100f : 0; - - /// - /// Gets the state of the left analogue stick in the down direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.LeftStick.Y", false)] - public float LeftStickDown => this.leftStickY < 0 ? -this.leftStickY / 100f : 0; - - /// - /// Gets the state of the right analogue stick in the left direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.RightStick.X", false)] - public float RightStickLeft => this.rightStickX < 0 ? -this.rightStickX / 100f : 0; - - /// - /// Gets the state of the right analogue stick in the right direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.RightStick.X", false)] - public float RightStickRight => this.rightStickX > 0 ? this.rightStickX / 100f : 0; - - /// - /// Gets the state of the right analogue stick in the up direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.RightStick.Y", false)] - public float RightStickUp => this.rightStickY > 0 ? this.rightStickY / 100f : 0; - - /// - /// Gets the state of the right analogue stick in the down direction between 0 (not tilted) and 1 (max tilt). - /// - [Obsolete("Use IGamepadState.RightStick.Y", false)] - public float RightStickDown => this.rightStickY < 0 ? -this.rightStickY / 100f : 0; - /// /// Gets buttons pressed bitmask, set once when the button is pressed. See for the mapping. /// @@ -156,18 +109,12 @@ public unsafe class GamepadState : IDisposable, IServiceType, IGamepadState /// /// Disposes this instance, alongside its hooks. /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.Dispose(true); GC.SuppressFinalize(this); } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.gamepadPoll?.Enable(); - } - private int GamepadPollDetour(IntPtr gamepadInput) { var original = this.gamepadPoll!.Original(gamepadInput); diff --git a/Dalamud/Game/ClientState/JobGauge/JobGauges.cs b/Dalamud/Game/ClientState/JobGauge/JobGauges.cs index 683f5c61f..74e22ddbe 100644 --- a/Dalamud/Game/ClientState/JobGauge/JobGauges.cs +++ b/Dalamud/Game/ClientState/JobGauge/JobGauges.cs @@ -19,7 +19,7 @@ namespace Dalamud.Game.ClientState.JobGauge; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public class JobGauges : IServiceType, IJobGauges +internal class JobGauges : IServiceType, IJobGauges { private Dictionary cache = new(); diff --git a/Dalamud/Game/ClientState/Keys/KeyState.cs b/Dalamud/Game/ClientState/Keys/KeyState.cs index 685973e17..76bee51bf 100644 --- a/Dalamud/Game/ClientState/Keys/KeyState.cs +++ b/Dalamud/Game/ClientState/Keys/KeyState.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Plugin.Services; using Serilog; namespace Dalamud.Game.ClientState.Keys; @@ -23,7 +25,10 @@ namespace Dalamud.Game.ClientState.Keys; [PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public class KeyState : IServiceType +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class KeyState : IServiceType, IKeyState { // The array is accessed in a way that this limit doesn't appear to exist // but there is other state data past this point, and keys beyond here aren't @@ -31,10 +36,10 @@ public class KeyState : IServiceType private const int MaxKeyCode = 0xF0; private readonly IntPtr bufferBase; private readonly IntPtr indexBase; - private VirtualKey[] validVirtualKeyCache = null; + private VirtualKey[]? validVirtualKeyCache; [ServiceManager.ServiceConstructor] - private KeyState(SigScanner sigScanner, ClientState clientState) + private KeyState(TargetSigScanner sigScanner, ClientState clientState) { var moduleBaseAddress = sigScanner.Module.BaseAddress; var addressResolver = clientState.AddressResolver; @@ -44,46 +49,29 @@ public class KeyState : IServiceType Log.Verbose($"Keyboard state buffer address 0x{this.bufferBase.ToInt64():X}"); } - /// - /// Get or set the key-pressed state for a given vkCode. - /// - /// The virtual key to change. - /// Whether the specified key is currently pressed. - /// If the vkCode is not valid. Refer to or . - /// If the set value is non-zero. - public unsafe bool this[int vkCode] + /// + public bool this[int vkCode] { get => this.GetRawValue(vkCode) != 0; set => this.SetRawValue(vkCode, value ? 1 : 0); } - /// + /// public bool this[VirtualKey vkCode] { get => this[(int)vkCode]; set => this[(int)vkCode] = value; } - /// - /// Gets the value in the index array. - /// - /// The virtual key to change. - /// The raw value stored in the index array. - /// If the vkCode is not valid. Refer to or . + /// public int GetRawValue(int vkCode) => this.GetRefValue(vkCode); - /// + /// public int GetRawValue(VirtualKey vkCode) => this.GetRawValue((int)vkCode); - /// - /// Sets the value in the index array. - /// - /// The virtual key to change. - /// The raw value to set in the index array. - /// If the vkCode is not valid. Refer to or . - /// If the set value is non-zero. + /// public void SetRawValue(int vkCode, int value) { if (value != 0) @@ -92,32 +80,23 @@ public class KeyState : IServiceType this.GetRefValue(vkCode) = value; } - /// + /// public void SetRawValue(VirtualKey vkCode, int value) => this.SetRawValue((int)vkCode, value); - /// - /// Gets a value indicating whether the given VirtualKey code is regarded as valid input by the game. - /// - /// Virtual key code. - /// If the code is valid. + /// public bool IsVirtualKeyValid(int vkCode) => this.ConvertVirtualKey(vkCode) != 0; - /// + /// public bool IsVirtualKeyValid(VirtualKey vkCode) => this.IsVirtualKeyValid((int)vkCode); - /// - /// Gets an array of virtual keys the game considers valid input. - /// - /// An array of valid virtual keys. - public VirtualKey[] GetValidVirtualKeys() - => this.validVirtualKeyCache ??= Enum.GetValues().Where(vk => this.IsVirtualKeyValid(vk)).ToArray(); + /// + public IEnumerable GetValidVirtualKeys() + => this.validVirtualKeyCache ??= Enum.GetValues().Where(this.IsVirtualKeyValid).ToArray(); - /// - /// Clears the pressed state for all keys. - /// + /// public void ClearAll() { foreach (var vk in this.GetValidVirtualKeys()) diff --git a/Dalamud/Game/ClientState/Objects/ObjectTable.cs b/Dalamud/Game/ClientState/Objects/ObjectTable.cs index 16cf7c277..278c0772f 100644 --- a/Dalamud/Game/ClientState/Objects/ObjectTable.cs +++ b/Dalamud/Game/ClientState/Objects/ObjectTable.cs @@ -21,9 +21,9 @@ namespace Dalamud.Game.ClientState.Objects; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed partial class ObjectTable : IServiceType, IObjectTable +internal sealed partial class ObjectTable : IServiceType, IObjectTable { - private const int ObjectTableLength = 596; + private const int ObjectTableLength = 599; private readonly ClientStateAddressResolver address; @@ -109,7 +109,7 @@ public sealed partial class ObjectTable : IServiceType, IObjectTable /// /// This collection represents the currently spawned FFXIV game objects. /// -public sealed partial class ObjectTable +internal sealed partial class ObjectTable { /// int IReadOnlyCollection.Count => this.Length; diff --git a/Dalamud/Game/ClientState/Objects/SubKinds/BattleNpc.cs b/Dalamud/Game/ClientState/Objects/SubKinds/BattleNpc.cs index 59f32e33d..add7a7f9f 100644 --- a/Dalamud/Game/ClientState/Objects/SubKinds/BattleNpc.cs +++ b/Dalamud/Game/ClientState/Objects/SubKinds/BattleNpc.cs @@ -1,5 +1,3 @@ -using System; - using Dalamud.Game.ClientState.Objects.Enums; namespace Dalamud.Game.ClientState.Objects.Types; @@ -25,5 +23,5 @@ public unsafe class BattleNpc : BattleChara public BattleNpcSubKind BattleNpcKind => (BattleNpcSubKind)this.Struct->Character.GameObject.SubKind; /// - public override ulong TargetObjectId => this.Struct->Character.TargetObjectID; + public override ulong TargetObjectId => this.Struct->Character.TargetId; } diff --git a/Dalamud/Game/ClientState/Objects/SubKinds/PlayerCharacter.cs b/Dalamud/Game/ClientState/Objects/SubKinds/PlayerCharacter.cs index 7fc9c0079..9de11e3ec 100644 --- a/Dalamud/Game/ClientState/Objects/SubKinds/PlayerCharacter.cs +++ b/Dalamud/Game/ClientState/Objects/SubKinds/PlayerCharacter.cs @@ -1,5 +1,3 @@ -using System; - using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.ClientState.Resolvers; @@ -33,5 +31,5 @@ public unsafe class PlayerCharacter : BattleChara /// /// Gets the target actor ID of the PlayerCharacter. /// - public override ulong TargetObjectId => this.Struct->Character.PlayerTargetObjectID; + public override ulong TargetObjectId => this.Struct->Character.LookTargetId; } diff --git a/Dalamud/Game/ClientState/Objects/TargetManager.cs b/Dalamud/Game/ClientState/Objects/TargetManager.cs index ff1bdc5ba..fcb242c1e 100644 --- a/Dalamud/Game/ClientState/Objects/TargetManager.cs +++ b/Dalamud/Game/ClientState/Objects/TargetManager.cs @@ -16,7 +16,7 @@ namespace Dalamud.Game.ClientState.Objects; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed unsafe class TargetManager : IServiceType, ITargetManager +internal sealed unsafe class TargetManager : IServiceType, ITargetManager { [ServiceManager.ServiceDependency] private readonly ClientState clientState = Service.Get(); @@ -39,136 +39,50 @@ public sealed unsafe class TargetManager : IServiceType, ITargetManager public GameObject? Target { get => this.objectTable.CreateObjectReference((IntPtr)Struct->Target); - set => this.SetTarget(value); + set => Struct->Target = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)value?.Address; } /// public GameObject? MouseOverTarget { get => this.objectTable.CreateObjectReference((IntPtr)Struct->MouseOverTarget); - set => this.SetMouseOverTarget(value); + set => Struct->MouseOverTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)value?.Address; } /// public GameObject? FocusTarget { get => this.objectTable.CreateObjectReference((IntPtr)Struct->FocusTarget); - set => this.SetFocusTarget(value); + set => Struct->FocusTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)value?.Address; } /// public GameObject? PreviousTarget { get => this.objectTable.CreateObjectReference((IntPtr)Struct->PreviousTarget); - set => this.SetPreviousTarget(value); + set => Struct->PreviousTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)value?.Address; } /// public GameObject? SoftTarget { get => this.objectTable.CreateObjectReference((IntPtr)Struct->SoftTarget); - set => this.SetSoftTarget(value); + set => Struct->SoftTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)value?.Address; + } + + /// + public GameObject? GPoseTarget + { + get => this.objectTable.CreateObjectReference((IntPtr)Struct->GPoseTarget); + set => Struct->GPoseTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)value?.Address; + } + + /// + public GameObject? MouseOverNameplateTarget + { + get => this.objectTable.CreateObjectReference((IntPtr)Struct->MouseOverNameplateTarget); + set => Struct->MouseOverNameplateTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)value?.Address; } private FFXIVClientStructs.FFXIV.Client.Game.Control.TargetSystem* Struct => (FFXIVClientStructs.FFXIV.Client.Game.Control.TargetSystem*)this.Address; - - /// - /// Sets the current target. - /// - /// Actor to target. - [Obsolete("Use Target Property", false)] - public void SetTarget(GameObject? actor) => this.SetTarget(actor?.Address ?? IntPtr.Zero); - - /// - /// Sets the mouseover target. - /// - /// Actor to target. - [Obsolete("Use MouseOverTarget Property", false)] - public void SetMouseOverTarget(GameObject? actor) => this.SetMouseOverTarget(actor?.Address ?? IntPtr.Zero); - - /// - /// Sets the focus target. - /// - /// Actor to target. - [Obsolete("Use FocusTarget Property", false)] - public void SetFocusTarget(GameObject? actor) => this.SetFocusTarget(actor?.Address ?? IntPtr.Zero); - - /// - /// Sets the previous target. - /// - /// Actor to target. - [Obsolete("Use PreviousTarget Property", false)] - public void SetPreviousTarget(GameObject? actor) => this.SetTarget(actor?.Address ?? IntPtr.Zero); - - /// - /// Sets the soft target. - /// - /// Actor to target. - [Obsolete("Use SoftTarget Property", false)] - public void SetSoftTarget(GameObject? actor) => this.SetTarget(actor?.Address ?? IntPtr.Zero); - - /// - /// Sets the current target. - /// - /// Actor (address) to target. - [Obsolete("Use Target Property", false)] - public void SetTarget(IntPtr actorAddress) => Struct->Target = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actorAddress; - - /// - /// Sets the mouseover target. - /// - /// Actor (address) to target. - [Obsolete("Use MouseOverTarget Property", false)] - public void SetMouseOverTarget(IntPtr actorAddress) => Struct->MouseOverTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actorAddress; - - /// - /// Sets the focus target. - /// - /// Actor (address) to target. - [Obsolete("Use FocusTarget Property", false)] - public void SetFocusTarget(IntPtr actorAddress) => Struct->FocusTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actorAddress; - - /// - /// Sets the previous target. - /// - /// Actor (address) to target. - [Obsolete("Use PreviousTarget Property", false)] - public void SetPreviousTarget(IntPtr actorAddress) => Struct->PreviousTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actorAddress; - - /// - /// Sets the soft target. - /// - /// Actor (address) to target. - [Obsolete("Use SoftTarget Property", false)] - public void SetSoftTarget(IntPtr actorAddress) => Struct->SoftTarget = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actorAddress; - - /// - /// Clears the current target. - /// - [Obsolete("Use Target Property", false)] - public void ClearTarget() => this.SetTarget(IntPtr.Zero); - - /// - /// Clears the mouseover target. - /// - [Obsolete("Use MouseOverTarget Property", false)] - public void ClearMouseOverTarget() => this.SetMouseOverTarget(IntPtr.Zero); - - /// - /// Clears the focus target. - /// - [Obsolete("Use FocusTarget Property", false)] - public void ClearFocusTarget() => this.SetFocusTarget(IntPtr.Zero); - - /// - /// Clears the previous target. - /// - [Obsolete("Use PreviousTarget Property", false)] - public void ClearPreviousTarget() => this.SetPreviousTarget(IntPtr.Zero); - - /// - /// Clears the soft target. - /// - [Obsolete("Use SoftTarget Property", false)] - public void ClearSoftTarget() => this.SetSoftTarget(IntPtr.Zero); } diff --git a/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs b/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs index 63a5b828a..0c5d16675 100644 --- a/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs +++ b/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs @@ -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 /// /// Gets the total casting time of the spell being cast by the chara. /// + /// + /// This can only be a portion of the total cast for some actions. + /// Use AdjustedTotalCastTime if you always need the total cast time. + /// + [Api10ToDo("Rename so it is not confused with AdjustedTotalCastTime")] public float TotalCastTime => this.Struct->GetCastInfo->TotalCastTime; + /// + /// Gets the plus any adjustments from the game, such as Action offset 2B. Used for display purposes. + /// + /// + /// This is the actual total cast time for all actions. + /// + [Api10ToDo("Rename so it is not confused with TotalCastTime")] + public float AdjustedTotalCastTime => this.Struct->GetCastInfo->AdjustedTotalCastTime; + /// /// Gets the underlying structure. /// diff --git a/Dalamud/Game/ClientState/Objects/Types/Character.cs b/Dalamud/Game/ClientState/Objects/Types/Character.cs index ee8418362..ac11bcdd0 100644 --- a/Dalamud/Game/ClientState/Objects/Types/Character.cs +++ b/Dalamud/Game/ClientState/Objects/Types/Character.cs @@ -1,5 +1,3 @@ -using System; - using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Resolvers; using Dalamud.Game.Text.SeStringHandling; @@ -63,6 +61,11 @@ public unsafe class Character : GameObject /// public uint MaxCp => this.Struct->CharacterData.MaxCraftingPoints; + /// + /// Gets the shield percentage of this Chara. + /// + public byte ShieldPercentage => this.Struct->CharacterData.ShieldValue; + /// /// Gets the ClassJob of this Chara. /// @@ -87,7 +90,7 @@ public unsafe class Character : GameObject /// /// Gets the target object ID of the character. /// - public override ulong TargetObjectId => this.Struct->TargetObjectID; + public override ulong TargetObjectId => this.Struct->TargetId; /// /// Gets the name ID of the character. @@ -115,5 +118,6 @@ public unsafe class Character : GameObject /// /// Gets the underlying structure. /// - protected internal new FFXIVClientStructs.FFXIV.Client.Game.Character.Character* Struct => (FFXIVClientStructs.FFXIV.Client.Game.Character.Character*)this.Address; + protected internal new FFXIVClientStructs.FFXIV.Client.Game.Character.Character* Struct => + (FFXIVClientStructs.FFXIV.Client.Game.Character.Character*)this.Address; } diff --git a/Dalamud/Game/ClientState/Party/PartyList.cs b/Dalamud/Game/ClientState/Party/PartyList.cs index 529b57b6f..946c73245 100644 --- a/Dalamud/Game/ClientState/Party/PartyList.cs +++ b/Dalamud/Game/ClientState/Party/PartyList.cs @@ -19,7 +19,7 @@ namespace Dalamud.Game.ClientState.Party; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed unsafe partial class PartyList : IServiceType, IPartyList +internal sealed unsafe partial class PartyList : IServiceType, IPartyList { private const int GroupLength = 8; private const int AllianceLength = 20; @@ -130,7 +130,7 @@ public sealed unsafe partial class PartyList : IServiceType, IPartyList /// /// This collection represents the party members present in your party or alliance. /// -public sealed partial class PartyList +internal sealed partial class PartyList { /// int IReadOnlyCollection.Count => this.Length; diff --git a/Dalamud/Game/ClientState/Statuses/StatusList.cs b/Dalamud/Game/ClientState/Statuses/StatusList.cs index bcff50360..fce59e29b 100644 --- a/Dalamud/Game/ClientState/Statuses/StatusList.cs +++ b/Dalamud/Game/ClientState/Statuses/StatusList.cs @@ -10,8 +10,6 @@ namespace Dalamud.Game.ClientState.Statuses; /// public sealed unsafe partial class StatusList { - private const int StatusListLength = 30; - /// /// Initializes a new instance of the class. /// @@ -38,7 +36,7 @@ public sealed unsafe partial class StatusList /// /// Gets the amount of status effect slots the actor has. /// - public int Length => StatusListLength; + public int Length => Struct->NumValidStatuses; private static int StatusSize { get; } = Marshal.SizeOf(); @@ -53,7 +51,7 @@ public sealed unsafe partial class StatusList { get { - if (index < 0 || index > StatusListLength) + if (index < 0 || index > this.Length) return null; var addr = this.GetStatusAddress(index); @@ -107,7 +105,7 @@ public sealed unsafe partial class StatusList /// The memory address of the party member. public IntPtr GetStatusAddress(int index) { - if (index < 0 || index >= StatusListLength) + if (index < 0 || index >= this.Length) return IntPtr.Zero; return (IntPtr)(this.Struct->Status + (index * StatusSize)); @@ -134,7 +132,7 @@ public sealed partial class StatusList : IReadOnlyCollection, ICollectio /// public IEnumerator GetEnumerator() { - for (var i = 0; i < StatusListLength; i++) + for (var i = 0; i < this.Length; i++) { var status = this[i]; diff --git a/Dalamud/Game/Command/CommandInfo.cs b/Dalamud/Game/Command/CommandInfo.cs index 9b559599a..bc0250a66 100644 --- a/Dalamud/Game/Command/CommandInfo.cs +++ b/Dalamud/Game/Command/CommandInfo.cs @@ -15,7 +15,6 @@ public sealed class CommandInfo public CommandInfo(HandlerDelegate handler) { this.Handler = handler; - this.LoaderAssemblyName = Assembly.GetCallingAssembly()?.GetName()?.Name; } /// diff --git a/Dalamud/Game/Command/CommandManager.cs b/Dalamud/Game/Command/CommandManager.cs index 63a1a3d09..7dcca763b 100644 --- a/Dalamud/Game/Command/CommandManager.cs +++ b/Dalamud/Game/Command/CommandManager.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -9,22 +8,21 @@ using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Services; -using Serilog; namespace Dalamud.Game.Command; /// /// This class manages registered in-game slash commands. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -public sealed class CommandManager : IServiceType, IDisposable, ICommandManager +internal sealed class CommandManager : IInternalDisposableService, ICommandManager { + private static readonly ModuleLog Log = new("Command"); + private readonly ConcurrentDictionary commandMap = new(); private readonly Regex commandRegexEn = new(@"^The command (?.+) does not exist\.$", RegexOptions.Compiled); private readonly Regex commandRegexJp = new(@"^そのコマンドはありません。: (?.+)$", RegexOptions.Compiled); @@ -37,15 +35,15 @@ public sealed class CommandManager : IServiceType, IDisposable, ICommandManager private readonly ChatGui chatGui = Service.Get(); [ServiceManager.ServiceConstructor] - private CommandManager(DalamudStartInfo startInfo) + private CommandManager(Dalamud dalamud) { - this.currentLangCommandRegex = startInfo.Language switch + this.currentLangCommandRegex = (ClientLanguage)dalamud.StartInfo.Language switch { ClientLanguage.Japanese => this.commandRegexJp, ClientLanguage.English => this.commandRegexEn, ClientLanguage.German => this.commandRegexDe, ClientLanguage.French => this.commandRegexFr, - _ => this.currentLangCommandRegex, + _ => this.commandRegexEn, }; this.chatGui.CheckMessageHandled += this.OnCheckMessageHandled; @@ -84,7 +82,7 @@ public sealed class CommandManager : IServiceType, IDisposable, ICommandManager // => command: 0-12 (12 chars) // => argument: 13-17 (4 chars) // => content.IndexOf(' ') == 12 - command = content.Substring(0, separatorPosition); + command = content[..separatorPosition]; var argStart = separatorPosition + 1; argument = content[argStart..]; @@ -132,7 +130,7 @@ public sealed class CommandManager : IServiceType, IDisposable, ICommandManager } /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.chatGui.CheckMessageHandled -= this.OnCheckMessageHandled; } @@ -162,3 +160,93 @@ public sealed class CommandManager : IServiceType, IDisposable, ICommandManager } } } + +/// +/// Plugin-scoped version of a AddonLifecycle service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class CommandManagerPluginScoped : IInternalDisposableService, ICommandManager +{ + private static readonly ModuleLog Log = new("Command"); + + [ServiceManager.ServiceDependency] + private readonly CommandManager commandManagerService = Service.Get(); + + private readonly List pluginRegisteredCommands = new(); + private readonly LocalPlugin pluginInfo; + + /// + /// Initializes a new instance of the class. + /// + /// Info for the plugin that requests this service. + public CommandManagerPluginScoped(LocalPlugin localPlugin) + { + this.pluginInfo = localPlugin; + } + + /// + public ReadOnlyDictionary Commands => this.commandManagerService.Commands; + + /// + void IInternalDisposableService.DisposeService() + { + foreach (var command in this.pluginRegisteredCommands) + { + this.commandManagerService.RemoveHandler(command); + } + + this.pluginRegisteredCommands.Clear(); + } + + /// + public bool ProcessCommand(string content) + => this.commandManagerService.ProcessCommand(content); + + /// + public void DispatchCommand(string command, string argument, CommandInfo info) + => this.commandManagerService.DispatchCommand(command, argument, info); + + /// + public bool AddHandler(string command, CommandInfo info) + { + if (!this.pluginRegisteredCommands.Contains(command)) + { + info.LoaderAssemblyName = this.pluginInfo.InternalName; + if (this.commandManagerService.AddHandler(command, info)) + { + this.pluginRegisteredCommands.Add(command); + return true; + } + } + else + { + Log.Error($"Command {command} is already registered."); + } + + return false; + } + + /// + public bool RemoveHandler(string command) + { + if (this.pluginRegisteredCommands.Contains(command)) + { + if (this.commandManagerService.RemoveHandler(command)) + { + this.pluginRegisteredCommands.Remove(command); + return true; + } + } + else + { + Log.Error($"Command {command} not found."); + } + + return false; + } +} diff --git a/Dalamud/Game/Config/GameConfig.cs b/Dalamud/Game/Config/GameConfig.cs index dfdb8b5d2..a021025b1 100644 --- a/Dalamud/Game/Config/GameConfig.cs +++ b/Dalamud/Game/Config/GameConfig.cs @@ -1,4 +1,5 @@ -using System; +using System.Threading.Tasks; + using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; @@ -13,47 +14,86 @@ namespace Dalamud.Game.Config; /// This class represents the game's configuration. /// [InterfaceVersion("1.0")] -[PluginInterface] -[ServiceManager.EarlyLoadedService] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -public sealed class GameConfig : IServiceType, IGameConfig, IDisposable +[ServiceManager.BlockingEarlyLoadedService] +internal sealed class GameConfig : IInternalDisposableService, IGameConfig { + private readonly TaskCompletionSource tcsInitialization = new(); + private readonly TaskCompletionSource tcsSystem = new(); + private readonly TaskCompletionSource tcsUiConfig = new(); + private readonly TaskCompletionSource tcsUiControl = new(); + private readonly GameConfigAddressResolver address = new(); private Hook? configChangeHook; [ServiceManager.ServiceConstructor] - private unsafe GameConfig(Framework framework, SigScanner sigScanner) + private unsafe GameConfig(Framework framework, TargetSigScanner sigScanner) { framework.RunOnTick(() => { - Log.Verbose("[GameConfig] Initializing"); - var csFramework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.Instance(); - var commonConfig = &csFramework->SystemConfig.CommonSystemConfig; - this.System = new GameConfigSection("System", framework, &commonConfig->ConfigBase); - this.UiConfig = new GameConfigSection("UiConfig", framework, &commonConfig->UiConfig); - this.UiControl = new GameConfigSection("UiControl", framework, () => this.UiConfig.TryGetBool("PadMode", out var padMode) && padMode ? &commonConfig->UiControlGamepadConfig : &commonConfig->UiControlConfig); - - this.address.Setup(sigScanner); - this.configChangeHook = Hook.FromAddress(this.address.ConfigChangeAddress, this.OnConfigChanged); - this.configChangeHook?.Enable(); + try + { + Log.Verbose("[GameConfig] Initializing"); + var csFramework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.Instance(); + var commonConfig = &csFramework->SystemConfig.CommonSystemConfig; + this.tcsSystem.SetResult(new("System", framework, &commonConfig->ConfigBase)); + this.tcsUiConfig.SetResult(new("UiConfig", framework, &commonConfig->UiConfig)); + this.tcsUiControl.SetResult( + new( + "UiControl", + framework, + () => this.UiConfig.TryGetBool("PadMode", out var padMode) && padMode + ? &commonConfig->UiControlGamepadConfig + : &commonConfig->UiControlConfig)); + + this.address.Setup(sigScanner); + this.configChangeHook = Hook.FromAddress( + this.address.ConfigChangeAddress, + this.OnConfigChanged); + this.configChangeHook.Enable(); + this.tcsInitialization.SetResult(); + } + catch (Exception ex) + { + this.tcsInitialization.SetExceptionIfIncomplete(ex); + } }); } private unsafe delegate nint ConfigChangeDelegate(ConfigBase* configBase, ConfigEntry* configEntry); /// - public event EventHandler Changed; + public event EventHandler? Changed; + +#pragma warning disable 67 + /// + /// Unused internally, used as a proxy for System.Changed via GameConfigPluginScoped + /// + public event EventHandler? SystemChanged; - /// - public GameConfigSection System { get; private set; } + /// + /// Unused internally, used as a proxy for UiConfig.Changed via GameConfigPluginScoped + /// + public event EventHandler? UiConfigChanged; + + /// + /// Unused internally, used as a proxy for UiControl.Changed via GameConfigPluginScoped + /// + public event EventHandler? UiControlChanged; +#pragma warning restore 67 + + /// + /// Gets a task representing the initialization state of this class. + /// + public Task InitializationTask => this.tcsInitialization.Task; /// - public GameConfigSection UiConfig { get; private set; } + public GameConfigSection System => this.tcsSystem.Task.Result; /// - public GameConfigSection UiControl { get; private set; } + public GameConfigSection UiConfig => this.tcsUiConfig.Task.Result; + + /// + public GameConfigSection UiControl => this.tcsUiControl.Task.Result; /// public bool TryGet(SystemConfigOption option, out bool value) => this.System.TryGet(option.GetName(), out value); @@ -155,8 +195,13 @@ public sealed class GameConfig : IServiceType, IGameConfig, IDisposable public void Set(UiControlOption option, string value) => this.UiControl.Set(option.GetName(), value); /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { + var ode = new ObjectDisposedException(nameof(GameConfig)); + this.tcsInitialization.SetExceptionIfIncomplete(ode); + this.tcsSystem.SetExceptionIfIncomplete(ode); + this.tcsUiConfig.SetExceptionIfIncomplete(ode); + this.tcsUiControl.SetExceptionIfIncomplete(ode); this.configChangeHook?.Disable(); this.configChangeHook?.Dispose(); } @@ -193,3 +238,219 @@ public sealed class GameConfig : IServiceType, IGameConfig, IDisposable return returnValue; } } + +/// +/// Plugin-scoped version of a GameConfig service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class GameConfigPluginScoped : IInternalDisposableService, IGameConfig +{ + [ServiceManager.ServiceDependency] + private readonly GameConfig gameConfigService = Service.Get(); + + private readonly Task initializationTask; + + /// + /// Initializes a new instance of the class. + /// + internal GameConfigPluginScoped() + { + this.gameConfigService.Changed += this.ConfigChangedForward; + this.initializationTask = this.gameConfigService.InitializationTask.ContinueWith( + r => + { + if (!r.IsCompletedSuccessfully) + return r; + this.gameConfigService.System.Changed += this.SystemConfigChangedForward; + this.gameConfigService.UiConfig.Changed += this.UiConfigConfigChangedForward; + this.gameConfigService.UiControl.Changed += this.UiControlConfigChangedForward; + return Task.CompletedTask; + }).Unwrap(); + } + + /// + public event EventHandler? Changed; + + /// + public event EventHandler? SystemChanged; + + /// + public event EventHandler? UiConfigChanged; + + /// + public event EventHandler? UiControlChanged; + + /// + public GameConfigSection System => this.gameConfigService.System; + + /// + public GameConfigSection UiConfig => this.gameConfigService.UiConfig; + + /// + public GameConfigSection UiControl => this.gameConfigService.UiControl; + + /// + void IInternalDisposableService.DisposeService() + { + this.gameConfigService.Changed -= this.ConfigChangedForward; + this.initializationTask.ContinueWith( + r => + { + if (!r.IsCompletedSuccessfully) + return; + this.gameConfigService.System.Changed -= this.SystemConfigChangedForward; + this.gameConfigService.UiConfig.Changed -= this.UiConfigConfigChangedForward; + this.gameConfigService.UiControl.Changed -= this.UiControlConfigChangedForward; + }); + + this.Changed = null; + this.SystemChanged = null; + this.UiConfigChanged = null; + this.UiControlChanged = null; + } + + /// + public bool TryGet(SystemConfigOption option, out bool value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(SystemConfigOption option, out uint value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(SystemConfigOption option, out float value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(SystemConfigOption option, out string value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(SystemConfigOption option, out UIntConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(SystemConfigOption option, out FloatConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(SystemConfigOption option, out StringConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(UiConfigOption option, out bool value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiConfigOption option, out uint value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiConfigOption option, out float value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiConfigOption option, out string value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiConfigOption option, out UIntConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(UiConfigOption option, out FloatConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(UiConfigOption option, out StringConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(UiControlOption option, out bool value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiControlOption option, out uint value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiControlOption option, out float value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiControlOption option, out string value) + => this.gameConfigService.TryGet(option, out value); + + /// + public bool TryGet(UiControlOption option, out UIntConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(UiControlOption option, out FloatConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public bool TryGet(UiControlOption option, out StringConfigProperties? properties) + => this.gameConfigService.TryGet(option, out properties); + + /// + public void Set(SystemConfigOption option, bool value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(SystemConfigOption option, uint value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(SystemConfigOption option, float value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(SystemConfigOption option, string value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiConfigOption option, bool value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiConfigOption option, uint value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiConfigOption option, float value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiConfigOption option, string value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiControlOption option, bool value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiControlOption option, uint value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiControlOption option, float value) + => this.gameConfigService.Set(option, value); + + /// + public void Set(UiControlOption option, string value) + => this.gameConfigService.Set(option, value); + + private void ConfigChangedForward(object sender, ConfigChangeEvent data) => this.Changed?.Invoke(sender, data); + + private void SystemConfigChangedForward(object sender, ConfigChangeEvent data) => this.SystemChanged?.Invoke(sender, data); + + private void UiConfigConfigChangedForward(object sender, ConfigChangeEvent data) => this.UiConfigChanged?.Invoke(sender, data); + + private void UiControlConfigChangedForward(object sender, ConfigChangeEvent data) => this.UiControlChanged?.Invoke(sender, data); +} diff --git a/Dalamud/Game/Config/GameConfigAddressResolver.cs b/Dalamud/Game/Config/GameConfigAddressResolver.cs index 6a207807a..c171932a9 100644 --- a/Dalamud/Game/Config/GameConfigAddressResolver.cs +++ b/Dalamud/Game/Config/GameConfigAddressResolver.cs @@ -3,7 +3,7 @@ /// /// Game config system address resolver. /// -public sealed class GameConfigAddressResolver : BaseAddressResolver +internal sealed class GameConfigAddressResolver : BaseAddressResolver { /// /// Gets the address of the method called when any config option is changed. @@ -11,7 +11,7 @@ public sealed class GameConfigAddressResolver : BaseAddressResolver public nint ConfigChangeAddress { get; private set; } /// - protected override void Setup64Bit(SigScanner scanner) + protected override void Setup64Bit(ISigScanner scanner) { this.ConfigChangeAddress = scanner.ScanText("E8 ?? ?? ?? ?? 48 8B 3F 49 3B 3E"); } diff --git a/Dalamud/Game/Config/GameConfigSection.cs b/Dalamud/Game/Config/GameConfigSection.cs index 6c87ad3cf..31e4a0b3f 100644 --- a/Dalamud/Game/Config/GameConfigSection.cs +++ b/Dalamud/Game/Config/GameConfigSection.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics; using Dalamud.Memory; @@ -18,11 +17,6 @@ public class GameConfigSection private readonly ConcurrentDictionary indexMap = new(); private readonly ConcurrentDictionary enumMap = new(); - /// - /// Event which is fired when a game config option is changed within the section. - /// - public event EventHandler Changed; - /// /// Initializes a new instance of the class. /// @@ -54,6 +48,11 @@ public class GameConfigSection /// Pointer to unmanaged ConfigBase. internal unsafe delegate ConfigBase* GetConfigBaseDelegate(); + /// + /// Event which is fired when a game config option is changed within the section. + /// + internal event EventHandler? Changed; + /// /// Gets the number of config entries contained within the section. /// Some entries may be empty with no data. diff --git a/Dalamud/Game/Config/UiConfigOption.cs b/Dalamud/Game/Config/UiConfigOption.cs index 82f823ffe..aaa86230a 100644 --- a/Dalamud/Game/Config/UiConfigOption.cs +++ b/Dalamud/Game/Config/UiConfigOption.cs @@ -3473,4 +3473,67 @@ public enum UiConfigOption /// [GameConfigOption("ItemInventryStoreEnd", ConfigType.UInt)] ItemInventryStoreEnd, + + /// + /// System option with the internal name HotbarXHBEditEnable. + /// This option is a UInt. + /// + [GameConfigOption("HotbarXHBEditEnable", ConfigType.UInt)] + HotbarXHBEditEnable, + + /// + /// System option with the internal name NamePlateDispJobIconInPublicParty. + /// This option is a UInt. + /// + [GameConfigOption("NamePlateDispJobIconInPublicParty", ConfigType.UInt)] + NamePlateDispJobIconInPublicParty, + + /// + /// System option with the internal name NamePlateDispJobIconInPublicOther. + /// This option is a UInt. + /// + [GameConfigOption("NamePlateDispJobIconInPublicOther", ConfigType.UInt)] + NamePlateDispJobIconInPublicOther, + + /// + /// System option with the internal name NamePlateDispJobIconInInstanceParty. + /// This option is a UInt. + /// + [GameConfigOption("NamePlateDispJobIconInInstanceParty", ConfigType.UInt)] + NamePlateDispJobIconInInstanceParty, + + /// + /// System option with the internal name NamePlateDispJobIconInInstanceOther. + /// This option is a UInt. + /// + [GameConfigOption("NamePlateDispJobIconInInstanceOther", ConfigType.UInt)] + NamePlateDispJobIconInInstanceOther, + + /// + /// System option with the internal name CCProgressAllyFixLeftSide. + /// This option is a UInt. + /// + [GameConfigOption("CCProgressAllyFixLeftSide", ConfigType.UInt)] + CCProgressAllyFixLeftSide, + + /// + /// System option with the internal name CCMapAllyFixLeftSide. + /// This option is a UInt. + /// + [GameConfigOption("CCMapAllyFixLeftSide", ConfigType.UInt)] + CCMapAllyFixLeftSide, + + /// + /// System option with the internal name DispCCCountDown. + /// This option is a UInt. + /// + [GameConfigOption("DispCCCountDown", ConfigType.UInt)] + DispCCCountDown, + + /// + /// System option with the internal name TelepoCategoryType. + /// This option is a UInt. + /// + [GameConfigOption("TelepoCategoryType", ConfigType.UInt)] + TelepoCategoryType, } diff --git a/Dalamud/Game/DutyState/DutyState.cs b/Dalamud/Game/DutyState/DutyState.cs index 49fc874e3..e2e4aef15 100644 --- a/Dalamud/Game/DutyState/DutyState.cs +++ b/Dalamud/Game/DutyState/DutyState.cs @@ -1,25 +1,19 @@ -using System; -using System.Runtime.InteropServices; +using System.Runtime.InteropServices; using Dalamud.Game.ClientState.Conditions; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Plugin.Services; -using Dalamud.Utility; namespace Dalamud.Game.DutyState; /// /// This class represents the state of the currently occupied duty. /// -[PluginInterface] [InterfaceVersion("1.0")] -[ServiceManager.EarlyLoadedService] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -public unsafe class DutyState : IDisposable, IServiceType, IDutyState +[ServiceManager.BlockingEarlyLoadedService] +internal unsafe class DutyState : IInternalDisposableService, IDutyState { private readonly DutyStateAddressResolver address; private readonly Hook contentDirectorNetworkMessageHook; @@ -34,7 +28,7 @@ public unsafe class DutyState : IDisposable, IServiceType, IDutyState private readonly ClientState.ClientState clientState = Service.Get(); [ServiceManager.ServiceConstructor] - private DutyState(SigScanner sigScanner) + private DutyState(TargetSigScanner sigScanner) { this.address = new DutyStateAddressResolver(); this.address.Setup(sigScanner); @@ -43,22 +37,24 @@ public unsafe class DutyState : IDisposable, IServiceType, IDutyState this.framework.Update += this.FrameworkOnUpdateEvent; this.clientState.TerritoryChanged += this.TerritoryOnChangedEvent; + + this.contentDirectorNetworkMessageHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate byte SetupContentDirectNetworkMessageDelegate(IntPtr a1, IntPtr a2, ushort* a3); /// - public event EventHandler DutyStarted; + public event EventHandler? DutyStarted; /// - public event EventHandler DutyWiped; + public event EventHandler? DutyWiped; /// - public event EventHandler DutyRecommenced; + public event EventHandler? DutyRecommenced; /// - public event EventHandler DutyCompleted; + public event EventHandler? DutyCompleted; /// public bool IsDutyStarted { get; private set; } @@ -66,19 +62,13 @@ public unsafe class DutyState : IDisposable, IServiceType, IDutyState private bool CompletedThisTerritory { get; set; } /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.contentDirectorNetworkMessageHook.Dispose(); this.framework.Update -= this.FrameworkOnUpdateEvent; this.clientState.TerritoryChanged -= this.TerritoryOnChangedEvent; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.contentDirectorNetworkMessageHook.Enable(); - } - private byte ContentDirectorNetworkMessageDetour(IntPtr a1, IntPtr a2, ushort* a3) { var category = *a3; @@ -92,33 +82,33 @@ public unsafe class DutyState : IDisposable, IServiceType, IDutyState // Duty Commenced case 0x4000_0001: this.IsDutyStarted = true; - this.DutyStarted.InvokeSafely(this, this.clientState.TerritoryType); + this.DutyStarted?.Invoke(this, this.clientState.TerritoryType); break; // Party Wipe case 0x4000_0005: this.IsDutyStarted = false; - this.DutyWiped.InvokeSafely(this, this.clientState.TerritoryType); + this.DutyWiped?.Invoke(this, this.clientState.TerritoryType); break; // Duty Recommence case 0x4000_0006: this.IsDutyStarted = true; - this.DutyRecommenced.InvokeSafely(this, this.clientState.TerritoryType); + this.DutyRecommenced?.Invoke(this, this.clientState.TerritoryType); break; // Duty Completed Flytext Shown case 0x4000_0002 when !this.CompletedThisTerritory: this.IsDutyStarted = false; this.CompletedThisTerritory = true; - this.DutyCompleted.InvokeSafely(this, this.clientState.TerritoryType); + this.DutyCompleted?.Invoke(this, this.clientState.TerritoryType); break; // Duty Completed case 0x4000_0003 when !this.CompletedThisTerritory: this.IsDutyStarted = false; this.CompletedThisTerritory = true; - this.DutyCompleted.InvokeSafely(this, this.clientState.TerritoryType); + this.DutyCompleted?.Invoke(this, this.clientState.TerritoryType); break; } } @@ -126,7 +116,7 @@ public unsafe class DutyState : IDisposable, IServiceType, IDutyState return this.contentDirectorNetworkMessageHook.Original(a1, a2, a3); } - private void TerritoryOnChangedEvent(object? sender, ushort e) + private void TerritoryOnChangedEvent(ushort territoryId) { if (this.IsDutyStarted) { @@ -141,7 +131,7 @@ public unsafe class DutyState : IDisposable, IServiceType, IDutyState /// Joining a duty in progress, or disconnecting and reconnecting will cause the player to miss the event. /// /// Framework reference. - private void FrameworkOnUpdateEvent(Framework framework1) + private void FrameworkOnUpdateEvent(IFramework framework1) { // If the duty hasn't been started, and has not been completed yet this territory if (!this.IsDutyStarted && !this.CompletedThisTerritory) @@ -161,11 +151,73 @@ public unsafe class DutyState : IDisposable, IServiceType, IDutyState } private bool IsBoundByDuty() + => this.condition.Any(ConditionFlag.BoundByDuty, + ConditionFlag.BoundByDuty56, + ConditionFlag.BoundByDuty95); + + private bool IsInCombat() + => this.condition.Any(ConditionFlag.InCombat); +} + +/// +/// Plugin scoped version of DutyState. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class DutyStatePluginScoped : IInternalDisposableService, IDutyState +{ + [ServiceManager.ServiceDependency] + private readonly DutyState dutyStateService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal DutyStatePluginScoped() { - return this.condition[ConditionFlag.BoundByDuty] || - this.condition[ConditionFlag.BoundByDuty56] || - this.condition[ConditionFlag.BoundByDuty95]; + this.dutyStateService.DutyStarted += this.DutyStartedForward; + this.dutyStateService.DutyWiped += this.DutyWipedForward; + this.dutyStateService.DutyRecommenced += this.DutyRecommencedForward; + this.dutyStateService.DutyCompleted += this.DutyCompletedForward; } - private bool IsInCombat() => this.condition[ConditionFlag.InCombat]; + /// + public event EventHandler? DutyStarted; + + /// + public event EventHandler? DutyWiped; + + /// + public event EventHandler? DutyRecommenced; + + /// + public event EventHandler? DutyCompleted; + + /// + public bool IsDutyStarted => this.dutyStateService.IsDutyStarted; + + /// + void IInternalDisposableService.DisposeService() + { + this.dutyStateService.DutyStarted -= this.DutyStartedForward; + this.dutyStateService.DutyWiped -= this.DutyWipedForward; + this.dutyStateService.DutyRecommenced -= this.DutyRecommencedForward; + this.dutyStateService.DutyCompleted -= this.DutyCompletedForward; + + this.DutyStarted = null; + this.DutyWiped = null; + this.DutyRecommenced = null; + this.DutyCompleted = null; + } + + private void DutyStartedForward(object sender, ushort territoryId) => this.DutyStarted?.Invoke(sender, territoryId); + + private void DutyWipedForward(object sender, ushort territoryId) => this.DutyWiped?.Invoke(sender, territoryId); + + private void DutyRecommencedForward(object sender, ushort territoryId) => this.DutyRecommenced?.Invoke(sender, territoryId); + + private void DutyCompletedForward(object sender, ushort territoryId) => this.DutyCompleted?.Invoke(sender, territoryId); } diff --git a/Dalamud/Game/DutyState/DutyStateAddressResolver.cs b/Dalamud/Game/DutyState/DutyStateAddressResolver.cs index 801e5ef55..c7160bddb 100644 --- a/Dalamud/Game/DutyState/DutyStateAddressResolver.cs +++ b/Dalamud/Game/DutyState/DutyStateAddressResolver.cs @@ -1,11 +1,9 @@ -using System; - namespace Dalamud.Game.DutyState; /// /// Duty state memory address resolver. /// -public class DutyStateAddressResolver : BaseAddressResolver +internal class DutyStateAddressResolver : BaseAddressResolver { /// /// Gets the address of the method which is called when the client receives a content director update. @@ -16,7 +14,7 @@ public class DutyStateAddressResolver : BaseAddressResolver /// Scan for and setup any configured address pointers. /// /// The signature scanner to facilitate setup. - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.ContentDirectorNetworkMessage = sig.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 48 8B D9 49 8B F8 41 0F B7 08"); } diff --git a/Dalamud/Game/Framework.cs b/Dalamud/Game/Framework.cs index b3083e913..9e520daab 100644 --- a/Dalamud/Game/Framework.cs +++ b/Dalamud/Game/Framework.cs @@ -1,4 +1,4 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -12,19 +12,21 @@ using Dalamud.Game.Gui.Toast; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Services; using Dalamud.Utility; -using Serilog; namespace Dalamud.Game; /// /// This class represents the Framework of the native game client and grants access to various subsystems. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public sealed class Framework : IDisposable, IServiceType +internal sealed class Framework : IInternalDisposableService, IFramework { + private static readonly ModuleLog Log = new("Framework"); + private static readonly Stopwatch StatsStopwatch = new(); private readonly GameLifecycle lifecycle; @@ -35,34 +37,43 @@ public sealed class Framework : IDisposable, IServiceType private readonly Hook updateHook; private readonly Hook destroyHook; + private readonly FrameworkAddressResolver addressResolver; + [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); - private readonly object runOnNextTickTaskListSync = new(); - private List runOnNextTickTaskList = new(); - private List runOnNextTickTaskList2 = new(); + private readonly CancellationTokenSource frameworkDestroy; + private readonly ThreadBoundTaskScheduler frameworkThreadTaskScheduler; - private Thread? frameworkUpdateThread; + private readonly ConcurrentDictionary + tickDelayedTaskCompletionSources = new(); + + private ulong tickCounter; [ServiceManager.ServiceConstructor] - private Framework(SigScanner sigScanner, GameLifecycle lifecycle) + private Framework(TargetSigScanner sigScanner, GameLifecycle lifecycle) { this.lifecycle = lifecycle; this.hitchDetector = new HitchDetector("FrameworkUpdate", this.configuration.FrameworkUpdateHitch); - this.Address = new FrameworkAddressResolver(); - this.Address.Setup(sigScanner); + this.addressResolver = new FrameworkAddressResolver(); + this.addressResolver.Setup(sigScanner); - this.updateHook = Hook.FromAddress(this.Address.TickAddress, this.HandleFrameworkUpdate); - this.destroyHook = Hook.FromAddress(this.Address.DestroyAddress, this.HandleFrameworkDestroy); + this.frameworkDestroy = new(); + this.frameworkThreadTaskScheduler = new(); + this.FrameworkThreadTaskFactory = new( + this.frameworkDestroy.Token, + TaskCreationOptions.None, + TaskContinuationOptions.None, + this.frameworkThreadTaskScheduler); + + this.updateHook = Hook.FromAddress(this.addressResolver.TickAddress, this.HandleFrameworkUpdate); + this.destroyHook = Hook.FromAddress(this.addressResolver.DestroyAddress, this.HandleFrameworkDestroy); + + this.updateHook.Enable(); + this.destroyHook.Enable(); } - /// - /// A delegate type used with the event. - /// - /// The Framework instance. - public delegate void OnUpdateDelegate(Framework framework); - /// /// A delegate type used during the native Framework::destroy. /// @@ -70,21 +81,11 @@ public sealed class Framework : IDisposable, IServiceType /// A value indicating if the call was successful. public delegate bool OnRealDestroyDelegate(IntPtr framework); - /// - /// A delegate type used during the native Framework::free. - /// - /// The native Framework address. - public delegate IntPtr OnDestroyDelegate(); - [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate bool OnUpdateDetour(IntPtr framework); - private delegate IntPtr OnDestroyDetour(); // OnDestroyDelegate - - /// - /// Event that gets fired every time the game framework updates. - /// - public event OnUpdateDelegate Update; + /// + public event IFramework.OnUpdateDelegate? Update; /// /// Gets or sets a value indicating whether the collection of stats is enabled. @@ -96,55 +97,86 @@ public sealed class Framework : IDisposable, IServiceType /// public static Dictionary> StatsHistory { get; } = new(); - /// - /// Gets a raw pointer to the instance of Client::Framework. - /// - public FrameworkAddressResolver Address { get; } - - /// - /// Gets the last time that the Framework Update event was triggered. - /// + /// public DateTime LastUpdate { get; private set; } = DateTime.MinValue; - /// - /// Gets the last time in UTC that the Framework Update event was triggered. - /// + /// public DateTime LastUpdateUTC { get; private set; } = DateTime.MinValue; - /// - /// Gets the delta between the last Framework Update and the currently executing one. - /// + /// public TimeSpan UpdateDelta { get; private set; } = TimeSpan.Zero; - /// - /// Gets a value indicating whether currently executing code is running in the game's framework update thread. - /// - public bool IsInFrameworkUpdateThread => Thread.CurrentThread == this.frameworkUpdateThread; + /// + public bool IsInFrameworkUpdateThread => this.frameworkThreadTaskScheduler.IsOnBoundThread; + + /// + public bool IsFrameworkUnloading => this.frameworkDestroy.IsCancellationRequested; /// - /// Gets a value indicating whether game Framework is unloading. + /// Gets the list of update sub-delegates that didn't get updated this frame. /// - public bool IsFrameworkUnloading { get; internal set; } + internal List NonUpdatedSubDelegates { get; private set; } = new(); /// /// Gets or sets a value indicating whether to dispatch update events. /// internal bool DispatchUpdateEvents { get; set; } = true; - /// - /// 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. - /// - /// Return type. - /// Function to call. - /// Task representing the pending or already completed function. + private TaskFactory FrameworkThreadTaskFactory { get; } + + /// + public TaskFactory GetTaskFactory() => this.FrameworkThreadTaskFactory; + + /// + 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; + } + + /// + public Task Run(Action action, CancellationToken cancellationToken = default) + { + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken); + } + + /// + public Task Run(Func action, CancellationToken cancellationToken = default) + { + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken); + } + + /// + public Task Run(Func action, CancellationToken cancellationToken = default) + { + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken).Unwrap(); + } + + /// + public Task Run(Func> action, CancellationToken cancellationToken = default) + { + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken).Unwrap(); + } + + /// public Task RunOnFrameworkThread(Func func) => this.IsInFrameworkUpdateThread || this.IsFrameworkUnloading ? Task.FromResult(func()) : this.RunOnTick(func); - /// - /// 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. - /// - /// Function to call. - /// Task representing the pending or already completed function. + /// public Task RunOnFrameworkThread(Action action) { if (this.IsInFrameworkUpdateThread || this.IsFrameworkUnloading) @@ -165,32 +197,15 @@ public sealed class Framework : IDisposable, IServiceType } } - /// - /// 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. - /// - /// Return type. - /// Function to call. - /// Task representing the pending or already completed function. + /// public Task RunOnFrameworkThread(Func> func) => this.IsInFrameworkUpdateThread || this.IsFrameworkUnloading ? func() : this.RunOnTick(func); - /// - /// 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. - /// - /// Function to call. - /// Task representing the pending or already completed function. + /// public Task RunOnFrameworkThread(Func func) => this.IsInFrameworkUpdateThread || this.IsFrameworkUnloading ? func() : this.RunOnTick(func); - /// - /// Run given function in upcoming Framework.Tick call. - /// - /// Return type. - /// Function to call. - /// Wait for given timespan before calling this function. - /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. - /// Cancellation token which will prevent the execution of this function if wait conditions are not met. - /// Task representing the pending function. + /// public Task RunOnTick(Func func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) { if (this.IsFrameworkUnloading) @@ -203,30 +218,21 @@ public sealed class Framework : IDisposable, IServiceType return Task.FromCanceled(cts.Token); } - var tcs = new TaskCompletionSource(); - lock (this.runOnNextTickTaskListSync) - { - this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc() + 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, + TaskContinuationOptions.HideScheduler, + this.frameworkThreadTaskScheduler); } - /// - /// Run given function in upcoming Framework.Tick call. - /// - /// Function to call. - /// Wait for given timespan before calling this function. - /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. - /// Cancellation token which will prevent the execution of this function if wait conditions are not met. - /// Task representing the pending function. + /// public Task RunOnTick(Action action, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) { if (this.IsFrameworkUnloading) @@ -239,31 +245,21 @@ public sealed class Framework : IDisposable, IServiceType 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, + TaskContinuationOptions.HideScheduler, + this.frameworkThreadTaskScheduler); } - /// - /// Run given function in upcoming Framework.Tick call. - /// - /// Return type. - /// Function to call. - /// Wait for given timespan before calling this function. - /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. - /// Cancellation token which will prevent the execution of this function if wait conditions are not met. - /// Task representing the pending function. + /// public Task RunOnTick(Func> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) { if (this.IsFrameworkUnloading) @@ -276,30 +272,21 @@ public sealed class Framework : IDisposable, IServiceType return Task.FromCanceled(cts.Token); } - var tcs = new TaskCompletionSource>(); - lock (this.runOnNextTickTaskListSync) - { - this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc>() + 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, + TaskContinuationOptions.HideScheduler, + this.frameworkThreadTaskScheduler).Unwrap(); } - /// - /// Run given function in upcoming Framework.Tick call. - /// - /// Function to call. - /// Wait for given timespan before calling this function. - /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. - /// Cancellation token which will prevent the execution of this function if wait conditions are not met. - /// Task representing the pending function. + /// public Task RunOnTick(Func func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) { if (this.IsFrameworkUnloading) @@ -312,26 +299,24 @@ public sealed class Framework : IDisposable, IServiceType return Task.FromCanceled(cts.Token); } - var tcs = new TaskCompletionSource(); - lock (this.runOnNextTickTaskListSync) - { - this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc() + 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, + TaskContinuationOptions.HideScheduler, + this.frameworkThreadTaskScheduler).Unwrap(); } /// /// Dispose of managed and unmanaged resources. /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.RunOnFrameworkThread(() => { @@ -348,31 +333,62 @@ public sealed class Framework : IDisposable, IServiceType this.updateStopwatch.Reset(); StatsStopwatch.Reset(); } - - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() + + /// + /// Adds a update time to the stats history. + /// + /// Delegate Name. + /// Runtime. + internal static void AddToStats(string key, double ms) { - this.updateHook.Enable(); - this.destroyHook.Enable(); + if (!StatsHistory.ContainsKey(key)) + StatsHistory.Add(key, new List()); + + StatsHistory[key].Add(ms); + + if (StatsHistory[key].Count > 1000) + { + StatsHistory[key].RemoveRange(0, StatsHistory[key].Count - 1000); + } } - private void RunPendingTickTasks() + /// + /// Profiles each sub-delegate in the eventDelegate and logs to StatsHistory. + /// + /// The Delegate to Profile. + /// The Framework Instance to pass to delegate. + internal void ProfileAndInvoke(IFramework.OnUpdateDelegate? eventDelegate, IFramework frameworkInstance) { - if (this.runOnNextTickTaskList.Count == 0 && this.runOnNextTickTaskList2.Count == 0) - return; + if (eventDelegate is null) return; + + var invokeList = eventDelegate.GetInvocationList(); - for (var i = 0; i < 2; i++) + // Individually invoke OnUpdate handlers and time them. + foreach (var d in invokeList) { - lock (this.runOnNextTickTaskListSync) - (this.runOnNextTickTaskList, this.runOnNextTickTaskList2) = (this.runOnNextTickTaskList2, this.runOnNextTickTaskList); + var stopwatch = Stopwatch.StartNew(); + try + { + d.Method.Invoke(d.Target, new object[] { frameworkInstance }); + } + catch (Exception ex) + { + Log.Error(ex, "Exception while dispatching Framework::Update event."); + } - this.runOnNextTickTaskList2.RemoveAll(x => x.Run()); + stopwatch.Stop(); + + var key = $"{d.Target}::{d.Method.Name}"; + if (this.NonUpdatedSubDelegates.Contains(key)) + this.NonUpdatedSubDelegates.Remove(key); + + AddToStats(key, stopwatch.Elapsed.TotalMilliseconds); } } private bool HandleFrameworkUpdate(IntPtr framework) { - this.frameworkUpdateThread ??= Thread.CurrentThread; + this.frameworkThreadTaskScheduler.BoundThread ??= Thread.CurrentThread; ThreadSafety.MarkMainThread(); @@ -404,65 +420,42 @@ public sealed class Framework : IDisposable, IServiceType this.LastUpdate = DateTime.Now; this.LastUpdateUTC = DateTime.UtcNow; - - void AddToStats(string key, double ms) + this.tickCounter++; + foreach (var (k, (expiry, ct)) in this.tickDelayedTaskCompletionSources) { - if (!StatsHistory.ContainsKey(key)) - StatsHistory.Add(key, new List()); + if (ct.IsCancellationRequested) + k.SetCanceled(ct); + else if (expiry <= this.tickCounter) + k.SetResult(); + else + continue; - StatsHistory[key].Add(ms); - - if (StatsHistory[key].Count > 1000) - { - StatsHistory[key].RemoveRange(0, StatsHistory[key].Count - 1000); - } + 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) { // Stat Tracking for Framework Updates - var invokeList = this.Update.GetInvocationList(); - var notUpdated = StatsHistory.Keys.ToList(); - - // Individually invoke OnUpdate handlers and time them. - foreach (var d in invokeList) - { - StatsStopwatch.Restart(); - try - { - d.Method.Invoke(d.Target, new object[] { this }); - } - catch (Exception ex) - { - Log.Error(ex, "Exception while dispatching Framework::Update event."); - } - - StatsStopwatch.Stop(); - - var key = $"{d.Target}::{d.Method.Name}"; - if (notUpdated.Contains(key)) - notUpdated.Remove(key); - - AddToStats(key, StatsStopwatch.Elapsed.TotalMilliseconds); - } + this.NonUpdatedSubDelegates = StatsHistory.Keys.ToList(); + this.ProfileAndInvoke(this.Update, this); // Cleanup handlers that are no longer being called - foreach (var key in notUpdated) + foreach (var key in this.NonUpdatedSubDelegates) { - if (key == nameof(this.RunPendingTickTasks)) + if (key == nameof(this.FrameworkThreadTaskFactory)) continue; if (StatsHistory[key].Count > 0) @@ -489,8 +482,11 @@ public sealed class Framework : IDisposable, IServiceType 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(); @@ -498,93 +494,126 @@ public sealed class Framework : IDisposable, IServiceType Log.Information("Framework::Destroy!"); Service.Get().Unload(); - this.RunPendingTickTasks(); + this.frameworkThreadTaskScheduler.Run(); ServiceManager.WaitForServiceUnload(); Log.Information("Framework::Destroy OK!"); return this.destroyHook.OriginalDisposeSafe(framework); } +} - private abstract class RunOnNextTickTaskBase +/// +/// Plugin-scoped version of a Framework service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class FrameworkPluginScoped : IInternalDisposableService, IFramework +{ + [ServiceManager.ServiceDependency] + private readonly Framework frameworkService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal FrameworkPluginScoped() { - 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(); + this.frameworkService.Update += this.OnUpdateForward; } - private class RunOnNextTickTaskFunc : RunOnNextTickTaskBase + /// + public event IFramework.OnUpdateDelegate? Update; + + /// + public DateTime LastUpdate => this.frameworkService.LastUpdate; + + /// + public DateTime LastUpdateUTC => this.frameworkService.LastUpdateUTC; + + /// + public TimeSpan UpdateDelta => this.frameworkService.UpdateDelta; + + /// + public bool IsInFrameworkUpdateThread => this.frameworkService.IsInFrameworkUpdateThread; + + /// + public bool IsFrameworkUnloading => this.frameworkService.IsFrameworkUnloading; + + /// + void IInternalDisposableService.DisposeService() { - internal TaskCompletionSource TaskCompletionSource { get; init; } + this.frameworkService.Update -= this.OnUpdateForward; - internal Func 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(); - } + this.Update = null; } - private class RunOnNextTickTaskAction : RunOnNextTickTaskBase + /// + public TaskFactory GetTaskFactory() => this.frameworkService.GetTaskFactory(); + + /// + public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default) => + this.frameworkService.DelayTicks(numTicks, cancellationToken); + + /// + public Task Run(Action action, CancellationToken cancellationToken = default) => + this.frameworkService.Run(action, cancellationToken); + + /// + public Task Run(Func action, CancellationToken cancellationToken = default) => + this.frameworkService.Run(action, cancellationToken); + + /// + public Task Run(Func action, CancellationToken cancellationToken = default) => + this.frameworkService.Run(action, cancellationToken); + + /// + public Task Run(Func> action, CancellationToken cancellationToken = default) => + this.frameworkService.Run(action, cancellationToken); + + /// + public Task RunOnFrameworkThread(Func func) + => this.frameworkService.RunOnFrameworkThread(func); + + /// + public Task RunOnFrameworkThread(Action action) + => this.frameworkService.RunOnFrameworkThread(action); + + /// + public Task RunOnFrameworkThread(Func> func) + => this.frameworkService.RunOnFrameworkThread(func); + + /// + public Task RunOnFrameworkThread(Func func) + => this.frameworkService.RunOnFrameworkThread(func); + + /// + public Task RunOnTick(Func func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) + => this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken); + + /// + public Task RunOnTick(Action action, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) + => this.frameworkService.RunOnTick(action, delay, delayTicks, cancellationToken); + + /// + public Task RunOnTick(Func> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) + => this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken); + + /// + public Task RunOnTick(Func func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default) + => this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken); + + private void OnUpdateForward(IFramework framework) { - internal TaskCompletionSource TaskCompletionSource { get; init; } - - internal Action Action { get; init; } - - protected override void RunImpl() + if (Framework.StatsEnabled && this.Update != null) { - try - { - this.Action(); - this.TaskCompletionSource.SetResult(); - } - catch (Exception ex) - { - this.TaskCompletionSource.SetException(ex); - } + this.frameworkService.ProfileAndInvoke(this.Update, framework); } - - protected override void CancelImpl() + else { - this.TaskCompletionSource.SetCanceled(); + this.Update?.Invoke(framework); } } } diff --git a/Dalamud/Game/FrameworkAddressResolver.cs b/Dalamud/Game/FrameworkAddressResolver.cs index e3d128f0f..39ae15155 100644 --- a/Dalamud/Game/FrameworkAddressResolver.cs +++ b/Dalamud/Game/FrameworkAddressResolver.cs @@ -5,14 +5,8 @@ namespace Dalamud.Game; /// /// The address resolver for the class. /// -public sealed unsafe class FrameworkAddressResolver : BaseAddressResolver +internal sealed class FrameworkAddressResolver : BaseAddressResolver { - /// - /// Gets the base address of the Framework object. - /// - [Obsolete("Please use FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.Instance() instead.")] - public IntPtr BaseAddress => new(FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.Instance()); - /// /// Gets the address for the function that is called once the Framework is destroyed. /// @@ -29,12 +23,12 @@ public sealed unsafe class FrameworkAddressResolver : BaseAddressResolver public IntPtr TickAddress { get; private set; } /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.SetupFramework(sig); } - private void SetupFramework(SigScanner scanner) + private void SetupFramework(ISigScanner scanner) { this.DestroyAddress = scanner.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 48 8B 3D ?? ?? ?? ?? 48 8B D9 48 85 FF"); diff --git a/Dalamud/Game/GameLifecycle.cs b/Dalamud/Game/GameLifecycle.cs index 5c1acc989..4192d055b 100644 --- a/Dalamud/Game/GameLifecycle.cs +++ b/Dalamud/Game/GameLifecycle.cs @@ -15,7 +15,7 @@ namespace Dalamud.Game; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public class GameLifecycle : IServiceType, IGameLifecycle +internal class GameLifecycle : IServiceType, IGameLifecycle { private readonly CancellationTokenSource dalamudUnloadCts = new(); private readonly CancellationTokenSource gameShutdownCts = new(); diff --git a/Dalamud/Game/Gui/ChatGui.cs b/Dalamud/Game/Gui/ChatGui.cs index 93185caf9..e0b90b382 100644 --- a/Dalamud/Game/Gui/ChatGui.cs +++ b/Dalamud/Game/Gui/ChatGui.cs @@ -1,29 +1,38 @@ -using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Runtime.InteropServices; using Dalamud.Configuration.Internal; -using Dalamud.Game.Libc; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; 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 Serilog; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; namespace Dalamud.Game.Gui; +// TODO(api10): Update IChatGui, ChatGui and XivChatEntry to use correct types and names: +// "uint SenderId" should be "int Timestamp". +// "IntPtr Parameters" should be something like "bool Silent". It suppresses new message sounds in certain channels. +// This has to be a 1 byte boolean, so only change it to bool if marshalling is disabled. + /// /// This class handles interacting with the native chat UI. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public sealed class ChatGui : IDisposable, IServiceType +internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui { + private static readonly ModuleLog Log = new("ChatGui"); + private readonly ChatGuiAddressResolver address; private readonly Queue chatQueue = new(); @@ -36,62 +45,25 @@ public sealed class ChatGui : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); - [ServiceManager.ServiceDependency] - private readonly LibcFunction libcFunction = Service.Get(); - - private IntPtr baseAddress = IntPtr.Zero; + private ImmutableDictionary<(string PluginName, uint CommandId), Action>? dalamudLinkHandlersCopy; [ServiceManager.ServiceConstructor] - private ChatGui(SigScanner sigScanner) + private ChatGui(TargetSigScanner sigScanner) { this.address = new ChatGuiAddressResolver(); this.address.Setup(sigScanner); - this.printMessageHook = Hook.FromAddress(this.address.PrintMessage, this.HandlePrintMessageDetour); + this.printMessageHook = Hook.FromAddress((nint)RaptureLogModule.Addresses.PrintMessage.Value, this.HandlePrintMessageDetour); this.populateItemLinkHook = Hook.FromAddress(this.address.PopulateItemLinkObject, this.HandlePopulateItemLinkDetour); this.interactableLinkClickedHook = Hook.FromAddress(this.address.InteractableLinkClicked, this.InteractableLinkClickedDetour); + + this.printMessageHook.Enable(); + this.populateItemLinkHook.Enable(); + this.interactableLinkClickedHook.Enable(); } - - /// - /// A delegate type used with the event. - /// - /// The type of chat. - /// The sender ID. - /// The sender name. - /// The message sent. - /// A value indicating whether the message was handled or should be propagated. - public delegate void OnMessageDelegate(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled); - - /// - /// A delegate type used with the event. - /// - /// The type of chat. - /// The sender ID. - /// The sender name. - /// The message sent. - /// A value indicating whether the message was handled or should be propagated. - public delegate void OnCheckMessageHandledDelegate(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled); - - /// - /// A delegate type used with the event. - /// - /// The type of chat. - /// The sender ID. - /// The sender name. - /// The message sent. - public delegate void OnMessageHandledDelegate(XivChatType type, uint senderId, SeString sender, SeString message); - - /// - /// A delegate type used with the event. - /// - /// The type of chat. - /// The sender ID. - /// The sender name. - /// The message sent. - public delegate void OnMessageUnhandledDelegate(XivChatType type, uint senderId, SeString sender, SeString message); - + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate IntPtr PrintMessageDelegate(IntPtr manager, XivChatType chatType, IntPtr senderName, IntPtr message, uint senderId, IntPtr parameter); + private delegate uint PrintMessageDelegate(RaptureLogModule* manager, XivChatType chatType, Utf8String* sender, Utf8String* message, int timestamp, byte silent); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void PopulateItemLinkDelegate(IntPtr linkObjectPtr, IntPtr itemInfoPtr); @@ -99,116 +71,81 @@ public sealed class ChatGui : IDisposable, IServiceType [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void InteractableLinkClickedDelegate(IntPtr managerPtr, IntPtr messagePtr); - /// - /// Event that will be fired when a chat message is sent to chat by the game. - /// - public event OnMessageDelegate ChatMessage; + /// + public event IChatGui.OnMessageDelegate? ChatMessage; - /// - /// Event that allows you to stop messages from appearing in chat by setting the isHandled parameter to true. - /// - public event OnCheckMessageHandledDelegate CheckMessageHandled; + /// + public event IChatGui.OnCheckMessageHandledDelegate? CheckMessageHandled; - /// - /// Event that will be fired when a chat message is handled by Dalamud or a Plugin. - /// - public event OnMessageHandledDelegate ChatMessageHandled; + /// + public event IChatGui.OnMessageHandledDelegate? ChatMessageHandled; - /// - /// Event that will be fired when a chat message is not handled by Dalamud or a Plugin. - /// - public event OnMessageUnhandledDelegate ChatMessageUnhandled; + /// + public event IChatGui.OnMessageUnhandledDelegate? ChatMessageUnhandled; - /// - /// Gets the ID of the last linked item. - /// + /// public int LastLinkedItemId { get; private set; } - /// - /// Gets the flags of the last linked item. - /// + /// public byte LastLinkedItemFlags { get; private set; } + /// + public IReadOnlyDictionary<(string PluginName, uint CommandId), Action> RegisteredLinkHandlers + { + get + { + var copy = this.dalamudLinkHandlersCopy; + if (copy is not null) + return copy; + + lock (this.dalamudLinkHandlers) + { + return this.dalamudLinkHandlersCopy ??= + this.dalamudLinkHandlers.ToImmutableDictionary(x => x.Key, x => x.Value); + } + } + } + /// /// Dispose of managed and unmanaged resources. /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.printMessageHook.Dispose(); this.populateItemLinkHook.Dispose(); this.interactableLinkClickedHook.Dispose(); } - /// - /// Queue a chat message. While method is named as PrintChat, it only add a entry to the queue, - /// later to be processed when UpdateQueue() is called. - /// - /// A message to send. - public void PrintChat(XivChatEntry chat) + /// + public void Print(XivChatEntry chat) { this.chatQueue.Enqueue(chat); } - - /// - /// Queue a chat message. While method is named as PrintChat (it calls it internally), it only add a entry to the queue, - /// later to be processed when UpdateQueue() is called. - /// - /// A message to send. - public void Print(string message) + + /// + public void Print(string message, string? messageTag = null, ushort? tagColor = null) { - // Log.Verbose("[CHATGUI PRINT REGULAR]{0}", message); - this.PrintChat(new XivChatEntry - { - Message = message, - Type = this.configuration.GeneralChatType, - }); + this.PrintTagged(message, this.configuration.GeneralChatType, messageTag, tagColor); } - - /// - /// Queue a chat message. While method is named as PrintChat (it calls it internally), it only add a entry to the queue, - /// later to be processed when UpdateQueue() is called. - /// - /// A message to send. - public void Print(SeString message) + + /// + public void Print(SeString message, string? messageTag = null, ushort? tagColor = null) { - // Log.Verbose("[CHATGUI PRINT SESTRING]{0}", message.TextValue); - this.PrintChat(new XivChatEntry - { - Message = message, - Type = this.configuration.GeneralChatType, - }); + this.PrintTagged(message, this.configuration.GeneralChatType, messageTag, tagColor); } - - /// - /// Queue an error chat message. While method is named as PrintChat (it calls it internally), it only add a entry to - /// the queue, later to be processed when UpdateQueue() is called. - /// - /// A message to send. - public void PrintError(string message) + + /// + public void PrintError(string message, string? messageTag = null, ushort? tagColor = null) { - // Log.Verbose("[CHATGUI PRINT REGULAR ERROR]{0}", message); - this.PrintChat(new XivChatEntry - { - Message = message, - Type = XivChatType.Urgent, - }); + this.PrintTagged(message, XivChatType.Urgent, messageTag, tagColor); } - - /// - /// Queue an error chat message. While method is named as PrintChat (it calls it internally), it only add a entry to - /// the queue, later to be processed when UpdateQueue() is called. - /// - /// A message to send. - public void PrintError(SeString message) + + /// + public void PrintError(SeString message, string? messageTag = null, ushort? tagColor = null) { - // Log.Verbose("[CHATGUI PRINT SESTRING ERROR]{0}", message.TextValue); - this.PrintChat(new XivChatEntry - { - Message = message, - Type = XivChatType.Urgent, - }); + this.PrintTagged(message, XivChatType.Urgent, messageTag, tagColor); } - + /// /// Process a chat queue. /// @@ -218,18 +155,13 @@ public sealed class ChatGui : IDisposable, IServiceType { var chat = this.chatQueue.Dequeue(); - if (this.baseAddress == IntPtr.Zero) - { - continue; - } + var sender = Utf8String.FromSequence(chat.Name.Encode()); + var message = Utf8String.FromSequence(chat.Message.Encode()); - var senderRaw = (chat.Name ?? string.Empty).Encode(); - using var senderOwned = this.libcFunction.NewString(senderRaw); + this.HandlePrintMessageDetour(RaptureLogModule.Instance(), chat.Type, sender, message, (int)chat.SenderId, (byte)(chat.Parameters != 0 ? 1 : 0)); - var messageRaw = (chat.Message ?? string.Empty).Encode(); - using var messageOwned = this.libcFunction.NewString(messageRaw); - - this.HandlePrintMessageDetour(this.baseAddress, chat.Type, senderOwned.Address, messageOwned.Address, chat.SenderId, chat.Parameters); + sender->Dtor(true); + message->Dtor(true); } } @@ -242,8 +174,13 @@ public sealed class ChatGui : IDisposable, IServiceType /// A payload for handling. internal DalamudLinkPayload AddChatLinkHandler(string pluginName, uint commandId, Action commandAction) { - var payload = new DalamudLinkPayload() { Plugin = pluginName, CommandId = commandId }; - this.dalamudLinkHandlers.Add((pluginName, commandId), commandAction); + var payload = new DalamudLinkPayload { Plugin = pluginName, CommandId = commandId }; + lock (this.dalamudLinkHandlers) + { + this.dalamudLinkHandlers.Add((pluginName, commandId), commandAction); + this.dalamudLinkHandlersCopy = null; + } + return payload; } @@ -253,9 +190,14 @@ public sealed class ChatGui : IDisposable, IServiceType /// The name of the plugin handling the links. internal void RemoveChatLinkHandler(string pluginName) { - foreach (var handler in this.dalamudLinkHandlers.Keys.ToList().Where(k => k.PluginName == pluginName)) + lock (this.dalamudLinkHandlers) { - this.dalamudLinkHandlers.Remove(handler); + var changed = false; + + foreach (var handler in this.RegisteredLinkHandlers.Keys.Where(k => k.PluginName == pluginName)) + changed |= this.dalamudLinkHandlers.Remove(handler); + if (changed) + this.dalamudLinkHandlersCopy = null; } } @@ -266,18 +208,57 @@ public sealed class ChatGui : IDisposable, IServiceType /// The ID of the command to be removed. internal void RemoveChatLinkHandler(string pluginName, uint commandId) { - if (this.dalamudLinkHandlers.ContainsKey((pluginName, commandId))) + lock (this.dalamudLinkHandlers) { - this.dalamudLinkHandlers.Remove((pluginName, commandId)); + if (this.dalamudLinkHandlers.Remove((pluginName, commandId))) + this.dalamudLinkHandlersCopy = null; } } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(GameGui gameGui, LibcFunction libcFunction) + private void PrintTagged(string message, XivChatType channel, string? tag, ushort? color) { - this.printMessageHook.Enable(); - this.populateItemLinkHook.Enable(); - this.interactableLinkClickedHook.Enable(); + var builder = new SeStringBuilder(); + + if (!tag.IsNullOrEmpty()) + { + if (color is not null) + { + builder.AddUiForeground($"[{tag}] ", color.Value); + } + else + { + builder.AddText($"[{tag}] "); + } + } + + this.Print(new XivChatEntry + { + Message = builder.AddText(message).Build(), + Type = channel, + }); + } + + private void PrintTagged(SeString message, XivChatType channel, string? tag, ushort? color) + { + var builder = new SeStringBuilder(); + + if (!tag.IsNullOrEmpty()) + { + if (color is not null) + { + builder.AddUiForeground($"[{tag}] ", color.Value); + } + else + { + builder.AddText($"[{tag}] "); + } + } + + this.Print(new XivChatEntry + { + Message = builder.Build().Append(message), + Type = channel, + }); } private void HandlePopulateItemLinkDetour(IntPtr linkObjectPtr, IntPtr itemInfoPtr) @@ -298,40 +279,28 @@ public sealed class ChatGui : IDisposable, IServiceType } } - private IntPtr HandlePrintMessageDetour(IntPtr manager, XivChatType chattype, IntPtr pSenderName, IntPtr pMessage, uint senderid, IntPtr parameter) + private uint HandlePrintMessageDetour(RaptureLogModule* manager, XivChatType chatType, Utf8String* sender, Utf8String* message, int timestamp, byte silent) { - var retVal = IntPtr.Zero; + var messageId = 0u; try { - var sender = StdString.ReadFromPointer(pSenderName); - var parsedSender = SeString.Parse(sender.RawData); - var originalSenderData = (byte[])sender.RawData.Clone(); - var oldEditedSender = parsedSender.Encode(); - var senderPtr = pSenderName; - OwnedStdString allocatedString = null; + var originalSenderData = sender->AsSpan().ToArray(); + var originalMessageData = message->AsSpan().ToArray(); - var message = StdString.ReadFromPointer(pMessage); - var parsedMessage = SeString.Parse(message.RawData); - var originalMessageData = (byte[])message.RawData.Clone(); - var oldEdited = parsedMessage.Encode(); - var messagePtr = pMessage; - OwnedStdString allocatedStringSender = null; - - // Log.Verbose("[CHATGUI][{0}][{1}]", parsedSender.TextValue, parsedMessage.TextValue); - - // Log.Debug($"HandlePrintMessageDetour {manager} - [{chattype}] [{BitConverter.ToString(message.RawData).Replace("-", " ")}] {message.Value} from {senderName.Value}"); + var parsedSender = SeString.Parse(originalSenderData); + var parsedMessage = SeString.Parse(originalMessageData); // Call events var isHandled = false; - var invocationList = this.CheckMessageHandled.GetInvocationList(); + var invocationList = this.CheckMessageHandled!.GetInvocationList(); foreach (var @delegate in invocationList) { try { - var messageHandledDelegate = @delegate as OnCheckMessageHandledDelegate; - messageHandledDelegate!.Invoke(chattype, senderid, ref parsedSender, ref parsedMessage, ref isHandled); + var messageHandledDelegate = @delegate as IChatGui.OnCheckMessageHandledDelegate; + messageHandledDelegate!.Invoke(chatType, (uint)timestamp, ref parsedSender, ref parsedMessage, ref isHandled); } catch (Exception e) { @@ -341,13 +310,13 @@ public sealed class ChatGui : IDisposable, IServiceType if (!isHandled) { - invocationList = this.ChatMessage.GetInvocationList(); + invocationList = this.ChatMessage!.GetInvocationList(); foreach (var @delegate in invocationList) { try { - var messageHandledDelegate = @delegate as OnMessageDelegate; - messageHandledDelegate!.Invoke(chattype, senderid, ref parsedSender, ref parsedMessage, ref isHandled); + var messageHandledDelegate = @delegate as IChatGui.OnMessageDelegate; + messageHandledDelegate!.Invoke(chatType, (uint)timestamp, ref parsedSender, ref parsedMessage, ref isHandled); } catch (Exception e) { @@ -356,61 +325,39 @@ public sealed class ChatGui : IDisposable, IServiceType } } - var newEdited = parsedMessage.Encode(); - if (!Util.FastByteArrayCompare(oldEdited, newEdited)) + var possiblyModifiedSenderData = parsedSender.Encode(); + var possiblyModifiedMessageData = parsedMessage.Encode(); + + if (!Util.FastByteArrayCompare(originalSenderData, possiblyModifiedSenderData)) { - Log.Verbose("SeString was edited, taking precedence over StdString edit."); - message.RawData = newEdited; - // Log.Debug($"\nOLD: {BitConverter.ToString(originalMessageData)}\nNEW: {BitConverter.ToString(newEdited)}"); + Log.Verbose($"HandlePrintMessageDetour Sender modified: {SeString.Parse(originalSenderData)} -> {parsedSender}"); + sender->SetString(possiblyModifiedSenderData); } - if (!Util.FastByteArrayCompare(originalMessageData, message.RawData)) + if (!Util.FastByteArrayCompare(originalMessageData, possiblyModifiedMessageData)) { - allocatedString = this.libcFunction.NewString(message.RawData); - Log.Debug($"HandlePrintMessageDetour String modified: {originalMessageData}({messagePtr}) -> {message}({allocatedString.Address})"); - messagePtr = allocatedString.Address; - } - - var newEditedSender = parsedSender.Encode(); - if (!Util.FastByteArrayCompare(oldEditedSender, newEditedSender)) - { - Log.Verbose("SeString was edited, taking precedence over StdString edit."); - sender.RawData = newEditedSender; - // Log.Debug($"\nOLD: {BitConverter.ToString(originalMessageData)}\nNEW: {BitConverter.ToString(newEdited)}"); - } - - if (!Util.FastByteArrayCompare(originalSenderData, sender.RawData)) - { - allocatedStringSender = this.libcFunction.NewString(sender.RawData); - Log.Debug( - $"HandlePrintMessageDetour Sender modified: {originalSenderData}({senderPtr}) -> {sender}({allocatedStringSender.Address})"); - senderPtr = allocatedStringSender.Address; + Log.Verbose($"HandlePrintMessageDetour Message modified: {SeString.Parse(originalMessageData)} -> {parsedMessage}"); + message->SetString(possiblyModifiedMessageData); } // Print the original chat if it's handled. if (isHandled) { - this.ChatMessageHandled?.Invoke(chattype, senderid, parsedSender, parsedMessage); + this.ChatMessageHandled?.Invoke(chatType, (uint)timestamp, parsedSender, parsedMessage); } else { - retVal = this.printMessageHook.Original(manager, chattype, senderPtr, messagePtr, senderid, parameter); - this.ChatMessageUnhandled?.Invoke(chattype, senderid, parsedSender, parsedMessage); + messageId = this.printMessageHook.Original(manager, chatType, sender, message, timestamp, silent); + this.ChatMessageUnhandled?.Invoke(chatType, (uint)timestamp, parsedSender, parsedMessage); } - - if (this.baseAddress == IntPtr.Zero) - this.baseAddress = manager; - - allocatedString?.Dispose(); - allocatedStringSender?.Dispose(); } catch (Exception ex) { Log.Error(ex, "Exception on OnChatMessage hook."); - retVal = this.printMessageHook.Original(manager, chattype, pSenderName, pMessage, senderid, parameter); + messageId = this.printMessageHook.Original(manager, chatType, sender, message, timestamp, silent); } - return retVal; + return messageId; } private void InteractableLinkClickedDetour(IntPtr managerPtr, IntPtr messagePtr) @@ -428,21 +375,17 @@ public sealed class ChatGui : IDisposable, IServiceType Log.Verbose($"InteractableLinkClicked: {Payload.EmbeddedInfoType.DalamudLink}"); var payloadPtr = Marshal.ReadIntPtr(messagePtr, 0x10); - var messageSize = 0; - while (Marshal.ReadByte(payloadPtr, messageSize) != 0) messageSize++; - var payloadBytes = new byte[messageSize]; - Marshal.Copy(payloadPtr, payloadBytes, 0, messageSize); - var seStr = SeString.Parse(payloadBytes); + var seStr = MemoryHelper.ReadSeStringNullTerminated(payloadPtr); var terminatorIndex = seStr.Payloads.IndexOf(RawPayload.LinkTerminator); var payloads = terminatorIndex >= 0 ? seStr.Payloads.Take(terminatorIndex + 1).ToList() : seStr.Payloads; if (payloads.Count == 0) return; var linkPayload = payloads[0]; if (linkPayload is DalamudLinkPayload link) { - if (this.dalamudLinkHandlers.ContainsKey((link.Plugin, link.CommandId))) + if (this.RegisteredLinkHandlers.TryGetValue((link.Plugin, link.CommandId), out var value)) { Log.Verbose($"Sending DalamudLink to {link.Plugin}: {link.CommandId}"); - this.dalamudLinkHandlers[(link.Plugin, link.CommandId)].Invoke(link.CommandId, new SeString(payloads)); + value.Invoke(link.CommandId, new SeString(payloads)); } else { @@ -456,3 +399,96 @@ public sealed class ChatGui : IDisposable, IServiceType } } } + +/// +/// Plugin scoped version of ChatGui. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class ChatGuiPluginScoped : IInternalDisposableService, IChatGui +{ + [ServiceManager.ServiceDependency] + private readonly ChatGui chatGuiService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal ChatGuiPluginScoped() + { + this.chatGuiService.ChatMessage += this.OnMessageForward; + this.chatGuiService.CheckMessageHandled += this.OnCheckMessageForward; + this.chatGuiService.ChatMessageHandled += this.OnMessageHandledForward; + this.chatGuiService.ChatMessageUnhandled += this.OnMessageUnhandledForward; + } + + /// + public event IChatGui.OnMessageDelegate? ChatMessage; + + /// + public event IChatGui.OnCheckMessageHandledDelegate? CheckMessageHandled; + + /// + public event IChatGui.OnMessageHandledDelegate? ChatMessageHandled; + + /// + public event IChatGui.OnMessageUnhandledDelegate? ChatMessageUnhandled; + + /// + public int LastLinkedItemId => this.chatGuiService.LastLinkedItemId; + + /// + public byte LastLinkedItemFlags => this.chatGuiService.LastLinkedItemFlags; + + /// + public IReadOnlyDictionary<(string PluginName, uint CommandId), Action> RegisteredLinkHandlers => this.chatGuiService.RegisteredLinkHandlers; + + /// + void IInternalDisposableService.DisposeService() + { + this.chatGuiService.ChatMessage -= this.OnMessageForward; + this.chatGuiService.CheckMessageHandled -= this.OnCheckMessageForward; + this.chatGuiService.ChatMessageHandled -= this.OnMessageHandledForward; + this.chatGuiService.ChatMessageUnhandled -= this.OnMessageUnhandledForward; + + this.ChatMessage = null; + this.CheckMessageHandled = null; + this.ChatMessageHandled = null; + this.ChatMessageUnhandled = null; + } + + /// + public void Print(XivChatEntry chat) + => this.chatGuiService.Print(chat); + + /// + public void Print(string message, string? messageTag = null, ushort? tagColor = null) + => this.chatGuiService.Print(message, messageTag, tagColor); + + /// + public void Print(SeString message, string? messageTag = null, ushort? tagColor = null) + => this.chatGuiService.Print(message, messageTag, tagColor); + + /// + public void PrintError(string message, string? messageTag = null, ushort? tagColor = null) + => this.chatGuiService.PrintError(message, messageTag, tagColor); + + /// + public void PrintError(SeString message, string? messageTag = null, ushort? tagColor = null) + => this.chatGuiService.PrintError(message, messageTag, tagColor); + + private void OnMessageForward(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) + => this.ChatMessage?.Invoke(type, senderId, ref sender, ref message, ref isHandled); + + private void OnCheckMessageForward(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) + => this.CheckMessageHandled?.Invoke(type, senderId, ref sender, ref message, ref isHandled); + + private void OnMessageHandledForward(XivChatType type, uint senderId, SeString sender, SeString message) + => this.ChatMessageHandled?.Invoke(type, senderId, sender, message); + + private void OnMessageUnhandledForward(XivChatType type, uint senderId, SeString sender, SeString message) + => this.ChatMessageUnhandled?.Invoke(type, senderId, sender, message); +} diff --git a/Dalamud/Game/Gui/ChatGuiAddressResolver.cs b/Dalamud/Game/Gui/ChatGuiAddressResolver.cs index 4686d5725..ae53f90e9 100644 --- a/Dalamud/Game/Gui/ChatGuiAddressResolver.cs +++ b/Dalamud/Game/Gui/ChatGuiAddressResolver.cs @@ -1,17 +1,10 @@ -using System; - namespace Dalamud.Game.Gui; /// /// The address resolver for the class. /// -public sealed class ChatGuiAddressResolver : BaseAddressResolver +internal sealed class ChatGuiAddressResolver : BaseAddressResolver { - /// - /// Gets the address of the native PrintMessage method. - /// - public IntPtr PrintMessage { get; private set; } - /// /// Gets the address of the native PopulateItemLinkObject method. /// @@ -22,77 +15,9 @@ public sealed class ChatGuiAddressResolver : BaseAddressResolver /// public IntPtr InteractableLinkClicked { get; private set; } - /* - --- for reference: 4.57 --- - .text:00000001405CD210 ; __int64 __fastcall Xiv::Gui::ChatGui::PrintMessage(__int64 handler, unsigned __int16 chatType, __int64 senderName, __int64 message, int senderActorId, char isLocal) - .text:00000001405CD210 Xiv__Gui__ChatGui__PrintMessage proc near - .text:00000001405CD210 ; CODE XREF: sub_1401419F0+201↑p - .text:00000001405CD210 ; sub_140141D10+220↑p ... - .text:00000001405CD210 - .text:00000001405CD210 var_220 = qword ptr -220h - .text:00000001405CD210 var_218 = byte ptr -218h - .text:00000001405CD210 var_210 = word ptr -210h - .text:00000001405CD210 var_208 = byte ptr -208h - .text:00000001405CD210 var_200 = word ptr -200h - .text:00000001405CD210 var_1FC = dword ptr -1FCh - .text:00000001405CD210 var_1F8 = qword ptr -1F8h - .text:00000001405CD210 var_1F0 = qword ptr -1F0h - .text:00000001405CD210 var_1E8 = qword ptr -1E8h - .text:00000001405CD210 var_1E0 = dword ptr -1E0h - .text:00000001405CD210 var_1DC = word ptr -1DCh - .text:00000001405CD210 var_1DA = word ptr -1DAh - .text:00000001405CD210 var_1D8 = qword ptr -1D8h - .text:00000001405CD210 var_1D0 = byte ptr -1D0h - .text:00000001405CD210 var_1C8 = qword ptr -1C8h - .text:00000001405CD210 var_1B0 = dword ptr -1B0h - .text:00000001405CD210 var_1AC = dword ptr -1ACh - .text:00000001405CD210 var_1A8 = dword ptr -1A8h - .text:00000001405CD210 var_1A4 = dword ptr -1A4h - .text:00000001405CD210 var_1A0 = dword ptr -1A0h - .text:00000001405CD210 var_160 = dword ptr -160h - .text:00000001405CD210 var_15C = dword ptr -15Ch - .text:00000001405CD210 var_140 = dword ptr -140h - .text:00000001405CD210 var_138 = dword ptr -138h - .text:00000001405CD210 var_130 = byte ptr -130h - .text:00000001405CD210 var_C0 = byte ptr -0C0h - .text:00000001405CD210 var_50 = qword ptr -50h - .text:00000001405CD210 var_38 = qword ptr -38h - .text:00000001405CD210 var_30 = qword ptr -30h - .text:00000001405CD210 var_28 = qword ptr -28h - .text:00000001405CD210 var_20 = qword ptr -20h - .text:00000001405CD210 senderActorId = dword ptr 30h - .text:00000001405CD210 isLocal = byte ptr 38h - .text:00000001405CD210 - .text:00000001405CD210 ; __unwind { // __GSHandlerCheck - .text:00000001405CD210 push rbp - .text:00000001405CD212 push rdi - .text:00000001405CD213 push r14 - .text:00000001405CD215 push r15 - .text:00000001405CD217 lea rbp, [rsp-128h] - .text:00000001405CD21F sub rsp, 228h - .text:00000001405CD226 mov rax, cs:__security_cookie - .text:00000001405CD22D xor rax, rsp - .text:00000001405CD230 mov [rbp+140h+var_50], rax - .text:00000001405CD237 xor r10b, r10b - .text:00000001405CD23A mov [rsp+240h+var_1F8], rcx - .text:00000001405CD23F xor eax, eax - .text:00000001405CD241 mov r11, r9 - .text:00000001405CD244 mov r14, r8 - .text:00000001405CD247 mov r9d, eax - .text:00000001405CD24A movzx r15d, dx - .text:00000001405CD24E lea r8, [rcx+0C10h] - .text:00000001405CD255 mov rdi, rcx - */ - /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { - // PrintMessage = sig.ScanText("4055 57 41 ?? 41 ?? 488DAC24D8FEFFFF 4881EC28020000 488B05???????? 4833C4 488985F0000000 4532D2 48894C2448"); LAST PART FOR 5.1??? - this.PrintMessage = sig.ScanText("40 55 53 56 41 54 41 57 48 8D AC 24 ?? ?? ?? ?? 48 81 EC 20 02 00 00 48 8B 05"); - // PrintMessage = sig.ScanText("4055 57 41 ?? 41 ?? 488DAC24E8FEFFFF 4881EC18020000 488B05???????? 4833C4 488985E0000000 4532D2 48894C2438"); old - - // PrintMessage = sig.ScanText("40 55 57 41 56 41 57 48 8D AC 24 D8 FE FF FF 48 81 EC 28 02 00 00 48 8B 05 63 47 4A 01 48 33 C4 48 89 85 F0 00 00 00 45 32 D2 48 89 4C 24 48 33"); - // PopulateItemLinkObject = sig.ScanText("48 89 5C 24 08 57 48 83 EC 20 80 7A 06 00 48 8B DA 48 8B F9 74 14 48 8B CA E8 32 03 00 00 48 8B C8 E8 FA F2 B0 FF 8B C8 EB 1D 0F B6 42 14 8B 4A"); // PopulateItemLinkObject = sig.ScanText( "48 89 5C 24 08 57 48 83 EC 20 80 7A 06 00 48 8B DA 48 8B F9 74 14 48 8B CA E8 32 03 00 00 48 8B C8 E8 ?? ?? B0 FF 8B C8 EB 1D 0F B6 42 14 8B 4A"); 5.0 diff --git a/Dalamud/Game/Gui/ContextMenu/ContextMenu.cs b/Dalamud/Game/Gui/ContextMenu/ContextMenu.cs new file mode 100644 index 000000000..f136d017a --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/ContextMenu.cs @@ -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; + +/// +/// This class handles interacting with the game's (right-click) context menu. +/// +[InterfaceVersion("1.0")] +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class ContextMenu : IInternalDisposableService, IContextMenu +{ + private static readonly ModuleLog Log = new("ContextMenu"); + + private readonly Hook raptureAtkModuleOpenAddonByAgentHook; + private readonly Hook addonContextMenuOnMenuSelectedHook; + private readonly RaptureAtkModuleOpenAddonDelegate raptureAtkModuleOpenAddon; + + [ServiceManager.ServiceConstructor] + private ContextMenu() + { + this.raptureAtkModuleOpenAddonByAgentHook = Hook.FromAddress((nint)RaptureAtkModule.Addresses.OpenAddonByAgent.Value, this.RaptureAtkModuleOpenAddonByAgentDetour); + this.addonContextMenuOnMenuSelectedHook = Hook.FromAddress((nint)AddonContextMenu.StaticVTable.OnMenuSelected, this.AddonContextMenuOnMenuSelectedDetour); + this.raptureAtkModuleOpenAddon = Marshal.GetDelegateForFunctionPointer((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); + + /// + public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened; + + private Dictionary> MenuItems { get; } = new(); + + private object MenuItemsLock { get; } = new(); + + private AgentInterface* SelectedAgent { get; set; } + + private ContextMenuType? SelectedMenuType { get; set; } + + private List? SelectedItems { get; set; } + + private HashSet SelectedEventInterfaces { get; } = new(); + + private AtkUnitBase* SelectedParentAddon { get; set; } + + // -1 -> -inf: native items + // 0 -> inf: selected items + private List MenuCallbackIds { get; } = new(); + + private IReadOnlyList? SubmenuItems { get; set; } + + /// + void IInternalDisposableService.DisposeService() + { + 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(); + } + + /// + 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); + } + } + + /// + 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 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.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 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(values, headerCount); + var nameData = new Span(values + headerCount, nativeMenuSize + items.Count); + var disabledData = hasAnyDisabled ? new Span(values + headerCount + nativeMenuSize + items.Count, nativeMenuSize + items.Count) : Span.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(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 disabledData, Span 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 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 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>(menu->EventHandlerArray, 32); + var ids = new Span(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 FixupMenuList(List 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 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); + } +} + +/// +/// Plugin-scoped version of a service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class ContextMenuPluginScoped : IInternalDisposableService, IContextMenu +{ + [ServiceManager.ServiceDependency] + private readonly ContextMenu parentService = Service.Get(); + + private ContextMenuPluginScoped() + { + this.parentService.OnMenuOpened += this.OnMenuOpenedForward; + } + + /// + public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened; + + private Dictionary> MenuItems { get; } = new(); + + private object MenuItemsLock { get; } = new(); + + /// + void IInternalDisposableService.DisposeService() + { + 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); + } + } + } + + /// + 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); + } + + /// + 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); +} diff --git a/Dalamud/Game/Gui/ContextMenu/ContextMenuType.cs b/Dalamud/Game/Gui/ContextMenu/ContextMenuType.cs new file mode 100644 index 000000000..2cd52a4b7 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/ContextMenuType.cs @@ -0,0 +1,18 @@ +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// The type of context menu. +/// Each one has a different associated . +/// +public enum ContextMenuType +{ + /// + /// The default context menu. + /// + Default, + + /// + /// The inventory context menu. Used when right-clicked on an item. + /// + Inventory, +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuArgs.cs b/Dalamud/Game/Gui/ContextMenu/MenuArgs.cs new file mode 100644 index 000000000..d0d8ec0dc --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuArgs.cs @@ -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; + +/// +/// Base class for menu args. +/// +public abstract unsafe class MenuArgs +{ + private IReadOnlySet? eventInterfaces; + + /// + /// Initializes a new instance of the class. + /// + /// Addon associated with the context menu. + /// Agent associated with the context menu. + /// The type of context menu. + /// List of AtkEventInterfaces associated with the context menu. + protected internal MenuArgs(AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet? 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)), + }; + } + + /// + /// Gets the name of the addon that opened the context menu. + /// + public string? AddonName { get; } + + /// + /// Gets the memory pointer of the addon that opened the context menu. + /// + public nint AddonPtr { get; } + + /// + /// Gets the memory pointer of the agent that opened the context menu. + /// + public nint AgentPtr { get; } + + /// + /// Gets the type of the context menu. + /// + public ContextMenuType MenuType { get; } + + /// + /// Gets the target info of the context menu. The actual type depends on . + /// signifies a . + /// signifies a . + /// + public MenuTarget Target { get; } + + /// + /// Gets a list of AtkEventInterface pointers associated with the context menu. + /// Only available with . + /// Almost always an agent pointer. You can use this to find out what type of context menu it is. + /// + /// Thrown when the context menu is not a . + public IReadOnlySet EventInterfaces => + this.MenuType != ContextMenuType.Default ? + this.eventInterfaces : + throw new InvalidOperationException("Not a default context menu"); +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuItem.cs b/Dalamud/Game/Gui/ContextMenu/MenuItem.cs new file mode 100644 index 000000000..fdeb64d13 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuItem.cs @@ -0,0 +1,91 @@ +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; + +using Lumina.Excel.GeneratedSheets; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// A menu item that can be added to a context menu. +/// +public sealed record MenuItem +{ + /// + /// Gets or sets the display name of the menu item. + /// + public SeString Name { get; set; } = SeString.Empty; + + /// + /// Gets or sets the prefix attached to the beginning of . + /// + public SeIconChar? Prefix { get; set; } + + /// + /// Sets the character to prefix the with. Will be converted into a fancy boxed letter icon. Must be an uppercase letter. + /// + /// must be an uppercase letter. + 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; + } + } + } + + /// + /// Gets or sets the color of the . Specifies a row id. + /// + public ushort PrefixColor { get; set; } + + /// + /// Gets or sets the callback to be invoked when the menu item is clicked. + /// + public Action? OnClicked { get; set; } + + /// + /// 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. + /// + public int Priority { get; set; } + + /// + /// Gets or sets a value indicating whether the menu item is enabled. + /// Disabled items will be faded and cannot be clicked on. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 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. + /// + public bool IsSubmenu { get; set; } + + /// + /// 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 and are true, the return arrow will take precedence. + /// + public bool IsReturn { get; set; } + + /// + /// Gets the name with the given prefix. + /// + internal SeString PrefixedName => + this.Prefix is { } prefix + ? new SeStringBuilder() + .AddUiForeground($"{prefix.ToIconString()} ", this.PrefixColor) + .Append(this.Name) + .Build() + : this.Name; +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuItemClickedArgs.cs b/Dalamud/Game/Gui/ContextMenu/MenuItemClickedArgs.cs new file mode 100644 index 000000000..bec16590d --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuItemClickedArgs.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +using Dalamud.Game.Text.SeStringHandling; + +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Callback args used when a menu item is clicked. +/// +public sealed unsafe class MenuItemClickedArgs : MenuArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// Callback for opening a submenu. + /// Addon associated with the context menu. + /// Agent associated with the context menu. + /// The type of context menu. + /// List of AtkEventInterfaces associated with the context menu. + internal MenuItemClickedArgs(Action> openSubmenu, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet eventInterfaces) + : base(addon, agent, type, eventInterfaces) + { + this.OnOpenSubmenu = openSubmenu; + } + + private Action> OnOpenSubmenu { get; } + + /// + /// Opens a submenu with the given name and items. + /// + /// The name of the submenu, displayed at the top. + /// The items to display in the submenu. + public void OpenSubmenu(SeString name, IReadOnlyList items) => + this.OnOpenSubmenu(name, items); + + /// + /// Opens a submenu with the given items. + /// + /// The items to display in the submenu. + public void OpenSubmenu(IReadOnlyList items) => + this.OnOpenSubmenu(null, items); +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuOpenedArgs.cs b/Dalamud/Game/Gui/ContextMenu/MenuOpenedArgs.cs new file mode 100644 index 000000000..de3347f63 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuOpenedArgs.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Callback args used when a menu item is opened. +/// +public sealed unsafe class MenuOpenedArgs : MenuArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// Callback for adding a custom menu item. + /// Addon associated with the context menu. + /// Agent associated with the context menu. + /// The type of context menu. + /// List of AtkEventInterfaces associated with the context menu. + internal MenuOpenedArgs(Action addMenuItem, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet eventInterfaces) + : base(addon, agent, type, eventInterfaces) + { + this.OnAddMenuItem = addMenuItem; + } + + private Action OnAddMenuItem { get; } + + /// + /// Adds a custom menu item to the context menu. + /// + /// The menu item to add. + public void AddMenuItem(MenuItem item) => + this.OnAddMenuItem(item); +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuTarget.cs b/Dalamud/Game/Gui/ContextMenu/MenuTarget.cs new file mode 100644 index 000000000..c486a3b9b --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuTarget.cs @@ -0,0 +1,9 @@ +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Base class for contexts. +/// Discriminated based on . +/// +public abstract class MenuTarget +{ +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuTargetDefault.cs b/Dalamud/Game/Gui/ContextMenu/MenuTargetDefault.cs new file mode 100644 index 000000000..d87bc36b6 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuTargetDefault.cs @@ -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; + +/// +/// Target information on a default context menu. +/// +public sealed unsafe class MenuTargetDefault : MenuTarget +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent associated with the context menu. + internal MenuTargetDefault(AgentContext* context) + { + this.Context = context; + } + + /// + /// Gets the name of the target. + /// + public string TargetName => this.Context->TargetName.ToString(); + + /// + /// Gets the object id of the target. + /// + public ulong TargetObjectId => this.Context->TargetObjectId; + + /// + /// Gets the target object. + /// + public GameObject? TargetObject => Service.Get().SearchById(this.TargetObjectId); + + /// + /// Gets the content id of the target. + /// + public ulong TargetContentId => this.Context->TargetContentId; + + /// + /// Gets the home world id of the target. + /// + public ExcelResolver TargetHomeWorld => new((uint)this.Context->TargetHomeWorldId); + + /// + /// Gets the currently targeted character. Only shows up for specific targets, like friends, party finder listings, or party members. + /// Just because this is doesn't mean the target isn't a character. + /// + public CharacterData? TargetCharacter + { + get + { + var target = this.Context->CurrentContextMenuTarget; + if (target != null) + return new(target); + return null; + } + } + + private AgentContext* Context { get; } +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuTargetInventory.cs b/Dalamud/Game/Gui/ContextMenu/MenuTargetInventory.cs new file mode 100644 index 000000000..dee550370 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuTargetInventory.cs @@ -0,0 +1,36 @@ +using Dalamud.Game.Inventory; + +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Target information on an inventory context menu. +/// +public sealed unsafe class MenuTargetInventory : MenuTarget +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent associated with the context menu. + internal MenuTargetInventory(AgentInventoryContext* context) + { + this.Context = context; + } + + /// + /// Gets the target item. + /// + public GameInventoryItem? TargetItem + { + get + { + var target = this.Context->TargetInventorySlot; + if (target != null) + return new(*target); + return null; + } + } + + private AgentInventoryContext* Context { get; } +} diff --git a/Dalamud/Game/Gui/Dtr/DtrBar.cs b/Dalamud/Game/Gui/Dtr/DtrBar.cs index dd1e7aa30..dbf6fba3c 100644 --- a/Dalamud/Game/Gui/Dtr/DtrBar.cs +++ b/Dalamud/Game/Gui/Dtr/DtrBar.cs @@ -1,31 +1,33 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using Dalamud.Configuration.Internal; +using Dalamud.Game.Addon.Events; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.Text.SeStringHandling; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Graphics; using FFXIVClientStructs.FFXIV.Client.System.Memory; using FFXIVClientStructs.FFXIV.Component.GUI; -using Serilog; namespace Dalamud.Game.Gui.Dtr; /// /// Class used to interface with the server info bar. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar +internal sealed unsafe class DtrBar : IInternalDisposableService, IDtrBar { private const uint BaseNodeId = 1000; + private static readonly ModuleLog Log = new("DtrBar"); + [ServiceManager.ServiceDependency] private readonly Framework framework = Service.Get(); @@ -35,12 +37,35 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); - private List entries = new(); + [ServiceManager.ServiceDependency] + private readonly AddonEventManager uiEventManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly AddonLifecycle addonLifecycle = Service.Get(); + + private readonly AddonLifecycleEventListener dtrPostDrawListener; + private readonly AddonLifecycleEventListener dtrPostRequestedUpdateListener; + private readonly AddonLifecycleEventListener dtrPreFinalizeListener; + + private readonly ConcurrentBag newEntries = new(); + private readonly List entries = new(); + + private readonly Dictionary> eventHandles = new(); + private uint runningNodeIds = BaseNodeId; + private float entryStartPos = float.NaN; [ServiceManager.ServiceConstructor] private DtrBar() { + this.dtrPostDrawListener = new AddonLifecycleEventListener(AddonEvent.PostDraw, "_DTR", this.FixCollision); + this.dtrPostRequestedUpdateListener = new AddonLifecycleEventListener(AddonEvent.PostRequestedUpdate, "_DTR", this.FixCollision); + this.dtrPreFinalizeListener = new AddonLifecycleEventListener(AddonEvent.PreFinalize, "_DTR", this.PreFinalize); + + this.addonLifecycle.RegisterListener(this.dtrPostDrawListener); + this.addonLifecycle.RegisterListener(this.dtrPostRequestedUpdateListener); + this.addonLifecycle.RegisterListener(this.dtrPreFinalizeListener); + this.framework.Update += this.Update; this.configuration.DtrOrder ??= new List(); @@ -51,25 +76,37 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar /// public DtrBarEntry Get(string title, SeString? text = null) { - if (this.entries.Any(x => x.Title == title)) + if (this.entries.Any(x => x.Title == title) || this.newEntries.Any(x => x.Title == title)) throw new ArgumentException("An entry with the same title already exists."); - var node = this.MakeNode(++this.runningNodeIds); - var entry = new DtrBarEntry(title, node); + var entry = new DtrBarEntry(title, null); entry.Text = text; // Add the entry to the end of the order list, if it's not there already. if (!this.configuration.DtrOrder!.Contains(title)) this.configuration.DtrOrder!.Add(title); - this.entries.Add(entry); - this.ApplySort(); + + this.newEntries.Add(entry); return entry; } + + /// + public void Remove(string title) + { + if (this.entries.FirstOrDefault(entry => entry.Title == title) is { } dtrBarEntry) + { + dtrBarEntry.Remove(); + } + } /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { + this.addonLifecycle.UnregisterListener(this.dtrPostDrawListener); + this.addonLifecycle.UnregisterListener(this.dtrPostRequestedUpdateListener); + this.addonLifecycle.UnregisterListener(this.dtrPreFinalizeListener); + foreach (var entry in this.entries) this.RemoveNode(entry.TextNode); @@ -133,12 +170,13 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar private AtkUnitBase* GetDtr() => (AtkUnitBase*)this.gameGui.GetAddonByName("_DTR").ToPointer(); - private void Update(Framework unused) + private void Update(IFramework unused) { this.HandleRemovedNodes(); + this.HandleAddedNodes(); var dtr = this.GetDtr(); - if (dtr == null) return; + if (dtr == null || dtr->RootNode == null || dtr->RootNode->ChildNode == null) return; // The collision node on the DTR element is always the width of its content if (dtr->UldManager.NodeList == null) return; @@ -148,37 +186,32 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar if (!this.CheckForDalamudNodes()) this.RecreateNodes(); - var collisionNode = dtr->UldManager.NodeList[1]; + var collisionNode = dtr->GetNodeById(17); if (collisionNode == null) return; - // If we are drawing backwards, we should start from the right side of the collision node. That is, - // collisionNode->X + collisionNode->Width. - var runningXPos = this.configuration.DtrSwapDirection - ? collisionNode->X + collisionNode->Width - : collisionNode->X; + // We haven't calculated the native size yet, so we don't know where to start positioning. + if (float.IsNaN(this.entryStartPos)) return; - for (var i = 0; i < this.entries.Count; i++) + var runningXPos = this.entryStartPos; + + foreach (var data in this.entries) { - var data = this.entries[i]; var isHide = this.configuration.DtrIgnore!.Any(x => x == data.Title) || !data.Shown; - if (data.Dirty && data.Added && data.Text != null && data.TextNode != null) + if (data is { Dirty: true, Added: true, Text: not null, TextNode: not null }) { var node = data.TextNode; - node->SetText(data.Text?.Encode()); + node->SetText(data.Text.Encode()); ushort w = 0, h = 0; - if (isHide) + if (!isHide) { - node->AtkResNode.ToggleVisibility(false); - } - else - { - node->AtkResNode.ToggleVisibility(true); node->GetTextDrawSize(&w, &h, node->NodeText.StringPtr); node->AtkResNode.SetWidth(w); } + node->AtkResNode.ToggleVisibility(!isHide); + data.Dirty = false; } @@ -193,7 +226,7 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar if (this.configuration.DtrSwapDirection) { - data.TextNode->AtkResNode.SetPositionFloat(runningXPos, 2); + data.TextNode->AtkResNode.SetPositionFloat(runningXPos + this.configuration.DtrSpacing, 2); runningXPos += elementWidth; } else @@ -202,11 +235,86 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar data.TextNode->AtkResNode.SetPositionFloat(runningXPos, 2); } } - - this.entries[i] = data; + else + { + // If we want the node hidden, shift it up, to prevent collision conflicts + data.TextNode->AtkResNode.SetY(-collisionNode->Height * dtr->RootNode->ScaleX); + } } } + private void HandleAddedNodes() + { + if (this.newEntries.Any()) + { + foreach (var newEntry in this.newEntries) + { + newEntry.TextNode = this.MakeNode(++this.runningNodeIds); + this.entries.Add(newEntry); + } + + this.newEntries.Clear(); + this.ApplySort(); + } + } + + private void FixCollision(AddonEvent eventType, AddonArgs addonInfo) + { + var addon = (AtkUnitBase*)addonInfo.Addon; + if (addon->RootNode is null || addon->UldManager.NodeList is null) return; + + float minX = addon->RootNode->Width; + var additionalWidth = 0; + AtkResNode* collisionNode = null; + + foreach (var index in Enumerable.Range(0, addon->UldManager.NodeListCount)) + { + var node = addon->UldManager.NodeList[index]; + if (node->IsVisible) + { + var nodeId = node->NodeID; + var nodeType = node->Type; + + if (nodeType == NodeType.Collision) + { + collisionNode = node; + } + else if (nodeId >= BaseNodeId) + { + // Dalamud-created node + additionalWidth += node->Width + this.configuration.DtrSpacing; + } + else if ((nodeType == NodeType.Res || (ushort)nodeType >= 1000) && + (node->ChildNode == null || node->ChildNode->IsVisible)) + { + // Native top-level node. These are are either res nodes or button components. + // Both the node and its child (if it has one) must be visible for the node to be counted. + minX = MathF.Min(minX, node->X); + } + } + } + + if (collisionNode == null) return; + + var nativeWidth = addon->RootNode->Width - (int)minX; + var targetX = minX - (this.configuration.DtrSwapDirection ? 0 : additionalWidth); + var targetWidth = (ushort)(nativeWidth + additionalWidth); + + if (collisionNode->Width != targetWidth || collisionNode->X != targetX) + { + collisionNode->SetWidth(targetWidth); + collisionNode->SetX(targetX); + } + + // If we are drawing backwards, we should start from the right side of the native nodes. + this.entryStartPos = this.configuration.DtrSwapDirection ? minX + nativeWidth : minX; + } + + private void PreFinalize(AddonEvent type, AddonArgs args) + { + this.entryStartPos = float.NaN; + } + /// /// Checks if there are any Dalamud nodes in the DTR. /// @@ -228,6 +336,11 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar private void RecreateNodes() { this.runningNodeIds = BaseNodeId; + if (this.entries.Any()) + { + this.eventHandles.Clear(); + } + foreach (var entry in this.entries) { entry.TextNode = this.MakeNode(++this.runningNodeIds); @@ -240,6 +353,14 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar var dtr = this.GetDtr(); if (dtr == null || dtr->RootNode == null || dtr->UldManager.NodeList == null || node == null) return false; + this.eventHandles.TryAdd(node->AtkResNode.NodeID, new List()); + this.eventHandles[node->AtkResNode.NodeID].AddRange(new List + { + this.uiEventManager.AddEvent(AddonEventManager.DalamudInternalKey, (nint)dtr, (nint)node, AddonEventType.MouseOver, this.DtrEventHandler), + this.uiEventManager.AddEvent(AddonEventManager.DalamudInternalKey, (nint)dtr, (nint)node, AddonEventType.MouseOut, this.DtrEventHandler), + this.uiEventManager.AddEvent(AddonEventManager.DalamudInternalKey, (nint)dtr, (nint)node, AddonEventType.MouseClick, this.DtrEventHandler), + }); + var lastChild = dtr->RootNode->ChildNode; while (lastChild->PrevSiblingNode != null) lastChild = lastChild->PrevSiblingNode; Log.Debug($"Found last sibling: {(ulong)lastChild:X}"); @@ -251,14 +372,18 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar Log.Debug("Set last sibling of DTR and updated child count"); dtr->UldManager.UpdateDrawNodeList(); + dtr->UpdateCollisionNodeList(false); Log.Debug("Updated node draw list"); return true; } - private bool RemoveNode(AtkTextNode* node) + private void RemoveNode(AtkTextNode* node) { var dtr = this.GetDtr(); - if (dtr == null || dtr->RootNode == null || dtr->UldManager.NodeList == null || node == null) return false; + if (dtr == null || dtr->RootNode == null || dtr->UldManager.NodeList == null || node == null) return; + + this.eventHandles[node->AtkResNode.NodeID].ForEach(handle => this.uiEventManager.RemoveEvent(AddonEventManager.DalamudInternalKey, handle)); + this.eventHandles[node->AtkResNode.NodeID].Clear(); var tmpPrevNode = node->AtkResNode.PrevSiblingNode; var tmpNextNode = node->AtkResNode.NextSiblingNode; @@ -272,25 +397,22 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar dtr->RootNode->ChildCount = (ushort)(dtr->RootNode->ChildCount - 1); Log.Debug("Set last sibling of DTR and updated child count"); dtr->UldManager.UpdateDrawNodeList(); + dtr->UpdateCollisionNodeList(false); Log.Debug("Updated node draw list"); - return true; } private AtkTextNode* MakeNode(uint nodeId) { - var newTextNode = (AtkTextNode*)IMemorySpace.GetUISpace()->Malloc((ulong)sizeof(AtkTextNode), 8); + var newTextNode = IMemorySpace.GetUISpace()->Create(); // AtkUldManager.CreateAtkTextNode(); if (newTextNode == null) { - Log.Debug("Failed to allocate memory for text node"); + Log.Debug("Failed to allocate memory for AtkTextNode"); return null; } - IMemorySpace.Memset(newTextNode, 0, (ulong)sizeof(AtkTextNode)); - newTextNode->Ctor(); - newTextNode->AtkResNode.NodeID = nodeId; newTextNode->AtkResNode.Type = NodeType.Text; - newTextNode->AtkResNode.NodeFlags = NodeFlags.AnchorLeft | NodeFlags.AnchorTop; + newTextNode->AtkResNode.NodeFlags = NodeFlags.AnchorLeft | NodeFlags.AnchorTop | NodeFlags.Enabled | NodeFlags.RespondToMouse | NodeFlags.HasCollision | NodeFlags.EmitsEvents; newTextNode->AtkResNode.DrawFlags = 12; newTextNode->AtkResNode.SetWidth(22); newTextNode->AtkResNode.SetHeight(22); @@ -304,16 +426,107 @@ public sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar newTextNode->SetText(" "); - newTextNode->TextColor.R = 255; - newTextNode->TextColor.G = 255; - newTextNode->TextColor.B = 255; - newTextNode->TextColor.A = 255; + newTextNode->TextColor = new ByteColor { R = 255, G = 255, B = 255, A = 255 }; + newTextNode->EdgeColor = new ByteColor { R = 142, G = 106, B = 12, A = 255 }; - newTextNode->EdgeColor.R = 142; - newTextNode->EdgeColor.G = 106; - newTextNode->EdgeColor.B = 12; - newTextNode->EdgeColor.A = 255; + // ICreatable was restored, this may be necessary if AtkUldManager.CreateAtkTextNode(); is used instead of Create + // // Memory is filled with random data after being created, zero out some things to avoid issues. + // newTextNode->UnkPtr_1 = null; + // newTextNode->SelectStart = 0; + // newTextNode->SelectEnd = 0; + // newTextNode->FontCacheHandle = 0; + // newTextNode->CharSpacing = 0; + // newTextNode->BackgroundColor = new ByteColor { R = 0, G = 0, B = 0, A = 0 }; + // newTextNode->TextId = 0; + // newTextNode->SheetType = 0; return newTextNode; } + + private void DtrEventHandler(AddonEventType atkEventType, IntPtr atkUnitBase, IntPtr atkResNode) + { + var addon = (AtkUnitBase*)atkUnitBase; + var node = (AtkResNode*)atkResNode; + + if (this.entries.FirstOrDefault(entry => entry.TextNode == node) is not { } dtrBarEntry) return; + + if (dtrBarEntry is { Tooltip: not null }) + { + switch (atkEventType) + { + case AddonEventType.MouseOver: + AtkStage.GetSingleton()->TooltipManager.ShowTooltip(addon->ID, node, dtrBarEntry.Tooltip.Encode()); + break; + + case AddonEventType.MouseOut: + AtkStage.GetSingleton()->TooltipManager.HideTooltip(addon->ID); + break; + } + } + + if (dtrBarEntry is { OnClick: not null }) + { + switch (atkEventType) + { + case AddonEventType.MouseOver: + this.uiEventManager.SetCursor(AddonCursorType.Clickable); + break; + + case AddonEventType.MouseOut: + this.uiEventManager.ResetCursor(); + break; + + case AddonEventType.MouseClick: + dtrBarEntry.OnClick.Invoke(); + break; + } + } + } +} + +/// +/// Plugin-scoped version of a AddonEventManager service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class DtrBarPluginScoped : IInternalDisposableService, IDtrBar +{ + [ServiceManager.ServiceDependency] + private readonly DtrBar dtrBarService = Service.Get(); + + private readonly Dictionary pluginEntries = new(); + + /// + void IInternalDisposableService.DisposeService() + { + foreach (var entry in this.pluginEntries) + { + entry.Value.Remove(); + } + + this.pluginEntries.Clear(); + } + + /// + public DtrBarEntry Get(string title, SeString? text = null) + { + // If we already have a known entry for this plugin, return it. + if (this.pluginEntries.TryGetValue(title, out var existingEntry)) return existingEntry; + + return this.pluginEntries[title] = this.dtrBarService.Get(title, text); + } + + /// + public void Remove(string title) + { + if (this.pluginEntries.TryGetValue(title, out var existingEntry)) + { + existingEntry.Remove(); + this.pluginEntries.Remove(title); + } + } } diff --git a/Dalamud/Game/Gui/Dtr/DtrBarEntry.cs b/Dalamud/Game/Gui/Dtr/DtrBarEntry.cs index c5bdb7e85..f04e1427d 100644 --- a/Dalamud/Game/Gui/Dtr/DtrBarEntry.cs +++ b/Dalamud/Game/Gui/Dtr/DtrBarEntry.cs @@ -41,6 +41,16 @@ public sealed unsafe class DtrBarEntry : IDisposable this.Dirty = true; } } + + /// + /// Gets or sets a tooltip to be shown when the user mouses over the dtr entry. + /// + public SeString? Tooltip { get; set; } + + /// + /// Gets or sets a action to be invoked when the user clicks on the dtr entry. + /// + public Action? OnClick { get; set; } /// /// Gets or sets a value indicating whether this entry is visible. diff --git a/Dalamud/Game/Gui/FlyText/FlyTextGui.cs b/Dalamud/Game/Gui/FlyText/FlyTextGui.cs index f2222a7cd..9310529e4 100644 --- a/Dalamud/Game/Gui/FlyText/FlyTextGui.cs +++ b/Dalamud/Game/Gui/FlyText/FlyTextGui.cs @@ -1,4 +1,3 @@ -using System; using System.Runtime.InteropServices; using System.Threading.Tasks; @@ -7,6 +6,7 @@ using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Memory; +using Dalamud.Plugin.Services; using Serilog; namespace Dalamud.Game.Gui.FlyText; @@ -14,10 +14,9 @@ namespace Dalamud.Game.Gui.FlyText; /// /// This class facilitates interacting with and creating native in-game "fly text". /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public sealed class FlyTextGui : IDisposable, IServiceType +internal sealed class FlyTextGui : IInternalDisposableService, IFlyTextGui { /// /// The native function responsible for adding fly text to the UI. See . @@ -30,40 +29,16 @@ public sealed class FlyTextGui : IDisposable, IServiceType private readonly Hook createFlyTextHook; [ServiceManager.ServiceConstructor] - private FlyTextGui(SigScanner sigScanner) + private FlyTextGui(TargetSigScanner sigScanner) { this.Address = new FlyTextGuiAddressResolver(); this.Address.Setup(sigScanner); this.addFlyTextNative = Marshal.GetDelegateForFunctionPointer(this.Address.AddFlyText); this.createFlyTextHook = Hook.FromAddress(this.Address.CreateFlyText, this.CreateFlyTextDetour); - } - /// - /// The delegate defining the type for the FlyText event. - /// - /// The FlyTextKind. See . - /// Value1 passed to the native flytext function. - /// Value2 passed to the native flytext function. Seems unused. - /// Text1 passed to the native flytext function. - /// Text2 passed to the native flytext function. - /// Color passed to the native flytext function. Changes flytext color. - /// Icon ID passed to the native flytext function. Only displays with select FlyTextKind. - /// Damage Type Icon ID passed to the native flytext function. Displayed next to damage values to denote damage type. - /// The vertical offset to place the flytext at. 0 is default. Negative values result - /// in text appearing higher on the screen. This does not change where the element begins to fade. - /// Whether this flytext has been handled. If a subscriber sets this to true, the FlyText will not appear. - public delegate void OnFlyTextCreatedDelegate( - ref FlyTextKind kind, - ref int val1, - ref int val2, - ref SeString text1, - ref SeString text2, - ref uint color, - ref uint icon, - ref uint damageTypeIcon, - ref float yOffset, - ref bool handled); + this.createFlyTextHook.Enable(); + } /// /// Private delegate for the native CreateFlyText function's hook. @@ -95,41 +70,26 @@ public sealed class FlyTextGui : IDisposable, IServiceType uint offsetStrMax, int unknown); - /// - /// The FlyText event that can be subscribed to. - /// - public event OnFlyTextCreatedDelegate? FlyTextCreated; - - private Dalamud Dalamud { get; } + /// + public event IFlyTextGui.OnFlyTextCreatedDelegate? FlyTextCreated; private FlyTextGuiAddressResolver Address { get; } /// /// Disposes of managed and unmanaged resources. /// - public void Dispose() + void IInternalDisposableService.DisposeService() { this.createFlyTextHook.Dispose(); } - /// - /// Displays a fly text in-game on the local player. - /// - /// The FlyTextKind. See . - /// The index of the actor to place flytext on. Indexing unknown. 1 places flytext on local player. - /// Value1 passed to the native flytext function. - /// Value2 passed to the native flytext function. Seems unused. - /// Text1 passed to the native flytext function. - /// Text2 passed to the native flytext function. - /// Color passed to the native flytext function. Changes flytext color. - /// Icon ID passed to the native flytext function. Only displays with select FlyTextKind. - /// Damage Type Icon ID passed to the native flytext function. Displayed next to damage values to denote damage type. + /// public unsafe void AddFlyText(FlyTextKind kind, uint actorIndex, uint val1, uint val2, SeString text1, SeString text2, uint color, uint icon, uint damageTypeIcon) { // Known valid flytext region within the atk arrays - var numIndex = 28; - var strIndex = 25; - var numOffset = 147u; + var numIndex = 30; + var strIndex = 27; + var numOffset = 161u; var strOffset = 28u; // Get the UI module and flytext addon pointers @@ -154,32 +114,26 @@ public sealed class FlyTextGui : IDisposable, IServiceType numArray->IntArray[numOffset + 2] = unchecked((int)val1); numArray->IntArray[numOffset + 3] = unchecked((int)val2); numArray->IntArray[numOffset + 4] = unchecked((int)damageTypeIcon); // Icons for damage type - numArray->IntArray[numOffset + 5] = 5; // Unknown + numArray->IntArray[numOffset + 5] = 5; // Unknown numArray->IntArray[numOffset + 6] = unchecked((int)color); numArray->IntArray[numOffset + 7] = unchecked((int)icon); numArray->IntArray[numOffset + 8] = 0; // Unknown numArray->IntArray[numOffset + 9] = 0; // Unknown, has something to do with yOffset - fixed (byte* pText1 = text1.Encode()) - { - fixed (byte* pText2 = text2.Encode()) - { - strArray->StringArray[strOffset + 0] = pText1; - strArray->StringArray[strOffset + 1] = pText2; + strArray->SetValue((int)strOffset + 0, text1.Encode(), false, true, false); + strArray->SetValue((int)strOffset + 1, text2.Encode(), false, true, false); - this.addFlyTextNative( - flytext, - actorIndex, - 1, - (IntPtr)numArray, - numOffset, - 9, - (IntPtr)strArray, - strOffset, - 2, - 0); - } - } + this.addFlyTextNative( + flytext, + actorIndex, + 1, + (IntPtr)numArray, + numOffset, + 10, + (IntPtr)strArray, + strOffset, + 2, + 0); } private static byte[] Terminate(byte[] source) @@ -191,12 +145,6 @@ public sealed class FlyTextGui : IDisposable, IServiceType return terminated; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(GameGui gameGui) - { - this.createFlyTextHook.Enable(); - } - private IntPtr CreateFlyTextDetour( IntPtr addonFlyText, FlyTextKind kind, @@ -271,7 +219,8 @@ public sealed class FlyTextGui : IDisposable, IServiceType if (!dirty) { Log.Verbose("[FlyText] Calling flytext with original args."); - return this.createFlyTextHook.Original(addonFlyText, kind, val1, val2, text2, color, icon, damageTypeIcon, text1, yOffset); + return this.createFlyTextHook.Original(addonFlyText, kind, val1, val2, text2, color, icon, + damageTypeIcon, text1, yOffset); } var terminated1 = Terminate(tmpText1.Encode()); @@ -318,3 +267,46 @@ public sealed class FlyTextGui : IDisposable, IServiceType return retVal; } } + +/// +/// Plugin scoped version of FlyTextGui. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class FlyTextGuiPluginScoped : IInternalDisposableService, IFlyTextGui +{ + [ServiceManager.ServiceDependency] + private readonly FlyTextGui flyTextGuiService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal FlyTextGuiPluginScoped() + { + this.flyTextGuiService.FlyTextCreated += this.FlyTextCreatedForward; + } + + /// + public event IFlyTextGui.OnFlyTextCreatedDelegate? FlyTextCreated; + + /// + void IInternalDisposableService.DisposeService() + { + this.flyTextGuiService.FlyTextCreated -= this.FlyTextCreatedForward; + + this.FlyTextCreated = null; + } + + /// + public void AddFlyText(FlyTextKind kind, uint actorIndex, uint val1, uint val2, SeString text1, SeString text2, uint color, uint icon, uint damageTypeIcon) + { + this.flyTextGuiService.AddFlyText(kind, actorIndex, val1, val2, text1, text2, color, icon, damageTypeIcon); + } + + private void FlyTextCreatedForward(ref FlyTextKind kind, ref int val1, ref int val2, ref SeString text1, ref SeString text2, ref uint color, ref uint icon, ref uint damageTypeIcon, ref float yOffset, ref bool handled) + => this.FlyTextCreated?.Invoke(ref kind, ref val1, ref val2, ref text1, ref text2, ref color, ref icon, ref damageTypeIcon, ref yOffset, ref handled); +} diff --git a/Dalamud/Game/Gui/FlyText/FlyTextGuiAddressResolver.cs b/Dalamud/Game/Gui/FlyText/FlyTextGuiAddressResolver.cs index 588177032..c4bdc8dd5 100644 --- a/Dalamud/Game/Gui/FlyText/FlyTextGuiAddressResolver.cs +++ b/Dalamud/Game/Gui/FlyText/FlyTextGuiAddressResolver.cs @@ -1,11 +1,9 @@ -using System; - namespace Dalamud.Game.Gui.FlyText; /// /// An address resolver for the class. /// -public class FlyTextGuiAddressResolver : BaseAddressResolver +internal class FlyTextGuiAddressResolver : BaseAddressResolver { /// /// Gets the address of the native AddFlyText method, which occurs @@ -23,7 +21,7 @@ public class FlyTextGuiAddressResolver : BaseAddressResolver public IntPtr CreateFlyText { get; private set; } /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.AddFlyText = sig.ScanText("E8 ?? ?? ?? ?? FF C7 41 D1 C7"); this.CreateFlyText = sig.ScanText("40 53 55 41 56 48 83 EC 40 48 63 EA"); diff --git a/Dalamud/Game/Gui/FlyText/FlyTextKind.cs b/Dalamud/Game/Gui/FlyText/FlyTextKind.cs index 68650fb5c..3727fd0f8 100644 --- a/Dalamud/Game/Gui/FlyText/FlyTextKind.cs +++ b/Dalamud/Game/Gui/FlyText/FlyTextKind.cs @@ -1,57 +1,58 @@ namespace Dalamud.Game.Gui.FlyText; /// -/// Enum of FlyTextKind values. Members suffixed with -/// a number seem to be a duplicate, or perform duplicate behavior. +/// Enum of FlyTextKind values. /// public enum FlyTextKind : int { /// /// Val1 in serif font, Text2 in sans-serif as subtitle. - /// Used for autos and incoming DoTs. /// - AutoAttack = 0, + AutoAttackOrDot = 0, /// /// Val1 in serif font, Text2 in sans-serif as subtitle. /// Does a bounce effect on appearance. /// - DirectHit = 1, + AutoAttackOrDotDh = 1, /// /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle. /// Does a bigger bounce effect on appearance. /// - CriticalHit = 2, + AutoAttackOrDotCrit = 2, /// - /// Val1 in even larger serif font with 2 exclamations, Text2 in - /// sans-serif as subtitle. Does a large bounce effect on appearance. - /// Does not scroll up or down the screen. + /// Val1 in even larger serif font with 2 exclamations, Text2 in sans-serif as subtitle. + /// Does a large bounce effect on appearance. Does not scroll up or down the screen. /// - CriticalDirectHit = 3, + AutoAttackOrDotCritDh = 3, /// - /// AutoAttack with sans-serif Text1 to the left of the Val1. + /// Val1 in serif font, Text2 in sans-serif as subtitle with sans-serif Text1 to the left of the Val1. /// - NamedAttack = 4, + Damage = 4, /// - /// DirectHit with sans-serif Text1 to the left of the Val1. + /// Val1 in serif font, Text2 in sans-serif as subtitle with sans-serif Text1 to the left of the Val1. + /// Does a bounce effect on appearance. /// - NamedDirectHit = 5, + DamageDh = 5, /// - /// CriticalHit with sans-serif Text1 to the left of the Val1. + /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle with sans-serif Text1 to the left of the Val1. + /// Does a bigger bounce effect on appearance. /// - NamedCriticalHit = 6, + DamageCrit = 6, /// - /// CriticalDirectHit with sans-serif Text1 to the left of the Val1. + /// Val1 in even larger serif font with 2 exclamations, Text2 in sans-serif as subtitle with sans-serif Text1 to the left of the Val1. + /// Does a large bounce effect on appearance. Does not scroll up or down the screen. /// - NamedCriticalDirectHit = 7, + DamageCritDh = 7, /// + /// The text changes to DODGE under certain circumstances. /// All caps, serif MISS. /// Miss = 8, @@ -74,12 +75,12 @@ public enum FlyTextKind : int /// /// Icon next to sans-serif Text1. /// - NamedIcon = 12, + Buff = 12, /// - /// Icon next to sans-serif Text1 (2). + /// Icon next to sans-serif Text1. /// - NamedIcon2 = 13, + Debuff = 13, /// /// Serif Val1 with all caps condensed font EXP with Text2 in sans-serif as subtitle. @@ -94,42 +95,44 @@ public enum FlyTextKind : int /// /// Sans-serif Text1 next to serif Val1 with all caps condensed font MP with Text2 in sans-serif as subtitle. /// - NamedMp = 16, + MpDrain = 16, /// + /// Currently not used by the game. /// Sans-serif Text1 next to serif Val1 with all caps condensed font TP with Text2 in sans-serif as subtitle. /// NamedTp = 17, /// - /// AutoAttack with sans-serif Text1 to the left of the Val1 (2). + /// Val1 in serif font, Text2 in sans-serif as subtitle with sans-serif Text1 to the left of the Val1. /// - NamedAttack2 = 18, + Healing = 18, /// - /// Sans-serif Text1 next to serif Val1 with all caps condensed font MP with Text2 in sans-serif as subtitle (2). + /// Sans-serif Text1 next to serif Val1 with all caps condensed font MP with Text2 in sans-serif as subtitle. /// - NamedMp2 = 19, + MpRegen = 19, /// - /// Sans-serif Text1 next to serif Val1 with all caps condensed font TP with Text2 in sans-serif as subtitle (2). + /// Currently not used by the game. + /// Sans-serif Text1 next to serif Val1 with all caps condensed font TP with Text2 in sans-serif as subtitle. /// NamedTp2 = 20, /// /// Sans-serif Text1 next to serif Val1 with all caps condensed font EP with Text2 in sans-serif as subtitle. /// - NamedEp = 21, + EpRegen = 21, /// /// Sans-serif Text1 next to serif Val1 with all caps condensed font CP with Text2 in sans-serif as subtitle. /// - NamedCp = 22, + CpRegen = 22, /// /// Sans-serif Text1 next to serif Val1 with all caps condensed font GP with Text2 in sans-serif as subtitle. /// - NamedGp = 23, + GpRegen = 23, /// /// Displays nothing. @@ -149,57 +152,59 @@ public enum FlyTextKind : int Interrupted = 26, /// - /// AutoAttack with no Text2. + /// Val1 in serif font. /// - AutoAttackNoText = 27, + CraftingProgress = 27, /// - /// AutoAttack with no Text2 (2). + /// Val1 in serif font. /// - AutoAttackNoText2 = 28, + CraftingQuality = 28, /// - /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle. Does a bigger bounce effect on appearance (2). + /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle. Does a bigger bounce effect on appearance. /// - CriticalHit2 = 29, + CraftingQualityCrit = 29, /// - /// AutoAttack with no Text2 (3). + /// Currently not used by the game. + /// Val1 in serif font. /// AutoAttackNoText3 = 30, /// /// CriticalHit with sans-serif Text1 to the left of the Val1 (2). /// - NamedCriticalHit2 = 31, + HealingCrit = 31, /// - /// Same as NamedCriticalHit with a green (cannot change) MP in condensed font to the right of Val1. + /// Currently not used by the game. + /// Same as DamageCrit with a MP in condensed font to the right of Val1. /// Does a jiggle effect to the right on appearance. /// NamedCriticalHitWithMp = 32, /// - /// Same as NamedCriticalHit with a yellow (cannot change) TP in condensed font to the right of Val1. + /// Currently not used by the game. + /// Same as DamageCrit with a TP in condensed font to the right of Val1. /// Does a jiggle effect to the right on appearance. /// NamedCriticalHitWithTp = 33, /// - /// Same as NamedIcon with sans-serif "has no effect!" to the right. + /// Icon next to sans-serif Text1 with sans-serif "has no effect!" to the right. /// - NamedIconHasNoEffect = 34, + DebuffNoEffect = 34, /// - /// Same as NamedIcon but Text1 is slightly faded. Used for buff expiration. + /// Icon next to sans-serif slightly faded Text1. /// - NamedIconFaded = 35, + BuffFading = 35, /// - /// Same as NamedIcon but Text1 is slightly faded (2). - /// Used for buff expiration. + /// Icon next to sans-serif slightly faded Text1. /// - NamedIconFaded2 = 36, + DebuffFading = 36, /// /// Text1 in sans-serif font. @@ -207,9 +212,9 @@ public enum FlyTextKind : int Named = 37, /// - /// Same as NamedIcon with sans-serif "(fully resisted)" to the right. + /// Icon next to sans-serif Text1 with sans-serif "(fully resisted)" to the right. /// - NamedIconFullyResisted = 38, + DebuffResisted = 38, /// /// All caps serif 'INCAPACITATED!'. @@ -219,32 +224,34 @@ public enum FlyTextKind : int /// /// Text1 with sans-serif "(fully resisted)" to the right. /// - NamedFullyResisted = 40, + FullyResisted = 40, /// /// Text1 with sans-serif "has no effect!" to the right. /// - NamedHasNoEffect = 41, + HasNoEffect = 41, /// - /// AutoAttack with sans-serif Text1 to the left of the Val1 (3). + /// Val1 in serif font, Text2 in sans-serif as subtitle with sans-serif Text1 to the left of the Val1. /// - NamedAttack3 = 42, + HpDrain = 42, /// - /// Sans-serif Text1 next to serif Val1 with all caps condensed font MP with Text2 in sans-serif as subtitle (3). + /// Currently not used by the game. + /// Sans-serif Text1 next to serif Val1 with all caps condensed font MP with Text2 in sans-serif as subtitle. /// NamedMp3 = 43, /// - /// Sans-serif Text1 next to serif Val1 with all caps condensed font TP with Text2 in sans-serif as subtitle (3). + /// Currently not used by the game. + /// Sans-serif Text1 next to serif Val1 with all caps condensed font TP with Text2 in sans-serif as subtitle. /// NamedTp3 = 44, /// - /// Same as NamedIcon with serif "INVULNERABLE!" beneath the Text1. + /// Icon next to sans-serif Text1 with serif "INVULNERABLE!" beneath the Text1. /// - NamedIconInvulnerable = 45, + DebuffInvulnerable = 45, /// /// All caps serif RESIST. @@ -252,20 +259,20 @@ public enum FlyTextKind : int Resist = 46, /// - /// Same as NamedIcon but places the given icon in the item icon outline. + /// Icon with an item icon outline next to sans-serif Text1. /// - NamedIconWithItemOutline = 47, + LootedItem = 47, /// - /// AutoAttack with no Text2 (4). + /// Val1 in serif font. /// - AutoAttackNoText4 = 48, + Collectability = 48, /// - /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle (3). + /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle. /// Does a bigger bounce effect on appearance. /// - CriticalHit3 = 49, + CollectabilityCrit = 49, /// /// All caps serif REFLECT. @@ -278,20 +285,21 @@ public enum FlyTextKind : int Reflected = 51, /// - /// Val1 in serif font, Text2 in sans-serif as subtitle (2). + /// Val1 in serif font, Text2 in sans-serif as subtitle. /// Does a bounce effect on appearance. /// - DirectHit2 = 52, + CraftingQualityDh = 52, /// - /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle (4). + /// Currently not used by the game. + /// Val1 in larger serif font with exclamation, with Text2 in sans-serif as subtitle. /// Does a bigger bounce effect on appearance. /// CriticalHit4 = 53, /// - /// Val1 in even larger serif font with 2 exclamations, Text2 in sans-serif as subtitle (2). + /// Val1 in even larger serif font with 2 exclamations, Text2 in sans-serif as subtitle. /// Does a large bounce effect on appearance. Does not scroll up or down the screen. /// - CriticalDirectHit2 = 54, + CraftingQualityCritDh = 54, } diff --git a/Dalamud/Game/Gui/GameGui.cs b/Dalamud/Game/Gui/GameGui.cs index 0235bef5a..9272aa824 100644 --- a/Dalamud/Game/Gui/GameGui.cs +++ b/Dalamud/Game/Gui/GameGui.cs @@ -1,12 +1,12 @@ -using System; using System.Numerics; using System.Runtime.InteropServices; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Hooking; -using Dalamud.Interface; +using Dalamud.Interface.Utility; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; using Dalamud.Plugin.Services; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; @@ -15,7 +15,6 @@ using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Common.Component.BGCollision; using FFXIVClientStructs.FFXIV.Component.GUI; using ImGuiNET; -using Serilog; using SharpDX; using Vector2 = System.Numerics.Vector2; @@ -26,14 +25,12 @@ namespace Dalamud.Game.Gui; /// /// A class handling many aspects of the in-game UI. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui +internal sealed unsafe class GameGui : IInternalDisposableService, IGameGui { + private static readonly ModuleLog Log = new("GameGui"); + private readonly GameGuiAddressResolver address; private readonly GetMatrixSingletonDelegate getMatrixSingleton; @@ -47,11 +44,11 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui private readonly Hook toggleUiHideHook; private readonly Hook utf8StringFromSequenceHook; - private GetUIMapObjectDelegate getUIMapObject; - private OpenMapWithFlagDelegate openMapWithFlag; + private GetUIMapObjectDelegate? getUIMapObject; + private OpenMapWithFlagDelegate? openMapWithFlag; [ServiceManager.ServiceConstructor] - private GameGui(SigScanner sigScanner) + private GameGui(TargetSigScanner sigScanner) { this.address = new GameGuiAddressResolver(); this.address.Setup(sigScanner); @@ -78,6 +75,15 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui this.toggleUiHideHook = Hook.FromAddress(this.address.ToggleUiHide, this.ToggleUiHideDetour); this.utf8StringFromSequenceHook = Hook.FromAddress(this.address.Utf8StringFromSequence, this.Utf8StringFromSequenceDetour); + + this.setGlobalBgmHook.Enable(); + this.handleItemHoverHook.Enable(); + this.handleItemOutHook.Enable(); + this.handleImmHook.Enable(); + this.toggleUiHideHook.Enable(); + this.handleActionHoverHook.Enable(); + this.handleActionOutHook.Enable(); + this.utf8StringFromSequenceHook.Enable(); } // Marshaled delegates @@ -115,16 +121,16 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui private delegate char HandleImmDelegate(IntPtr framework, char a2, byte a3); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate IntPtr ToggleUiHideDelegate(IntPtr thisPtr, byte unknownByte); + private delegate IntPtr ToggleUiHideDelegate(IntPtr thisPtr, bool uiVisible); /// - public event EventHandler UiHideToggled; + public event EventHandler? UiHideToggled; /// - public event EventHandler HoveredItemChanged; + public event EventHandler? HoveredItemChanged; /// - public event EventHandler HoveredActionChanged; + public event EventHandler? HoveredActionChanged; /// public bool GameUiHidden { get; private set; } @@ -146,7 +152,7 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui return false; } - this.getUIMapObject = this.address.GetVirtualFunction(uiModule, 0, 8); + this.getUIMapObject ??= this.address.GetVirtualFunction(uiModule, 0, 8); var uiMapObjectPtr = this.getUIMapObject(uiModule); @@ -156,7 +162,7 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui return false; } - this.openMapWithFlag = this.address.GetVirtualFunction(uiMapObjectPtr, 0, 63); + this.openMapWithFlag ??= this.address.GetVirtualFunction(uiMapObjectPtr, 0, 63); var mapLinkString = mapLink.DataString; @@ -216,14 +222,13 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui // Read current ViewProjectionMatrix plus game window size var viewProjectionMatrix = default(Matrix); - float width, height; var rawMatrix = (float*)(matrixSingleton + 0x1b4).ToPointer(); for (var i = 0; i < 16; i++, rawMatrix++) viewProjectionMatrix[i] = *rawMatrix; - width = *rawMatrix; - height = *(rawMatrix + 1); + var width = *rawMatrix; + var height = *(rawMatrix + 1); viewProjectionMatrix.Invert(); @@ -339,7 +344,7 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui /// /// Disables the hooks and submodules of this module. /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.setGlobalBgmHook.Dispose(); this.handleItemHoverHook.Dispose(); @@ -380,19 +385,6 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui this.GameUiHidden = false; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.setGlobalBgmHook.Enable(); - this.handleItemHoverHook.Enable(); - this.handleItemOutHook.Enable(); - this.handleImmHook.Enable(); - this.toggleUiHideHook.Enable(); - this.handleActionHoverHook.Enable(); - this.handleActionOutHook.Enable(); - this.utf8StringFromSequenceHook.Enable(); - } - private IntPtr HandleSetGlobalBgmDetour(ushort bgmKey, byte a2, uint a3, uint a4, uint a5, byte a6) { var retVal = this.setGlobalBgmHook.Original(bgmKey, a2, a3, a4, a5, a6); @@ -413,7 +405,7 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui this.HoveredItemChanged?.InvokeSafely(this, itemId); - Log.Verbose("HoverItemId:{0} this:{1}", itemId, hoverState.ToInt64().ToString("X")); + Log.Verbose($"HoverItemId:{itemId} this:{hoverState.ToInt64()}"); } return retVal; @@ -455,7 +447,7 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui this.HoveredAction.ActionID = (uint)Marshal.ReadInt32(hoverState, 0x3C); this.HoveredActionChanged?.InvokeSafely(this, this.HoveredAction); - Log.Verbose("HoverActionId: {0}/{1} this:{2}", actionKind, actionId, hoverState.ToInt64().ToString("X")); + Log.Verbose($"HoverActionId: {actionKind}/{actionId} this:{hoverState.ToInt64():X}"); } private IntPtr HandleActionOutDetour(IntPtr agentActionDetail, IntPtr a2, IntPtr a3, int a4) @@ -488,16 +480,16 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui return retVal; } - private IntPtr ToggleUiHideDetour(IntPtr thisPtr, byte unknownByte) + private IntPtr ToggleUiHideDetour(IntPtr thisPtr, bool unknownByte) { - // TODO(goat): We should read this from memory directly, instead of relying on catching every toggle. - this.GameUiHidden = !this.GameUiHidden; + var result = this.toggleUiHideHook.Original(thisPtr, unknownByte); + this.GameUiHidden = !RaptureAtkModule.Instance()->IsUiVisible; this.UiHideToggled?.InvokeSafely(this, this.GameUiHidden); Log.Debug("UiHide toggled: {0}", this.GameUiHidden); - return this.toggleUiHideHook.Original(thisPtr, unknownByte); + return result; } private char HandleImmDetour(IntPtr framework, char a2, byte a3) @@ -513,8 +505,109 @@ public sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui if (sourcePtr != null) this.utf8StringFromSequenceHook.Original(thisPtr, sourcePtr, sourceLen); else - thisPtr->Ctor(); // this is in clientstructs but you could do it manually too + thisPtr->Ctor(); // this is in ClientStructs but you could do it manually too return thisPtr; // this function shouldn't need to return but the original asm moves this into rax before returning so be safe? } } + +/// +/// Plugin-scoped version of a AddonLifecycle service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class GameGuiPluginScoped : IInternalDisposableService, IGameGui +{ + [ServiceManager.ServiceDependency] + private readonly GameGui gameGuiService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal GameGuiPluginScoped() + { + this.gameGuiService.UiHideToggled += this.UiHideToggledForward; + this.gameGuiService.HoveredItemChanged += this.HoveredItemForward; + this.gameGuiService.HoveredActionChanged += this.HoveredActionForward; + } + + /// + public event EventHandler? UiHideToggled; + + /// + public event EventHandler? HoveredItemChanged; + + /// + public event EventHandler? HoveredActionChanged; + + /// + public bool GameUiHidden => this.gameGuiService.GameUiHidden; + + /// + public ulong HoveredItem + { + get => this.gameGuiService.HoveredItem; + set => this.gameGuiService.HoveredItem = value; + } + + /// + public HoveredAction HoveredAction => this.gameGuiService.HoveredAction; + + /// + void IInternalDisposableService.DisposeService() + { + this.gameGuiService.UiHideToggled -= this.UiHideToggledForward; + this.gameGuiService.HoveredItemChanged -= this.HoveredItemForward; + this.gameGuiService.HoveredActionChanged -= this.HoveredActionForward; + + this.UiHideToggled = null; + this.HoveredItemChanged = null; + this.HoveredActionChanged = null; + } + + /// + public bool OpenMapWithMapLink(MapLinkPayload mapLink) + => this.gameGuiService.OpenMapWithMapLink(mapLink); + + /// + public bool WorldToScreen(Vector3 worldPos, out Vector2 screenPos) + => this.gameGuiService.WorldToScreen(worldPos, out screenPos); + + /// + public bool WorldToScreen(Vector3 worldPos, out Vector2 screenPos, out bool inView) + => this.gameGuiService.WorldToScreen(worldPos, out screenPos, out inView); + + /// + public bool ScreenToWorld(Vector2 screenPos, out Vector3 worldPos, float rayDistance = 100000) + => this.gameGuiService.ScreenToWorld(screenPos, out worldPos, rayDistance); + + /// + public IntPtr GetUIModule() + => this.gameGuiService.GetUIModule(); + + /// + public IntPtr GetAddonByName(string name, int index = 1) + => this.gameGuiService.GetAddonByName(name, index); + + /// + public IntPtr FindAgentInterface(string addonName) + => this.gameGuiService.FindAgentInterface(addonName); + + /// + public unsafe IntPtr FindAgentInterface(void* addon) + => this.gameGuiService.FindAgentInterface(addon); + + /// + public IntPtr FindAgentInterface(IntPtr addonPtr) + => this.gameGuiService.FindAgentInterface(addonPtr); + + private void UiHideToggledForward(object sender, bool toggled) => this.UiHideToggled?.Invoke(sender, toggled); + + private void HoveredItemForward(object sender, ulong itemId) => this.HoveredItemChanged?.Invoke(sender, itemId); + + private void HoveredActionForward(object sender, HoveredAction hoverAction) => this.HoveredActionChanged?.Invoke(sender, hoverAction); +} diff --git a/Dalamud/Game/Gui/GameGuiAddressResolver.cs b/Dalamud/Game/Gui/GameGuiAddressResolver.cs index e45b07487..cbed42a65 100644 --- a/Dalamud/Game/Gui/GameGuiAddressResolver.cs +++ b/Dalamud/Game/Gui/GameGuiAddressResolver.cs @@ -58,7 +58,7 @@ internal sealed class GameGuiAddressResolver : BaseAddressResolver public IntPtr Utf8StringFromSequence { get; private set; } /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.SetGlobalBgm = sig.ScanText("4C 8B 15 ?? ?? ?? ?? 4D 85 D2 74 58"); this.HandleItemHover = sig.ScanText("E8 ?? ?? ?? ?? 48 8B 5C 24 ?? 48 89 AE ?? ?? ?? ?? 48 89 AE ?? ?? ?? ??"); @@ -67,7 +67,7 @@ internal sealed class GameGuiAddressResolver : BaseAddressResolver this.HandleActionOut = sig.ScanText("48 89 5C 24 ?? 57 48 83 EC 20 48 8B DA 48 8B F9 4D 85 C0 74 1F"); this.HandleImm = sig.ScanText("E8 ?? ?? ?? ?? 84 C0 75 10 48 83 FF 09"); this.GetMatrixSingleton = sig.ScanText("E8 ?? ?? ?? ?? 48 8D 4C 24 ?? 48 89 4c 24 ?? 4C 8D 4D ?? 4C 8D 44 24 ??"); - this.ToggleUiHide = sig.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 0F B6 B9 ?? ?? ?? ?? B8 ?? ?? ?? ??"); + this.ToggleUiHide = sig.ScanText("48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 0F B6 B9 ?? ?? ?? ??"); this.Utf8StringFromSequence = sig.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 48 8D 41 22 66 C7 41 ?? ?? ?? 48 89 01 49 8B D8"); } } diff --git a/Dalamud/Game/Gui/Internal/DalamudIME.cs b/Dalamud/Game/Gui/Internal/DalamudIME.cs deleted file mode 100644 index 37c072806..000000000 --- a/Dalamud/Game/Gui/Internal/DalamudIME.cs +++ /dev/null @@ -1,301 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; - -using Dalamud.Hooking; -using Dalamud.Interface.Internal; -using Dalamud.Logging.Internal; -using ImGuiNET; -using PInvoke; - -using static Dalamud.NativeFunctions; - -namespace Dalamud.Game.Gui.Internal; - -/// -/// This class handles IME for non-English users. -/// -[ServiceManager.EarlyLoadedService] -internal unsafe class DalamudIME : IDisposable, IServiceType -{ - private static readonly ModuleLog Log = new("IME"); - - private AsmHook imguiTextInputCursorHook; - private Vector2* cursorPos; - - [ServiceManager.ServiceConstructor] - private DalamudIME() - { - } - - /// - /// Gets a value indicating whether the module is enabled. - /// - internal bool IsEnabled { get; private set; } - - /// - /// Gets the index of the first imm candidate in relation to the full list. - /// - internal CandidateList ImmCandNative { get; private set; } = default; - - /// - /// Gets the imm candidates. - /// - internal List ImmCand { get; private set; } = new(); - - /// - /// Gets the selected imm component. - /// - internal string ImmComp { get; private set; } = string.Empty; - - /// - public void Dispose() - { - this.imguiTextInputCursorHook?.Dispose(); - Marshal.FreeHGlobal((IntPtr)this.cursorPos); - } - - /// - /// Processes window messages. - /// - /// Handle of the window. - /// Type of window message. - /// wParam or the pointer to it. - /// lParam or the pointer to it. - /// Return value, if not doing further processing. - public unsafe IntPtr? ProcessWndProcW(IntPtr hWnd, User32.WindowMessage msg, void* wParamPtr, void* lParamPtr) - { - try - { - if (ImGui.GetCurrentContext() != IntPtr.Zero && ImGui.GetIO().WantTextInput) - { - var io = ImGui.GetIO(); - var wmsg = (WindowsMessage)msg; - long wParam = (long)wParamPtr, lParam = (long)lParamPtr; - try - { - wParam = Marshal.ReadInt32((IntPtr)wParamPtr); - } - catch - { - // ignored - } - - try - { - lParam = Marshal.ReadInt32((IntPtr)lParamPtr); - } - catch - { - // ignored - } - - switch (wmsg) - { - case WindowsMessage.WM_IME_NOTIFY: - switch ((IMECommand)(IntPtr)wParam) - { - case IMECommand.ChangeCandidate: - this.ToggleWindow(true); - this.LoadCand(hWnd); - break; - case IMECommand.OpenCandidate: - this.ToggleWindow(true); - this.ImmCandNative = default; - // this.ImmCand.Clear(); - break; - - case IMECommand.CloseCandidate: - this.ToggleWindow(false); - this.ImmCandNative = default; - // this.ImmCand.Clear(); - break; - - default: - break; - } - - break; - case WindowsMessage.WM_IME_COMPOSITION: - if (((long)(IMEComposition.CompStr | IMEComposition.CompAttr | IMEComposition.CompClause | - IMEComposition.CompReadAttr | IMEComposition.CompReadClause | IMEComposition.CompReadStr) & (long)(IntPtr)lParam) > 0) - { - var hIMC = ImmGetContext(hWnd); - if (hIMC == IntPtr.Zero) - return IntPtr.Zero; - - var dwSize = ImmGetCompositionStringW(hIMC, IMEComposition.CompStr, IntPtr.Zero, 0); - var unmanagedPointer = Marshal.AllocHGlobal((int)dwSize); - ImmGetCompositionStringW(hIMC, IMEComposition.CompStr, unmanagedPointer, (uint)dwSize); - - var bytes = new byte[dwSize]; - Marshal.Copy(unmanagedPointer, bytes, 0, (int)dwSize); - Marshal.FreeHGlobal(unmanagedPointer); - - var lpstr = Encoding.Unicode.GetString(bytes); - this.ImmComp = lpstr; - if (lpstr == string.Empty) - { - this.ToggleWindow(false); - } - else - { - this.LoadCand(hWnd); - } - } - - if (((long)(IntPtr)lParam & (long)IMEComposition.ResultStr) > 0) - { - var hIMC = ImmGetContext(hWnd); - if (hIMC == IntPtr.Zero) - return IntPtr.Zero; - - var dwSize = ImmGetCompositionStringW(hIMC, IMEComposition.ResultStr, IntPtr.Zero, 0); - var unmanagedPointer = Marshal.AllocHGlobal((int)dwSize); - ImmGetCompositionStringW(hIMC, IMEComposition.ResultStr, unmanagedPointer, (uint)dwSize); - - var bytes = new byte[dwSize]; - Marshal.Copy(unmanagedPointer, bytes, 0, (int)dwSize); - Marshal.FreeHGlobal(unmanagedPointer); - - var lpstr = Encoding.Unicode.GetString(bytes); - io.AddInputCharactersUTF8(lpstr); - - this.ImmComp = string.Empty; - this.ImmCandNative = default; - this.ImmCand.Clear(); - this.ToggleWindow(false); - } - - break; - - default: - break; - } - } - } - catch (Exception ex) - { - Log.Error(ex, "Prevented a crash in an IME hook"); - } - - return null; - } - - /// - /// Get the position of the cursor. - /// - /// The position of the cursor. - internal Vector2 GetCursorPos() - { - return new Vector2(this.cursorPos->X, this.cursorPos->Y); - } - - private unsafe void LoadCand(IntPtr hWnd) - { - if (hWnd == IntPtr.Zero) - return; - - var hImc = ImmGetContext(hWnd); - if (hImc == IntPtr.Zero) - return; - - var size = ImmGetCandidateListW(hImc, 0, IntPtr.Zero, 0); - if (size == 0) - return; - - var candlistPtr = Marshal.AllocHGlobal((int)size); - size = ImmGetCandidateListW(hImc, 0, candlistPtr, (uint)size); - - var candlist = this.ImmCandNative = Marshal.PtrToStructure(candlistPtr); - var pageSize = candlist.PageSize; - var candCount = candlist.Count; - - if (pageSize > 0 && candCount > 1) - { - var dwOffsets = new int[candCount]; - for (var i = 0; i < candCount; i++) - { - dwOffsets[i] = Marshal.ReadInt32(candlistPtr + ((i + 6) * sizeof(int))); - } - - var pageStart = candlist.PageStart; - - var cand = new string[pageSize]; - this.ImmCand.Clear(); - - for (var i = 0; i < pageSize; i++) - { - var offStart = dwOffsets[i + pageStart]; - var offEnd = i + pageStart + 1 < candCount ? dwOffsets[i + pageStart + 1] : size; - - var pStrStart = candlistPtr + (int)offStart; - var pStrEnd = candlistPtr + (int)offEnd; - - var len = (int)(pStrEnd.ToInt64() - pStrStart.ToInt64()); - if (len > 0) - { - var candBytes = new byte[len]; - Marshal.Copy(pStrStart, candBytes, 0, len); - - var candStr = Encoding.Unicode.GetString(candBytes); - cand[i] = candStr; - - this.ImmCand.Add(candStr); - } - } - - Marshal.FreeHGlobal(candlistPtr); - } - } - - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene) - { - try - { - var module = Process.GetCurrentProcess().Modules.Cast().First(m => m.ModuleName == "cimgui.dll"); - var scanner = new SigScanner(module); - var cursorDrawingPtr = scanner.ScanModule("F3 0F 11 75 ?? 0F 28 CF"); - Log.Debug($"Found cursorDrawingPtr at {cursorDrawingPtr:X}"); - - this.cursorPos = (Vector2*)Marshal.AllocHGlobal(sizeof(Vector2)); - this.cursorPos->X = 0f; - this.cursorPos->Y = 0f; - - var asm = new[] - { - "use64", - $"push rax", - $"mov rax, {(IntPtr)this.cursorPos + sizeof(float)}", - $"movss [rax],xmm7", - $"mov rax, {(IntPtr)this.cursorPos}", - $"movss [rax],xmm6", - $"pop rax", - }; - - Log.Debug($"Asm Code:\n{string.Join("\n", asm)}"); - this.imguiTextInputCursorHook = new AsmHook(cursorDrawingPtr, asm, "ImguiTextInputCursorHook"); - this.imguiTextInputCursorHook?.Enable(); - - this.IsEnabled = true; - Log.Information("Enabled!"); - } - catch (Exception ex) - { - Log.Information(ex, "Enable failed"); - } - } - - private void ToggleWindow(bool visible) - { - if (visible) - Service.GetNullable()?.OpenImeWindow(); - else - Service.GetNullable()?.CloseImeWindow(); - } -} diff --git a/Dalamud/Game/Gui/PartyFinder/PartyFinderAddressResolver.cs b/Dalamud/Game/Gui/PartyFinder/PartyFinderAddressResolver.cs index aa9d28cb1..9cfbd8a12 100644 --- a/Dalamud/Game/Gui/PartyFinder/PartyFinderAddressResolver.cs +++ b/Dalamud/Game/Gui/PartyFinder/PartyFinderAddressResolver.cs @@ -1,11 +1,9 @@ -using System; - namespace Dalamud.Game.Gui.PartyFinder; /// /// The address resolver for the class. /// -public class PartyFinderAddressResolver : BaseAddressResolver +internal class PartyFinderAddressResolver : BaseAddressResolver { /// /// Gets the address of the native ReceiveListing method. @@ -13,7 +11,7 @@ public class PartyFinderAddressResolver : BaseAddressResolver public IntPtr ReceiveListing { get; private set; } /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.ReceiveListing = sig.ScanText("40 53 41 57 48 83 EC 28 48 8B D9"); } diff --git a/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs b/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs index 6427f2a54..f19fe3b0a 100644 --- a/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs +++ b/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs @@ -1,4 +1,3 @@ -using System; using System.Runtime.InteropServices; using Dalamud.Game.Gui.PartyFinder.Internal; @@ -6,6 +5,7 @@ using Dalamud.Game.Gui.PartyFinder.Types; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Plugin.Services; using Serilog; namespace Dalamud.Game.Gui.PartyFinder; @@ -13,10 +13,9 @@ namespace Dalamud.Game.Gui.PartyFinder; /// /// This class handles interacting with the native PartyFinder window. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public sealed class PartyFinderGui : IDisposable, IServiceType +internal sealed class PartyFinderGui : IInternalDisposableService, IPartyFinderGui { private readonly PartyFinderAddressResolver address; private readonly IntPtr memory; @@ -28,37 +27,27 @@ public sealed class PartyFinderGui : IDisposable, IServiceType /// /// Sig scanner to use. [ServiceManager.ServiceConstructor] - private PartyFinderGui(SigScanner sigScanner) + private PartyFinderGui(TargetSigScanner sigScanner) { this.address = new PartyFinderAddressResolver(); this.address.Setup(sigScanner); this.memory = Marshal.AllocHGlobal(PartyFinderPacket.PacketSize); - this.receiveListingHook = Hook.FromAddress(this.address.ReceiveListing, new ReceiveListingDelegate(this.HandleReceiveListingDetour)); + this.receiveListingHook = Hook.FromAddress(this.address.ReceiveListing, this.HandleReceiveListingDetour); + this.receiveListingHook.Enable(); } - /// - /// Event type fired each time the game receives an individual Party Finder listing. - /// Cannot modify listings but can hide them. - /// - /// The listings received. - /// Additional arguments passed by the game. - public delegate void PartyFinderListingEventDelegate(PartyFinderListing listing, PartyFinderListingEventArgs args); - [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void ReceiveListingDelegate(IntPtr managerPtr, IntPtr data); - /// - /// Event fired each time the game receives an individual Party Finder listing. - /// Cannot modify listings but can hide them. - /// - public event PartyFinderListingEventDelegate ReceiveListing; + /// + public event IPartyFinderGui.PartyFinderListingEventDelegate? ReceiveListing; /// /// Dispose of managed and unmanaged resources. /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.receiveListingHook.Dispose(); @@ -72,12 +61,6 @@ public sealed class PartyFinderGui : IDisposable, IServiceType } } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(GameGui gameGui) - { - this.receiveListingHook.Enable(); - } - private void HandleReceiveListingDetour(IntPtr managerPtr, IntPtr data) { try @@ -138,3 +121,39 @@ public sealed class PartyFinderGui : IDisposable, IServiceType } } } + +/// +/// A scoped variant of the PartyFinderGui service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class PartyFinderGuiPluginScoped : IInternalDisposableService, IPartyFinderGui +{ + [ServiceManager.ServiceDependency] + private readonly PartyFinderGui partyFinderGuiService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal PartyFinderGuiPluginScoped() + { + this.partyFinderGuiService.ReceiveListing += this.ReceiveListingForward; + } + + /// + public event IPartyFinderGui.PartyFinderListingEventDelegate? ReceiveListing; + + /// + void IInternalDisposableService.DisposeService() + { + this.partyFinderGuiService.ReceiveListing -= this.ReceiveListingForward; + + this.ReceiveListing = null; + } + + private void ReceiveListingForward(PartyFinderListing listing, PartyFinderListingEventArgs args) => this.ReceiveListing?.Invoke(listing, args); +} diff --git a/Dalamud/Game/Gui/PartyFinder/Types/JobFlagsExtensions.cs b/Dalamud/Game/Gui/PartyFinder/Types/JobFlagsExtensions.cs index c7630acfa..46e83b972 100644 --- a/Dalamud/Game/Gui/PartyFinder/Types/JobFlagsExtensions.cs +++ b/Dalamud/Game/Gui/PartyFinder/Types/JobFlagsExtensions.cs @@ -1,4 +1,4 @@ -using Dalamud.Data; +using Dalamud.Plugin.Services; using Lumina.Excel.GeneratedSheets; namespace Dalamud.Game.Gui.PartyFinder.Types; @@ -14,7 +14,7 @@ public static class JobFlagsExtensions /// A JobFlags enum member. /// A DataManager to get the ClassJob from. /// A ClassJob if found or null if not. - public static ClassJob ClassJob(this JobFlags job, DataManager data) + public static ClassJob? ClassJob(this JobFlags job, IDataManager data) { var jobs = data.GetExcelSheet(); @@ -52,6 +52,6 @@ public static class JobFlagsExtensions _ => null, }; - return row == null ? null : jobs.GetRow((uint)row); + return row == null ? null : jobs?.GetRow((uint)row); } } diff --git a/Dalamud/Game/Gui/Toast/ToastGui.cs b/Dalamud/Game/Gui/Toast/ToastGui.cs index e65fa1444..2cf327007 100644 --- a/Dalamud/Game/Gui/Toast/ToastGui.cs +++ b/Dalamud/Game/Gui/Toast/ToastGui.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Text; @@ -6,16 +5,16 @@ using Dalamud.Game.Text.SeStringHandling; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Plugin.Services; namespace Dalamud.Game.Gui.Toast; /// /// This class facilitates interacting with and creating native toast windows. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public sealed partial class ToastGui : IDisposable, IServiceType +internal sealed partial class ToastGui : IInternalDisposableService, IToastGui { private const uint QuestToastCheckmarkMagic = 60081; @@ -34,43 +33,20 @@ public sealed partial class ToastGui : IDisposable, IServiceType /// /// Sig scanner to use. [ServiceManager.ServiceConstructor] - private ToastGui(SigScanner sigScanner) + private ToastGui(TargetSigScanner sigScanner) { this.address = new ToastGuiAddressResolver(); this.address.Setup(sigScanner); - this.showNormalToastHook = Hook.FromAddress(this.address.ShowNormalToast, new ShowNormalToastDelegate(this.HandleNormalToastDetour)); - this.showQuestToastHook = Hook.FromAddress(this.address.ShowQuestToast, new ShowQuestToastDelegate(this.HandleQuestToastDetour)); - this.showErrorToastHook = Hook.FromAddress(this.address.ShowErrorToast, new ShowErrorToastDelegate(this.HandleErrorToastDetour)); + this.showNormalToastHook = Hook.FromAddress(this.address.ShowNormalToast, this.HandleNormalToastDetour); + this.showQuestToastHook = Hook.FromAddress(this.address.ShowQuestToast, this.HandleQuestToastDetour); + this.showErrorToastHook = Hook.FromAddress(this.address.ShowErrorToast, this.HandleErrorToastDetour); + + this.showNormalToastHook.Enable(); + this.showQuestToastHook.Enable(); + this.showErrorToastHook.Enable(); } - #region Event delegates - - /// - /// A delegate type used when a normal toast window appears. - /// - /// The message displayed. - /// Assorted toast options. - /// Whether the toast has been handled or should be propagated. - public delegate void OnNormalToastDelegate(ref SeString message, ref ToastOptions options, ref bool isHandled); - - /// - /// A delegate type used when a quest toast window appears. - /// - /// The message displayed. - /// Assorted toast options. - /// Whether the toast has been handled or should be propagated. - public delegate void OnQuestToastDelegate(ref SeString message, ref QuestToastOptions options, ref bool isHandled); - - /// - /// A delegate type used when an error toast window appears. - /// - /// The message displayed. - /// Whether the toast has been handled or should be propagated. - public delegate void OnErrorToastDelegate(ref SeString message, ref bool isHandled); - - #endregion - #region Marshal delegates private delegate IntPtr ShowNormalToastDelegate(IntPtr manager, IntPtr text, int layer, byte isTop, byte isFast, int logMessageId); @@ -82,28 +58,22 @@ public sealed partial class ToastGui : IDisposable, IServiceType #endregion #region Events + + /// + public event IToastGui.OnNormalToastDelegate? Toast; - /// - /// Event that will be fired when a toast is sent by the game or a plugin. - /// - public event OnNormalToastDelegate Toast; + /// + public event IToastGui.OnQuestToastDelegate? QuestToast; - /// - /// Event that will be fired when a quest toast is sent by the game or a plugin. - /// - public event OnQuestToastDelegate QuestToast; - - /// - /// Event that will be fired when an error toast is sent by the game or a plugin. - /// - public event OnErrorToastDelegate ErrorToast; + /// + public event IToastGui.OnErrorToastDelegate? ErrorToast; #endregion /// /// Disposes of managed and unmanaged resources. /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.showNormalToastHook.Dispose(); this.showQuestToastHook.Dispose(); @@ -143,14 +113,6 @@ public sealed partial class ToastGui : IDisposable, IServiceType return terminated; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(GameGui gameGui) - { - this.showNormalToastHook.Enable(); - this.showQuestToastHook.Enable(); - this.showErrorToastHook.Enable(); - } - private SeString ParseString(IntPtr text) { var bytes = new List(); @@ -172,31 +134,23 @@ public sealed partial class ToastGui : IDisposable, IServiceType /// /// Handles normal toasts. /// -public sealed partial class ToastGui +internal sealed partial class ToastGui { - /// - /// Show a toast message with the given content. - /// - /// The message to be shown. - /// Options for the toast. - public void ShowNormal(string message, ToastOptions options = null) + /// + public void ShowNormal(string message, ToastOptions? options = null) { options ??= new ToastOptions(); this.normalQueue.Enqueue((Encoding.UTF8.GetBytes(message), options)); } - - /// - /// Show a toast message with the given content. - /// - /// The message to be shown. - /// Options for the toast. - public void ShowNormal(SeString message, ToastOptions options = null) + + /// + public void ShowNormal(SeString message, ToastOptions? options = null) { options ??= new ToastOptions(); this.normalQueue.Enqueue((message.Encode(), options)); } - private void ShowNormal(byte[] bytes, ToastOptions options = null) + private void ShowNormal(byte[] bytes, ToastOptions? options = null) { options ??= new ToastOptions(); @@ -255,31 +209,23 @@ public sealed partial class ToastGui /// /// Handles quest toasts. /// -public sealed partial class ToastGui +internal sealed partial class ToastGui { - /// - /// Show a quest toast message with the given content. - /// - /// The message to be shown. - /// Options for the toast. - public void ShowQuest(string message, QuestToastOptions options = null) + /// + public void ShowQuest(string message, QuestToastOptions? options = null) { options ??= new QuestToastOptions(); this.questQueue.Enqueue((Encoding.UTF8.GetBytes(message), options)); } - - /// - /// Show a quest toast message with the given content. - /// - /// The message to be shown. - /// Options for the toast. - public void ShowQuest(SeString message, QuestToastOptions options = null) + + /// + public void ShowQuest(SeString message, QuestToastOptions? options = null) { options ??= new QuestToastOptions(); this.questQueue.Enqueue((message.Encode(), options)); } - private void ShowQuest(byte[] bytes, QuestToastOptions options = null) + private void ShowQuest(byte[] bytes, QuestToastOptions? options = null) { options ??= new QuestToastOptions(); @@ -365,21 +311,15 @@ public sealed partial class ToastGui /// /// Handles error toasts. /// -public sealed partial class ToastGui +internal sealed partial class ToastGui { - /// - /// Show an error toast message with the given content. - /// - /// The message to be shown. + /// public void ShowError(string message) { this.errorQueue.Enqueue(Encoding.UTF8.GetBytes(message)); } - /// - /// Show an error toast message with the given content. - /// - /// The message to be shown. + /// public void ShowError(SeString message) { this.errorQueue.Enqueue(message.Encode()); @@ -433,3 +373,76 @@ public sealed partial class ToastGui } } } + +/// +/// Plugin scoped version of ToastGui. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class ToastGuiPluginScoped : IInternalDisposableService, IToastGui +{ + [ServiceManager.ServiceDependency] + private readonly ToastGui toastGuiService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal ToastGuiPluginScoped() + { + this.toastGuiService.Toast += this.ToastForward; + this.toastGuiService.QuestToast += this.QuestToastForward; + this.toastGuiService.ErrorToast += this.ErrorToastForward; + } + + /// + public event IToastGui.OnNormalToastDelegate? Toast; + + /// + public event IToastGui.OnQuestToastDelegate? QuestToast; + + /// + public event IToastGui.OnErrorToastDelegate? ErrorToast; + + /// + void IInternalDisposableService.DisposeService() + { + this.toastGuiService.Toast -= this.ToastForward; + this.toastGuiService.QuestToast -= this.QuestToastForward; + this.toastGuiService.ErrorToast -= this.ErrorToastForward; + + this.Toast = null; + this.QuestToast = null; + this.ErrorToast = null; + } + + /// + public void ShowNormal(string message, ToastOptions? options = null) => this.toastGuiService.ShowNormal(message, options); + + /// + public void ShowNormal(SeString message, ToastOptions? options = null) => this.toastGuiService.ShowNormal(message, options); + + /// + public void ShowQuest(string message, QuestToastOptions? options = null) => this.toastGuiService.ShowQuest(message, options); + + /// + public void ShowQuest(SeString message, QuestToastOptions? options = null) => this.toastGuiService.ShowQuest(message, options); + + /// + public void ShowError(string message) => this.toastGuiService.ShowError(message); + + /// + public void ShowError(SeString message) => this.toastGuiService.ShowError(message); + + private void ToastForward(ref SeString message, ref ToastOptions options, ref bool isHandled) + => this.Toast?.Invoke(ref message, ref options, ref isHandled); + + private void QuestToastForward(ref SeString message, ref QuestToastOptions options, ref bool isHandled) + => this.QuestToast?.Invoke(ref message, ref options, ref isHandled); + + private void ErrorToastForward(ref SeString message, ref bool isHandled) + => this.ErrorToast?.Invoke(ref message, ref isHandled); +} diff --git a/Dalamud/Game/Gui/Toast/ToastGuiAddressResolver.cs b/Dalamud/Game/Gui/Toast/ToastGuiAddressResolver.cs index 4f935b465..0a8775540 100644 --- a/Dalamud/Game/Gui/Toast/ToastGuiAddressResolver.cs +++ b/Dalamud/Game/Gui/Toast/ToastGuiAddressResolver.cs @@ -1,11 +1,9 @@ -using System; - namespace Dalamud.Game.Gui.Toast; /// /// An address resolver for the class. /// -public class ToastGuiAddressResolver : BaseAddressResolver +internal class ToastGuiAddressResolver : BaseAddressResolver { /// /// Gets the address of the native ShowNormalToast method. @@ -23,7 +21,7 @@ public class ToastGuiAddressResolver : BaseAddressResolver public IntPtr ShowErrorToast { get; private set; } /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.ShowNormalToast = sig.ScanText("48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC 30 83 3D ?? ?? ?? ?? ??"); this.ShowQuestToast = sig.ScanText("48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 48 89 7C 24 ?? 41 56 48 83 EC 40 83 3D ?? ?? ?? ?? ??"); diff --git a/Dalamud/Game/Internal/AntiDebug.cs b/Dalamud/Game/Internal/AntiDebug.cs index ba482ef48..5ab024012 100644 --- a/Dalamud/Game/Internal/AntiDebug.cs +++ b/Dalamud/Game/Internal/AntiDebug.cs @@ -12,14 +12,14 @@ namespace Dalamud.Game.Internal; /// This class disables anti-debug functionality in the game client. /// [ServiceManager.EarlyLoadedService] -internal sealed partial class AntiDebug : IServiceType +internal sealed class AntiDebug : IInternalDisposableService { private readonly byte[] nop = new byte[] { 0x31, 0xC0, 0x90, 0x90, 0x90, 0x90 }; private byte[] original; private IntPtr debugCheckAddress; [ServiceManager.ServiceConstructor] - private AntiDebug(SigScanner sigScanner) + private AntiDebug(TargetSigScanner sigScanner) { try { @@ -43,16 +43,25 @@ internal sealed partial class AntiDebug : IServiceType } } + /// Finalizes an instance of the class. + ~AntiDebug() => this.Disable(); + /// /// Gets a value indicating whether the anti-debugging is enabled. /// public bool IsEnabled { get; private set; } = false; + /// + void IInternalDisposableService.DisposeService() => this.Disable(); + /// /// Enables the anti-debugging by overwriting code in memory. /// public void Enable() { + if (this.IsEnabled) + return; + this.original = new byte[this.nop.Length]; if (this.debugCheckAddress != IntPtr.Zero && !this.IsEnabled) { @@ -73,6 +82,9 @@ internal sealed partial class AntiDebug : IServiceType /// public void Disable() { + if (!this.IsEnabled) + return; + if (this.debugCheckAddress != IntPtr.Zero && this.original != null) { Log.Information($"Reverting debug check at 0x{this.debugCheckAddress.ToInt64():X}"); @@ -86,45 +98,3 @@ internal sealed partial class AntiDebug : IServiceType this.IsEnabled = false; } } - -/// -/// Implementing IDisposable. -/// -internal sealed partial class AntiDebug : IDisposable -{ - private bool disposed = false; - - /// - /// Finalizes an instance of the class. - /// - ~AntiDebug() => this.Dispose(false); - - /// - /// Disposes of managed and unmanaged resources. - /// - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Disposes of managed and unmanaged resources. - /// - /// If this was disposed through calling Dispose() or from being finalized. - private void Dispose(bool disposing) - { - if (this.disposed) - return; - - if (disposing) - { - // If anti-debug is enabled and is being disposed, odds are either the game is exiting, or Dalamud is being reloaded. - // If it is the latter, there's half a chance a debugger is currently attached. There's no real need to disable the - // check in either situation anyways. However if Dalamud is being reloaded, the sig may fail so may as well undo it. - this.Disable(); - } - - this.disposed = true; - } -} diff --git a/Dalamud/Game/Internal/DalamudAtkTweaks.cs b/Dalamud/Game/Internal/DalamudAtkTweaks.cs index 60e61b2f7..9f9328de1 100644 --- a/Dalamud/Game/Internal/DalamudAtkTweaks.cs +++ b/Dalamud/Game/Internal/DalamudAtkTweaks.cs @@ -20,7 +20,7 @@ namespace Dalamud.Game.Internal; /// This class implements in-game Dalamud options in the in-game System menu. /// [ServiceManager.EarlyLoadedService] -internal sealed unsafe partial class DalamudAtkTweaks : IServiceType +internal sealed unsafe class DalamudAtkTweaks : IInternalDisposableService { private readonly AtkValueChangeType atkValueChangeType; private readonly AtkValueSetString atkValueSetString; @@ -40,8 +40,10 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType private readonly string locDalamudPlugins; private readonly string locDalamudSettings; + private bool disposed = false; + [ServiceManager.ServiceConstructor] - private DalamudAtkTweaks(SigScanner sigScanner) + private DalamudAtkTweaks(TargetSigScanner sigScanner) { var openSystemMenuAddress = sigScanner.ScanText("E8 ?? ?? ?? ?? 32 C0 4C 8B AC 24 ?? ?? ?? ?? 48 8B 8D ?? ?? ?? ??"); @@ -63,8 +65,15 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType this.locDalamudSettings = Loc.Localize("SystemMenuSettings", "Dalamud Settings"); // this.contextMenu.ContextMenuOpened += this.ContextMenuOnContextMenuOpened; + + this.hookAgentHudOpenSystemMenu.Enable(); + this.hookUiModuleRequestMainCommand.Enable(); + this.hookAtkUnitBaseReceiveGlobalEvent.Enable(); } + /// Finalizes an instance of the class. + ~DalamudAtkTweaks() => this.Dispose(false); + private delegate void AgentHudOpenSystemMenuPrototype(void* thisPtr, AtkValue* atkValueArgs, uint menuSize); private delegate void AtkValueChangeType(AtkValue* thisPtr, ValueType type); @@ -75,12 +84,24 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType private delegate IntPtr AtkUnitBaseReceiveGlobalEvent(AtkUnitBase* thisPtr, ushort cmd, uint a3, IntPtr a4, uint* a5); - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(DalamudInterface dalamudInterface) + /// + void IInternalDisposableService.DisposeService() => this.Dispose(true); + + private void Dispose(bool disposing) { - this.hookAgentHudOpenSystemMenu.Enable(); - this.hookUiModuleRequestMainCommand.Enable(); - this.hookAtkUnitBaseReceiveGlobalEvent.Enable(); + if (this.disposed) + return; + + if (disposing) + { + this.hookAgentHudOpenSystemMenu.Dispose(); + this.hookUiModuleRequestMainCommand.Dispose(); + this.hookAtkUnitBaseReceiveGlobalEvent.Dispose(); + + // this.contextMenu.ContextMenuOpened -= this.ContextMenuOnContextMenuOpened; + } + + this.disposed = true; } /* @@ -111,7 +132,7 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType private IntPtr AtkUnitBaseReceiveGlobalEventDetour(AtkUnitBase* thisPtr, ushort cmd, uint a3, IntPtr a4, uint* arg) { - // Log.Information("{0}: cmd#{1} a3#{2} - HasAnyFocus:{3}", Marshal.PtrToStringAnsi(new IntPtr(thisPtr->Name)), cmd, a3, WindowSystem.HasAnyWindowSystemFocus); + // Log.Information("{0}: cmd#{1} a3#{2} - HasAnyFocus:{3}", MemoryHelper.ReadSeStringAsString(out _, new IntPtr(thisPtr->Name)), cmd, a3, WindowSystem.HasAnyWindowSystemFocus); // "SendHotkey" // 3 == Close @@ -222,10 +243,10 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType switch (commandId) { case 69420: - dalamudInterface?.TogglePluginInstallerWindow(); + dalamudInterface?.OpenPluginInstaller(); break; case 69421: - dalamudInterface?.ToggleSettingsWindow(); + dalamudInterface?.OpenSettings(); break; default: this.hookUiModuleRequestMainCommand.Original(thisPtr, commandId); @@ -233,45 +254,3 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType } } } - -/// -/// Implements IDisposable. -/// -internal sealed partial class DalamudAtkTweaks : IDisposable -{ - private bool disposed = false; - - /// - /// Finalizes an instance of the class. - /// - ~DalamudAtkTweaks() => this.Dispose(false); - - /// - /// Dispose of managed and unmanaged resources. - /// - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose of managed and unmanaged resources. - /// - private void Dispose(bool disposing) - { - if (this.disposed) - return; - - if (disposing) - { - this.hookAgentHudOpenSystemMenu.Dispose(); - this.hookUiModuleRequestMainCommand.Dispose(); - this.hookAtkUnitBaseReceiveGlobalEvent.Dispose(); - - // this.contextMenu.ContextMenuOpened -= this.ContextMenuOnContextMenuOpened; - } - - this.disposed = true; - } -} diff --git a/Dalamud/Game/Inventory/GameInventory.cs b/Dalamud/Game/Inventory/GameInventory.cs new file mode 100644 index 000000000..3e3dbc685 --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventory.cs @@ -0,0 +1,547 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Game.Inventory.InventoryEventArgTypes; +using Dalamud.Hooking; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Services; + +using FFXIVClientStructs.FFXIV.Client.UI; + +namespace Dalamud.Game.Inventory; + +/// +/// This class provides events for the players in-game inventory. +/// +[InterfaceVersion("1.0")] +[ServiceManager.BlockingEarlyLoadedService] +internal class GameInventory : IInternalDisposableService +{ + private readonly List subscribersPendingChange = new(); + private readonly List subscribers = new(); + + private readonly List addedEvents = new(); + private readonly List removedEvents = new(); + private readonly List changedEvents = new(); + private readonly List movedEvents = new(); + private readonly List splitEvents = new(); + private readonly List mergedEvents = new(); + + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + + private readonly Hook raptureAtkModuleUpdateHook; + + private readonly GameInventoryType[] inventoryTypes; + private readonly GameInventoryItem[]?[] inventoryItems; + + private bool subscribersChanged; + private bool inventoriesMightBeChanged; + + [ServiceManager.ServiceConstructor] + private GameInventory() + { + this.inventoryTypes = Enum.GetValues(); + this.inventoryItems = new GameInventoryItem[this.inventoryTypes.Length][]; + + unsafe + { + this.raptureAtkModuleUpdateHook = Hook.FromFunctionPointerVariable( + new(&((RaptureAtkModule.RaptureAtkModuleVTable*)RaptureAtkModule.StaticAddressPointers.VTable)->Update), + this.RaptureAtkModuleUpdateDetour); + } + + this.raptureAtkModuleUpdateHook.Enable(); + } + + private unsafe delegate void RaptureAtkModuleUpdateDelegate(RaptureAtkModule* ram, float f1); + + /// + void IInternalDisposableService.DisposeService() + { + lock (this.subscribersPendingChange) + { + this.subscribers.Clear(); + this.subscribersPendingChange.Clear(); + this.subscribersChanged = false; + this.framework.Update -= this.OnFrameworkUpdate; + this.raptureAtkModuleUpdateHook.Dispose(); + } + } + + /// + /// Subscribe to events. + /// + /// The event target. + public void Subscribe(GameInventoryPluginScoped s) + { + lock (this.subscribersPendingChange) + { + this.subscribersPendingChange.Add(s); + this.subscribersChanged = true; + if (this.subscribersPendingChange.Count == 1) + { + this.inventoriesMightBeChanged = true; + this.framework.Update += this.OnFrameworkUpdate; + } + } + } + + /// + /// Unsubscribe from events. + /// + /// The event target. + public void Unsubscribe(GameInventoryPluginScoped s) + { + lock (this.subscribersPendingChange) + { + if (!this.subscribersPendingChange.Remove(s)) + return; + this.subscribersChanged = true; + if (this.subscribersPendingChange.Count == 0) + this.framework.Update -= this.OnFrameworkUpdate; + } + } + + private void OnFrameworkUpdate(IFramework framework1) + { + if (!this.inventoriesMightBeChanged) + return; + + this.inventoriesMightBeChanged = false; + + for (var i = 0; i < this.inventoryTypes.Length; i++) + { + var newItems = GameInventoryItem.GetReadOnlySpanOfInventory(this.inventoryTypes[i]); + if (newItems.IsEmpty) + continue; + + // Assumption: newItems is sorted by slots, and the last item has the highest slot number. + var oldItems = this.inventoryItems[i] ??= new GameInventoryItem[newItems[^1].InternalItem.Slot + 1]; + + foreach (ref readonly var newItem in newItems) + { + ref var oldItem = ref oldItems[newItem.InternalItem.Slot]; + + if (oldItem.IsEmpty) + { + if (!newItem.IsEmpty) + { + this.addedEvents.Add(new(newItem)); + oldItem = newItem; + } + } + else + { + if (newItem.IsEmpty) + { + this.removedEvents.Add(new(oldItem)); + oldItem = newItem; + } + else if (!oldItem.Equals(newItem)) + { + this.changedEvents.Add(new(oldItem, newItem)); + oldItem = newItem; + } + } + } + } + + // Was there any change? If not, stop further processing. + // Note that only these three are checked; the rest will be populated after this check. + if (this.addedEvents.Count == 0 && this.removedEvents.Count == 0 && this.changedEvents.Count == 0) + return; + + // Make a copy of subscribers, to accommodate self removal during the loop. + if (this.subscribersChanged) + { + bool isNew; + lock (this.subscribersPendingChange) + { + isNew = this.subscribersPendingChange.Any() && !this.subscribers.Any(); + this.subscribers.Clear(); + this.subscribers.AddRange(this.subscribersPendingChange); + this.subscribersChanged = false; + } + + // Is this the first time (resuming) scanning for changes? Then discard the "changes". + if (isNew) + { + this.addedEvents.Clear(); + this.removedEvents.Clear(); + this.changedEvents.Clear(); + return; + } + } + + // Broadcast InventoryChangedRaw. + // Same reason with the above on why are there 3 lists of events involved. + var allRawEventsCollection = new DeferredReadOnlyCollection( + this.addedEvents.Count + + this.removedEvents.Count + + this.changedEvents.Count, + () => Array.Empty() + .Concat(this.addedEvents) + .Concat(this.removedEvents) + .Concat(this.changedEvents)); + foreach (var s in this.subscribers) + s.InvokeChangedRaw(allRawEventsCollection); + + // Resolve moved items, from 1 added + 1 removed event. + for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded) + { + var added = this.addedEvents[iAdded]; + for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved) + { + var removed = this.removedEvents[iRemoved]; + if (added.Item.ItemId != removed.Item.ItemId) + continue; + + this.movedEvents.Add(new(removed, added)); + + // Remove the reinterpreted entries. + this.addedEvents.RemoveAt(iAdded); + this.removedEvents.RemoveAt(iRemoved); + break; + } + } + + // Resolve moved items, from 2 changed events. + for (var i = this.changedEvents.Count - 1; i >= 0; --i) + { + var e1 = this.changedEvents[i]; + for (var j = i - 1; j >= 0; --j) + { + var e2 = this.changedEvents[j]; + if (e1.Item.ItemId != e2.OldItemState.ItemId || e1.OldItemState.ItemId != e2.Item.ItemId) + continue; + + // Move happened, and e2 has an item. + if (!e2.Item.IsEmpty) + this.movedEvents.Add(new(e1, e2)); + + // Move happened, and e1 has an item. + if (!e1.Item.IsEmpty) + this.movedEvents.Add(new(e2, e1)); + + // Remove the reinterpreted entries. Note that i > j. + this.changedEvents.RemoveAt(i); + this.changedEvents.RemoveAt(j); + + // We've removed two. Adjust the outer counter. + --i; + break; + } + } + + // Resolve split items, from 1 added + 1 changed event. + for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded) + { + var added = this.addedEvents[iAdded]; + for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged) + { + var changed = this.changedEvents[iChanged]; + if (added.Item.ItemId != changed.Item.ItemId || added.Item.ItemId != changed.OldItemState.ItemId) + continue; + + this.splitEvents.Add(new(changed, added)); + + // Remove the reinterpreted entries. + this.addedEvents.RemoveAt(iAdded); + this.changedEvents.RemoveAt(iChanged); + break; + } + } + + // Resolve merged items, from 1 removed + 1 changed event. + for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved) + { + var removed = this.removedEvents[iRemoved]; + for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged) + { + var changed = this.changedEvents[iChanged]; + if (removed.Item.ItemId != changed.Item.ItemId || removed.Item.ItemId != changed.OldItemState.ItemId) + continue; + + this.mergedEvents.Add(new(removed, changed)); + + // Remove the reinterpreted entries. + this.removedEvents.RemoveAt(iRemoved); + this.changedEvents.RemoveAt(iChanged); + break; + } + } + + // Create a collection view of all events. + var allEventsCollection = new DeferredReadOnlyCollection( + this.addedEvents.Count + + this.removedEvents.Count + + this.changedEvents.Count + + this.movedEvents.Count + + this.splitEvents.Count + + this.mergedEvents.Count, + () => Array.Empty() + .Concat(this.addedEvents) + .Concat(this.removedEvents) + .Concat(this.changedEvents) + .Concat(this.movedEvents) + .Concat(this.splitEvents) + .Concat(this.mergedEvents)); + + // Broadcast the rest. + foreach (var s in this.subscribers) + { + s.InvokeChanged(allEventsCollection); + s.Invoke(this.addedEvents); + s.Invoke(this.removedEvents); + s.Invoke(this.changedEvents); + s.Invoke(this.movedEvents); + s.Invoke(this.splitEvents); + s.Invoke(this.mergedEvents); + } + + // We're done using the lists. Clean them up. + this.addedEvents.Clear(); + this.removedEvents.Clear(); + this.changedEvents.Clear(); + this.movedEvents.Clear(); + this.splitEvents.Clear(); + this.mergedEvents.Clear(); + } + + private unsafe void RaptureAtkModuleUpdateDetour(RaptureAtkModule* ram, float f1) + { + this.inventoriesMightBeChanged |= ram->AgentUpdateFlag != 0; + this.raptureAtkModuleUpdateHook.Original(ram, f1); + } + + /// + /// A view of , so that the number of items + /// contained within can be known in advance, and it can be enumerated multiple times. + /// + /// The type of elements being enumerated. + private class DeferredReadOnlyCollection : IReadOnlyCollection + { + private readonly Func> enumerableGenerator; + + public DeferredReadOnlyCollection(int count, Func> enumerableGenerator) + { + this.enumerableGenerator = enumerableGenerator; + this.Count = count; + } + + public int Count { get; } + + public IEnumerator GetEnumerator() => this.enumerableGenerator().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.enumerableGenerator().GetEnumerator(); + } +} + +/// +/// Plugin-scoped version of a GameInventory service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class GameInventoryPluginScoped : IInternalDisposableService, IGameInventory +{ + private static readonly ModuleLog Log = new(nameof(GameInventoryPluginScoped)); + + [ServiceManager.ServiceDependency] + private readonly GameInventory gameInventoryService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + public GameInventoryPluginScoped() => this.gameInventoryService.Subscribe(this); + + /// + public event IGameInventory.InventoryChangelogDelegate? InventoryChanged; + + /// + public event IGameInventory.InventoryChangelogDelegate? InventoryChangedRaw; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemAdded; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemRemoved; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemChanged; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMoved; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemSplit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMerged; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemAddedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemRemovedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemChangedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMovedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemSplitExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMergedExplicit; + + /// + void IInternalDisposableService.DisposeService() + { + this.gameInventoryService.Unsubscribe(this); + + this.InventoryChanged = null; + this.InventoryChangedRaw = null; + this.ItemAdded = null; + this.ItemRemoved = null; + this.ItemChanged = null; + this.ItemMoved = null; + this.ItemSplit = null; + this.ItemMerged = null; + this.ItemAddedExplicit = null; + this.ItemRemovedExplicit = null; + this.ItemChangedExplicit = null; + this.ItemMovedExplicit = null; + this.ItemSplitExplicit = null; + this.ItemMergedExplicit = null; + } + + /// + /// Invoke . + /// + /// The data. + internal void InvokeChanged(IReadOnlyCollection data) + { + try + { + this.InventoryChanged?.Invoke(data); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during {argType} callback", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + nameof(this.InventoryChanged)); + } + } + + /// + /// Invoke . + /// + /// The data. + internal void InvokeChangedRaw(IReadOnlyCollection data) + { + try + { + this.InventoryChangedRaw?.Invoke(data); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during {argType} callback", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + nameof(this.InventoryChangedRaw)); + } + } + + // Note below: using List instead of IEnumerable, since List has a specialized lightweight enumerator. + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemAdded, this.ItemAddedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemRemoved, this.ItemRemovedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemChanged, this.ItemChangedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemMoved, this.ItemMovedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemSplit, this.ItemSplitExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemMerged, this.ItemMergedExplicit, events); + + private static void Invoke( + IGameInventory.InventoryChangedDelegate? cb, + IGameInventory.InventoryChangedDelegate? cbt, + List events) where T : InventoryEventArgs + { + foreach (var evt in events) + { + try + { + cb?.Invoke(evt.Type, evt); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during untyped callback for {evt}", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + evt); + } + + try + { + cbt?.Invoke(evt); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during typed callback for {evt}", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + evt); + } + } + } +} diff --git a/Dalamud/Game/Inventory/GameInventoryEvent.cs b/Dalamud/Game/Inventory/GameInventoryEvent.cs new file mode 100644 index 000000000..16efab648 --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventoryEvent.cs @@ -0,0 +1,43 @@ +namespace Dalamud.Game.Inventory; + +/// +/// Class representing a item's changelog state. +/// +public enum GameInventoryEvent +{ + /// + /// A value indicating that there was no event.
+ /// You should not see this value, unless you explicitly used it yourself, or APIs using this enum say otherwise. + ///
+ Empty = 0, + + /// + /// Item was added to an inventory. + /// + Added = 1, + + /// + /// Item was removed from an inventory. + /// + Removed = 2, + + /// + /// Properties are changed for an item in an inventory. + /// + Changed = 3, + + /// + /// Item has been moved, possibly across different inventories. + /// + Moved = 4, + + /// + /// Item has been split into two stacks from one, possibly across different inventories. + /// + Split = 5, + + /// + /// Item has been merged into one stack from two, possibly across different inventories. + /// + Merged = 6, +} diff --git a/Dalamud/Game/Inventory/GameInventoryItem.cs b/Dalamud/Game/Inventory/GameInventoryItem.cs new file mode 100644 index 000000000..d37e1081f --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventoryItem.cs @@ -0,0 +1,211 @@ +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; + +/// +/// Dalamud wrapper around a ClientStructs InventoryItem. +/// +[StructLayout(LayoutKind.Explicit, Size = StructSizeInBytes)] +public unsafe struct GameInventoryItem : IEquatable +{ + /// + /// The actual data. + /// + [FieldOffset(0)] + internal readonly InventoryItem InternalItem; + + private const int StructSizeInBytes = 0x38; + + /// + /// The view of the backing data, in . + /// + [FieldOffset(0)] + private fixed ulong dataUInt64[StructSizeInBytes / 0x8]; + + static GameInventoryItem() + { + Debug.Assert( + sizeof(InventoryItem) == StructSizeInBytes, + $"Definition of {nameof(InventoryItem)} has been changed. " + + $"Update {nameof(StructSizeInBytes)} to {sizeof(InventoryItem)} to accommodate for the size change."); + } + + /// + /// Initializes a new instance of the struct. + /// + /// Inventory item to wrap. + internal GameInventoryItem(InventoryItem item) => this.InternalItem = item; + + /// + /// Gets a value indicating whether the this is empty. + /// + public bool IsEmpty => this.InternalItem.ItemID == 0; + + /// + /// Gets the container inventory type. + /// + public GameInventoryType ContainerType => (GameInventoryType)this.InternalItem.Container; + + /// + /// Gets the inventory slot index this item is in. + /// + public uint InventorySlot => (uint)this.InternalItem.Slot; + + /// + /// Gets the item id. + /// + public uint ItemId => this.InternalItem.ItemID; + + /// + /// Gets the quantity of items in this item stack. + /// + public uint Quantity => this.InternalItem.Quantity; + + /// + /// Gets the spiritbond of this item. + /// + public uint Spiritbond => this.InternalItem.Spiritbond; + + /// + /// Gets the repair condition of this item. + /// + public uint Condition => this.InternalItem.Condition; + + /// + /// Gets a value indicating whether the item is High Quality. + /// + public bool IsHq => (this.InternalItem.Flags & InventoryItem.ItemFlags.HQ) != 0; + + /// + /// Gets a value indicating whether the item has a company crest applied. + /// + public bool IsCompanyCrestApplied => (this.InternalItem.Flags & InventoryItem.ItemFlags.CompanyCrestApplied) != 0; + + /// + /// Gets a value indicating whether the item is a relic. + /// + public bool IsRelic => (this.InternalItem.Flags & InventoryItem.ItemFlags.Relic) != 0; + + /// + /// Gets a value indicating whether the is a collectable. + /// + public bool IsCollectable => (this.InternalItem.Flags & InventoryItem.ItemFlags.Collectable) != 0; + + /// + /// Gets the array of materia types. + /// + public ReadOnlySpan Materia => new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.Materia[0])), 5); + + /// + /// Gets the array of materia grades. + /// + // TODO: Replace with MateriaGradeBytes + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public ReadOnlySpan MateriaGrade => + this.MateriaGradeBytes.ToArray().Select(g => (ushort)g).ToArray().AsSpan(); + + /// + /// Gets the address of native inventory item in the game.
+ /// Can be 0 if this instance of does not point to a valid set of container type and slot.
+ /// Note that this instance of can be a snapshot; it may not necessarily match the + /// data you can query from the game using this address value. + ///
+ public nint Address + { + get + { + var s = GetReadOnlySpanOfInventory(this.ContainerType); + if (s.IsEmpty) + return 0; + + foreach (ref readonly var i in s) + { + if (i.InventorySlot == this.InventorySlot) + return (nint)Unsafe.AsPointer(ref Unsafe.AsRef(in i)); + } + + return 0; + } + } + + /// + /// Gets the color used for this item. + /// + public byte Stain => this.InternalItem.Stain; + + /// + /// Gets the glamour id for this item. + /// + public uint GlamourId => this.InternalItem.GlamourID; + + /// + /// Gets the items crafter's content id. + /// NOTE: I'm not sure if this is a good idea to include or not in the dalamud api. Marked internal for now. + /// + internal ulong CrafterContentId => this.InternalItem.CrafterContentID; + + private ReadOnlySpan 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); + + /// + readonly bool IEquatable.Equals(GameInventoryItem other) => this.Equals(other); + + /// Indicates whether the current object is equal to another object of the same type. + /// An object to compare with this object. + /// true if the current object is equal to the parameter; otherwise, false. + public readonly bool Equals(in GameInventoryItem other) + { + for (var i = 0; i < StructSizeInBytes / 8; i++) + { + if (this.dataUInt64[i] != other.dataUInt64[i]) + return false; + } + + return true; + } + + /// + public override bool Equals(object obj) => obj is GameInventoryItem gii && this.Equals(gii); + + /// + public override int GetHashCode() + { + var k = 0x5a8447b91aff51b4UL; + for (var i = 0; i < StructSizeInBytes / 8; i++) + k ^= this.dataUInt64[i]; + return unchecked((int)(k ^ (k >> 32))); + } + + /// + public override string ToString() => + this.IsEmpty + ? "empty" + : $"item({this.ItemId}@{this.ContainerType}#{this.InventorySlot})"; + + /// + /// Gets a view of s, wrapped as . + /// + /// The inventory type. + /// The span. + internal static ReadOnlySpan GetReadOnlySpanOfInventory(GameInventoryType type) + { + var inventoryManager = InventoryManager.Instance(); + if (inventoryManager is null) return default; + + var inventory = inventoryManager->GetInventoryContainer((InventoryType)type); + if (inventory is null) return default; + + return new ReadOnlySpan(inventory->Items, (int)inventory->Size); + } +} diff --git a/Dalamud/Game/Inventory/GameInventoryType.cs b/Dalamud/Game/Inventory/GameInventoryType.cs new file mode 100644 index 000000000..00c65046f --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventoryType.cs @@ -0,0 +1,356 @@ +namespace Dalamud.Game.Inventory; + +/// +/// Enum representing various player inventories. +/// +public enum GameInventoryType : ushort +{ + /// + /// First panel of main player inventory. + /// + Inventory1 = 0, + + /// + /// Second panel of main player inventory. + /// + Inventory2 = 1, + + /// + /// Third panel of main player inventory. + /// + Inventory3 = 2, + + /// + /// Fourth panel of main player inventory. + /// + Inventory4 = 3, + + /// + /// Items that are currently equipped by the player. + /// + EquippedItems = 1000, + + /// + /// Player currency container. + /// ie, gil, serpent seals, sacks of nuts. + /// + Currency = 2000, + + /// + /// Crystal container. + /// + Crystals = 2001, + + /// + /// Mail container. + /// + Mail = 2003, + + /// + /// Key item container. + /// + KeyItems = 2004, + + /// + /// Quest item hand-in inventory. + /// + HandIn = 2005, + + /// + /// DamagedGear container. + /// + DamagedGear = 2007, + + /// + /// Examine window container. + /// + Examine = 2009, + + /// + /// Doman Enclave Reconstruction Reclamation Box. + /// + ReconstructionBuyback = 2013, + + /// + /// Armory off-hand weapon container. + /// + ArmoryOffHand = 3200, + + /// + /// Armory head container. + /// + ArmoryHead = 3201, + + /// + /// Armory body container. + /// + ArmoryBody = 3202, + + /// + /// Armory hand/gloves container. + /// + ArmoryHands = 3203, + + /// + /// Armory waist container. + /// + /// This container should be unused as belt items were removed from the game in Shadowbringers. + /// + /// + ArmoryWaist = 3204, + + /// + /// Armory legs/pants/skirt container. + /// + ArmoryLegs = 3205, + + /// + /// Armory feet/boots/shoes container. + /// + ArmoryFeets = 3206, + + /// + /// Armory earring container. + /// + ArmoryEar = 3207, + + /// + /// Armory necklace container. + /// + ArmoryNeck = 3208, + + /// + /// Armory bracelet container. + /// + ArmoryWrist = 3209, + + /// + /// Armory ring container. + /// + ArmoryRings = 3300, + + /// + /// Armory soul crystal container. + /// + ArmorySoulCrystal = 3400, + + /// + /// Armory main-hand weapon container. + /// + ArmoryMainHand = 3500, + + /// + /// First panel of saddelbag inventory. + /// + SaddleBag1 = 4000, + + /// + /// Second panel of Saddlebag inventory. + /// + SaddleBag2 = 4001, + + /// + /// First panel of premium saddlebag inventory. + /// + PremiumSaddleBag1 = 4100, + + /// + /// Second panel of premium saddlebag inventory. + /// + PremiumSaddleBag2 = 4101, + + /// + /// First panel of retainer inventory. + /// + RetainerPage1 = 10000, + + /// + /// Second panel of retainer inventory. + /// + RetainerPage2 = 10001, + + /// + /// Third panel of retainer inventory. + /// + RetainerPage3 = 10002, + + /// + /// Fourth panel of retainer inventory. + /// + RetainerPage4 = 10003, + + /// + /// Fifth panel of retainer inventory. + /// + RetainerPage5 = 10004, + + /// + /// Sixth panel of retainer inventory. + /// + RetainerPage6 = 10005, + + /// + /// Seventh panel of retainer inventory. + /// + RetainerPage7 = 10006, + + /// + /// Retainer equipment container. + /// + RetainerEquippedItems = 11000, + + /// + /// Retainer currency container. + /// + RetainerGil = 12000, + + /// + /// Retainer crystal container. + /// + RetainerCrystals = 12001, + + /// + /// Retainer market item container. + /// + RetainerMarket = 12002, + + /// + /// First panel of Free Company inventory. + /// + FreeCompanyPage1 = 20000, + + /// + /// Second panel of Free Company inventory. + /// + FreeCompanyPage2 = 20001, + + /// + /// Third panel of Free Company inventory. + /// + FreeCompanyPage3 = 20002, + + /// + /// Fourth panel of Free Company inventory. + /// + FreeCompanyPage4 = 20003, + + /// + /// Fifth panel of Free Company inventory. + /// + FreeCompanyPage5 = 20004, + + /// + /// Free Company currency container. + /// + FreeCompanyGil = 22000, + + /// + /// Free Company crystal container. + /// + FreeCompanyCrystals = 22001, + + /// + /// Housing exterior appearance container. + /// + HousingExteriorAppearance = 25000, + + /// + /// Housing exterior placed items container. + /// + HousingExteriorPlacedItems = 25001, + + /// + /// Housing interior appearance container. + /// + HousingInteriorAppearance = 25002, + + /// + /// First panel of housing interior inventory. + /// + HousingInteriorPlacedItems1 = 25003, + + /// + /// Second panel of housing interior inventory. + /// + HousingInteriorPlacedItems2 = 25004, + + /// + /// Third panel of housing interior inventory. + /// + HousingInteriorPlacedItems3 = 25005, + + /// + /// Fourth panel of housing interior inventory. + /// + HousingInteriorPlacedItems4 = 25006, + + /// + /// Fifth panel of housing interior inventory. + /// + HousingInteriorPlacedItems5 = 25007, + + /// + /// Sixth panel of housing interior inventory. + /// + HousingInteriorPlacedItems6 = 25008, + + /// + /// Seventh panel of housing interior inventory. + /// + HousingInteriorPlacedItems7 = 25009, + + /// + /// Eighth panel of housing interior inventory. + /// + HousingInteriorPlacedItems8 = 25010, + + /// + /// Housing exterior storeroom inventory. + /// + HousingExteriorStoreroom = 27000, + + /// + /// First panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom1 = 27001, + + /// + /// Second panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom2 = 27002, + + /// + /// Third panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom3 = 27003, + + /// + /// Fourth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom4 = 27004, + + /// + /// Fifth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom5 = 27005, + + /// + /// Sixth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom6 = 27006, + + /// + /// Seventh panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom7 = 27007, + + /// + /// Eighth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom8 = 27008, + + /// + /// An invalid value. + /// + Invalid = ushort.MaxValue, +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs new file mode 100644 index 000000000..95d7e8238 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs @@ -0,0 +1,54 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being affected across different slots, possibly in different containers. +/// +public abstract class InventoryComplexEventArgs : InventoryEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// Type of the event. + /// The item at before slot. + /// The item at after slot. + internal InventoryComplexEventArgs( + GameInventoryEvent type, InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(type, targetEvent.Item) + { + this.SourceEvent = sourceEvent; + this.TargetEvent = targetEvent; + } + + /// + /// Gets the inventory this item was at. + /// + public GameInventoryType SourceInventory => this.SourceEvent.Item.ContainerType; + + /// + /// Gets the inventory this item now is. + /// + public GameInventoryType TargetInventory => this.Item.ContainerType; + + /// + /// Gets the slot this item was at. + /// + public uint SourceSlot => this.SourceEvent.Item.InventorySlot; + + /// + /// Gets the slot this item now is. + /// + public uint TargetSlot => this.Item.InventorySlot; + + /// + /// Gets the associated source event. + /// + public InventoryEventArgs SourceEvent { get; } + + /// + /// Gets the associated target event. + /// + public InventoryEventArgs TargetEvent { get; } + + /// + public override string ToString() => $"{this.Type}({this.SourceEvent}, {this.TargetEvent})"; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs new file mode 100644 index 000000000..198e0395b --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs @@ -0,0 +1,37 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Abstract base class representing inventory changed events. +/// +public abstract class InventoryEventArgs +{ + private readonly GameInventoryItem item; + + /// + /// Initializes a new instance of the class. + /// + /// Type of the event. + /// Item about the event. + protected InventoryEventArgs(GameInventoryEvent type, in GameInventoryItem item) + { + this.Type = type; + this.item = item; + } + + /// + /// Gets the type of event for these args. + /// + public GameInventoryEvent Type { get; } + + /// + /// Gets the item associated with this event. + /// This is a copy of the item data. + /// + // impl note: we return a ref readonly view, to avoid making copies every time this property is accessed. + // see: https://devblogs.microsoft.com/premier-developer/avoiding-struct-and-readonly-reference-performance-pitfalls-with-errorprone-net/ + // "Consider using ref readonly locals and ref return for library code" + public ref readonly GameInventoryItem Item => ref this.item; + + /// + public override string ToString() => $"{this.Type}({this.Item})"; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs new file mode 100644 index 000000000..ceb64c6f9 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being added to an inventory. +/// +public sealed class InventoryItemAddedArgs : InventoryEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item. + internal InventoryItemAddedArgs(in GameInventoryItem item) + : base(GameInventoryEvent.Added, item) + { + } + + /// + /// Gets the inventory this item was added to. + /// + public GameInventoryType Inventory => this.Item.ContainerType; + + /// + /// Gets the slot this item was added to. + /// + public uint Slot => this.Item.InventorySlot; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs new file mode 100644 index 000000000..372418793 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs @@ -0,0 +1,38 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an items properties being changed. +/// This also includes an items stack count changing. +/// +public sealed class InventoryItemChangedArgs : InventoryEventArgs +{ + private readonly GameInventoryItem oldItemState; + + /// + /// Initializes a new instance of the class. + /// + /// The item before change. + /// The item after change. + internal InventoryItemChangedArgs(in GameInventoryItem oldItem, in GameInventoryItem newItem) + : base(GameInventoryEvent.Changed, newItem) + { + this.oldItemState = oldItem; + } + + /// + /// Gets the inventory this item is in. + /// + public GameInventoryType Inventory => this.Item.ContainerType; + + /// + /// Gets the inventory slot this item is in. + /// + public uint Slot => this.Item.InventorySlot; + + /// + /// Gets the state of the item from before it was changed. + /// This is a copy of the item data. + /// + // impl note: see InventoryEventArgs.Item. + public ref readonly GameInventoryItem OldItemState => ref this.oldItemState; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs new file mode 100644 index 000000000..d7056356e --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being merged from two stacks into one. +/// +public sealed class InventoryItemMergedArgs : InventoryComplexEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item at before slot. + /// The item at after slot. + internal InventoryItemMergedArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(GameInventoryEvent.Merged, sourceEvent, targetEvent) + { + } + + /// + public override string ToString() => + this.TargetEvent is InventoryItemChangedArgs iica + ? $"{this.Type}(" + + $"item({this.Item.ItemId}), " + + $"{this.SourceInventory}#{this.SourceSlot}({this.SourceEvent.Item.Quantity} to 0), " + + $"{this.TargetInventory}#{this.TargetSlot}({iica.OldItemState.Quantity} to {iica.Item.Quantity}))" + : base.ToString(); +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs new file mode 100644 index 000000000..8d0bbca17 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs @@ -0,0 +1,21 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being moved from one inventory and added to another. +/// +public sealed class InventoryItemMovedArgs : InventoryComplexEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item at before slot. + /// The item at after slot. + internal InventoryItemMovedArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(GameInventoryEvent.Moved, sourceEvent, targetEvent) + { + } + + /// + public override string ToString() => + $"{this.Type}(item({this.Item.ItemId}) from {this.SourceInventory}#{this.SourceSlot} to {this.TargetInventory}#{this.TargetSlot})"; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs new file mode 100644 index 000000000..5677e3cc4 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being removed from an inventory. +/// +public sealed class InventoryItemRemovedArgs : InventoryEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item. + internal InventoryItemRemovedArgs(in GameInventoryItem item) + : base(GameInventoryEvent.Removed, item) + { + } + + /// + /// Gets the inventory this item was removed from. + /// + public GameInventoryType Inventory => this.Item.ContainerType; + + /// + /// Gets the slot this item was removed from. + /// + public uint Slot => this.Item.InventorySlot; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs new file mode 100644 index 000000000..5f717cf60 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being split from one stack into two. +/// +public sealed class InventoryItemSplitArgs : InventoryComplexEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item at before slot. + /// The item at after slot. + internal InventoryItemSplitArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(GameInventoryEvent.Split, sourceEvent, targetEvent) + { + } + + /// + public override string ToString() => + this.SourceEvent is InventoryItemChangedArgs iica + ? $"{this.Type}(" + + $"item({this.Item.ItemId}), " + + $"{this.SourceInventory}#{this.SourceSlot}({iica.OldItemState.Quantity} to {iica.Item.Quantity}), " + + $"{this.TargetInventory}#{this.TargetSlot}(0 to {this.Item.Quantity}))" + : base.ToString(); +} diff --git a/Dalamud/Game/Libc/LibcFunction.cs b/Dalamud/Game/Libc/LibcFunction.cs index 7dfc26b3b..f1cd07080 100644 --- a/Dalamud/Game/Libc/LibcFunction.cs +++ b/Dalamud/Game/Libc/LibcFunction.cs @@ -17,14 +17,14 @@ namespace Dalamud.Game.Libc; #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 -public sealed class LibcFunction : IServiceType, ILibcFunction +internal sealed class LibcFunction : IServiceType, ILibcFunction { private readonly LibcFunctionAddressResolver address; private readonly StdStringFromCStringDelegate stdStringCtorCString; private readonly StdStringDeallocateDelegate stdStringDeallocate; [ServiceManager.ServiceConstructor] - private LibcFunction(SigScanner sigScanner) + private LibcFunction(TargetSigScanner sigScanner) { this.address = new LibcFunctionAddressResolver(); this.address.Setup(sigScanner); diff --git a/Dalamud/Game/Libc/LibcFunctionAddressResolver.cs b/Dalamud/Game/Libc/LibcFunctionAddressResolver.cs index 89b721a87..3b8742678 100644 --- a/Dalamud/Game/Libc/LibcFunctionAddressResolver.cs +++ b/Dalamud/Game/Libc/LibcFunctionAddressResolver.cs @@ -5,7 +5,7 @@ namespace Dalamud.Game.Libc; /// /// The address resolver for the class. /// -public sealed class LibcFunctionAddressResolver : BaseAddressResolver +internal sealed class LibcFunctionAddressResolver : BaseAddressResolver { private delegate IntPtr StringFromCString(); @@ -20,7 +20,7 @@ public sealed class LibcFunctionAddressResolver : BaseAddressResolver public IntPtr StdStringDeallocate { get; private set; } /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { this.StdStringFromCstring = sig.ScanText("48 89 5C 24 08 48 89 74 24 10 57 48 83 EC 20 48 8D 41 22 66 C7 41 20 01 01 48 89 01 49 8B D8"); this.StdStringDeallocate = sig.ScanText("80 79 21 00 75 12 48 8B 51 08 41 B8 33 00 00 00 48 8B 09 E9 ?? ?? ?? 00 C3"); diff --git a/Dalamud/Game/Network/GameNetwork.cs b/Dalamud/Game/Network/GameNetwork.cs index d1fc0bfba..954612af7 100644 --- a/Dalamud/Game/Network/GameNetwork.cs +++ b/Dalamud/Game/Network/GameNetwork.cs @@ -1,10 +1,10 @@ -using System; using System.Runtime.InteropServices; using Dalamud.Configuration.Internal; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Plugin.Services; using Dalamud.Utility; using Serilog; @@ -13,10 +13,9 @@ namespace Dalamud.Game.Network; /// /// This class handles interacting with game network events. /// -[PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -public sealed class GameNetwork : IDisposable, IServiceType +internal sealed class GameNetwork : IInternalDisposableService, IGameNetwork { private readonly GameNetworkAddressResolver address; private readonly Hook processZonePacketDownHook; @@ -31,7 +30,7 @@ public sealed class GameNetwork : IDisposable, IServiceType private IntPtr baseAddress; [ServiceManager.ServiceConstructor] - private GameNetwork(SigScanner sigScanner) + private GameNetwork(TargetSigScanner sigScanner) { this.hitchDetectorUp = new HitchDetector("GameNetworkUp", this.configuration.GameNetworkUpHitch); this.hitchDetectorDown = new HitchDetector("GameNetworkDown", this.configuration.GameNetworkDownHitch); @@ -45,17 +44,10 @@ public sealed class GameNetwork : IDisposable, IServiceType this.processZonePacketDownHook = Hook.FromAddress(this.address.ProcessZonePacketDown, this.ProcessZonePacketDownDetour); this.processZonePacketUpHook = Hook.FromAddress(this.address.ProcessZonePacketUp, this.ProcessZonePacketUpDetour); - } - /// - /// The delegate type of a network message event. - /// - /// The pointer to the raw data. - /// The operation ID code. - /// The source actor ID. - /// The taret actor ID. - /// The direction of the packed. - public delegate void OnNetworkMessageDelegate(IntPtr dataPtr, ushort opCode, uint sourceActorId, uint targetActorId, NetworkMessageDirection direction); + this.processZonePacketDownHook.Enable(); + this.processZonePacketUpHook.Enable(); + } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void ProcessZonePacketDownDelegate(IntPtr a, uint targetId, IntPtr dataPtr); @@ -63,27 +55,16 @@ public sealed class GameNetwork : IDisposable, IServiceType [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate byte ProcessZonePacketUpDelegate(IntPtr a1, IntPtr dataPtr, IntPtr a3, byte a4); - /// - /// Event that is called when a network message is sent/received. - /// - public event OnNetworkMessageDelegate NetworkMessage; + /// + public event IGameNetwork.OnNetworkMessageDelegate? NetworkMessage; - /// - /// Dispose of managed and unmanaged resources. - /// - void IDisposable.Dispose() + /// + void IInternalDisposableService.DisposeService() { this.processZonePacketDownHook.Dispose(); this.processZonePacketUpHook.Dispose(); } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.processZonePacketDownHook.Enable(); - this.processZonePacketUpHook.Enable(); - } - private void ProcessZonePacketDownDetour(IntPtr a, uint targetId, IntPtr dataPtr) { this.baseAddress = a; @@ -154,3 +135,40 @@ public sealed class GameNetwork : IDisposable, IServiceType return this.processZonePacketUpHook.Original(a1, dataPtr, a3, a4); } } + +/// +/// Plugin-scoped version of a AddonLifecycle service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class GameNetworkPluginScoped : IInternalDisposableService, IGameNetwork +{ + [ServiceManager.ServiceDependency] + private readonly GameNetwork gameNetworkService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + internal GameNetworkPluginScoped() + { + this.gameNetworkService.NetworkMessage += this.NetworkMessageForward; + } + + /// + public event IGameNetwork.OnNetworkMessageDelegate? NetworkMessage; + + /// + void IInternalDisposableService.DisposeService() + { + this.gameNetworkService.NetworkMessage -= this.NetworkMessageForward; + + this.NetworkMessage = null; + } + + private void NetworkMessageForward(nint dataPtr, ushort opCode, uint sourceActorId, uint targetActorId, NetworkMessageDirection direction) + => this.NetworkMessage?.Invoke(dataPtr, opCode, sourceActorId, targetActorId, direction); +} diff --git a/Dalamud/Game/Network/GameNetworkAddressResolver.cs b/Dalamud/Game/Network/GameNetworkAddressResolver.cs index c698ee813..f8a1b278d 100644 --- a/Dalamud/Game/Network/GameNetworkAddressResolver.cs +++ b/Dalamud/Game/Network/GameNetworkAddressResolver.cs @@ -1,11 +1,9 @@ -using System; - namespace Dalamud.Game.Network; /// /// The address resolver for the class. /// -public sealed class GameNetworkAddressResolver : BaseAddressResolver +internal sealed class GameNetworkAddressResolver : BaseAddressResolver { /// /// Gets the address of the ProcessZonePacketDown method. @@ -18,7 +16,7 @@ public sealed class GameNetworkAddressResolver : BaseAddressResolver public IntPtr ProcessZonePacketUp { get; private set; } /// - protected override void Setup64Bit(SigScanner sig) + protected override void Setup64Bit(ISigScanner sig) { // ProcessZonePacket = sig.ScanText("48 89 74 24 18 57 48 83 EC 50 8B F2 49 8B F8 41 0F B7 50 02 8B CE E8 ?? ?? 7A FF 0F B7 57 02 8D 42 89 3D 5F 02 00 00 0F 87 60 01 00 00 4C 8D 05"); // ProcessZonePacket = sig.ScanText("48 89 74 24 18 57 48 83 EC 50 8B F2 49 8B F8 41 0F B7 50 02 8B CE E8 ?? ?? 73 FF 0F B7 57 02 8D 42 ?? 3D ?? ?? 00 00 0F 87 60 01 00 00 4C 8D 05"); diff --git a/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs b/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs index b3175cad3..34a255e19 100644 --- a/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs +++ b/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Net.Http; using System.Text; @@ -22,14 +21,14 @@ internal class UniversalisMarketBoardUploader : IMarketBoardUploader private const string ApiKey = "GGD6RdSfGyRiHM5WDnAo0Nj9Nv7aC5NDhMj3BebT"; - private readonly HttpClient httpClient = Service.Get().SharedHttpClient; + private readonly HttpClient httpClient; /// /// Initializes a new instance of the class. /// - public UniversalisMarketBoardUploader() - { - } + /// An instance of . + public UniversalisMarketBoardUploader(HappyHttpClient happyHttpClient) => + this.httpClient = happyHttpClient.SharedHttpClient; /// public async Task Upload(MarketBoardItemRequest request) diff --git a/Dalamud/Game/Network/Internal/NetworkHandlers.cs b/Dalamud/Game/Network/Internal/NetworkHandlers.cs index 1ccf6c6d5..2a46af3d3 100644 --- a/Dalamud/Game/Network/Internal/NetworkHandlers.cs +++ b/Dalamud/Game/Network/Internal/NetworkHandlers.cs @@ -1,11 +1,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Reactive.Concurrency; using System.Reactive.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Dalamud.Configuration.Internal; @@ -14,7 +12,11 @@ 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; +using FFXIVClientStructs.FFXIV.Client.UI.Info; using Lumina.Excel.GeneratedSheets; using Serilog; @@ -23,17 +25,31 @@ namespace Dalamud.Game.Network.Internal; /// /// This class handles network notifications and uploading market board data. /// -[ServiceManager.EarlyLoadedService] -internal class NetworkHandlers : IDisposable, IServiceType +[ServiceManager.BlockingEarlyLoadedService] +internal unsafe class NetworkHandlers : IInternalDisposableService { private readonly IMarketBoardUploader uploader; - private readonly IObservable messages; + private readonly IObservable mbPurchaseObservable; + private readonly IObservable mbHistoryObservable; + private readonly IObservable mbTaxesObservable; + private readonly IObservable mbItemRequestObservable; + private readonly IObservable mbOfferingsObservable; + private readonly IObservable mbPurchaseSentObservable; private readonly IDisposable handleMarketBoardItemRequest; private readonly IDisposable handleMarketTaxRates; private readonly IDisposable handleMarketBoardPurchaseHandler; - private readonly IDisposable handleCfPop; + + private readonly NetworkHandlersAddressResolver addressResolver; + + private readonly Hook cfPopHook; + private readonly Hook mbPurchaseHook; + private readonly Hook mbHistoryHook; + private readonly Hook customTalkHook; // used for marketboard taxes + private readonly Hook mbItemRequestStartHook; + private readonly Hook mbOfferingsHook; + private readonly Hook mbSendPurchaseRequestHook; [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); @@ -41,46 +57,163 @@ internal class NetworkHandlers : IDisposable, IServiceType private bool disposing; [ServiceManager.ServiceConstructor] - private NetworkHandlers(GameNetwork gameNetwork) + private NetworkHandlers( + GameNetwork gameNetwork, + TargetSigScanner sigScanner, + HappyHttpClient happyHttpClient) { - this.uploader = new UniversalisMarketBoardUploader(); - this.CfPop = (_, _) => { }; + this.uploader = new UniversalisMarketBoardUploader(happyHttpClient); - this.messages = Observable.Create(observer => + this.addressResolver = new NetworkHandlersAddressResolver(); + this.addressResolver.Setup(sigScanner); + + this.CfPop = _ => { }; + + this.mbPurchaseObservable = Observable.Create(observer => { - void Observe(IntPtr dataPtr, ushort opCode, uint sourceActorId, uint targetActorId, NetworkMessageDirection direction) - { - var dataManager = Service.GetNullable(); - observer.OnNext(new NetworkMessage - { - DataManager = dataManager, - Data = dataPtr, - Opcode = opCode, - SourceActorId = sourceActorId, - TargetActorId = targetActorId, - Direction = direction, - }); - } + this.MarketBoardPurchaseReceived += Observe; + return () => { this.MarketBoardPurchaseReceived -= Observe; }; - gameNetwork.NetworkMessage += Observe; - return () => { gameNetwork.NetworkMessage -= Observe; }; + void Observe(nint packetPtr) + { + observer.OnNext(MarketBoardPurchase.Read(packetPtr)); + } + }); + + this.mbHistoryObservable = Observable.Create(observer => + { + this.MarketBoardHistoryReceived += Observe; + return () => { this.MarketBoardHistoryReceived -= Observe; }; + + void Observe(nint packetPtr) + { + observer.OnNext(MarketBoardHistory.Read(packetPtr)); + } + }); + + this.mbTaxesObservable = Observable.Create(observer => + { + this.MarketBoardTaxesReceived += Observe; + return () => { this.MarketBoardTaxesReceived -= Observe; }; + + void Observe(nint dataPtr) + { + // n.b. we precleared the packet information so we're sure that this is *just* tax rate info. + observer.OnNext(MarketTaxRates.ReadFromCustomTalk(dataPtr)); + } + }); + + this.mbItemRequestObservable = Observable.Create(observer => + { + this.MarketBoardItemRequestStartReceived += Observe; + return () => this.MarketBoardItemRequestStartReceived -= Observe; + + void Observe(nint dataPtr) + { + observer.OnNext(MarketBoardItemRequest.Read(dataPtr)); + } + }); + + this.mbOfferingsObservable = Observable.Create(observer => + { + this.MarketBoardOfferingsReceived += Observe; + return () => { this.MarketBoardOfferingsReceived -= Observe; }; + + void Observe(nint packetPtr) + { + observer.OnNext(MarketBoardCurrentOfferings.Read(packetPtr)); + } + }); + + this.mbPurchaseSentObservable = Observable.Create(observer => + { + this.MarketBoardPurchaseRequestSent += Observe; + return () => { this.MarketBoardPurchaseRequestSent -= Observe; }; + + void Observe(nint dataPtr) + { + // fortunately, this dataptr has the same structure as the sent packet. + observer.OnNext(MarketBoardPurchaseHandler.Read(dataPtr)); + } }); this.handleMarketBoardItemRequest = this.HandleMarketBoardItemRequest(); this.handleMarketTaxRates = this.HandleMarketTaxRates(); this.handleMarketBoardPurchaseHandler = this.HandleMarketBoardPurchaseHandler(); - this.handleCfPop = this.HandleCfPop(); + + this.mbPurchaseHook = + Hook.FromAddress( + this.addressResolver.MarketBoardPurchasePacketHandler, + this.MarketPurchasePacketDetour); + this.mbPurchaseHook.Enable(); + + this.mbHistoryHook = + Hook.FromAddress( + this.addressResolver.MarketBoardHistoryPacketHandler, + this.MarketHistoryPacketDetour); + this.mbHistoryHook.Enable(); + + this.customTalkHook = + Hook.FromAddress( + this.addressResolver.CustomTalkEventResponsePacketHandler, + this.CustomTalkReceiveResponseDetour); + this.customTalkHook.Enable(); + + this.mbItemRequestStartHook = Hook.FromAddress( + this.addressResolver.MarketBoardItemRequestStartPacketHandler, + this.MarketItemRequestStartDetour); + this.mbItemRequestStartHook.Enable(); + + this.mbOfferingsHook = Hook.FromAddress( + this.addressResolver.InfoProxyItemSearchAddPage, + this.MarketBoardOfferingsDetour); + this.mbOfferingsHook.Enable(); + + this.mbSendPurchaseRequestHook = Hook.FromAddress( + this.addressResolver.BuildMarketBoardPurchaseHandlerPacket, + this.MarketBoardSendPurchaseRequestDetour); + this.mbSendPurchaseRequestHook.Enable(); + + this.cfPopHook = Hook.FromAddress(this.addressResolver.CfPopPacketHandler, this.CfPopDetour); + this.cfPopHook.Enable(); } + private delegate nint MarketBoardPurchasePacketHandler(nint a1, nint packetRef); + + private delegate nint MarketBoardHistoryPacketHandler(nint self, nint packetData, uint a3, char a4); + + private delegate void CustomTalkReceiveResponse( + nuint a1, ushort eventId, byte responseId, uint* args, byte argCount); + + private delegate nint MarketBoardItemRequestStartPacketHandler(nint a1, nint packetRef); + + private delegate byte InfoProxyItemSearchAddPage(nint self, nint packetRef); + + private delegate byte MarketBoardSendPurchaseRequestPacket(InfoProxyItemSearch* infoProxy); + + private delegate nint CfPopDelegate(nint packetData); + /// /// Event which gets fired when a duty is ready. /// - public event EventHandler CfPop; + public event Action CfPop; + + private event Action? MarketBoardPurchaseReceived; + + private event Action? MarketBoardHistoryReceived; + + private event Action? MarketBoardTaxesReceived; + + private event Action? MarketBoardItemRequestStartReceived; + + private event Action? MarketBoardOfferingsReceived; + + private event Action? MarketBoardPurchaseRequestSent; /// /// Disposes of managed and unmanaged resources. /// - public void Dispose() + void IInternalDisposableService.DisposeService() { this.disposing = true; this.Dispose(this.disposing); @@ -98,81 +231,78 @@ internal class NetworkHandlers : IDisposable, IServiceType this.handleMarketBoardItemRequest.Dispose(); this.handleMarketTaxRates.Dispose(); this.handleMarketBoardPurchaseHandler.Dispose(); - this.handleCfPop.Dispose(); + + this.mbPurchaseHook.Dispose(); + this.mbHistoryHook.Dispose(); + this.customTalkHook.Dispose(); + this.mbItemRequestStartHook.Dispose(); + this.mbOfferingsHook.Dispose(); + this.mbSendPurchaseRequestHook.Dispose(); + this.cfPopHook.Dispose(); } - private IObservable OnNetworkMessage() + private unsafe nint CfPopDetour(nint packetData) { - return this.messages.Where(message => message.DataManager?.IsDataReady == true); - } + var result = this.cfPopHook.OriginalDisposeSafe(packetData); - private IObservable OnMarketBoardItemRequestStart() - { - return this.OnNetworkMessage() - .Where(message => message.Direction == NetworkMessageDirection.ZoneDown) - .Where(message => message.Opcode == - message.DataManager?.ServerOpCodes["MarketBoardItemRequestStart"]) - .Select(message => MarketBoardItemRequest.Read(message.Data)); - } + try + { + using var stream = new UnmanagedMemoryStream((byte*)packetData, 64); + using var reader = new BinaryReader(stream); - private IObservable OnMarketBoardOfferings() - { - return this.OnNetworkMessage() - .Where(message => message.Direction == NetworkMessageDirection.ZoneDown) - .Where(message => message.Opcode == message.DataManager?.ServerOpCodes["MarketBoardOfferings"]) - .Select(message => MarketBoardCurrentOfferings.Read(message.Data)); - } + var notifyType = reader.ReadByte(); + stream.Position += 0x1B; + var conditionId = reader.ReadUInt16(); - private IObservable OnMarketBoardHistory() - { - return this.OnNetworkMessage() - .Where(message => message.Direction == NetworkMessageDirection.ZoneDown) - .Where(message => message.Opcode == message.DataManager?.ServerOpCodes["MarketBoardHistory"]) - .Select(message => MarketBoardHistory.Read(message.Data)); - } + if (notifyType != 3) + return result; - private IObservable OnMarketTaxRates() - { - return this.OnNetworkMessage() - .Where(message => message.Direction == NetworkMessageDirection.ZoneDown) - .Where(message => message.Opcode == message.DataManager?.ServerOpCodes["MarketTaxRates"]) - .Where(message => - { - // Only some categories of the result dialog packet contain market tax rates - var category = (uint)Marshal.ReadInt32(message.Data); - return category == 720905; - }) - .Select(message => MarketTaxRates.Read(message.Data)) - .Where(taxes => taxes.Category == 0xb0009); - } + if (this.configuration.DutyFinderTaskbarFlash) + Util.FlashWindow(); - private IObservable OnMarketBoardPurchaseHandler() - { - return this.OnNetworkMessage() - .Where(message => message.Direction == NetworkMessageDirection.ZoneUp) - .Where(message => message.Opcode == message.DataManager?.ClientOpCodes["MarketBoardPurchaseHandler"]) - .Select(message => MarketBoardPurchaseHandler.Read(message.Data)); - } + var cfConditionSheet = Service.Get().GetExcelSheet()!; + var cfCondition = cfConditionSheet.GetRow(conditionId); - private IObservable OnMarketBoardPurchase() - { - return this.OnNetworkMessage() - .Where(message => message.Direction == NetworkMessageDirection.ZoneDown) - .Where(message => message.Opcode == message.DataManager?.ServerOpCodes["MarketBoardPurchase"]) - .Select(message => MarketBoardPurchase.Read(message.Data)); - } + if (cfCondition == null) + { + Log.Error("CFC key {ConditionId} not in Lumina data", conditionId); + return result; + } - private IObservable OnCfNotifyPop() - { - return this.OnNetworkMessage() - .Where(message => message.Direction == NetworkMessageDirection.ZoneDown) - .Where(message => message.Opcode == message.DataManager?.ServerOpCodes["CfNotifyPop"]); + var cfcName = cfCondition.Name.ToDalamudString(); + if (cfcName.Payloads.Count == 0) + { + cfcName = "Duty Roulette"; + cfCondition.Image = 112324; + } + + Task.Run(() => + { + if (this.configuration.DutyFinderChatMessage) + { + var b = new SeStringBuilder(); + b.Append("Duty pop: "); + b.Append(cfcName); + Service.GetNullable()?.Print(b.Build()); + } + + this.CfPop.InvokeSafely(cfCondition); + }).ContinueWith( + task => Log.Error(task.Exception, "CfPop.Invoke failed"), + TaskContinuationOptions.OnlyOnFaulted); + } + catch (Exception ex) + { + Log.Error(ex, "CfPopDetour threw an exception"); + } + + return result; } private IObservable> OnMarketBoardListingsBatch( IObservable start) { - var offeringsObservable = this.OnMarketBoardOfferings().Publish().RefCount(); + var offeringsObservable = this.mbOfferingsObservable.Publish().RefCount(); void LogEndObserved(MarketBoardCurrentOfferings offerings) { @@ -222,7 +352,7 @@ internal class NetworkHandlers : IDisposable, IServiceType private IObservable> OnMarketBoardSalesBatch( IObservable start) { - var historyObservable = this.OnMarketBoardHistory().Publish().RefCount(); + var historyObservable = this.mbHistoryObservable.Publish().RefCount(); void LogHistoryObserved(MarketBoardHistory history) { @@ -265,7 +395,7 @@ internal class NetworkHandlers : IDisposable, IServiceType request.AmountToArrive); } - var startObservable = this.OnMarketBoardItemRequestStart() + var startObservable = this.mbItemRequestObservable .Where(request => request.Ok).Do(LogStartObserved) .Publish() .RefCount(); @@ -292,7 +422,9 @@ internal class NetworkHandlers : IDisposable, IServiceType { if (listings.Count != request.AmountToArrive) { - Log.Error("Wrong number of Market Board listings received for request: {ListingsCount} != {RequestAmountToArrive} item#{RequestCatalogId}", listings.Count, request.AmountToArrive, request.CatalogId); + Log.Error( + "Wrong number of Market Board listings received for request: {ListingsCount} != {RequestAmountToArrive} item#{RequestCatalogId}", + listings.Count, request.AmountToArrive, request.CatalogId); return; } @@ -319,7 +451,7 @@ internal class NetworkHandlers : IDisposable, IServiceType private IDisposable HandleMarketTaxRates() { - return this.OnMarketTaxRates() + return this.mbTaxesObservable .Where(this.ShouldUpload) .SubscribeOn(ThreadPoolScheduler.Instance) .Subscribe( @@ -345,8 +477,8 @@ internal class NetworkHandlers : IDisposable, IServiceType private IDisposable HandleMarketBoardPurchaseHandler() { - return this.OnMarketBoardPurchaseHandler() - .Zip(this.OnMarketBoardPurchase()) + return this.mbPurchaseSentObservable + .Zip(this.mbPurchaseObservable) .Where(this.ShouldUpload) .SubscribeOn(ThreadPoolScheduler.Instance) .Subscribe( @@ -376,85 +508,93 @@ internal class NetworkHandlers : IDisposable, IServiceType ex => Log.Error(ex, "Failed to handle Market Board purchase event")); } - private unsafe IDisposable HandleCfPop() - { - return this.OnCfNotifyPop() - .SubscribeOn(ThreadPoolScheduler.Instance) - .Subscribe( - message => - { - using var stream = new UnmanagedMemoryStream((byte*)message.Data.ToPointer(), 64); - using var reader = new BinaryReader(stream); - - var notifyType = reader.ReadByte(); - stream.Position += 0x1B; - var conditionId = reader.ReadUInt16(); - - if (notifyType != 3) - return; - - var cfConditionSheet = message.DataManager!.GetExcelSheet()!; - var cfCondition = cfConditionSheet.GetRow(conditionId); - - if (cfCondition == null) - { - Log.Error("CFC key {ConditionId} not in Lumina data", conditionId); - return; - } - - var cfcName = cfCondition.Name.ToString(); - if (cfcName.IsNullOrEmpty()) - { - cfcName = "Duty Roulette"; - cfCondition.Image = 112324; - } - - // Flash window - if (this.configuration.DutyFinderTaskbarFlash && !NativeFunctions.ApplicationIsActivated()) - { - var flashInfo = new NativeFunctions.FlashWindowInfo - { - Size = (uint)Marshal.SizeOf(), - Count = uint.MaxValue, - Timeout = 0, - Flags = NativeFunctions.FlashWindow.All | NativeFunctions.FlashWindow.TimerNoFG, - Hwnd = Process.GetCurrentProcess().MainWindowHandle, - }; - NativeFunctions.FlashWindowEx(ref flashInfo); - } - - Task.Run(() => - { - if (this.configuration.DutyFinderChatMessage) - { - Service.GetNullable()?.Print($"Duty pop: {cfcName}"); - } - - this.CfPop.InvokeSafely(this, cfCondition); - }).ContinueWith( - task => Log.Error(task.Exception, "CfPop.Invoke failed"), - TaskContinuationOptions.OnlyOnFaulted); - }, - ex => Log.Error(ex, "Failed to handle Market Board purchase event")); - } - private bool ShouldUpload(T any) { return this.configuration.IsMbCollect; } - private class NetworkMessage + private nint MarketPurchasePacketDetour(nint a1, nint packetData) { - public DataManager? DataManager { get; init; } + try + { + this.MarketBoardPurchaseReceived?.InvokeSafely(packetData); + } + catch (Exception ex) + { + Log.Error(ex, "MarketPurchasePacketHandler threw an exception"); + } + + return this.mbPurchaseHook.OriginalDisposeSafe(a1, packetData); + } - public IntPtr Data { get; init; } + private nint MarketHistoryPacketDetour(nint a1, nint packetData, uint a3, char a4) + { + try + { + this.MarketBoardHistoryReceived?.InvokeSafely(packetData); + } + catch (Exception ex) + { + Log.Error(ex, "MarketHistoryPacketDetour threw an exception"); + } + + return this.mbHistoryHook.OriginalDisposeSafe(a1, packetData, a3, a4); + } - public ushort Opcode { get; init; } + private void CustomTalkReceiveResponseDetour(nuint a1, ushort eventId, byte responseId, uint* args, byte argCount) + { + try + { + if (eventId == 7 && responseId == 8) + this.MarketBoardTaxesReceived?.InvokeSafely((nint)args); + } + catch (Exception ex) + { + Log.Error(ex, "CustomTalkReceiveResponseDetour threw an exception"); + } - public uint SourceActorId { get; init; } + this.customTalkHook.OriginalDisposeSafe(a1, eventId, responseId, args, argCount); + } - public uint TargetActorId { get; init; } + private nint MarketItemRequestStartDetour(nint a1, nint packetRef) + { + try + { + this.MarketBoardItemRequestStartReceived?.InvokeSafely(packetRef); + } + catch (Exception ex) + { + Log.Error(ex, "MarketItemRequestStartDetour threw an exception"); + } + + return this.mbItemRequestStartHook.OriginalDisposeSafe(a1, packetRef); + } - public NetworkMessageDirection Direction { get; init; } + private byte MarketBoardOfferingsDetour(nint a1, nint packetRef) + { + try + { + this.MarketBoardOfferingsReceived?.InvokeSafely(packetRef); + } + catch (Exception ex) + { + Log.Error(ex, "MarketBoardOfferingsDetour threw an exception"); + } + + return this.mbOfferingsHook.OriginalDisposeSafe(a1, packetRef); + } + + private byte MarketBoardSendPurchaseRequestDetour(InfoProxyItemSearch* infoProxyItemSearch) + { + try + { + this.MarketBoardPurchaseRequestSent?.InvokeSafely((nint)infoProxyItemSearch + 0x5680); + } + catch (Exception ex) + { + Log.Error(ex, "MarketBoardSendPurchaseRequestDetour threw an exception"); + } + + return this.mbSendPurchaseRequestHook.OriginalDisposeSafe(infoProxyItemSearch); } } diff --git a/Dalamud/Game/Network/Internal/NetworkHandlersAddressResolver.cs b/Dalamud/Game/Network/Internal/NetworkHandlersAddressResolver.cs new file mode 100644 index 000000000..cf47981c2 --- /dev/null +++ b/Dalamud/Game/Network/Internal/NetworkHandlersAddressResolver.cs @@ -0,0 +1,64 @@ +namespace Dalamud.Game.Network.Internal; + +/// +/// Internal address resolver for the network handlers. +/// +internal class NetworkHandlersAddressResolver : BaseAddressResolver +{ + /// + /// Gets or sets the pointer to the method responsible for handling CfPop packets. + /// + public nint CfPopPacketHandler { get; set; } + + /// + /// Gets or sets the pointer to the method responsible for handling market board history. In this case, we are + /// sigging the packet handler method directly. + /// + public nint MarketBoardHistoryPacketHandler { get; set; } + + /// + /// Gets or sets the pointer to the method responsible for processing the market board purchase packet. In this + /// case, we are sigging the packet handler method directly. + /// + public nint MarketBoardPurchasePacketHandler { get; set; } + + /// + /// Gets or sets the pointer to the method responsible for custom talk events. Necessary for marketboard tax data, + /// as this isn't really exposed anywhere else. + /// + public nint CustomTalkEventResponsePacketHandler { get; set; } + + /// + /// Gets or sets the pointer to the method responsible for the marketboard ItemRequestStart packet. + /// + public nint MarketBoardItemRequestStartPacketHandler { get; set; } + + /// + /// Gets or sets the pointer to the InfoProxyItemSearch.AddPage method, used to load market data. + /// + public nint InfoProxyItemSearchAddPage { get; set; } + + /// + /// Gets or sets the pointer to the method inside InfoProxyItemSearch that is responsible for building and sending + /// a purchase request packet. + /// + public nint BuildMarketBoardPurchaseHandlerPacket { get; set; } + + /// + protected override void Setup64Bit(ISigScanner scanner) + { + this.CfPopPacketHandler = scanner.ScanText("40 53 57 48 83 EC 78 48 8B D9 48 8D 0D"); + this.MarketBoardHistoryPacketHandler = scanner.ScanText( + "40 53 48 83 EC 20 48 8B 0D ?? ?? ?? ?? 48 8B DA E8 ?? ?? ?? ?? 48 85 C0 74 36 4C 8B 00 48 8B C8 41 FF 90 ?? ?? ?? ?? 48 8B C8 BA ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 85 C0 74 17 48 8D 53 04"); + this.MarketBoardPurchasePacketHandler = + scanner.ScanText("40 55 53 57 48 8B EC 48 83 EC 70 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 45 F0 48 8B 0D"); + this.CustomTalkEventResponsePacketHandler = + scanner.ScanText("48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 49 8B D9 41 0F B6 F8 0F B7 F2 8B E9 E8 ?? ?? ?? ?? 48 8B C8 44 0F B6 CF 0F B6 44 24 ?? 44 0F B7 C6 88 44 24 ?? 8B D5 48 89 5C 24"); + this.MarketBoardItemRequestStartPacketHandler = + scanner.ScanText("48 89 5C 24 ?? 57 48 83 EC 40 48 8B 0D ?? ?? ?? ?? 48 8B DA E8 ?? ?? ?? ?? 48 8B F8"); + this.InfoProxyItemSearchAddPage = + scanner.ScanText("48 89 5C 24 ?? 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 0F B6 82 ?? ?? ?? ?? 48 8B FA 48 8B D9 38 41 19 74 54"); + this.BuildMarketBoardPurchaseHandlerPacket = + scanner.ScanText("40 53 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 48 8B D9 48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 4C 8B D0 48 85 C0 0F 84 ?? ?? ?? ?? 8B 8B"); + } +} diff --git a/Dalamud/Game/Network/Internal/WinSockHandlers.cs b/Dalamud/Game/Network/Internal/WinSockHandlers.cs index 8439389ff..619c458c4 100644 --- a/Dalamud/Game/Network/Internal/WinSockHandlers.cs +++ b/Dalamud/Game/Network/Internal/WinSockHandlers.cs @@ -10,7 +10,7 @@ namespace Dalamud.Game.Network.Internal; /// This class enables TCP optimizations in the game socket for better performance. /// [ServiceManager.EarlyLoadedService] -internal sealed class WinSockHandlers : IDisposable, IServiceType +internal sealed class WinSockHandlers : IInternalDisposableService { private Hook ws2SocketHook; @@ -27,7 +27,7 @@ internal sealed class WinSockHandlers : IDisposable, IServiceType /// /// Disposes of managed and unmanaged resources. /// - public void Dispose() + void IInternalDisposableService.DisposeService() { this.ws2SocketHook?.Dispose(); } diff --git a/Dalamud/Game/Network/Structures/InfoProxy/CharacterData.cs b/Dalamud/Game/Network/Structures/InfoProxy/CharacterData.cs new file mode 100644 index 000000000..0ca35d672 --- /dev/null +++ b/Dalamud/Game/Network/Structures/InfoProxy/CharacterData.cs @@ -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; + +/// +/// Dalamud wrapper around a client structs . +/// +public unsafe class CharacterData +{ + /// + /// Initializes a new instance of the class. + /// + /// Character data to wrap. + internal CharacterData(InfoProxyCommonList.CharacterData* data) + { + this.Address = (nint)data; + } + + /// + /// Gets the address of the in memory. + /// + public nint Address { get; } + + /// + /// Gets the content id of the character. + /// + public ulong ContentId => this.Struct->ContentId; + + /// + /// Gets the status mask of the character. + /// + public ulong StatusMask => (ulong)this.Struct->State; + + /// + /// Gets the applicable statues of the character. + /// + public IReadOnlyList> Statuses + { + get + { + var statuses = new List>(); + for (var i = 0; i < 64; i++) + { + if ((this.StatusMask & (1UL << i)) != 0) + statuses.Add(new((uint)i)); + } + + return statuses; + } + } + + /// + /// Gets the display group of the character. + /// + public DisplayGroup DisplayGroup => (DisplayGroup)this.Struct->Group; + + /// + /// Gets a value indicating whether the character's home world is different from the current world. + /// + public bool IsFromOtherServer => this.Struct->IsOtherServer; + + /// + /// Gets the sort order of the character. + /// + public byte Sort => this.Struct->Sort; + + /// + /// Gets the current world of the character. + /// + public ExcelResolver CurrentWorld => new(this.Struct->CurrentWorld); + + /// + /// Gets the home world of the character. + /// + public ExcelResolver HomeWorld => new(this.Struct->HomeWorld); + + /// + /// Gets the location of the character. + /// + public ExcelResolver Location => new(this.Struct->Location); + + /// + /// Gets the grand company of the character. + /// + public ExcelResolver GrandCompany => new((uint)this.Struct->GrandCompany); + + /// + /// Gets the primary client language of the character. + /// + public ClientLanguage ClientLanguage => (ClientLanguage)this.Struct->ClientLanguage; + + /// + /// Gets the supported language mask of the character. + /// + public byte LanguageMask => (byte)this.Struct->Languages; + + /// + /// Gets the supported languages the character supports. + /// + public IReadOnlyList Languages + { + get + { + var languages = new List(); + for (var i = 0; i < 4; i++) + { + if ((this.LanguageMask & (1 << i)) != 0) + languages.Add((ClientLanguage)i); + } + + return languages; + } + } + + /// + /// Gets the gender of the character. + /// + public byte Gender => this.Struct->Sex; + + /// + /// Gets the job of the character. + /// + public ExcelResolver ClassJob => new(this.Struct->Job); + + /// + /// Gets the name of the character. + /// + public string Name => MemoryHelper.ReadString((nint)this.Struct->Name, 32); + + /// + /// Gets the free company tag of the character. + /// + public string FCTag => MemoryHelper.ReadString((nint)this.Struct->Name, 6); + + /// + /// Gets the underlying struct. + /// + internal InfoProxyCommonList.CharacterData* Struct => (InfoProxyCommonList.CharacterData*)this.Address; +} + +/// +/// Display group of a character. Used for friends. +/// +public enum DisplayGroup : sbyte +{ + /// + /// All display groups. + /// + All = -1, + + /// + /// No display group. + /// + None, + + /// + /// Star display group. + /// + Star, + + /// + /// Circle display group. + /// + Circle, + + /// + /// Triangle display group. + /// + Triangle, + + /// + /// Diamond display group. + /// + Diamond, + + /// + /// Heart display group. + /// + Heart, + + /// + /// Spade display group. + /// + Spade, + + /// + /// Club display group. + /// + Club, +} diff --git a/Dalamud/Game/Network/Structures/MarketBoardHistory.cs b/Dalamud/Game/Network/Structures/MarketBoardHistory.cs index 69532afd6..9a61b814e 100644 --- a/Dalamud/Game/Network/Structures/MarketBoardHistory.cs +++ b/Dalamud/Game/Network/Structures/MarketBoardHistory.cs @@ -42,10 +42,17 @@ public class MarketBoardHistory using var stream = new UnmanagedMemoryStream((byte*)dataPtr.ToPointer(), 1544); using var reader = new BinaryReader(stream); - var output = new MarketBoardHistory(); + var output = new MarketBoardHistory + { + CatalogId = reader.ReadUInt32(), + CatalogId2 = reader.ReadUInt32(), + }; - output.CatalogId = reader.ReadUInt32(); - output.CatalogId2 = reader.ReadUInt32(); + if (output.CatalogId2 == 0) + { + // No items found in the resulting packet - just return the empty history. + return output; + } for (var i = 0; i < 20; i++) { diff --git a/Dalamud/Game/Network/Structures/MarketTaxRates.cs b/Dalamud/Game/Network/Structures/MarketTaxRates.cs index 53ce41d44..42e1d8cce 100644 --- a/Dalamud/Game/Network/Structures/MarketTaxRates.cs +++ b/Dalamud/Game/Network/Structures/MarketTaxRates.cs @@ -1,4 +1,3 @@ -using System; using System.IO; namespace Dalamud.Game.Network.Structures; @@ -77,4 +76,27 @@ public class MarketTaxRates return output; } + + /// + /// Generate a MarketTaxRates wrapper class from information located in a CustomTalk packet. + /// + /// The pointer to the relevant CustomTalk data. + /// Returns a wrapped and ready-to-go MarketTaxRates record. + public static unsafe MarketTaxRates ReadFromCustomTalk(IntPtr dataPtr) + { + using var stream = new UnmanagedMemoryStream((byte*)dataPtr.ToPointer(), 1544); + using var reader = new BinaryReader(stream); + + return new MarketTaxRates + { + Category = 0xb0009, // shim + LimsaLominsaTax = reader.ReadUInt32(), + GridaniaTax = reader.ReadUInt32(), + UldahTax = reader.ReadUInt32(), + IshgardTax = reader.ReadUInt32(), + KuganeTax = reader.ReadUInt32(), + CrystariumTax = reader.ReadUInt32(), + SharlayanTax = reader.ReadUInt32(), + }; + } } diff --git a/Dalamud/Game/SigScanner.cs b/Dalamud/Game/SigScanner.cs index b5fe0b5b3..5e49052ae 100644 --- a/Dalamud/Game/SigScanner.cs +++ b/Dalamud/Game/SigScanner.cs @@ -20,12 +20,7 @@ namespace Dalamud.Game; /// /// A SigScanner facilitates searching for memory signatures in a given ProcessModule. /// -[PluginInterface] -[InterfaceVersion("1.0")] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -public class SigScanner : IDisposable, IServiceType, ISigScanner +public class SigScanner : IDisposable, ISigScanner { private readonly FileInfo? cacheFile; @@ -109,6 +104,10 @@ public class SigScanner : IDisposable, IServiceType, ISigScanner /// public ProcessModule Module { get; } + /// Gets or sets a value indicating whether this instance of is meant to be a + /// Dalamud service. + private protected bool IsService { get; set; } + private IntPtr TextSectionTop => this.TextSectionBase + this.TextSectionSize; /// @@ -314,13 +313,11 @@ public class SigScanner : IDisposable, IServiceType, ISigScanner } } - /// - /// Free the memory of the copied module search area on object disposal, if applicable. - /// + /// public void Dispose() { - this.Save(); - Marshal.FreeHGlobal(this.moduleCopyPtr); + if (!this.IsService) + this.DisposeCore(); } /// @@ -342,6 +339,15 @@ public class SigScanner : IDisposable, IServiceType, ISigScanner } } + /// + /// Free the memory of the copied module search area on object disposal, if applicable. + /// + private protected void DisposeCore() + { + this.Save(); + Marshal.FreeHGlobal(this.moduleCopyPtr); + } + /// /// Helper for ScanText to get the correct address for IDA sigs that mark the first JMP or CALL location. /// diff --git a/Dalamud/Game/TargetSigScanner.cs b/Dalamud/Game/TargetSigScanner.cs new file mode 100644 index 000000000..e169ea904 --- /dev/null +++ b/Dalamud/Game/TargetSigScanner.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using System.IO; + +using Dalamud.IoC; +using Dalamud.IoC.Internal; + +namespace Dalamud.Game; + +/// +/// A SigScanner facilitates searching for memory signatures in a given ProcessModule. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ProvidedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class TargetSigScanner : SigScanner, IPublicDisposableService +{ + /// + /// Initializes a new instance of the class. + /// + /// Whether or not to copy the module upon initialization for search operations to use, as to not get disturbed by possible hooks. + /// File used to cached signatures. + public TargetSigScanner(bool doCopy = false, FileInfo? cacheFile = null) + : base(Process.GetCurrentProcess().MainModule!, doCopy, cacheFile) + { + } + + /// + void IInternalDisposableService.DisposeService() + { + if (this.IsService) + this.DisposeCore(); + } + + /// + void IPublicDisposableService.MarkDisposeOnlyFromService() => this.IsService = true; +} diff --git a/Dalamud/Game/Text/SeIconChar.cs b/Dalamud/Game/Text/SeIconChar.cs index c1be00613..17924c671 100644 --- a/Dalamud/Game/Text/SeIconChar.cs +++ b/Dalamud/Game/Text/SeIconChar.cs @@ -611,29 +611,51 @@ public enum SeIconChar QuestRepeatable = 0xE0BF, /// - /// The IME hiragana icon unicode character. + /// The [あ] character indicating that the Japanese IME is in full-width Hiragana input mode. /// + /// + /// Half-width Hiragana exists as a Windows API constant, but the feature is unused, or at least unexposed to the end user via the IME. + /// ImeHiragana = 0xE020, /// - /// The IME katakana icon unicode character. + /// The [ア] character indicating that the Japanese IME is in full-width Katakana input mode. /// ImeKatakana = 0xE021, /// - /// The IME alphanumeric icon unicode character. + /// The [A] character indicating that Japanese or Korean IME is in full-width Latin character input mode. /// ImeAlphanumeric = 0xE022, /// - /// The IME katakana half-width icon unicode character. + /// The [_ア] character indicating that the Japanese IME is in half-width Katakana input mode. /// ImeKatakanaHalfWidth = 0xE023, /// - /// The IME alphanumeric half-width icon unicode character. + /// The [_A] character indicating that Japanese or Korean IME is in half-width Latin character input mode. /// ImeAlphanumericHalfWidth = 0xE024, + + /// + /// The [가] character indicating that the Korean IME is in Hangul input mode. + /// + /// + /// Use and for alphanumeric input mode, + /// toggled via Alt+=. + /// + ImeKoreanHangul = 0xE025, + + /// + /// The [中] character indicating that the Chinese IME is in Han character input mode. + /// + ImeChineseHan = 0xE026, + + /// + /// The [英] character indicating that the Chinese IME is in Latin character input mode. + /// + ImeChineseLatin = 0xE027, /// /// The instance (1) icon unicode character. diff --git a/Dalamud/Game/Text/SeStringHandling/Payload.cs b/Dalamud/Game/Text/SeStringHandling/Payload.cs index dbd70a58e..ff7332f12 100644 --- a/Dalamud/Game/Text/SeStringHandling/Payload.cs +++ b/Dalamud/Game/Text/SeStringHandling/Payload.cs @@ -5,6 +5,7 @@ using System.IO; using Dalamud.Data; using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Plugin.Services; using Newtonsoft.Json; using Serilog; @@ -27,12 +28,6 @@ public abstract partial class Payload // To force-invalidate it, Dirty can be set to true private byte[] encodedData; - /// - /// Gets the Lumina instance to use for any necessary data lookups. - /// - [JsonIgnore] - public DataManager DataResolver => Service.Get(); - /// /// Gets the type of this payload. /// @@ -43,6 +38,13 @@ public abstract partial class Payload /// public bool Dirty { get; protected set; } = true; + /// + /// Gets the Lumina instance to use for any necessary data lookups. + /// + [JsonIgnore] + // TODO: We should refactor this. It should not be possible to get IDataManager through here. + protected IDataManager DataResolver => Service.Get(); + /// /// Decodes a binary representation of a payload into its corresponding nice object payload. /// @@ -206,9 +208,9 @@ public abstract partial class Payload case SeStringChunkType.Icon: payload = new IconPayload(); break; - + default: - Log.Verbose("Unhandled SeStringChunkType: {0}", chunkType); + // Log.Verbose("Unhandled SeStringChunkType: {0}", chunkType); break; } @@ -307,6 +309,11 @@ public abstract partial class Payload /// protected enum SeStringChunkType { + /// + /// See the . + /// + NewLine = 0x10, + /// /// See the class. /// @@ -317,11 +324,6 @@ public abstract partial class Payload /// EmphasisItalic = 0x1A, - /// - /// See the . - /// - NewLine = 0x10, - /// /// See the class. /// diff --git a/Dalamud/Game/Text/SeStringHandling/Payloads/MapLinkPayload.cs b/Dalamud/Game/Text/SeStringHandling/Payloads/MapLinkPayload.cs index 50945a7ce..667b52e36 100644 --- a/Dalamud/Game/Text/SeStringHandling/Payloads/MapLinkPayload.cs +++ b/Dalamud/Game/Text/SeStringHandling/Payloads/MapLinkPayload.cs @@ -130,7 +130,13 @@ public class MapLinkPayload : Payload var y = Math.Truncate((this.YCoord + fudge) * 10.0f) / 10.0f; // the formatting and spacing the game uses - return $"( {x:0.0} , {y:0.0} )"; + var clientState = Service.Get(); + return clientState.ClientLanguage switch + { + ClientLanguage.German => $"( {x:0.0}, {y:0.0} )", + ClientLanguage.Japanese => $"({x:0.0}, {y:0.0})", + _ => $"( {x:0.0} , {y:0.0} )", + }; } } diff --git a/Dalamud/Game/Text/SeStringHandling/SeString.cs b/Dalamud/Game/Text/SeStringHandling/SeString.cs index 6d0c8b0fb..91dceb5d1 100644 --- a/Dalamud/Game/Text/SeStringHandling/SeString.cs +++ b/Dalamud/Game/Text/SeStringHandling/SeString.cs @@ -52,14 +52,27 @@ public class SeString /// with the appropriate glow and coloring. ///
/// A list of all the payloads required to insert the link marker. - public static IEnumerable TextArrowPayloads => new List(new Payload[] + public static IEnumerable TextArrowPayloads { - new UIForegroundPayload(0x01F4), - new UIGlowPayload(0x01F5), - new TextPayload($"{(char)SeIconChar.LinkMarker}"), - UIGlowPayload.UIGlowOff, - UIForegroundPayload.UIForegroundOff, - }); + get + { + var clientState = Service.Get(); + var markerSpace = clientState.ClientLanguage switch + { + ClientLanguage.German => " ", + ClientLanguage.French => " ", + _ => string.Empty, + }; + return new List + { + new UIForegroundPayload(500), + new UIGlowPayload(501), + new TextPayload($"{(char)SeIconChar.LinkMarker}{markerSpace}"), + UIGlowPayload.UIGlowOff, + UIForegroundPayload.UIForegroundOff, + }; + } + } /// /// Gets an empty SeString. @@ -171,6 +184,7 @@ public class SeString var data = Service.Get(); var displayName = displayNameOverride; + var rarity = 1; // default: white if (displayName == null) { switch (kind) @@ -178,7 +192,9 @@ public class SeString case ItemPayload.ItemKind.Normal: case ItemPayload.ItemKind.Collectible: case ItemPayload.ItemKind.Hq: - displayName = data.GetExcelSheet()?.GetRow(itemId)?.Name; + var item = data.GetExcelSheet()?.GetRow(itemId); + displayName = item?.Name; + rarity = item?.Rarity ?? 1; break; case ItemPayload.ItemKind.EventItem: displayName = data.GetExcelSheet()?.GetRow(itemId)?.Name; @@ -202,21 +218,20 @@ public class SeString displayName += $" {(char)SeIconChar.Collectible}"; } - // TODO: probably a cleaner way to build these than doing the bulk+insert - var payloads = new List(new Payload[] - { - new UIForegroundPayload(0x0225), - new UIGlowPayload(0x0226), - new ItemPayload(itemId, kind), - // arrow goes here - new TextPayload(displayName), - RawPayload.LinkTerminator, - // sometimes there is another set of uiglow/foreground off payloads here - // might be necessary when including additional text after the item name - }); - payloads.InsertRange(3, TextArrowPayloads); + var textColor = (ushort)(549 + ((rarity - 1) * 2)); + var textGlowColor = (ushort)(textColor + 1); - return new SeString(payloads); + // Note: `SeStringBuilder.AddItemLink` uses this function, so don't call it here! + return new SeStringBuilder() + .AddUiForeground(textColor) + .AddUiGlow(textGlowColor) + .Add(new ItemPayload(itemId, kind)) + .Append(TextArrowPayloads) + .AddText(displayName) + .AddUiGlowOff() + .AddUiForegroundOff() + .Add(RawPayload.LinkTerminator) + .Build(); } /// @@ -239,10 +254,22 @@ public class SeString /// The raw x-coordinate for this link. /// The raw y-coordinate for this link.. /// An SeString containing all of the payloads necessary to display a map link in the chat log. - public static SeString CreateMapLink(uint territoryId, uint mapId, int rawX, int rawY) + public static SeString CreateMapLink(uint territoryId, uint mapId, int rawX, int rawY) => + CreateMapLinkWithInstance(territoryId, mapId, null, rawX, rawY); + + /// + /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. + /// + /// The id of the TerritoryType for this map link. + /// The id of the Map for this map link. + /// An optional area instance number to be included in this link. + /// The raw x-coordinate for this link. + /// The raw y-coordinate for this link.. + /// An SeString containing all of the payloads necessary to display a map link in the chat log. + public static SeString CreateMapLinkWithInstance(uint territoryId, uint mapId, int? instance, int rawX, int rawY) { var mapPayload = new MapLinkPayload(territoryId, mapId, rawX, rawY); - var nameString = $"{mapPayload.PlaceName} {mapPayload.CoordinateString}"; + var nameString = GetMapLinkNameString(mapPayload.PlaceName, instance, mapPayload.CoordinateString); var payloads = new List(new Payload[] { @@ -265,10 +292,24 @@ public class SeString /// The human-readable y-coordinate for this link. /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. /// An SeString containing all of the payloads necessary to display a map link in the chat log. - public static SeString CreateMapLink(uint territoryId, uint mapId, float xCoord, float yCoord, float fudgeFactor = 0.05f) + public static SeString CreateMapLink( + uint territoryId, uint mapId, float xCoord, float yCoord, float fudgeFactor = 0.05f) => + CreateMapLinkWithInstance(territoryId, mapId, null, xCoord, yCoord, fudgeFactor); + + /// + /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. + /// + /// The id of the TerritoryType for this map link. + /// The id of the Map for this map link. + /// An optional area instance number to be included in this link. + /// The human-readable x-coordinate for this link. + /// The human-readable y-coordinate for this link. + /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. + /// An SeString containing all of the payloads necessary to display a map link in the chat log. + public static SeString CreateMapLinkWithInstance(uint territoryId, uint mapId, int? instance, float xCoord, float yCoord, float fudgeFactor = 0.05f) { var mapPayload = new MapLinkPayload(territoryId, mapId, xCoord, yCoord, fudgeFactor); - var nameString = $"{mapPayload.PlaceName} {mapPayload.CoordinateString}"; + var nameString = GetMapLinkNameString(mapPayload.PlaceName, instance, mapPayload.CoordinateString); var payloads = new List(new Payload[] { @@ -291,7 +332,20 @@ public class SeString /// The human-readable y-coordinate for this link. /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. /// An SeString containing all of the payloads necessary to display a map link in the chat log. - public static SeString? CreateMapLink(string placeName, float xCoord, float yCoord, float fudgeFactor = 0.05f) + public static SeString? CreateMapLink(string placeName, float xCoord, float yCoord, float fudgeFactor = 0.05f) => + CreateMapLinkWithInstance(placeName, null, xCoord, yCoord, fudgeFactor); + + /// + /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log, matching a specified zone name. + /// Returns null if no corresponding PlaceName was found. + /// + /// The name of the location for this link. This should be exactly the name as seen in a displayed map link in-game for the same zone. + /// An optional area instance number to be included in this link. + /// The human-readable x-coordinate for this link. + /// The human-readable y-coordinate for this link. + /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. + /// An SeString containing all of the payloads necessary to display a map link in the chat log. + public static SeString? CreateMapLinkWithInstance(string placeName, int? instance, float xCoord, float yCoord, float fudgeFactor = 0.05f) { var data = Service.Get(); @@ -306,7 +360,7 @@ public class SeString var map = mapSheet.FirstOrDefault(row => row.PlaceName.Row == place.RowId); if (map != null && map.TerritoryType.Row != 0) { - return CreateMapLink(map.TerritoryType.Row, map.RowId, xCoord, yCoord, fudgeFactor); + return CreateMapLinkWithInstance(map.TerritoryType.Row, map.RowId, instance, xCoord, yCoord, fudgeFactor); } } @@ -327,7 +381,7 @@ public class SeString { new PartyFinderPayload(listingId, isCrossWorld ? PartyFinderPayload.PartyFinderLinkType.NotSpecified : PartyFinderPayload.PartyFinderLinkType.LimitedToHomeWorld), // -> - new TextPayload($"Looking for Party ({recruiterName})"), + new TextPayload($"Looking for Party ({recruiterName})" + (isCrossWorld ? " " : string.Empty)), }; payloads.InsertRange(1, TextArrowPayloads); @@ -406,7 +460,7 @@ public class SeString /// /// The Payloads to append. /// This object. - public SeString Append(List payloads) + public SeString Append(IEnumerable payloads) { this.Payloads.AddRange(payloads); return this; @@ -447,4 +501,15 @@ public class SeString { return this.TextValue; } + + private static string GetMapLinkNameString(string placeName, int? instance, string coordinateString) + { + var instanceString = string.Empty; + if (instance is > 0 and < 10) + { + instanceString = (SeIconChar.Instance1 + instance.Value - 1).ToIconString(); + } + + return $"{placeName}{instanceString} {coordinateString}"; + } } diff --git a/Dalamud/Game/Text/SeStringHandling/SeStringBuilder.cs b/Dalamud/Game/Text/SeStringHandling/SeStringBuilder.cs index 36bb10a2d..dae9e11a9 100644 --- a/Dalamud/Game/Text/SeStringHandling/SeStringBuilder.cs +++ b/Dalamud/Game/Text/SeStringHandling/SeStringBuilder.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using System.Linq; + using Dalamud.Game.Text.SeStringHandling.Payloads; namespace Dalamud.Game.Text.SeStringHandling; @@ -30,6 +33,17 @@ public class SeStringBuilder /// The current builder. public SeStringBuilder Append(string text) => this.AddText(text); + /// + /// Append payloads to the builder. + /// + /// A list of payloads. + /// The current builder. + public SeStringBuilder Append(IEnumerable payloads) + { + this.BuiltString.Payloads.AddRange(payloads); + return this; + } + /// /// Append raw text to the builder. /// @@ -104,7 +118,7 @@ public class SeStringBuilder /// Override for the item's name. /// The current builder. public SeStringBuilder AddItemLink(uint itemId, bool isHq, string? itemNameOverride = null) => - this.Add(new ItemPayload(itemId, isHq, itemNameOverride)); + this.Append(SeString.CreateItemLink(itemId, isHq, itemNameOverride)); /// /// Add an item link to the builder. @@ -113,14 +127,15 @@ public class SeStringBuilder /// Kind of item to encode. /// Override for the item's name. /// The current builder. - public SeStringBuilder AddItemLink(uint itemId, ItemPayload.ItemKind kind, string? itemNameOverride = null) => - this.Add(new ItemPayload(itemId, kind, itemNameOverride)); + public SeStringBuilder AddItemLink(uint itemId, ItemPayload.ItemKind kind = ItemPayload.ItemKind.Normal, string? itemNameOverride = null) => + this.Append(SeString.CreateItemLink(itemId, kind, itemNameOverride)); /// /// Add an item link to the builder. /// /// The raw item ID. /// The current builder. + /// To terminate this item link, add a . public SeStringBuilder AddItemLinkRaw(uint rawItemId) => this.Add(ItemPayload.FromRaw(rawItemId)); diff --git a/Dalamud/Game/Text/SeStringHandling/SeStringManager.cs b/Dalamud/Game/Text/SeStringHandling/SeStringManager.cs deleted file mode 100644 index f0b38d429..000000000 --- a/Dalamud/Game/Text/SeStringHandling/SeStringManager.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Collections.Generic; - -using Dalamud.IoC; -using Dalamud.IoC.Internal; -using Lumina.Excel.GeneratedSheets; - -namespace Dalamud.Game.Text.SeStringHandling; - -/// -/// This class facilitates creating new SeStrings and breaking down existing ones into their individual payload components. -/// -[PluginInterface] -[InterfaceVersion("1.0")] -[ServiceManager.BlockingEarlyLoadedService] -[Obsolete("This class is obsolete. Please use the static methods on SeString instead.")] -public sealed class SeStringManager : IServiceType -{ - [ServiceManager.ServiceConstructor] - private SeStringManager() - { - } - - /// - /// Parse a binary game message into an SeString. - /// - /// Pointer to the string's data in memory. - /// Length of the string's data in memory. - /// An SeString containing parsed Payload objects for each payload in the data. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public unsafe SeString Parse(byte* ptr, int len) => SeString.Parse(ptr, len); - - /// - /// Parse a binary game message into an SeString. - /// - /// Binary message payload data in SE's internal format. - /// An SeString containing parsed Payload objects for each payload in the data. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public unsafe SeString Parse(ReadOnlySpan data) => SeString.Parse(data); - - /// - /// Parse a binary game message into an SeString. - /// - /// Binary message payload data in SE's internal format. - /// An SeString containing parsed Payload objects for each payload in the data. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public SeString Parse(byte[] bytes) => SeString.Parse(new ReadOnlySpan(bytes)); - - /// - /// Creates an SeString representing an entire Payload chain that can be used to link an item in the chat log. - /// - /// The id of the item to link. - /// Whether to link the high-quality variant of the item. - /// An optional name override to display, instead of the actual item name. - /// An SeString containing all the payloads necessary to display an item link in the chat log. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public SeString CreateItemLink(uint itemId, bool isHQ, string displayNameOverride = null) => SeString.CreateItemLink(itemId, isHQ, displayNameOverride); - - /// - /// Creates an SeString representing an entire Payload chain that can be used to link an item in the chat log. - /// - /// The Lumina Item to link. - /// Whether to link the high-quality variant of the item. - /// An optional name override to display, instead of the actual item name. - /// An SeString containing all the payloads necessary to display an item link in the chat log. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public SeString CreateItemLink(Item item, bool isHQ, string displayNameOverride = null) => SeString.CreateItemLink(item, isHQ, displayNameOverride); - - /// - /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. - /// - /// The id of the TerritoryType for this map link. - /// The id of the Map for this map link. - /// The raw x-coordinate for this link. - /// The raw y-coordinate for this link.. - /// An SeString containing all of the payloads necessary to display a map link in the chat log. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public SeString CreateMapLink(uint territoryId, uint mapId, int rawX, int rawY) => - SeString.CreateMapLink(territoryId, mapId, rawX, rawY); - - /// - /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log. - /// - /// The id of the TerritoryType for this map link. - /// The id of the Map for this map link. - /// The human-readable x-coordinate for this link. - /// The human-readable y-coordinate for this link. - /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. - /// An SeString containing all of the payloads necessary to display a map link in the chat log. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public SeString CreateMapLink(uint territoryId, uint mapId, float xCoord, float yCoord, float fudgeFactor = 0.05f) => SeString.CreateMapLink(territoryId, mapId, xCoord, yCoord, fudgeFactor); - - /// - /// Creates an SeString representing an entire Payload chain that can be used to link a map position in the chat log, matching a specified zone name. - /// - /// The name of the location for this link. This should be exactly the name as seen in a displayed map link in-game for the same zone. - /// The human-readable x-coordinate for this link. - /// The human-readable y-coordinate for this link. - /// An optional offset to account for rounding and truncation errors; it is best to leave this untouched in most cases. - /// An SeString containing all of the payloads necessary to display a map link in the chat log. - [Obsolete("This method is obsolete. Please use the static methods on SeString instead.", true)] - public SeString CreateMapLink(string placeName, float xCoord, float yCoord, float fudgeFactor = 0.05f) => SeString.CreateMapLink(placeName, xCoord, yCoord, fudgeFactor); - - /// - /// Creates a list of Payloads necessary to display the arrow link marker icon in chat - /// with the appropriate glow and coloring. - /// - /// A list of all the payloads required to insert the link marker. - [Obsolete("This data is obsolete. Please use the static version on SeString instead.", true)] - public List TextArrowPayloads() => new(SeString.TextArrowPayloads); -} diff --git a/Dalamud/GlobalSuppressions.cs b/Dalamud/GlobalSuppressions.cs index 7426ed5c8..1b869295b 100644 --- a/Dalamud/GlobalSuppressions.cs +++ b/Dalamud/GlobalSuppressions.cs @@ -15,3 +15,22 @@ using System.Diagnostics.CodeAnalysis; [assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1127:Generic type constraints should be on their own line", Justification = "I like this better")] [assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1028:Code should not contain trailing whitespace", Justification = "I don't care anymore")] [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1633:File should have header", Justification = "We don't do those yet")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1117:ParametersMustBeOnSameLineOrSeparateLines", Justification = "I don't care anymore")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1117:ParametersMustBeOnSameLineOrSeparateLines", Justification = "I don't care anymore")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1407:ArithmeticExpressionsMustDeclarePrecedence", Justification = "I don't care anymore")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1116:SplitParametersMustStartOnLineAfterDeclaration", Justification = "Reviewed.")] + +// ImRAII stuff +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Raii")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1601:PartialElementsMustBeDocumented", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Raii")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Table")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1601:PartialElementsMustBeDocumented", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Table")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Reviewed.", Scope = "type", Target = "Dalamud.Interface.Utility.ImGuiClip")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1601:PartialElementsMustBeDocumented", Justification = "Reviewed.", Scope = "type", Target = "Dalamud.Interface.Utility.ImGuiClip")] +[assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1025:CodeMustNotContainMultipleWhitespaceInARow", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:FieldsMustBePrivate", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Raii")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:FieldsMustBePrivate", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Table")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:ElementsMustAppearInTheCorrectOrder", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Raii")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:ElementsMustAppearInTheCorrectOrder", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Table")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1202:ElementsMustBeOrderedByAccess", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Raii")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1202:ElementsMustBeOrderedByAccess", Justification = "Reviewed.", Scope = "namespaceanddescendants", Target = "Dalamud.Interface.Utility.Table")] diff --git a/Dalamud/GlobalUsings.cs b/Dalamud/GlobalUsings.cs new file mode 100644 index 000000000..062a3f981 --- /dev/null +++ b/Dalamud/GlobalUsings.cs @@ -0,0 +1 @@ +global using System; diff --git a/Dalamud/Hooking/Hook.cs b/Dalamud/Hooking/Hook.cs index 74b9e6384..da65fedc7 100644 --- a/Dalamud/Hooking/Hook.cs +++ b/Dalamud/Hooking/Hook.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; @@ -13,7 +12,7 @@ namespace Dalamud.Hooking; /// This class is basically a thin wrapper around the LocalHook type to provide helper functions. /// /// Delegate type to represents a function prototype. This must be the same prototype as original function do. -public class Hook : IDisposable, IDalamudHook where T : Delegate +public abstract class Hook : IDalamudHook where T : Delegate { #pragma warning disable SA1310 // ReSharper disable once InconsistentNaming @@ -24,34 +23,6 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate private readonly IntPtr address; - private readonly Hook? compatHookImpl; - - /// - /// Initializes a new instance of the class. - /// Hook is not activated until Enable() method is called. - /// - /// A memory address to install a hook. - /// Callback function. Delegate must have a same original function prototype. - [Obsolete("Use Hook.FromAddress instead.")] - public Hook(IntPtr address, T detour) - : this(address, detour, false, Assembly.GetCallingAssembly()) - { - } - - /// - /// Initializes a new instance of the class. - /// Hook is not activated until Enable() method is called. - /// Please do not use MinHook unless you have thoroughly troubleshot why Reloaded does not work. - /// - /// A memory address to install a hook. - /// Callback function. Delegate must have a same original function prototype. - /// Use the MinHook hooking library instead of Reloaded. - [Obsolete("Use Hook.FromAddress instead.")] - public Hook(IntPtr address, T detour, bool useMinHook) - : this(address, detour, useMinHook, Assembly.GetCallingAssembly()) - { - } - /// /// Initializes a new instance of the class. /// @@ -61,19 +32,6 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate this.address = address; } - [Obsolete("Use Hook.FromAddress instead.")] - private Hook(IntPtr address, T detour, bool useMinHook, Assembly callingAssembly) - { - if (EnvironmentConfiguration.DalamudForceMinHook) - useMinHook = true; - - this.address = address = HookManager.FollowJmp(address); - if (useMinHook) - this.compatHookImpl = new MinHookHook(address, detour, callingAssembly); - else - this.compatHookImpl = new ReloadedHook(address, detour, callingAssembly); - } - /// /// Gets a memory address of the target function. /// @@ -91,28 +49,19 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate /// Gets a delegate function that can be used to call the actual function as if function is not hooked yet. /// /// Hook is already disposed. - public virtual T Original => this.compatHookImpl != null ? this.compatHookImpl!.Original : throw new NotImplementedException(); + public virtual T Original => throw new NotImplementedException(); /// /// Gets a delegate function that can be used to call the actual function as if function is not hooked yet. /// This can be called even after Dispose. /// public T OriginalDisposeSafe - { - get - { - if (this.compatHookImpl != null) - return this.compatHookImpl!.OriginalDisposeSafe; - if (this.IsDisposed) - return Marshal.GetDelegateForFunctionPointer(this.address); - return this.Original; - } - } + => this.IsDisposed ? Marshal.GetDelegateForFunctionPointer(this.address) : this.Original; /// /// Gets a value indicating whether or not the hook is enabled. /// - public virtual bool IsEnabled => this.compatHookImpl != null ? this.compatHookImpl!.IsEnabled : throw new NotImplementedException(); + public virtual bool IsEnabled => throw new NotImplementedException(); /// /// Gets a value indicating whether or not the hook has been disposed. @@ -120,7 +69,28 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate public bool IsDisposed { get; private set; } /// - public virtual string BackendName => this.compatHookImpl != null ? this.compatHookImpl!.BackendName : throw new NotImplementedException(); + public virtual string BackendName => throw new NotImplementedException(); + + /// + /// Remove a hook from the current process. + /// + public virtual void Dispose() + { + if (this.IsDisposed) + return; + + this.IsDisposed = true; + } + + /// + /// Starts intercepting a call to the function. + /// + public virtual void Enable() => throw new NotImplementedException(); + + /// + /// Stops intercepting a call to the function. + /// + public virtual void Disable() => throw new NotImplementedException(); /// /// Creates a hook by rewriting import table address. @@ -128,7 +98,7 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate /// A memory address to install a hook. /// Callback function. Delegate must have a same original function prototype. /// The hook with the supplied parameters. - public static unsafe Hook FromFunctionPointerVariable(IntPtr address, T detour) + internal static Hook FromFunctionPointerVariable(IntPtr address, T detour) { return new FunctionPointerVariableHook(address, detour, Assembly.GetCallingAssembly()); } @@ -142,7 +112,7 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate /// Hint or ordinal. 0 to unspecify. /// Callback function. Delegate must have a same original function prototype. /// The hook with the supplied parameters. - public static unsafe Hook FromImport(ProcessModule? module, string moduleName, string functionName, uint hintOrOrdinal, T detour) + internal static unsafe Hook FromImport(ProcessModule? module, string moduleName, string functionName, uint hintOrOrdinal, T detour) { module ??= Process.GetCurrentProcess().MainModule; if (module == null) @@ -207,7 +177,7 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate /// A name of the exported function name (e.g. send). /// Callback function. Delegate must have a same original function prototype. /// The hook with the supplied parameters. - public static Hook FromSymbol(string moduleName, string exportName, T detour) + internal static Hook FromSymbol(string moduleName, string exportName, T detour) => FromSymbol(moduleName, exportName, detour, false); /// @@ -220,7 +190,7 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate /// Callback function. Delegate must have a same original function prototype. /// Use the MinHook hooking library instead of Reloaded. /// The hook with the supplied parameters. - public static Hook FromSymbol(string moduleName, string exportName, T detour, bool useMinHook) + internal static Hook FromSymbol(string moduleName, string exportName, T detour, bool useMinHook) { if (EnvironmentConfiguration.DalamudForceMinHook) useMinHook = true; @@ -249,7 +219,7 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate /// Callback function. Delegate must have a same original function prototype. /// Use the MinHook hooking library instead of Reloaded. /// The hook with the supplied parameters. - public static Hook FromAddress(IntPtr procAddress, T detour, bool useMinHook = false) + internal static Hook FromAddress(IntPtr procAddress, T detour, bool useMinHook = false) { if (EnvironmentConfiguration.DalamudForceMinHook) useMinHook = true; @@ -261,41 +231,6 @@ public class Hook : IDisposable, IDalamudHook where T : Delegate return new ReloadedHook(procAddress, detour, Assembly.GetCallingAssembly()); } - /// - /// Remove a hook from the current process. - /// - public virtual void Dispose() - { - if (this.IsDisposed) - return; - - this.compatHookImpl?.Dispose(); - - this.IsDisposed = true; - } - - /// - /// Starts intercepting a call to the function. - /// - public virtual void Enable() - { - if (this.compatHookImpl != null) - this.compatHookImpl.Enable(); - else - throw new NotImplementedException(); - } - - /// - /// Stops intercepting a call to the function. - /// - public virtual void Disable() - { - if (this.compatHookImpl != null) - this.compatHookImpl.Disable(); - else - throw new NotImplementedException(); - } - /// /// Check if this object has been disposed already. /// diff --git a/Dalamud/Hooking/IDalamudHook.cs b/Dalamud/Hooking/IDalamudHook.cs index 1104597a1..bd7084d86 100644 --- a/Dalamud/Hooking/IDalamudHook.cs +++ b/Dalamud/Hooking/IDalamudHook.cs @@ -5,7 +5,7 @@ namespace Dalamud.Hooking; /// /// Interface describing a generic hook. /// -public interface IDalamudHook +public interface IDalamudHook : IDisposable { /// /// Gets the address to hook. diff --git a/Dalamud/Hooking/Internal/CallHook.cs b/Dalamud/Hooking/Internal/CallHook.cs new file mode 100644 index 000000000..2bef59c86 --- /dev/null +++ b/Dalamud/Hooking/Internal/CallHook.cs @@ -0,0 +1,89 @@ +using System; +using System.Runtime.InteropServices; + +using Reloaded.Hooks.Definitions; + +namespace Dalamud.Hooking.Internal; + +/// +/// This class represents a callsite hook. Only the specific address's instructions are replaced with this hook. +/// This is a destructive operation, no other callsite hooks can coexist at the same address. +/// +/// There's no .Original for this hook type. +/// This is only intended for be for functions where the parameters provided allow you to invoke the original call. +/// +/// This class was specifically added for hooking virtual function callsites. +/// Only the specific callsite hooked is modified, if the game calls the virtual function from other locations this hook will not be triggered. +/// +/// Delegate signature for this hook. +internal class CallHook : IDisposable where T : Delegate +{ + private readonly Reloaded.Hooks.AsmHook asmHook; + + private T? detour; + private bool activated; + + /// + /// Initializes a new instance of the class. + /// + /// Address of the instruction to replace. + /// Delegate to invoke. + internal CallHook(nint address, T detour) + { + this.detour = detour; + + var detourPtr = Marshal.GetFunctionPointerForDelegate(this.detour); + var code = new[] + { + "use64", + $"mov rax, 0x{detourPtr:X8}", + "call rax", + }; + + var opt = new AsmHookOptions + { + PreferRelativeJump = true, + Behaviour = Reloaded.Hooks.Definitions.Enums.AsmHookBehaviour.DoNotExecuteOriginal, + MaxOpcodeSize = 5, + }; + + this.asmHook = new Reloaded.Hooks.AsmHook(code, (nuint)address, opt); + } + + /// + /// Gets a value indicating whether or not the hook is enabled. + /// + public bool IsEnabled => this.asmHook.IsEnabled; + + /// + /// Starts intercepting a call to the function. + /// + public void Enable() + { + if (!this.activated) + { + this.activated = true; + this.asmHook.Activate(); + return; + } + + this.asmHook.Enable(); + } + + /// + /// Stops intercepting a call to the function. + /// + public void Disable() + { + this.asmHook.Disable(); + } + + /// + /// Remove a hook from the current process. + /// + public void Dispose() + { + this.asmHook.Disable(); + this.detour = null; + } +} diff --git a/Dalamud/Hooking/Internal/GameInteropProviderPluginScoped.cs b/Dalamud/Hooking/Internal/GameInteropProviderPluginScoped.cs new file mode 100644 index 000000000..1138d4e07 --- /dev/null +++ b/Dalamud/Hooking/Internal/GameInteropProviderPluginScoped.cs @@ -0,0 +1,100 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; + +using Dalamud.Game; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using Serilog; + +namespace Dalamud.Hooking.Internal; + +/// +/// Plugin-scoped version of service used to create hooks. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class GameInteropProviderPluginScoped : IGameInteropProvider, IInternalDisposableService +{ + private readonly LocalPlugin plugin; + private readonly SigScanner scanner; + + private readonly ConcurrentBag trackedHooks = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Plugin this instance belongs to. + /// SigScanner instance for target module. + public GameInteropProviderPluginScoped(LocalPlugin plugin, TargetSigScanner scanner) + { + this.plugin = plugin; + this.scanner = scanner; + } + + /// + public void InitializeFromAttributes(object self) + { + foreach (var hook in SignatureHelper.Initialize(self)) + this.trackedHooks.Add(hook); + } + + /// + public Hook HookFromFunctionPointerVariable(IntPtr address, T detour) where T : Delegate + { + var hook = Hook.FromFunctionPointerVariable(address, detour); + this.trackedHooks.Add(hook); + return hook; + } + + /// + public Hook HookFromImport(ProcessModule? module, string moduleName, string functionName, uint hintOrOrdinal, T detour) where T : Delegate + { + var hook = Hook.FromImport(module, moduleName, functionName, hintOrOrdinal, detour); + this.trackedHooks.Add(hook); + return hook; + } + + /// + public Hook HookFromSymbol(string moduleName, string exportName, T detour, IGameInteropProvider.HookBackend backend = IGameInteropProvider.HookBackend.Automatic) where T : Delegate + { + var hook = Hook.FromSymbol(moduleName, exportName, detour, backend == IGameInteropProvider.HookBackend.MinHook); + this.trackedHooks.Add(hook); + return hook; + } + + /// + public Hook HookFromAddress(IntPtr procAddress, T detour, IGameInteropProvider.HookBackend backend = IGameInteropProvider.HookBackend.Automatic) where T : Delegate + { + var hook = Hook.FromAddress(procAddress, detour, backend == IGameInteropProvider.HookBackend.MinHook); + this.trackedHooks.Add(hook); + return hook; + } + + /// + public Hook HookFromSignature(string signature, T detour, IGameInteropProvider.HookBackend backend = IGameInteropProvider.HookBackend.Automatic) where T : Delegate + => this.HookFromAddress(this.scanner.ScanText(signature), detour, backend); + + /// + void IInternalDisposableService.DisposeService() + { + var notDisposed = this.trackedHooks.Where(x => !x.IsDisposed).ToArray(); + if (notDisposed.Length != 0) + Log.Warning("{PluginName} is leaking {Num} hooks! Make sure that all of them are disposed properly.", this.plugin.InternalName, notDisposed.Length); + + foreach (var hook in notDisposed) + { + Log.Warning("\t\t\tLeaked hook at +0x{Address:X}", hook.Address.ToInt64() - this.scanner.Module.BaseAddress.ToInt64()); + hook.Dispose(); + } + + this.trackedHooks.Clear(); + } +} diff --git a/Dalamud/Hooking/Internal/HookManager.cs b/Dalamud/Hooking/Internal/HookManager.cs index 9c288a276..c8cdf3a46 100644 --- a/Dalamud/Hooking/Internal/HookManager.cs +++ b/Dalamud/Hooking/Internal/HookManager.cs @@ -14,7 +14,7 @@ namespace Dalamud.Hooking.Internal; /// This class manages the final disposition of hooks, cleaning up any that have not reverted their changes. /// [ServiceManager.EarlyLoadedService] -internal class HookManager : IDisposable, IServiceType +internal class HookManager : IInternalDisposableService { /// /// Logger shared with . @@ -74,7 +74,7 @@ internal class HookManager : IDisposable, IServiceType } /// - public void Dispose() + void IInternalDisposableService.DisposeService() { RevertHooks(); TrackedHooks.Clear(); diff --git a/Dalamud/Hooking/WndProcHook/WndProcEventArgs.cs b/Dalamud/Hooking/WndProcHook/WndProcEventArgs.cs new file mode 100644 index 000000000..b25df5d14 --- /dev/null +++ b/Dalamud/Hooking/WndProcHook/WndProcEventArgs.cs @@ -0,0 +1,144 @@ +using System.Runtime.InteropServices; + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Hooking.WndProcHook; + +/// +/// Event arguments for , +/// and the manager for individual WndProc hook. +/// +internal sealed unsafe class WndProcEventArgs +{ + private readonly WndProcHookManager owner; + private readonly delegate* unmanaged oldWndProcW; + private readonly WndProcDelegate myWndProc; + + private GCHandle gcHandle; + private bool released; + + /// + /// Initializes a new instance of the class. + /// + /// The owner. + /// The handle of the target window of the message. + /// The viewport ID. + internal WndProcEventArgs(WndProcHookManager owner, HWND hwnd, int viewportId) + { + this.Hwnd = hwnd; + this.owner = owner; + this.ViewportId = viewportId; + this.myWndProc = this.WndProcDetour; + this.oldWndProcW = (delegate* unmanaged)SetWindowLongPtrW( + hwnd, + GWLP.GWLP_WNDPROC, + Marshal.GetFunctionPointerForDelegate(this.myWndProc)); + this.gcHandle = GCHandle.Alloc(this); + } + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate LRESULT WndProcDelegate(HWND hwnd, uint uMsg, WPARAM wParam, LPARAM lParam); + + /// + /// Gets the handle of the target window of the message. + /// + public HWND Hwnd { get; } + + /// + /// Gets the ImGui viewport ID. + /// + public int ViewportId { get; } + + /// + /// Gets or sets the message. + /// + public uint Message { get; set; } + + /// + /// Gets or sets the WPARAM. + /// + public WPARAM WParam { get; set; } + + /// + /// Gets or sets the LPARAM. + /// + public LPARAM LParam { get; set; } + + /// + /// Gets or sets a value indicating whether to suppress calling the next WndProc in the chain.
+ /// Does nothing if changed from . + ///
+ public bool SuppressCall { get; set; } + + /// + /// Gets or sets the return value.
+ /// Has the return value from next window procedure, if accessed from . + ///
+ public LRESULT ReturnValue { get; set; } + + /// + /// Sets to true and sets . + /// + /// The new return value. + public void SuppressWithValue(LRESULT returnValue) + { + this.ReturnValue = returnValue; + this.SuppressCall = true; + } + + /// + /// Sets to true and sets from the result of + /// . + /// + public void SuppressWithDefault() + { + this.ReturnValue = DefWindowProcW(this.Hwnd, this.Message, this.WParam, this.LParam); + this.SuppressCall = true; + } + + /// + internal void InternalRelease() + { + if (this.released) + return; + + this.released = true; + SendMessageW(this.Hwnd, WM.WM_NULL, 0, 0); + this.FinalRelease(); + } + + private void FinalRelease() + { + if (!this.gcHandle.IsAllocated) + return; + + this.gcHandle.Free(); + SetWindowLongPtrW(this.Hwnd, GWLP.GWLP_WNDPROC, (nint)this.oldWndProcW); + this.owner.OnHookedWindowRemoved(this); + } + + private LRESULT WndProcDetour(HWND hwnd, uint uMsg, WPARAM wParam, LPARAM lParam) + { + if (hwnd != this.Hwnd) + return CallWindowProcW(this.oldWndProcW, hwnd, uMsg, wParam, lParam); + + this.SuppressCall = false; + this.ReturnValue = 0; + this.Message = uMsg; + this.WParam = wParam; + this.LParam = lParam; + this.owner.InvokePreWndProc(this); + + if (!this.SuppressCall) + this.ReturnValue = CallWindowProcW(this.oldWndProcW, hwnd, uMsg, wParam, lParam); + + this.owner.InvokePostWndProc(this); + + if (uMsg == WM.WM_NCDESTROY || this.released) + this.FinalRelease(); + + return this.ReturnValue; + } +} diff --git a/Dalamud/Hooking/WndProcHook/WndProcEventDelegate.cs b/Dalamud/Hooking/WndProcHook/WndProcEventDelegate.cs new file mode 100644 index 000000000..f753f16cc --- /dev/null +++ b/Dalamud/Hooking/WndProcHook/WndProcEventDelegate.cs @@ -0,0 +1,7 @@ +namespace Dalamud.Hooking.WndProcHook; + +/// +/// Delegate for overriding WndProc. +/// +/// The arguments. +internal delegate void WndProcEventDelegate(WndProcEventArgs args); diff --git a/Dalamud/Hooking/WndProcHook/WndProcHookManager.cs b/Dalamud/Hooking/WndProcHook/WndProcHookManager.cs new file mode 100644 index 000000000..a2253eb23 --- /dev/null +++ b/Dalamud/Hooking/WndProcHook/WndProcHookManager.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.Runtime.InteropServices; + +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Hooking.WndProcHook; + +/// +/// Manages WndProc hooks for game main window and extra ImGui viewport windows. +/// +[ServiceManager.BlockingEarlyLoadedService] +internal sealed class WndProcHookManager : IInternalDisposableService +{ + private static readonly ModuleLog Log = new(nameof(WndProcHookManager)); + + private readonly Hook dispatchMessageWHook; + private readonly Dictionary wndProcOverrides = new(); + + private HWND mainWindowHwnd; + + [ServiceManager.ServiceConstructor] + private unsafe WndProcHookManager() + { + this.dispatchMessageWHook = Hook.FromImport( + null, + "user32.dll", + "DispatchMessageW", + 0, + this.DispatchMessageWDetour); + this.dispatchMessageWHook.Enable(); + + // Capture the game main window handle, + // so that no guarantees would have to be made on the service dispose order. + Service + .GetAsync() + .ContinueWith(r => this.mainWindowHwnd = (HWND)r.Result.Manager.GameWindowHandle); + } + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private unsafe delegate nint DispatchMessageWDelegate(MSG* msg); + + /// + /// Called before WndProc. + /// + public event WndProcEventDelegate? PreWndProc; + + /// + /// Called after WndProc. + /// + public event WndProcEventDelegate? PostWndProc; + + /// + void IInternalDisposableService.DisposeService() + { + if (this.dispatchMessageWHook.IsDisposed) + return; + + this.dispatchMessageWHook.Dispose(); + + // Ensure that either we're on the main thread, or DispatchMessage is executed at least once. + // The game calls DispatchMessageW only from its main thread, so if we're already on one, + // this line does nothing; if not, it will require a cycle of GetMessage ... DispatchMessageW, + // which at the point of returning from DispatchMessageW(=point of returning from SendMessageW), + // the hook would be guaranteed to be fully disabled and detour delegate would be safe to be released. + SendMessageW(this.mainWindowHwnd, WM.WM_NULL, 0, 0); + + // Now this.wndProcOverrides cannot be touched from other thread. + foreach (var v in this.wndProcOverrides.Values) + v.InternalRelease(); + this.wndProcOverrides.Clear(); + } + + /// + /// Invokes . + /// + /// The arguments. + internal void InvokePreWndProc(WndProcEventArgs args) + { + try + { + this.PreWndProc?.Invoke(args); + } + catch (Exception e) + { + Log.Error(e, $"{nameof(this.PreWndProc)} error"); + } + } + + /// + /// Invokes . + /// + /// The arguments. + internal void InvokePostWndProc(WndProcEventArgs args) + { + try + { + this.PostWndProc?.Invoke(args); + } + catch (Exception e) + { + Log.Error(e, $"{nameof(this.PostWndProc)} error"); + } + } + + /// + /// Removes from the list of known WndProc overrides. + /// + /// Object to remove. + internal void OnHookedWindowRemoved(WndProcEventArgs args) + { + if (!this.dispatchMessageWHook.IsDisposed) + this.wndProcOverrides.Remove(args.Hwnd); + } + + /// + /// Detour for . Used to discover new windows to hook. + /// + /// The message. + /// The original return value. + private unsafe nint DispatchMessageWDetour(MSG* msg) + { + if (!this.wndProcOverrides.ContainsKey(msg->hwnd) + && ImGuiHelpers.FindViewportId(msg->hwnd) is var vpid and >= 0) + { + this.wndProcOverrides[msg->hwnd] = new(this, msg->hwnd, vpid); + } + + return this.dispatchMessageWHook.Original(msg); + } +} diff --git a/Dalamud/IServiceType.cs b/Dalamud/IServiceType.cs index 973795faf..3a5dde880 100644 --- a/Dalamud/IServiceType.cs +++ b/Dalamud/IServiceType.cs @@ -6,3 +6,20 @@ public interface IServiceType { } + +/// , but for . +/// Use this to prevent services from accidentally being disposed by plugins or using clauses. +internal interface IInternalDisposableService : IServiceType +{ + /// Disposes the service. + void DisposeService(); +} + +/// An which happens to be public and needs to expose +/// . +internal interface IPublicDisposableService : IInternalDisposableService, IDisposable +{ + /// Marks that only should respond, + /// while suppressing . + void MarkDisposeOnlyFromService(); +} diff --git a/Dalamud/Interface/Animation/Easing.cs b/Dalamud/Interface/Animation/Easing.cs index 2ac040143..54c41c16d 100644 --- a/Dalamud/Interface/Animation/Easing.cs +++ b/Dalamud/Interface/Animation/Easing.cs @@ -109,6 +109,14 @@ public abstract class Easing this.animationTimer.Restart(); } + /// + /// Resets the animation. + /// + public void Reset() + { + this.animationTimer.Reset(); + } + /// /// Updates the animation. /// diff --git a/Dalamud/Interface/ColorHelpers.cs b/Dalamud/Interface/ColorHelpers.cs new file mode 100644 index 000000000..74561f9ef --- /dev/null +++ b/Dalamud/Interface/ColorHelpers.cs @@ -0,0 +1,308 @@ +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Numerics; + +namespace Dalamud.Interface; + +/// +/// Class containing various methods for manipulating colors. +/// +public static class ColorHelpers +{ + /// + /// A struct representing a color using HSVA coordinates. + /// + /// The hue represented by this struct. + /// The saturation represented by this struct. + /// The value represented by this struct. + /// The alpha represented by this struct. + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", + Justification = "I don't like it.")] + public record struct HsvaColor(float H, float S, float V, float A); + + /// + /// Pack a vector4 color into a uint for use in ImGui APIs. + /// + /// The color to pack. + /// The packed color. + public static uint RgbaVector4ToUint(Vector4 color) + { + var r = (byte)(color.X * 255); + var g = (byte)(color.Y * 255); + var b = (byte)(color.Z * 255); + var a = (byte)(color.W * 255); + + return (uint)((a << 24) | (b << 16) | (g << 8) | r); + } + + /// + /// Convert a RGBA color in the range of 0.f to 1.f to a uint. + /// + /// The color to pack. + /// The packed color. + public static Vector4 RgbaUintToVector4(uint color) + { + var r = (color & 0x000000FF) / 255f; + var g = ((color & 0x0000FF00) >> 8) / 255f; + var b = ((color & 0x00FF0000) >> 16) / 255f; + var a = ((color & 0xFF000000) >> 24) / 255f; + + return new Vector4(r, g, b, a); + } + + /// + /// Convert a RGBA color in the range of 0.f to 1.f to a HSV color. + /// + /// The color to convert. + /// The color in a HSV representation. + public static HsvaColor RgbaToHsv(Vector4 color) + { + var r = color.X; + var g = color.Y; + var b = color.Z; + + var max = Math.Max(r, Math.Max(g, b)); + var min = Math.Min(r, Math.Min(g, b)); + + var h = max; + var s = max; + var v = max; + + var d = max - min; + s = max == 0 ? 0 : d / max; + + if (max == min) + { + h = 0; // achromatic + } + else + { + if (max == r) + { + h = ((g - b) / d) + (g < b ? 6 : 0); + } + else if (max == g) + { + h = ((b - r) / d) + 2; + } + else if (max == b) + { + h = ((r - g) / d) + 4; + } + + h /= 6; + } + + return new HsvaColor(h, s, v, color.W); + } + + /// + /// Convert a HSV color to a RGBA color in the range of 0.f to 1.f. + /// + /// The color to convert. + /// The RGB color. + public static Vector4 HsvToRgb(HsvaColor hsv) + { + var h = hsv.H; + var s = hsv.S; + var v = hsv.V; + + var r = 0f; + var g = 0f; + var b = 0f; + + var i = (int)Math.Floor(h * 6); + var f = (h * 6) - i; + var p = v * (1 - s); + var q = v * (1 - (f * s)); + var t = v * (1 - ((1 - f) * s)); + + switch (i % 6) + { + case 0: + r = v; + g = t; + b = p; + break; + + case 1: + r = q; + g = v; + b = p; + break; + + case 2: + r = p; + g = v; + b = t; + break; + + case 3: + r = p; + g = q; + b = v; + break; + + case 4: + r = t; + g = p; + b = v; + break; + + case 5: + r = v; + g = p; + b = q; + break; + } + + return new Vector4(r, g, b, hsv.A); + } + + /// + /// Lighten a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The lightened color. + public static Vector4 Lighten(this Vector4 color, float amount) + { + var hsv = RgbaToHsv(color); + hsv.V += amount; + return HsvToRgb(hsv); + } + + /// + /// Lighten a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The lightened color. + public static uint Lighten(uint color, float amount) + => RgbaVector4ToUint(Lighten(RgbaUintToVector4(color), amount)); + + /// + /// Darken a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The darkened color. + public static Vector4 Darken(this Vector4 color, float amount) + { + var hsv = RgbaToHsv(color); + hsv.V -= amount; + return HsvToRgb(hsv); + } + + /// + /// Darken a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The darkened color. + public static uint Darken(uint color, float amount) + => RgbaVector4ToUint(Darken(RgbaUintToVector4(color), amount)); + + /// + /// Saturate a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The saturated color. + public static Vector4 Saturate(this Vector4 color, float amount) + { + var hsv = RgbaToHsv(color); + hsv.S += amount; + return HsvToRgb(hsv); + } + + /// + /// Saturate a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The saturated color. + public static uint Saturate(uint color, float amount) + => RgbaVector4ToUint(Saturate(RgbaUintToVector4(color), amount)); + + /// + /// Desaturate a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The desaturated color. + public static Vector4 Desaturate(this Vector4 color, float amount) + { + var hsv = RgbaToHsv(color); + hsv.S -= amount; + return HsvToRgb(hsv); + } + + /// + /// Desaturate a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The desaturated color. + public static uint Desaturate(uint color, float amount) + => RgbaVector4ToUint(Desaturate(RgbaUintToVector4(color), amount)); + + /// + /// Fade a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The faded color. + public static Vector4 Fade(this Vector4 color, float amount) + { + var hsv = RgbaToHsv(color); + hsv.A -= amount; + return HsvToRgb(hsv); + } + + /// + /// Set alpha of a color. + /// + /// The color. + /// The alpha value to set. + /// The color with the set alpha value. + public static Vector4 WithAlpha(this Vector4 color, float alpha) + => color with { W = alpha }; + + /// + /// Fade a color. + /// + /// The color to lighten. + /// The amount to lighten. + /// The faded color. + public static uint Fade(uint color, float amount) + => RgbaVector4ToUint(Fade(RgbaUintToVector4(color), amount)); + + /// + /// Convert a KnownColor to a RGBA vector with values between 0.0f and 1.0f. + /// + /// Known Color to convert. + /// RGBA Vector with values between 0.0f and 1.0f. + public static Vector4 Vector(this KnownColor knownColor) + { + var rgbColor = Color.FromKnownColor(knownColor); + return new Vector4(rgbColor.R, rgbColor.G, rgbColor.B, rgbColor.A) / 255.0f; + } + + /// + /// Normalizes a Vector4 with RGBA 255 color values to values between 0.0f and 1.0f + /// If values are out of RGBA 255 range, the original value is returned. + /// + /// The color vector to convert. + /// A vector with values between 0.0f and 1.0f. + public static Vector4 NormalizeToUnitRange(this Vector4 color) => color switch + { + // If any components are out of range, return original value. + { W: > 255.0f or < 0.0f } or { X: > 255.0f or < 0.0f } or { Y: > 255.0f or < 0.0f } or { Z: > 255.0f or < 0.0f } => color, + + // If all components are already unit range, return original value. + { W: >= 0.0f and <= 1.0f, X: >= 0.0f and <= 1.0f, Y: >= 0.0f and <= 1.0f, Z: >= 0.0f and <= 1.0f } => color, + + _ => color / 255.0f, + }; +} diff --git a/Dalamud/Interface/Components/ImGuiComponents.ColorPickerWithPalette.cs b/Dalamud/Interface/Components/ImGuiComponents.ColorPickerWithPalette.cs index e9db345cb..aa707aecb 100644 --- a/Dalamud/Interface/Components/ImGuiComponents.ColorPickerWithPalette.cs +++ b/Dalamud/Interface/Components/ImGuiComponents.ColorPickerWithPalette.cs @@ -1,5 +1,6 @@ using System.Numerics; +using Dalamud.Interface.Utility; using ImGuiNET; namespace Dalamud.Interface.Components; diff --git a/Dalamud/Interface/Components/ImGuiComponents.IconButton.cs b/Dalamud/Interface/Components/ImGuiComponents.IconButton.cs index 99e43d68c..719f470b8 100644 --- a/Dalamud/Interface/Components/ImGuiComponents.IconButton.cs +++ b/Dalamud/Interface/Components/ImGuiComponents.IconButton.cs @@ -1,5 +1,7 @@ +using System; using System.Numerics; +using Dalamud.Interface.Utility; using ImGuiNET; namespace Dalamud.Interface.Components; @@ -108,15 +110,124 @@ public static partial class ImGuiComponents numColors++; } + var icon = iconText; + if (icon.Contains("#")) + icon = icon[..icon.IndexOf("#", StringComparison.Ordinal)]; + + ImGui.PushID(iconText); + ImGui.PushFont(UiBuilder.IconFont); - - var button = ImGui.Button(iconText); - + var iconSize = ImGui.CalcTextSize(icon); ImGui.PopFont(); + + var dl = ImGui.GetWindowDrawList(); + var cursor = ImGui.GetCursorScreenPos(); + + // Draw an ImGui button with the icon and text + var buttonWidth = iconSize.X + (ImGui.GetStyle().FramePadding.X * 2); + var buttonHeight = ImGui.GetFrameHeight(); + var button = ImGui.Button(string.Empty, new Vector2(buttonWidth, buttonHeight)); + + // Draw the icon on the window drawlist + var iconPos = new Vector2(cursor.X + ImGui.GetStyle().FramePadding.X, cursor.Y + ImGui.GetStyle().FramePadding.Y); + + ImGui.PushFont(UiBuilder.IconFont); + dl.AddText(iconPos, ImGui.GetColorU32(ImGuiCol.Text), icon); + ImGui.PopFont(); + + ImGui.PopID(); if (numColors > 0) ImGui.PopStyleColor(numColors); return button; } + + /// + /// IconButton component to use an icon as a button with color options. + /// + /// Icon to show. + /// Text to show. + /// The default color of the button. + /// The color of the button when active. + /// The color of the button when hovered. + /// Indicator if button is clicked. + public static bool IconButtonWithText(FontAwesomeIcon icon, string text, Vector4? defaultColor = null, Vector4? activeColor = null, Vector4? hoveredColor = null) + { + var numColors = 0; + + if (defaultColor.HasValue) + { + ImGui.PushStyleColor(ImGuiCol.Button, defaultColor.Value); + numColors++; + } + + if (activeColor.HasValue) + { + ImGui.PushStyleColor(ImGuiCol.ButtonActive, activeColor.Value); + numColors++; + } + + if (hoveredColor.HasValue) + { + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, hoveredColor.Value); + numColors++; + } + + ImGui.PushID(text); + + ImGui.PushFont(UiBuilder.IconFont); + var iconSize = ImGui.CalcTextSize(icon.ToIconString()); + ImGui.PopFont(); + + var textSize = ImGui.CalcTextSize(text); + var dl = ImGui.GetWindowDrawList(); + var cursor = ImGui.GetCursorScreenPos(); + + var iconPadding = 3 * ImGuiHelpers.GlobalScale; + + // Draw an ImGui button with the icon and text + var buttonWidth = iconSize.X + textSize.X + (ImGui.GetStyle().FramePadding.X * 2) + iconPadding; + var buttonHeight = ImGui.GetFrameHeight(); + var button = ImGui.Button(string.Empty, new Vector2(buttonWidth, buttonHeight)); + + // Draw the icon on the window drawlist + var iconPos = new Vector2(cursor.X + ImGui.GetStyle().FramePadding.X, cursor.Y + ImGui.GetStyle().FramePadding.Y); + + ImGui.PushFont(UiBuilder.IconFont); + dl.AddText(iconPos, ImGui.GetColorU32(ImGuiCol.Text), icon.ToIconString()); + ImGui.PopFont(); + + // Draw the text on the window drawlist + var textPos = new Vector2(iconPos.X + iconSize.X + iconPadding, cursor.Y + ImGui.GetStyle().FramePadding.Y); + dl.AddText(textPos, ImGui.GetColorU32(ImGuiCol.Text), text); + + ImGui.PopID(); + + if (numColors > 0) + ImGui.PopStyleColor(numColors); + + return button; + } + + /// + /// Get width of IconButtonWithText component. + /// + /// Icon to use. + /// Text to use. + /// Width. + internal static float GetIconButtonWithTextWidth(FontAwesomeIcon icon, string text) + { + ImGui.PushFont(UiBuilder.IconFont); + var iconSize = ImGui.CalcTextSize(icon.ToIconString()); + ImGui.PopFont(); + + var textSize = ImGui.CalcTextSize(text); + var dl = ImGui.GetWindowDrawList(); + var cursor = ImGui.GetCursorScreenPos(); + + var iconPadding = 3 * ImGuiHelpers.GlobalScale; + + return iconSize.X + textSize.X + (ImGui.GetStyle().FramePadding.X * 2) + iconPadding; + } } diff --git a/Dalamud/Interface/DragDrop/DragDropInterop.cs b/Dalamud/Interface/DragDrop/DragDropInterop.cs index 28a2644a5..6a7043861 100644 --- a/Dalamud/Interface/DragDrop/DragDropInterop.cs +++ b/Dalamud/Interface/DragDrop/DragDropInterop.cs @@ -34,9 +34,9 @@ internal partial class DragDropManager internal struct POINTL { [ComAliasName("Microsoft.VisualStudio.OLE.Interop.LONG")] - public int x; + public int X; [ComAliasName("Microsoft.VisualStudio.OLE.Interop.LONG")] - public int y; + public int Y; } private static class DragDropInterop @@ -101,7 +101,7 @@ internal partial class DragDropManager public static extern int RevokeDragDrop(nint hwnd); [DllImport("shell32.dll")] - public static extern int DragQueryFile(IntPtr hDrop, uint iFile, StringBuilder lpszFile, int cch); + public static extern int DragQueryFileW(IntPtr hDrop, uint iFile, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszFile, int cch); } } #pragma warning restore SA1600 // Elements should be documented diff --git a/Dalamud/Interface/DragDrop/DragDropManager.cs b/Dalamud/Interface/DragDrop/DragDropManager.cs index 8336edc11..adc0ebff7 100644 --- a/Dalamud/Interface/DragDrop/DragDropManager.cs +++ b/Dalamud/Interface/DragDrop/DragDropManager.cs @@ -15,9 +15,11 @@ namespace Dalamud.Interface.DragDrop; /// and can be used to create ImGui drag and drop sources and targets for those external events. ///
[PluginInterface] -[ServiceManager.EarlyLoadedService] +[ServiceManager.BlockingEarlyLoadedService] +#pragma warning disable SA1015 [ResolveVia] -internal partial class DragDropManager : IDisposable, IDragDropManager, IServiceType +#pragma warning restore SA1015 +internal partial class DragDropManager : IInternalDisposableService, IDragDropManager { private nint windowHandlePtr = nint.Zero; @@ -54,6 +56,9 @@ internal partial class DragDropManager : IDisposable, IDragDropManager, IService /// Gets the list of directory paths currently being dragged from an external application over any FFXIV-related viewport or stored from the last drop. public IReadOnlyList Directories { get; private set; } = Array.Empty(); + /// + void IInternalDisposableService.DisposeService() => this.Disable(); + /// Enable external drag and drop. public void Enable() { @@ -97,10 +102,6 @@ internal partial class DragDropManager : IDisposable, IDragDropManager, IService this.ServiceAvailable = false; } - /// - public void Dispose() - => this.Disable(); - /// public void CreateImGuiSource(string label, Func validityCheck, Func tooltipBuilder) { diff --git a/Dalamud/Interface/DragDrop/DragDropTarget.cs b/Dalamud/Interface/DragDrop/DragDropTarget.cs index 5e7166fb3..bbd7a5061 100644 --- a/Dalamud/Interface/DragDrop/DragDropTarget.cs +++ b/Dalamud/Interface/DragDrop/DragDropTarget.cs @@ -51,7 +51,7 @@ internal partial class DragDropManager : DragDropManager.IDropTarget this.Extensions = this.Files.Select(Path.GetExtension).Where(p => !p.IsNullOrEmpty()).Distinct().ToHashSet(); } - Log.Debug("[DragDrop] Entering external Drag and Drop with {KeyState} at {PtX}, {PtY} and with {N} files.", (DragDropInterop.ModifierKeys)grfKeyState, pt.x, pt.y, this.Files.Count + this.Directories.Count); + Log.Debug("[DragDrop] Entering external Drag and Drop with {KeyState} at {PtX}, {PtY} and with {N} files.", (DragDropInterop.ModifierKeys)grfKeyState, pt.X, pt.Y, this.Files.Count + this.Directories.Count); } /// Invoked every windows update-frame as long as the drag and drop process keeps hovering over an FFXIV-related viewport. @@ -67,7 +67,7 @@ internal partial class DragDropManager : DragDropManager.IDropTarget this.lastUpdateFrame = frame; this.lastKeyState = UpdateIo((DragDropInterop.ModifierKeys)grfKeyState, false); pdwEffect &= (uint)DragDropInterop.DropEffects.Copy; - Log.Verbose("[DragDrop] External Drag and Drop with {KeyState} at {PtX}, {PtY}.", (DragDropInterop.ModifierKeys)grfKeyState, pt.x, pt.y); + Log.Verbose("[DragDrop] External Drag and Drop with {KeyState} at {PtX}, {PtY}.", (DragDropInterop.ModifierKeys)grfKeyState, pt.X, pt.Y); } } @@ -101,7 +101,7 @@ internal partial class DragDropManager : DragDropManager.IDropTarget pdwEffect = 0; } - Log.Debug("[DragDrop] Dropping {N} files with {KeyState} at {PtX}, {PtY}.", this.Files.Count + this.Directories.Count, (DragDropInterop.ModifierKeys)grfKeyState, pt.x, pt.y); + Log.Debug("[DragDrop] Dropping {N} files with {KeyState} at {PtX}, {PtY}.", this.Files.Count + this.Directories.Count, (DragDropInterop.ModifierKeys)grfKeyState, pt.X, pt.Y); } private static DragDropInterop.ModifierKeys UpdateIo(DragDropInterop.ModifierKeys keys, bool entering) @@ -204,7 +204,7 @@ internal partial class DragDropManager : DragDropManager.IDropTarget try { data.GetData(ref this.formatEtc, out var stgMedium); - var numFiles = DragDropInterop.DragQueryFile(stgMedium.unionmember, uint.MaxValue, new StringBuilder(), 0); + var numFiles = DragDropInterop.DragQueryFileW(stgMedium.unionmember, uint.MaxValue, new StringBuilder(), 0); var files = new string[numFiles]; var sb = new StringBuilder(1024); var directoryCount = 0; @@ -212,11 +212,11 @@ internal partial class DragDropManager : DragDropManager.IDropTarget for (var i = 0u; i < numFiles; ++i) { sb.Clear(); - var ret = DragDropInterop.DragQueryFile(stgMedium.unionmember, i, sb, sb.Capacity); + var ret = DragDropInterop.DragQueryFileW(stgMedium.unionmember, i, sb, sb.Capacity); if (ret >= sb.Capacity) { sb.Capacity = ret + 1; - ret = DragDropInterop.DragQueryFile(stgMedium.unionmember, i, sb, sb.Capacity); + ret = DragDropInterop.DragQueryFileW(stgMedium.unionmember, i, sb, sb.Capacity); } if (ret > 0 && ret < sb.Capacity) diff --git a/Dalamud/Interface/DragDrop/IDragDropManager.cs b/Dalamud/Interface/DragDrop/IDragDropManager.cs index 736c8af24..a8a0d63b0 100644 --- a/Dalamud/Interface/DragDrop/IDragDropManager.cs +++ b/Dalamud/Interface/DragDrop/IDragDropManager.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; namespace Dalamud.Interface.DragDrop; @@ -23,20 +22,20 @@ public interface IDragDropManager /// Gets the list of directories currently being dragged from an external application over any of the games viewports. public IReadOnlyList Directories { get; } - /// Create an ImGui drag & drop source that is active only if anything is being dragged from an external source. - /// The label used for the drag & drop payload. + /// Create an ImGui drag and drop source that is active only if anything is being dragged from an external source. + /// The label used for the drag and drop payload. /// A function returning whether the current status is relevant for this source. Checked before creating the source but only if something is being dragged. public void CreateImGuiSource(string label, Func validityCheck) => this.CreateImGuiSource(label, validityCheck, _ => false); - /// Create an ImGui drag & drop source that is active only if anything is being dragged from an external source. - /// The label used for the drag & drop payload. + /// Create an ImGui drag and drop source that is active only if anything is being dragged from an external source. + /// The label used for the drag and drop payload. /// A function returning whether the current status is relevant for this source. Checked before creating the source but only if something is being dragged. /// Executes ImGui functions to build a tooltip. Should return true if it creates any tooltip and false otherwise. If multiple sources are active, only the first non-empty tooltip type drawn in a frame will be used. public void CreateImGuiSource(string label, Func validityCheck, Func tooltipBuilder); - /// Create an ImGui drag & drop target on the last ImGui object. - /// The label used for the drag & drop payload. + /// Create an ImGui drag and drop target on the last ImGui object. + /// The label used for the drag and drop payload. /// On success, contains the list of file paths dropped onto the target. /// On success, contains the list of directory paths dropped onto the target. /// True if items were dropped onto the target this frame, false otherwise. diff --git a/Dalamud/Interface/FontAwesome/FontAwesomeIcon.cs b/Dalamud/Interface/FontAwesome/FontAwesomeIcon.cs index 368ca55fe..f88d7f8f0 100644 --- a/Dalamud/Interface/FontAwesome/FontAwesomeIcon.cs +++ b/Dalamud/Interface/FontAwesome/FontAwesomeIcon.cs @@ -1,7 +1,7 @@ //------------------------------------------------------------------------------ // // Generated by Dalamud.FASharpGen - don't modify this file directly. -// Font-Awesome Version: 6.3.0 +// Font-Awesome Version: 6.4.2 // //------------------------------------------------------------------------------ @@ -19,34 +19,6 @@ public enum FontAwesomeIcon ///
None = 0, - /// - /// The Font Awesome "500px" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "500px" })] - _500Px = 0xF26E, - - /// - /// The Font Awesome "accessible-icon" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "accessible icon", "accessibility", "handicap", "person", "wheelchair", "wheelchair-alt" })] - [FontAwesomeCategoriesAttribute(new[] { "Accessibility", "Medical + Health", "Transportation", "Users + People" })] - AccessibleIcon = 0xF368, - - /// - /// The Font Awesome "accusoft" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "accusoft" })] - Accusoft = 0xF369, - - /// - /// The Font Awesome "acquisitionsincorporated" icon unicode character. - /// - [Obsolete] - AcquisitionsIncorporated = 0xF6AF, - /// /// The Font Awesome "rectangle-ad" icon unicode character. /// @@ -65,7 +37,7 @@ public enum FontAwesomeIcon /// The Font Awesome "address-card" icon unicode character. ///
[FontAwesomeSearchTerms(new[] { "address card", "about", "contact", "id", "identification", "postcard", "profile", "registration" })] - [FontAwesomeCategoriesAttribute(new[] { "Business", "Communication", "Users + People" })] + [FontAwesomeCategoriesAttribute(new[] { "Accessibility", "Alphabet", "Business", "Communication", "Users + People" })] AddressCard = 0xF2BB, /// @@ -75,40 +47,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Charts + Diagrams", "Design", "Editing", "Photos + Images", "Shapes" })] Adjust = 0xF042, - /// - /// The Font Awesome "adn" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "adn" })] - Adn = 0xF170, - - /// - /// The Font Awesome "adobe" icon unicode character. - /// - [Obsolete] - Adobe = 0xF778, - - /// - /// The Font Awesome "adversal" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "adversal" })] - Adversal = 0xF36A, - - /// - /// The Font Awesome "affiliatetheme" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "affiliatetheme" })] - Affiliatetheme = 0xF36B, - - /// - /// The Font Awesome "airbnb" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "airbnb" })] - Airbnb = 0xF834, - /// /// The Font Awesome "spray-can-sparkles" icon unicode character. /// @@ -116,13 +54,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Automotive" })] AirFreshener = 0xF5D0, - /// - /// The Font Awesome "algolia" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "algolia" })] - Algolia = 0xF36C, - /// /// The Font Awesome "align-center" icon unicode character. /// @@ -151,14 +82,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Text Formatting" })] AlignRight = 0xF038, - /// - /// The Font Awesome "alipay" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "alipay" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - Alipay = 0xF642, - /// /// The Font Awesome "hand-dots" icon unicode character. /// @@ -166,21 +89,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Hands", "Medical + Health" })] Allergies = 0xF461, - /// - /// The Font Awesome "amazon" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "amazon" })] - Amazon = 0xF270, - - /// - /// The Font Awesome "amazon-pay" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "amazon pay" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - AmazonPay = 0xF42C, - /// /// The Font Awesome "truck-medical" icon unicode character. /// @@ -195,13 +103,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Accessibility", "Communication" })] AmericanSignLanguageInterpreting = 0xF2A3, - /// - /// The Font Awesome "amilia" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "amilia" })] - Amilia = 0xF36D, - /// /// The Font Awesome "anchor" icon unicode character. /// @@ -237,20 +138,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Logistics", "Maritime" })] AnchorLock = 0xE4AD, - /// - /// The Font Awesome "android" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "android", "robot" })] - Android = 0xF17B, - - /// - /// The Font Awesome "angellist" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "angellist" })] - Angellist = 0xF209, - /// /// The Font Awesome "angles-down" icon unicode character. /// @@ -314,20 +201,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Emoji" })] Angry = 0xF556, - /// - /// The Font Awesome "angrycreative" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "angrycreative" })] - Angrycreative = 0xF36E, - - /// - /// The Font Awesome "angular" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "angular" })] - Angular = 0xF420, - /// /// The Font Awesome "ankh" icon unicode character. /// @@ -335,20 +208,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion" })] Ankh = 0xF644, - /// - /// The Font Awesome "apper" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "apper" })] - Apper = 0xF371, - - /// - /// The Font Awesome "apple" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "apple", "fruit", "ios", "mac", "operating system", "os", "osx" })] - Apple = 0xF179, - /// /// The Font Awesome "apple-whole" icon unicode character. /// @@ -356,28 +215,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Childhood", "Education", "Food + Beverage", "Fruits + Vegetables" })] AppleAlt = 0xF5D1, - /// - /// The Font Awesome "apple-pay" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "apple pay" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - ApplePay = 0xF415, - - /// - /// The Font Awesome "app-store" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "app store" })] - AppStore = 0xF36F, - - /// - /// The Font Awesome "app-store-ios" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "app store ios" })] - AppStoreIos = 0xF370, - /// /// The Font Awesome "box-archive" icon unicode character. /// @@ -728,13 +565,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Humanitarian" })] ArrowUpRightFromSquare = 0xF08E, - /// - /// The Font Awesome "artstation" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "artstation" })] - Artstation = 0xF77A, - /// /// The Font Awesome "ear-listen" icon unicode character. /// @@ -750,13 +580,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Punctuation + Symbols", "Spinners" })] Asterisk = 0xF069, - /// - /// The Font Awesome "asymmetrik" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "asymmetrik" })] - Asymmetrik = 0xF372, - /// /// The Font Awesome "at" icon unicode character. /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0x40. @@ -772,13 +595,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps", "Travel + Hotel" })] Atlas = 0xF558, - /// - /// The Font Awesome "atlassian" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "atlassian" })] - Atlassian = 0xF77B, - /// /// The Font Awesome "atom" icon unicode character. /// @@ -786,13 +602,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Education", "Energy", "Religion", "Science", "Science Fiction", "Spinners" })] Atom = 0xF5D2, - /// - /// The Font Awesome "audible" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "audible" })] - Audible = 0xF373, - /// /// The Font Awesome "audio-description" icon unicode character. /// @@ -807,27 +616,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] AustralSign = 0xE0A9, - /// - /// The Font Awesome "autoprefixer" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "autoprefixer" })] - Autoprefixer = 0xF41C, - - /// - /// The Font Awesome "avianex" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "avianex" })] - Avianex = 0xF374, - - /// - /// The Font Awesome "aviato" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "aviato" })] - Aviato = 0xF421, - /// /// The Font Awesome "award" icon unicode character. /// @@ -835,13 +623,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Education", "Political" })] Award = 0xF559, - /// - /// The Font Awesome "aws" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "aws" })] - Aws = 0xF375, - /// /// The Font Awesome "baby" icon unicode character. /// @@ -940,13 +721,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Editing", "Medical + Health" })] BandAid = 0xF462, - /// - /// The Font Awesome "bandcamp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bandcamp" })] - Bandcamp = 0xF2D5, - /// /// The Font Awesome "bangladeshi-taka-sign" icon unicode character. /// @@ -1038,13 +812,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Energy" })] BatteryThreeQuarters = 0xF241, - /// - /// The Font Awesome "battle-net" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "battle net" })] - BattleNet = 0xF835, - /// /// The Font Awesome "bed" icon unicode character. /// @@ -1059,20 +826,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage", "Maps" })] Beer = 0xF0FC, - /// - /// The Font Awesome "behance" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "behance" })] - Behance = 0xF1B4, - - /// - /// The Font Awesome "square-behance" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square behance" })] - BehanceSquare = 0xF1B5, - /// /// The Font Awesome "bell" icon unicode character. /// @@ -1115,18 +868,11 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Childhood", "Sports + Fitness", "Users + People" })] Biking = 0xF84A, - /// - /// The Font Awesome "bimobject" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bimobject" })] - Bimobject = 0xF378, - /// /// The Font Awesome "binoculars" icon unicode character. /// [FontAwesomeSearchTerms(new[] { "binoculars", "glasses", "magnify", "scenic", "spyglass", "view" })] - [FontAwesomeCategoriesAttribute(new[] { "Camping", "Maps", "Nature" })] + [FontAwesomeCategoriesAttribute(new[] { "Astronomy", "Camping", "Maps", "Nature" })] Binoculars = 0xF1E5, /// @@ -1143,21 +889,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Childhood", "Food + Beverage", "Maps", "Social" })] BirthdayCake = 0xF1FD, - /// - /// The Font Awesome "bitbucket" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bitbucket", "atlassian", "bitbucket-square", "git" })] - Bitbucket = 0xF171, - - /// - /// The Font Awesome "bitcoin" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bitcoin" })] - [FontAwesomeCategoriesAttribute(new[] { "Money", "Shopping" })] - Bitcoin = 0xF379, - /// /// The Font Awesome "bitcoin-sign" icon unicode character. /// @@ -1165,27 +896,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] BitcoinSign = 0xE0B4, - /// - /// The Font Awesome "bity" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bity" })] - Bity = 0xF37A, - - /// - /// The Font Awesome "blackberry" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "blackberry" })] - Blackberry = 0xF37B, - - /// - /// The Font Awesome "black-tie" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "black tie" })] - BlackTie = 0xF27E, - /// /// The Font Awesome "blender" icon unicode character. /// @@ -1214,36 +924,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Writing" })] Blog = 0xF781, - /// - /// The Font Awesome "blogger" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "blogger" })] - Blogger = 0xF37C, - - /// - /// The Font Awesome "blogger-b" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "blogger b" })] - BloggerB = 0xF37D, - - /// - /// The Font Awesome "bluetooth" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bluetooth", "signal" })] - [FontAwesomeCategoriesAttribute(new[] { "Connectivity" })] - Bluetooth = 0xF293, - - /// - /// The Font Awesome "bluetooth-b" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bluetooth b" })] - [FontAwesomeCategoriesAttribute(new[] { "Communication" })] - BluetoothB = 0xF294, - /// /// The Font Awesome "bold" icon unicode character. /// @@ -1342,13 +1022,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion" })] BookTanakh = 0xF827, - /// - /// The Font Awesome "bootstrap" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "bootstrap" })] - Bootstrap = 0xF836, - /// /// The Font Awesome "border-all" icon unicode character. /// @@ -1552,14 +1225,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Construction", "Design", "Editing" })] Brush = 0xF55D, - /// - /// The Font Awesome "btc" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "btc" })] - [FontAwesomeCategoriesAttribute(new[] { "Money", "Shopping" })] - Btc = 0xF15A, - /// /// The Font Awesome "bucket" icon unicode character. /// @@ -1567,13 +1232,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Camping", "Childhood", "Construction", "Humanitarian" })] Bucket = 0xE4CF, - /// - /// The Font Awesome "buffer" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "buffer" })] - Buffer = 0xF837, - /// /// The Font Awesome "bug" icon unicode character. /// @@ -1689,7 +1347,7 @@ public enum FontAwesomeIcon /// /// The Font Awesome "bullseye" icon unicode character. /// - [FontAwesomeSearchTerms(new[] { "bullseye", "archery", "goal", "objective", "target" })] + [FontAwesomeSearchTerms(new[] { "bullseye", "archery", "goal", "objective", "strategy", "target" })] [FontAwesomeCategoriesAttribute(new[] { "Business", "Marketing", "Toggle" })] Bullseye = 0xF140, @@ -1700,13 +1358,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Energy", "Humanitarian", "Medical + Health", "Science", "Sports + Fitness" })] Burn = 0xF46A, - /// - /// The Font Awesome "buromobelexperte" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "buromobelexperte" })] - Buromobelexperte = 0xF37F, - /// /// The Font Awesome "burst" icon unicode character. /// @@ -1735,20 +1386,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business" })] BusinessTime = 0xF64A, - /// - /// The Font Awesome "buy-n-large" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "buy n large" })] - BuyNLarge = 0xF8A6, - - /// - /// The Font Awesome "buysellads" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "buysellads" })] - Buysellads = 0xF20D, - /// /// The Font Awesome "calculator" icon unicode character. /// @@ -1840,13 +1477,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Camping" })] Campground = 0xF6BB, - /// - /// The Font Awesome "canadian-maple-leaf" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "canadian maple leaf", "canada", "flag", "flora", "nature", "plant" })] - CanadianMapleLeaf = 0xF785, - /// /// The Font Awesome "candy-cane" icon unicode character. /// @@ -2015,86 +1645,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Animals", "Halloween" })] Cat = 0xF6BE, - /// - /// The Font Awesome "cc-amazon-pay" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc amazon pay" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcAmazonPay = 0xF42D, - - /// - /// The Font Awesome "cc-amex" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc amex", "amex" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcAmex = 0xF1F3, - - /// - /// The Font Awesome "cc-apple-pay" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc apple pay" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcApplePay = 0xF416, - - /// - /// The Font Awesome "cc-diners-club" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc diners club" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcDinersClub = 0xF24C, - - /// - /// The Font Awesome "cc-discover" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc discover" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcDiscover = 0xF1F2, - - /// - /// The Font Awesome "cc-jcb" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc jcb" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcJcb = 0xF24B, - - /// - /// The Font Awesome "cc-mastercard" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc mastercard" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcMastercard = 0xF1F1, - - /// - /// The Font Awesome "cc-paypal" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc paypal" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcPaypal = 0xF1F4, - - /// - /// The Font Awesome "cc-stripe" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc stripe" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcStripe = 0xF1F5, - - /// - /// The Font Awesome "cc-visa" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cc visa" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - CcVisa = 0xF1F0, - /// /// The Font Awesome "cedi-sign" icon unicode character. /// @@ -2102,20 +1652,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] CediSign = 0xE0DF, - /// - /// The Font Awesome "centercode" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "centercode" })] - Centercode = 0xF380, - - /// - /// The Font Awesome "centos" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "centos", "linux", "operating system", "os" })] - Centos = 0xF789, - /// /// The Font Awesome "cent-sign" icon unicode character. /// @@ -2389,20 +1925,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Childhood", "Humanitarian", "Users + People" })] Children = 0xE4E1, - /// - /// The Font Awesome "chrome" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "chrome", "browser" })] - Chrome = 0xF268, - - /// - /// The Font Awesome "chromecast" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "chromecast" })] - Chromecast = 0xF838, - /// /// The Font Awesome "church" icon unicode character. /// @@ -2558,13 +2080,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Weather" })] CloudRain = 0xF73D, - /// - /// The Font Awesome "cloudscale" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cloudscale" })] - Cloudscale = 0xF383, - /// /// The Font Awesome "cloud-showers-heavy" icon unicode character. /// @@ -2579,13 +2094,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Disaster + Crisis", "Humanitarian", "Weather" })] CloudShowersWater = 0xE4E4, - /// - /// The Font Awesome "cloudsmith" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cloudsmith" })] - Cloudsmith = 0xF384, - /// /// The Font Awesome "cloud-sun" icon unicode character. /// @@ -2608,13 +2116,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Connectivity" })] CloudUploadAlt = 0xF382, - /// - /// The Font Awesome "cloudversify" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cloudversify" })] - Cloudversify = 0xF385, - /// /// The Font Awesome "clover" icon unicode character. /// @@ -2671,13 +2172,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Coding" })] CodeMerge = 0xF387, - /// - /// The Font Awesome "codepen" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "codepen" })] - Codepen = 0xF1CB, - /// /// The Font Awesome "code-pull-request" icon unicode character. /// @@ -2685,13 +2179,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Coding" })] CodePullRequest = 0xE13C, - /// - /// The Font Awesome "codiepie" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "codiepie" })] - Codiepie = 0xF284, - /// /// The Font Awesome "mug-saucer" icon unicode character. /// @@ -2703,7 +2190,7 @@ public enum FontAwesomeIcon /// The Font Awesome "gear" icon unicode character. /// [FontAwesomeSearchTerms(new[] { "cog", "cogwheel", "gear", "mechanical", "settings", "sprocket", "tool", "wheel" })] - [FontAwesomeCategoriesAttribute(new[] { "Spinners" })] + [FontAwesomeCategoriesAttribute(new[] { "Coding", "Editing", "Spinners" })] Cog = 0xF013, /// @@ -2839,27 +2326,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Travel + Hotel" })] ConciergeBell = 0xF562, - /// - /// The Font Awesome "confluence" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "confluence", "atlassian" })] - Confluence = 0xF78D, - - /// - /// The Font Awesome "connectdevelop" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "connectdevelop" })] - Connectdevelop = 0xF20E, - - /// - /// The Font Awesome "contao" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "contao" })] - Contao = 0xF26D, - /// /// The Font Awesome "cookie" icon unicode character. /// @@ -2888,13 +2354,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business" })] Copyright = 0xF1F9, - /// - /// The Font Awesome "cotton-bureau" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cotton bureau", "clothing", "t-shirts", "tshirts" })] - CottonBureau = 0xF89E, - /// /// The Font Awesome "couch" icon unicode character. /// @@ -2909,111 +2368,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Animals", "Humanitarian" })] Cow = 0xF6C8, - /// - /// The Font Awesome "cpanel" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cpanel" })] - Cpanel = 0xF388, - - /// - /// The Font Awesome "creative-commons" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons" })] - CreativeCommons = 0xF25E, - - /// - /// The Font Awesome "creative-commons-by" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons by" })] - CreativeCommonsBy = 0xF4E7, - - /// - /// The Font Awesome "creative-commons-nc" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons nc" })] - CreativeCommonsNc = 0xF4E8, - - /// - /// The Font Awesome "creative-commons-nc-eu" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons nc eu" })] - CreativeCommonsNcEu = 0xF4E9, - - /// - /// The Font Awesome "creative-commons-nc-jp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons nc jp" })] - CreativeCommonsNcJp = 0xF4EA, - - /// - /// The Font Awesome "creative-commons-nd" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons nd" })] - CreativeCommonsNd = 0xF4EB, - - /// - /// The Font Awesome "creative-commons-pd" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons pd" })] - CreativeCommonsPd = 0xF4EC, - - /// - /// The Font Awesome "creative-commons-pd-alt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons pd alt" })] - CreativeCommonsPdAlt = 0xF4ED, - - /// - /// The Font Awesome "creative-commons-remix" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons remix" })] - CreativeCommonsRemix = 0xF4EE, - - /// - /// The Font Awesome "creative-commons-sa" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons sa" })] - CreativeCommonsSa = 0xF4EF, - - /// - /// The Font Awesome "creative-commons-sampling" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons sampling" })] - CreativeCommonsSampling = 0xF4F0, - - /// - /// The Font Awesome "creative-commons-sampling-plus" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons sampling plus" })] - CreativeCommonsSamplingPlus = 0xF4F1, - - /// - /// The Font Awesome "creative-commons-share" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons share" })] - CreativeCommonsShare = 0xF4F2, - - /// - /// The Font Awesome "creative-commons-zero" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "creative commons zero" })] - CreativeCommonsZero = 0xF4F3, - /// /// The Font Awesome "credit-card" icon unicode character. /// @@ -3021,14 +2375,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money", "Shopping" })] CreditCard = 0xF09D, - /// - /// The Font Awesome "critical-role" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "critical role", "dungeons & dragons", "d&d", "dnd", "fantasy", "game", "gaming", "tabletop" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - CriticalRole = 0xF6C9, - /// /// The Font Awesome "crop" icon unicode character. /// @@ -3085,20 +2431,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] CruzeiroSign = 0xE152, - /// - /// The Font Awesome "css3" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "css3", "code" })] - Css3 = 0xF13C, - - /// - /// The Font Awesome "css3-alt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "css3 alt" })] - Css3Alt = 0xF38B, - /// /// The Font Awesome "cube" icon unicode character. /// @@ -3127,43 +2459,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Design", "Editing", "Files" })] Cut = 0xF0C4, - /// - /// The Font Awesome "cuttlefish" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "cuttlefish" })] - Cuttlefish = 0xF38C, - - /// - /// The Font Awesome "dailymotion" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dailymotion" })] - Dailymotion = 0xF952, - - /// - /// The Font Awesome "d-and-d" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "d and d" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - DAndD = 0xF38D, - - /// - /// The Font Awesome "d-and-d-beyond" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "d and d beyond", "dungeons & dragons", "d&d", "dnd", "fantasy", "gaming", "tabletop" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - DAndDBeyond = 0xF6CA, - - /// - /// The Font Awesome "dashcube" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dashcube" })] - Dashcube = 0xF210, - /// /// The Font Awesome "database" icon unicode character. /// @@ -3178,13 +2473,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Accessibility" })] Deaf = 0xF2A4, - /// - /// The Font Awesome "delicious" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "delicious" })] - Delicious = 0xF1A5, - /// /// The Font Awesome "democrat" icon unicode character. /// @@ -3192,20 +2480,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Political" })] Democrat = 0xF747, - /// - /// The Font Awesome "deploydog" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "deploydog" })] - Deploydog = 0xF38E, - - /// - /// The Font Awesome "deskpro" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "deskpro" })] - Deskpro = 0xF38F, - /// /// The Font Awesome "desktop" icon unicode character. /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0xF390. @@ -3214,20 +2488,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Devices + Hardware" })] Desktop = 0xF108, - /// - /// The Font Awesome "dev" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dev" })] - Dev = 0xF6CC, - - /// - /// The Font Awesome "deviantart" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "deviantart" })] - Deviantart = 0xF1BD, - /// /// The Font Awesome "dharmachakra" icon unicode character. /// @@ -3235,13 +2495,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion", "Spinners" })] Dharmachakra = 0xF655, - /// - /// The Font Awesome "dhl" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dhl", "dalsey", "hillblom and lynn", "german", "package", "shipping" })] - Dhl = 0xF790, - /// /// The Font Awesome "person-dots-from-line" icon unicode character. /// @@ -3277,13 +2530,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Gaming", "Shapes" })] Diamond = 0xF219, - /// - /// The Font Awesome "diaspora" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "diaspora" })] - Diaspora = 0xF791, - /// /// The Font Awesome "dice" icon unicode character. /// @@ -3347,20 +2593,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] DiceTwo = 0xF528, - /// - /// The Font Awesome "digg" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "digg" })] - Digg = 0xF1A6, - - /// - /// The Font Awesome "digital-ocean" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "digital ocean" })] - DigitalOcean = 0xF391, - /// /// The Font Awesome "tachograph-digital" icon unicode character. /// @@ -3375,20 +2607,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps" })] Directions = 0xF5EB, - /// - /// The Font Awesome "discord" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "discord" })] - Discord = 0xF392, - - /// - /// The Font Awesome "discourse" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "discourse" })] - Discourse = 0xF393, - /// /// The Font Awesome "disease" icon unicode character. /// @@ -3424,20 +2642,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health", "Science" })] Dna = 0xF471, - /// - /// The Font Awesome "dochub" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dochub" })] - Dochub = 0xF394, - - /// - /// The Font Awesome "docker" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "docker" })] - Docker = 0xF395, - /// /// The Font Awesome "dog" icon unicode character. /// @@ -3516,13 +2720,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Devices + Hardware" })] Download = 0xF019, - /// - /// The Font Awesome "draft2digital" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "draft2digital" })] - Draft2digital = 0xF396, - /// /// The Font Awesome "compass-drafting" icon unicode character. /// @@ -3544,27 +2741,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Design", "Maps" })] DrawPolygon = 0xF5EE, - /// - /// The Font Awesome "dribbble" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dribbble" })] - Dribbble = 0xF17D, - - /// - /// The Font Awesome "square-dribbble" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square dribbble" })] - DribbbleSquare = 0xF397, - - /// - /// The Font Awesome "dropbox" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dropbox" })] - Dropbox = 0xF16B, - /// /// The Font Awesome "drum" icon unicode character. /// @@ -3586,13 +2762,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage" })] DrumstickBite = 0xF6D7, - /// - /// The Font Awesome "drupal" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "drupal" })] - Drupal = 0xF1A9, - /// /// The Font Awesome "dumbbell" icon unicode character. /// @@ -3621,20 +2790,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Gaming", "Household", "Security" })] Dungeon = 0xF6D9, - /// - /// The Font Awesome "dyalog" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "dyalog" })] - Dyalog = 0xF399, - - /// - /// The Font Awesome "earlybirds" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "earlybirds" })] - Earlybirds = 0xF39A, - /// /// The Font Awesome "earth-oceania" icon unicode character. /// @@ -3642,20 +2797,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Travel + Hotel" })] EarthOceania = 0xE47B, - /// - /// The Font Awesome "ebay" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ebay" })] - Ebay = 0xF4F4, - - /// - /// The Font Awesome "edge" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "edge", "browser", "ie" })] - Edge = 0xF282, - /// /// The Font Awesome "pen-to-square" icon unicode character. /// @@ -3677,13 +2818,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Media Playback" })] Eject = 0xF052, - /// - /// The Font Awesome "elementor" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "elementor" })] - Elementor = 0xF430, - /// /// The Font Awesome "elevator" icon unicode character. /// @@ -3705,27 +2839,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Editing" })] EllipsisV = 0xF142, - /// - /// The Font Awesome "ello" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ello" })] - Ello = 0xF5F1, - - /// - /// The Font Awesome "ember" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ember" })] - Ember = 0xF423, - - /// - /// The Font Awesome "empire" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "empire" })] - Empire = 0xF1D1, - /// /// The Font Awesome "envelope" icon unicode character. /// @@ -3761,13 +2874,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Communication" })] EnvelopeSquare = 0xF199, - /// - /// The Font Awesome "envira" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "envira", "leaf" })] - Envira = 0xF299, - /// /// The Font Awesome "equals" icon unicode character. /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0x3D. @@ -3783,21 +2889,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Design", "Writing" })] Eraser = 0xF12D, - /// - /// The Font Awesome "erlang" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "erlang" })] - Erlang = 0xF39D, - - /// - /// The Font Awesome "ethereum" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ethereum" })] - [FontAwesomeCategoriesAttribute(new[] { "Money", "Shopping" })] - Ethereum = 0xF42E, - /// /// The Font Awesome "ethernet" icon unicode character. /// @@ -3805,13 +2896,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Connectivity", "Devices + Hardware" })] Ethernet = 0xF796, - /// - /// The Font Awesome "etsy" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "etsy" })] - Etsy = 0xF2D7, - /// /// The Font Awesome "euro-sign" icon unicode character. /// @@ -3819,13 +2903,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] EuroSign = 0xF153, - /// - /// The Font Awesome "evernote" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "evernote" })] - Evernote = 0xF839, - /// /// The Font Awesome "right-left" icon unicode character. /// @@ -3876,13 +2953,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Media Playback" })] ExpandArrowsAlt = 0xF31E, - /// - /// The Font Awesome "expeditedssl" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "expeditedssl" })] - Expeditedssl = 0xF23E, - /// /// The Font Awesome "explosion" icon unicode character. /// @@ -3925,34 +2995,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Design", "Editing", "Maps", "Photos + Images", "Security" })] EyeSlash = 0xF070, - /// - /// The Font Awesome "facebook" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "facebook", "facebook-official", "social network" })] - Facebook = 0xF09A, - - /// - /// The Font Awesome "facebook-f" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "facebook f", "facebook" })] - FacebookF = 0xF39E, - - /// - /// The Font Awesome "facebook-messenger" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "facebook messenger" })] - FacebookMessenger = 0xF39F, - - /// - /// The Font Awesome "square-facebook" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square facebook", "social network" })] - FacebookSquare = 0xF082, - /// /// The Font Awesome "fan" icon unicode character. /// @@ -3960,14 +3002,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Energy", "Household", "Spinners" })] Fan = 0xF863, - /// - /// The Font Awesome "fantasy-flight-games" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fantasy flight games", "dungeons & dragons", "d&d", "dnd", "fantasy", "game", "gaming", "tabletop" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - FantasyFlightGames = 0xF6DC, - /// /// The Font Awesome "backward-fast" icon unicode character. /// @@ -4017,20 +3051,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Animals", "Nature" })] FeatherAlt = 0xF56B, - /// - /// The Font Awesome "fedex" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fedex", "federal express", "package", "shipping" })] - Fedex = 0xF797, - - /// - /// The Font Awesome "fedora" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fedora", "linux", "operating system", "os" })] - Fedora = 0xF798, - /// /// The Font Awesome "person-dress" icon unicode character. /// @@ -4052,13 +3072,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps", "Transportation" })] FighterJet = 0xF0FB, - /// - /// The Font Awesome "figma" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "figma", "app", "design", "interface" })] - Figma = 0xF799, - /// /// The Font Awesome "file" icon unicode character. /// @@ -4346,20 +3359,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Coding", "Maps" })] FireExtinguisher = 0xF134, - /// - /// The Font Awesome "firefox" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "firefox", "browser" })] - Firefox = 0xF269, - - /// - /// The Font Awesome "firefox-browser" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "firefox browser", "browser" })] - FirefoxBrowser = 0xF907, - /// /// The Font Awesome "kit-medical" icon unicode character. /// @@ -4367,27 +3366,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Camping", "Medical + Health" })] FirstAid = 0xF479, - /// - /// The Font Awesome "firstdraft" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "firstdraft" })] - Firstdraft = 0xF3A1, - - /// - /// The Font Awesome "first-order" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "first order" })] - FirstOrder = 0xF2B0, - - /// - /// The Font Awesome "first-order-alt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "first order alt" })] - FirstOrderAlt = 0xF50A, - /// /// The Font Awesome "fish" icon unicode character. /// @@ -4433,31 +3411,17 @@ public enum FontAwesomeIcon /// /// The Font Awesome "flask" icon unicode character. /// - [FontAwesomeSearchTerms(new[] { "flask", "beaker", "experimental", "labs", "science" })] + [FontAwesomeSearchTerms(new[] { "flask", "beaker", "chemicals", "experiment", "experimental", "labs", "liquid", "potion", "science", "vial" })] [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage", "Maps", "Medical + Health", "Science" })] Flask = 0xF0C3, /// /// The Font Awesome "flask-vial" icon unicode character. /// - [FontAwesomeSearchTerms(new[] { "flask vial", "ampule", "chemistry", "lab", "laboratory", "test", "test tube" })] + [FontAwesomeSearchTerms(new[] { "flask vial", " beaker", " chemicals", " experiment", " experimental", " labs", " liquid", " science", " vial", "ampule", "chemistry", "lab", "laboratory", "potion", "test", "test tube" })] [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Medical + Health", "Science" })] FlaskVial = 0xE4F3, - /// - /// The Font Awesome "flickr" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "flickr" })] - Flickr = 0xF16E, - - /// - /// The Font Awesome "flipboard" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "flipboard" })] - Flipboard = 0xF44D, - /// /// The Font Awesome "florin-sign" icon unicode character. /// @@ -4472,13 +3436,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Emoji" })] Flushed = 0xF579, - /// - /// The Font Awesome "fly" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fly" })] - Fly = 0xF417, - /// /// The Font Awesome "folder" icon unicode character. /// @@ -4535,13 +3492,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Coding", "Design" })] FontAwesome = 0xF2B4, - /// - /// The Font Awesome "square-font-awesome-stroke" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square font awesome stroke" })] - FontAwesomeAlt = 0xF35C, - /// /// The Font Awesome "font-awesome" icon unicode character. /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0xF2B4. @@ -4558,20 +3508,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Coding", "Design" })] FontAwesomeLogoFull = 0xF4E6, - /// - /// The Font Awesome "fonticons" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fonticons" })] - Fonticons = 0xF280, - - /// - /// The Font Awesome "fonticons-fi" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fonticons fi" })] - FonticonsFi = 0xF3A2, - /// /// The Font Awesome "football" icon unicode character. /// @@ -4579,27 +3515,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Sports + Fitness" })] FootballBall = 0xF44E, - /// - /// The Font Awesome "fort-awesome" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fort awesome", "castle" })] - FortAwesome = 0xF286, - - /// - /// The Font Awesome "fort-awesome-alt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fort awesome alt", "castle" })] - FortAwesomeAlt = 0xF3A3, - - /// - /// The Font Awesome "forumbee" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "forumbee" })] - Forumbee = 0xF211, - /// /// The Font Awesome "forward" icon unicode character. /// @@ -4607,13 +3522,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Media Playback" })] Forward = 0xF04E, - /// - /// The Font Awesome "foursquare" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "foursquare" })] - Foursquare = 0xF180, - /// /// The Font Awesome "franc-sign" icon unicode character. /// @@ -4621,20 +3529,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] FrancSign = 0xE18F, - /// - /// The Font Awesome "freebsd" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "freebsd" })] - Freebsd = 0xF3A4, - - /// - /// The Font Awesome "free-code-camp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "free code camp" })] - FreeCodeCamp = 0xF2C5, - /// /// The Font Awesome "frog" icon unicode character. /// @@ -4656,13 +3550,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Emoji" })] FrownOpen = 0xF57A, - /// - /// The Font Awesome "fulcrum" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "fulcrum" })] - Fulcrum = 0xF50B, - /// /// The Font Awesome "filter-circle-dollar" icon unicode character. /// @@ -4677,22 +3564,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Sports + Fitness" })] Futbol = 0xF1E3, - /// - /// The Font Awesome "galactic-republic" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "galactic republic", "politics", "star wars" })] - [FontAwesomeCategoriesAttribute(new[] { "Science Fiction" })] - GalacticRepublic = 0xF50C, - - /// - /// The Font Awesome "galactic-senate" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "galactic senate", "star wars" })] - [FontAwesomeCategoriesAttribute(new[] { "Science Fiction" })] - GalacticSenate = 0xF50D, - /// /// The Font Awesome "gamepad" icon unicode character. /// @@ -4749,29 +3620,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Genders" })] Genderless = 0xF22D, - /// - /// The Font Awesome "get-pocket" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "get pocket" })] - GetPocket = 0xF265, - - /// - /// The Font Awesome "gg" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gg" })] - [FontAwesomeCategoriesAttribute(new[] { "Money" })] - Gg = 0xF260, - - /// - /// The Font Awesome "gg-circle" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gg circle" })] - [FontAwesomeCategoriesAttribute(new[] { "Money" })] - GgCircle = 0xF261, - /// /// The Font Awesome "ghost" icon unicode character. /// @@ -4793,69 +3641,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Holidays", "Shopping" })] Gifts = 0xF79C, - /// - /// The Font Awesome "git" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "git" })] - Git = 0xF1D3, - - /// - /// The Font Awesome "git-alt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "git alt" })] - GitAlt = 0xF841, - - /// - /// The Font Awesome "github" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "github", "octocat" })] - Github = 0xF09B, - - /// - /// The Font Awesome "github-alt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "github alt", "octocat" })] - GithubAlt = 0xF113, - - /// - /// The Font Awesome "square-github" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square github", "octocat" })] - GithubSquare = 0xF092, - - /// - /// The Font Awesome "gitkraken" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gitkraken" })] - Gitkraken = 0xF3A6, - - /// - /// The Font Awesome "gitlab" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gitlab", "axosoft" })] - Gitlab = 0xF296, - - /// - /// The Font Awesome "square-git" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square git" })] - GitSquare = 0xF1D2, - - /// - /// The Font Awesome "gitter" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gitter" })] - Gitter = 0xF426, - /// /// The Font Awesome "champagne-glasses" icon unicode character. /// @@ -4905,20 +3690,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage" })] GlassWhiskey = 0xF7A0, - /// - /// The Font Awesome "glide" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "glide" })] - Glide = 0xF2A5, - - /// - /// The Font Awesome "glide-g" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "glide g" })] - GlideG = 0xF2A6, - /// /// The Font Awesome "globe" icon unicode character. /// @@ -4954,13 +3725,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Travel + Hotel" })] GlobeEurope = 0xF7A2, - /// - /// The Font Awesome "gofore" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gofore" })] - Gofore = 0xF3A7, - /// /// The Font Awesome "golf-ball-tee" icon unicode character. /// @@ -4968,70 +3732,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Sports + Fitness" })] GolfBall = 0xF450, - /// - /// The Font Awesome "goodreads" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "goodreads" })] - Goodreads = 0xF3A8, - - /// - /// The Font Awesome "goodreads-g" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "goodreads g" })] - GoodreadsG = 0xF3A9, - - /// - /// The Font Awesome "google" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "google" })] - Google = 0xF1A0, - - /// - /// The Font Awesome "google-drive" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "google drive" })] - GoogleDrive = 0xF3AA, - - /// - /// The Font Awesome "google-play" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "google play" })] - GooglePlay = 0xF3AB, - - /// - /// The Font Awesome "google-plus" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "google plus", "google-plus-circle", "google-plus-official" })] - GooglePlus = 0xF2B3, - - /// - /// The Font Awesome "google-plus-g" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "google plus g", "google-plus", "social network" })] - GooglePlusG = 0xF0D5, - - /// - /// The Font Awesome "square-google-plus" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square google plus", "social network" })] - GooglePlusSquare = 0xF0D4, - - /// - /// The Font Awesome "google-wallet" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "google wallet" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - GoogleWallet = 0xF1EE, - /// /// The Font Awesome "gopuram" icon unicode character. /// @@ -5046,20 +3746,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Clothing + Fashion", "Education", "Maps" })] GraduationCap = 0xF19D, - /// - /// The Font Awesome "gratipay" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gratipay", "favorite", "heart", "like", "love" })] - Gratipay = 0xF184, - - /// - /// The Font Awesome "grav" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "grav" })] - Grav = 0xF2D6, - /// /// The Font Awesome "greater-than" icon unicode character. /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0x3E. @@ -5173,13 +3859,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Emoji" })] GrinWink = 0xF58C, - /// - /// The Font Awesome "gripfire" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gripfire" })] - Gripfire = 0xF3AC, - /// /// The Font Awesome "grip" icon unicode character. /// @@ -5215,13 +3894,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Marketing" })] GroupArrowsRotate = 0xE4F6, - /// - /// The Font Awesome "grunt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "grunt" })] - Grunt = 0xF3AD, - /// /// The Font Awesome "guarani-sign" icon unicode character. /// @@ -5236,13 +3908,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Music + Audio" })] Guitar = 0xF7A6, - /// - /// The Font Awesome "gulp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "gulp" })] - Gulp = 0xF3AE, - /// /// The Font Awesome "gun" icon unicode character. /// @@ -5250,27 +3915,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Security" })] Gun = 0xE19B, - /// - /// The Font Awesome "hacker-news" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hacker news" })] - HackerNews = 0xF1D4, - - /// - /// The Font Awesome "square-hacker-news" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square hacker news" })] - HackerNewsSquare = 0xF3AF, - - /// - /// The Font Awesome "hackerrank" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hackerrank" })] - Hackerrank = 0xF5F7, - /// /// The Font Awesome "burger" icon unicode character. /// @@ -5734,20 +4378,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Animals" })] Hippo = 0xF6ED, - /// - /// The Font Awesome "hips" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hips" })] - Hips = 0xF452, - - /// - /// The Font Awesome "hire-a-helper" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hire a helper" })] - HireAHelper = 0xF3B0, - /// /// The Font Awesome "clock-rotate-left" icon unicode character. /// @@ -5776,20 +4406,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Maps" })] Home = 0xF015, - /// - /// The Font Awesome "hooli" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hooli" })] - Hooli = 0xF427, - - /// - /// The Font Awesome "hornbill" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hornbill" })] - Hornbill = 0xF592, - /// /// The Font Awesome "horse" icon unicode character. /// @@ -5847,13 +4463,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Humanitarian", "Travel + Hotel" })] Hotel = 0xF594, - /// - /// The Font Awesome "hotjar" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hotjar" })] - Hotjar = 0xF3B1, - /// /// The Font Awesome "hot-tub-person" icon unicode character. /// @@ -6043,13 +4652,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Household", "Users + People" })] HouseUser = 0xE1B0, - /// - /// The Font Awesome "houzz" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "houzz" })] - Houzz = 0xF27C, - /// /// The Font Awesome "hryvnia-sign" icon unicode character. /// @@ -6064,20 +4666,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Alphabet", "Maps", "Medical + Health" })] HSquare = 0xF0FD, - /// - /// The Font Awesome "html5" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "html5" })] - Html5 = 0xF13B, - - /// - /// The Font Awesome "hubspot" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "hubspot" })] - Hubspot = 0xF3B2, - /// /// The Font Awesome "hurricane" icon unicode character. /// @@ -6134,13 +4722,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health", "Security", "Users + People" })] IdCardAlt = 0xF47F, - /// - /// The Font Awesome "ideal" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ideal" })] - Ideal = 0xF913, - /// /// The Font Awesome "igloo" icon unicode character. /// @@ -6162,13 +4743,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps", "Photos + Images", "Social" })] Images = 0xF302, - /// - /// The Font Awesome "imdb" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "imdb" })] - Imdb = 0xF2D8, - /// /// The Font Awesome "inbox" icon unicode character. /// @@ -6218,48 +4792,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Accessibility", "Maps" })] InfoCircle = 0xF05A, - /// - /// The Font Awesome "instagram" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "instagram" })] - Instagram = 0xF16D, - - /// - /// The Font Awesome "square-instagram" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square instagram" })] - InstagramSquare = 0xF955, - - /// - /// The Font Awesome "intercom" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "intercom", "app", "customer", "messenger" })] - Intercom = 0xF7AF, - - /// - /// The Font Awesome "internet-explorer" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "internet explorer", "browser", "ie" })] - InternetExplorer = 0xF26B, - - /// - /// The Font Awesome "invision" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "invision", "app", "design", "interface" })] - Invision = 0xF7B0, - - /// - /// The Font Awesome "ioxhost" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ioxhost" })] - Ioxhost = 0xF208, - /// /// The Font Awesome "italic" icon unicode character. /// @@ -6267,27 +4799,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Text Formatting" })] Italic = 0xF033, - /// - /// The Font Awesome "itch-io" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "itch io" })] - ItchIo = 0xF83A, - - /// - /// The Font Awesome "itunes" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "itunes" })] - Itunes = 0xF3B4, - - /// - /// The Font Awesome "itunes-note" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "itunes note" })] - ItunesNote = 0xF3B5, - /// /// The Font Awesome "jar" icon unicode character. /// @@ -6302,13 +4813,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage", "Household", "Humanitarian" })] JarWheat = 0xE517, - /// - /// The Font Awesome "java" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "java" })] - Java = 0xF4E4, - /// /// The Font Awesome "jedi" icon unicode character. /// @@ -6316,21 +4820,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion", "Science Fiction" })] Jedi = 0xF669, - /// - /// The Font Awesome "jedi-order" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "jedi order", "star wars" })] - [FontAwesomeCategoriesAttribute(new[] { "Science Fiction" })] - JediOrder = 0xF50E, - - /// - /// The Font Awesome "jenkins" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "jenkins" })] - Jenkins = 0xF3B6, - /// /// The Font Awesome "jet-fighter-up" icon unicode character. /// @@ -6338,20 +4827,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Logistics", "Transportation" })] JetFighterUp = 0xE518, - /// - /// The Font Awesome "jira" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "jira", "atlassian" })] - Jira = 0xF7B1, - - /// - /// The Font Awesome "joget" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "joget" })] - Joget = 0xF3B7, - /// /// The Font Awesome "joint" icon unicode character. /// @@ -6359,13 +4834,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] Joint = 0xF595, - /// - /// The Font Awesome "joomla" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "joomla" })] - Joomla = 0xF1AA, - /// /// The Font Awesome "book-journal-whills" icon unicode character. /// @@ -6373,27 +4841,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion", "Science Fiction" })] JournalWhills = 0xF66A, - /// - /// The Font Awesome "js" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "js" })] - Js = 0xF3B8, - - /// - /// The Font Awesome "jsfiddle" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "jsfiddle" })] - Jsfiddle = 0xF1CC, - - /// - /// The Font Awesome "square-js" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square js" })] - JsSquare = 0xF3B9, - /// /// The Font Awesome "jug-detergent" icon unicode character. /// @@ -6408,13 +4855,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Religion" })] Kaaba = 0xF66B, - /// - /// The Font Awesome "kaggle" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "kaggle" })] - Kaggle = 0xF5FA, - /// /// The Font Awesome "key" icon unicode character. /// @@ -6422,13 +4862,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps", "Security", "Shopping", "Travel + Hotel" })] Key = 0xF084, - /// - /// The Font Awesome "keybase" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "keybase" })] - Keybase = 0xF4F5, - /// /// The Font Awesome "keyboard" icon unicode character. /// @@ -6436,13 +4869,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Coding", "Devices + Hardware", "Writing" })] Keyboard = 0xF11C, - /// - /// The Font Awesome "keycdn" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "keycdn" })] - Keycdn = 0xF3BA, - /// /// The Font Awesome "khanda" icon unicode character. /// @@ -6450,20 +4876,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion" })] Khanda = 0xF66D, - /// - /// The Font Awesome "kickstarter" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "kickstarter" })] - Kickstarter = 0xF3BB, - - /// - /// The Font Awesome "kickstarter-k" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "kickstarter k" })] - KickstarterK = 0xF3BC, - /// /// The Font Awesome "kip-sign" icon unicode character. /// @@ -6506,13 +4918,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Animals" })] KiwiBird = 0xF535, - /// - /// The Font Awesome "korvue" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "korvue" })] - Korvue = 0xF42F, - /// /// The Font Awesome "landmark" icon unicode character. /// @@ -6576,13 +4981,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] LaptopMedical = 0xF812, - /// - /// The Font Awesome "laravel" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "laravel" })] - Laravel = 0xF3BD, - /// /// The Font Awesome "lari-sign" icon unicode character. /// @@ -6590,20 +4988,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] LariSign = 0xE1C8, - /// - /// The Font Awesome "lastfm" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "lastfm" })] - Lastfm = 0xF202, - - /// - /// The Font Awesome "square-lastfm" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square lastfm" })] - LastfmSquare = 0xF203, - /// /// The Font Awesome "face-laugh" icon unicode character. /// @@ -6646,13 +5030,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Charity", "Energy", "Fruits + Vegetables", "Maps", "Nature" })] Leaf = 0xF06C, - /// - /// The Font Awesome "leanpub" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "leanpub" })] - Leanpub = 0xF212, - /// /// The Font Awesome "lemon" icon unicode character. /// @@ -6660,13 +5037,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage", "Fruits + Vegetables", "Maps" })] Lemon = 0xF094, - /// - /// The Font Awesome "less" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "less" })] - Less = 0xF41D, - /// /// The Font Awesome "less-than" icon unicode character. /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0x3C. @@ -6706,17 +5076,10 @@ public enum FontAwesomeIcon /// /// The Font Awesome "lightbulb" icon unicode character. /// - [FontAwesomeSearchTerms(new[] { "lightbulb", "bulb", "comic", "electric", "energy", "idea", "inspiration", "light", "light bulb" })] + [FontAwesomeSearchTerms(new[] { "lightbulb", " comic", " electric", " idea", " innovation", " inspiration", " light", " light bulb", " bulb", "bulb", "comic", "electric", "energy", "idea", "inspiration", "mechanical" })] [FontAwesomeCategoriesAttribute(new[] { "Energy", "Household", "Maps", "Marketing" })] Lightbulb = 0xF0EB, - /// - /// The Font Awesome "line" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "line" })] - Line = 0xF3C0, - /// /// The Font Awesome "lines-leaning" icon unicode character. /// @@ -6731,34 +5094,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Editing" })] Link = 0xF0C1, - /// - /// The Font Awesome "linkedin" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "linkedin", "linkedin-square" })] - Linkedin = 0xF08C, - - /// - /// The Font Awesome "linkedin-in" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "linkedin in", "linkedin" })] - LinkedinIn = 0xF0E1, - - /// - /// The Font Awesome "linode" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "linode" })] - Linode = 0xF2B8, - - /// - /// The Font Awesome "linux" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "linux", "tux" })] - Linux = 0xF17C, - /// /// The Font Awesome "lira-sign" icon unicode character. /// @@ -6899,20 +5234,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] LungsVirus = 0xE067, - /// - /// The Font Awesome "lyft" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "lyft" })] - Lyft = 0xF3C3, - - /// - /// The Font Awesome "magento" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "magento" })] - Magento = 0xF3C4, - /// /// The Font Awesome "wand-magic" icon unicode character. /// @@ -6937,7 +5258,7 @@ public enum FontAwesomeIcon /// /// The Font Awesome "magnifying-glass-chart" icon unicode character. /// - [FontAwesomeSearchTerms(new[] { "magnifying glass chart", "analysis", "chart" })] + [FontAwesomeSearchTerms(new[] { "magnifying glass chart", " data", " graph", " intelligence", "analysis", "chart", "market" })] [FontAwesomeCategoriesAttribute(new[] { "Business", "Humanitarian", "Marketing" })] MagnifyingGlassChart = 0xE522, @@ -6948,13 +5269,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Marketing" })] MailBulk = 0xF674, - /// - /// The Font Awesome "mailchimp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mailchimp" })] - Mailchimp = 0xF59E, - /// /// The Font Awesome "person" icon unicode character. /// @@ -6969,13 +5283,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] ManatSign = 0xE1D5, - /// - /// The Font Awesome "mandalorian" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mandalorian" })] - Mandalorian = 0xF50F, - /// /// The Font Awesome "map" icon unicode character. /// @@ -7025,13 +5332,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Camping", "Maps", "Nature" })] MapSigns = 0xF277, - /// - /// The Font Awesome "markdown" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "markdown" })] - Markdown = 0xF60F, - /// /// The Font Awesome "marker" icon unicode character. /// @@ -7102,13 +5402,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Medical + Health" })] MaskVentilator = 0xE524, - /// - /// The Font Awesome "mastodon" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mastodon" })] - Mastodon = 0xF4F6, - /// /// The Font Awesome "mattress-pillow" icon unicode character. /// @@ -7116,20 +5409,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Camping", "Household", "Humanitarian" })] MattressPillow = 0xE525, - /// - /// The Font Awesome "maxcdn" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "maxcdn" })] - Maxcdn = 0xF136, - - /// - /// The Font Awesome "mdb" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mdb" })] - Mdb = 0xF8CA, - /// /// The Font Awesome "medal" icon unicode character. /// @@ -7137,28 +5416,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Sports + Fitness" })] Medal = 0xF5A2, - /// - /// The Font Awesome "medapps" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "medapps" })] - Medapps = 0xF3C6, - - /// - /// The Font Awesome "medium" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "medium" })] - Medium = 0xF23A, - - /// - /// The Font Awesome "medium" icon unicode character. - /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0xF23A. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "medium" })] - MediumM = 0xF3C7, - /// /// The Font Awesome "suitcase-medical" icon unicode character. /// @@ -7166,27 +5423,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Maps", "Medical + Health" })] Medkit = 0xF0FA, - /// - /// The Font Awesome "medrt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "medrt" })] - Medrt = 0xF3C8, - - /// - /// The Font Awesome "meetup" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "meetup" })] - Meetup = 0xF2E0, - - /// - /// The Font Awesome "megaport" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "megaport" })] - Megaport = 0xF5A3, - /// /// The Font Awesome "face-meh" icon unicode character. /// @@ -7215,13 +5451,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Devices + Hardware" })] Memory = 0xF538, - /// - /// The Font Awesome "mendeley" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mendeley" })] - Mendeley = 0xF7B3, - /// /// The Font Awesome "menorah" icon unicode character. /// @@ -7243,12 +5472,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Astronomy", "Weather" })] Meteor = 0xF753, - /// - /// The Font Awesome "microblog" icon unicode character. - /// - [Obsolete] - Microblog = 0xF91A, - /// /// The Font Awesome "microchip" icon unicode character. /// @@ -7291,13 +5514,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Education", "Humanitarian", "Medical + Health", "Science" })] Microscope = 0xF610, - /// - /// The Font Awesome "microsoft" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "microsoft" })] - Microsoft = 0xF3CA, - /// /// The Font Awesome "mill-sign" icon unicode character. /// @@ -7333,34 +5549,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Childhood", "Clothing + Fashion" })] Mitten = 0xF7B5, - /// - /// The Font Awesome "mix" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mix" })] - Mix = 0xF3CB, - - /// - /// The Font Awesome "mixcloud" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mixcloud" })] - Mixcloud = 0xF289, - - /// - /// The Font Awesome "mixer" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mixer" })] - Mixer = 0xF956, - - /// - /// The Font Awesome "mizuni" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "mizuni" })] - Mizuni = 0xF3CC, - /// /// The Font Awesome "mobile" icon unicode character. /// @@ -7396,20 +5584,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Communication", "Devices + Hardware", "Humanitarian" })] MobileScreen = 0xF3CF, - /// - /// The Font Awesome "modx" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "modx" })] - Modx = 0xF285, - - /// - /// The Font Awesome "monero" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "monero" })] - Monero = 0xF3D0, - /// /// The Font Awesome "money-bill" icon unicode character. /// @@ -7484,7 +5658,7 @@ public enum FontAwesomeIcon /// The Font Awesome "monument" icon unicode character. /// [FontAwesomeSearchTerms(new[] { "monument", "building", "historic", "landmark", "memorable" })] - [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Travel + Hotel" })] + [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Maps", "Travel + Hotel" })] Monument = 0xF5A6, /// @@ -7592,21 +5766,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] NairaSign = 0xE1F6, - /// - /// The Font Awesome "napster" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "napster" })] - [FontAwesomeCategoriesAttribute(new[] { "Music + Audio" })] - Napster = 0xF3D2, - - /// - /// The Font Awesome "neos" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "neos" })] - Neos = 0xF612, - /// /// The Font Awesome "network-wired" icon unicode character. /// @@ -7628,27 +5787,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps", "Writing" })] Newspaper = 0xF1EA, - /// - /// The Font Awesome "nimblr" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "nimblr" })] - Nimblr = 0xF5A8, - - /// - /// The Font Awesome "node" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "node" })] - Node = 0xF419, - - /// - /// The Font Awesome "node-js" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "node js" })] - NodeJs = 0xF3D3, - /// /// The Font Awesome "notdef" icon unicode character. /// @@ -7670,27 +5808,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] NotesMedical = 0xF481, - /// - /// The Font Awesome "npm" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "npm" })] - Npm = 0xF3D4, - - /// - /// The Font Awesome "ns8" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ns8" })] - Ns8 = 0xF3D5, - - /// - /// The Font Awesome "nutritionix" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "nutritionix" })] - Nutritionix = 0xF3D6, - /// /// The Font Awesome "object-group" icon unicode character. /// @@ -7705,20 +5822,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Design" })] ObjectUngroup = 0xF248, - /// - /// The Font Awesome "odnoklassniki" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "odnoklassniki" })] - Odnoklassniki = 0xF263, - - /// - /// The Font Awesome "square-odnoklassniki" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square odnoklassniki" })] - OdnoklassnikiSquare = 0xF264, - /// /// The Font Awesome "oil-can" icon unicode character. /// @@ -7733,14 +5836,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Energy", "Humanitarian" })] OilWell = 0xE532, - /// - /// The Font Awesome "old-republic" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "old republic", "politics", "star wars" })] - [FontAwesomeCategoriesAttribute(new[] { "Science Fiction" })] - OldRepublic = 0xF510, - /// /// The Font Awesome "om" icon unicode character. /// @@ -7748,48 +5843,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion" })] Om = 0xF679, - /// - /// The Font Awesome "opencart" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "opencart" })] - Opencart = 0xF23D, - - /// - /// The Font Awesome "openid" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "openid" })] - Openid = 0xF19B, - - /// - /// The Font Awesome "opera" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "opera" })] - Opera = 0xF26A, - - /// - /// The Font Awesome "optin-monster" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "optin monster" })] - OptinMonster = 0xF23C, - - /// - /// The Font Awesome "orcid" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "orcid" })] - Orcid = 0xF8D2, - - /// - /// The Font Awesome "osi" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "osi" })] - Osi = 0xF41A, - /// /// The Font Awesome "otter" icon unicode character. /// @@ -7804,20 +5857,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Text Formatting" })] Outdent = 0xF03B, - /// - /// The Font Awesome "page4" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "page4" })] - Page4 = 0xF3D7, - - /// - /// The Font Awesome "pagelines" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pagelines", "eco", "flora", "leaf", "leaves", "nature", "plant", "tree" })] - Pagelines = 0xF18C, - /// /// The Font Awesome "pager" icon unicode character. /// @@ -7846,13 +5885,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Design", "Spinners" })] Palette = 0xF53F, - /// - /// The Font Awesome "palfed" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "palfed" })] - Palfed = 0xF3D8, - /// /// The Font Awesome "pallet" icon unicode character. /// @@ -7923,13 +5955,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Design", "Files" })] Paste = 0xF0EA, - /// - /// The Font Awesome "patreon" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "patreon" })] - Patreon = 0xF3D9, - /// /// The Font Awesome "pause" icon unicode character. /// @@ -7951,14 +5976,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Animals", "Maps" })] Paw = 0xF1B0, - /// - /// The Font Awesome "paypal" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "paypal" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - Paypal = 0xF1ED, - /// /// The Font Awesome "peace" icon unicode character. /// @@ -8008,12 +6025,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Design", "Editing" })] PenNib = 0xF5AD, - /// - /// The Font Awesome "pennyarcade" icon unicode character. - /// - [Obsolete] - PennyArcade = 0xF704, - /// /// The Font Awesome "square-pen" icon unicode character. /// @@ -8093,13 +6104,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Mathematics", "Money", "Punctuation + Symbols" })] Percentage = 0xF541, - /// - /// The Font Awesome "periscope" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "periscope" })] - Periscope = 0xF3DA, - /// /// The Font Awesome "person-arrow-down-to-line" icon unicode character. /// @@ -8338,27 +6342,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] PesoSign = 0xE222, - /// - /// The Font Awesome "phabricator" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "phabricator" })] - Phabricator = 0xF3DB, - - /// - /// The Font Awesome "phoenix-framework" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "phoenix framework" })] - PhoenixFramework = 0xF3DC, - - /// - /// The Font Awesome "phoenix-squadron" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "phoenix squadron" })] - PhoenixSquadron = 0xF511, - /// /// The Font Awesome "phone" icon unicode character. /// @@ -8408,47 +6391,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Files", "Film + Video", "Photos + Images", "Social" })] PhotoVideo = 0xF87C, - /// - /// The Font Awesome "php" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "php" })] - Php = 0xF457, - - /// - /// The Font Awesome "pied-piper" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pied piper" })] - PiedPiper = 0xF2AE, - - /// - /// The Font Awesome "pied-piper-alt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pied piper alt" })] - PiedPiperAlt = 0xF1A8, - - /// - /// The Font Awesome "pied-piper-hat" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pied piper hat", "clothing" })] - PiedPiperHat = 0xF4E5, - - /// - /// The Font Awesome "pied-piper-pp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pied piper pp" })] - PiedPiperPp = 0xF1A7, - - /// - /// The Font Awesome "piedpipersquare" icon unicode character. - /// - [Obsolete] - PiedPiperSquare = 0xF91E, - /// /// The Font Awesome "piggy-bank" icon unicode character. /// @@ -8463,27 +6405,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Medical + Health", "Science" })] Pills = 0xF484, - /// - /// The Font Awesome "pinterest" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pinterest" })] - Pinterest = 0xF0D2, - - /// - /// The Font Awesome "pinterest-p" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pinterest p" })] - PinterestP = 0xF231, - - /// - /// The Font Awesome "square-pinterest" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square pinterest" })] - PinterestSquare = 0xF0D3, - /// /// The Font Awesome "pizza-slice" icon unicode character. /// @@ -8589,14 +6510,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Media Playback" })] PlayCircle = 0xF144, - /// - /// The Font Awesome "playstation" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "playstation" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - Playstation = 0xF3DF, - /// /// The Font Awesome "plug" icon unicode character. /// @@ -8787,13 +6700,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] Procedures = 0xF487, - /// - /// The Font Awesome "product-hunt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "product hunt" })] - ProductHunt = 0xF288, - /// /// The Font Awesome "diagram-project" icon unicode character. /// @@ -8815,13 +6721,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Household", "Humanitarian" })] PumpSoap = 0xE06B, - /// - /// The Font Awesome "pushed" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "pushed" })] - Pushed = 0xF3E1, - /// /// The Font Awesome "puzzle-piece" icon unicode character. /// @@ -8829,20 +6728,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Childhood", "Gaming" })] PuzzlePiece = 0xF12E, - /// - /// The Font Awesome "python" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "python" })] - Python = 0xF3E2, - - /// - /// The Font Awesome "qq" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "qq" })] - Qq = 0xF1D6, - /// /// The Font Awesome "qrcode" icon unicode character. /// @@ -8872,20 +6757,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Sports + Fitness" })] Quidditch = 0xF458, - /// - /// The Font Awesome "quinscape" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "quinscape" })] - Quinscape = 0xF459, - - /// - /// The Font Awesome "quora" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "quora" })] - Quora = 0xF2C4, - /// /// The Font Awesome "quote-left" icon unicode character. /// @@ -8949,48 +6820,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Marketing", "Sports + Fitness" })] RankingStar = 0xE561, - /// - /// The Font Awesome "raspberry-pi" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "raspberry pi" })] - RaspberryPi = 0xF7BB, - - /// - /// The Font Awesome "ravelry" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ravelry" })] - Ravelry = 0xF2D9, - - /// - /// The Font Awesome "react" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "react" })] - React = 0xF41B, - - /// - /// The Font Awesome "reacteurope" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "reacteurope" })] - Reacteurope = 0xF75D, - - /// - /// The Font Awesome "readme" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "readme" })] - Readme = 0xF4D5, - - /// - /// The Font Awesome "rebel" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "rebel" })] - Rebel = 0xF1D0, - /// /// The Font Awesome "receipt" icon unicode character. /// @@ -9012,34 +6841,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Maps" })] Recycle = 0xF1B8, - /// - /// The Font Awesome "reddit" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "reddit" })] - Reddit = 0xF1A1, - - /// - /// The Font Awesome "reddit-alien" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "reddit alien" })] - RedditAlien = 0xF281, - - /// - /// The Font Awesome "square-reddit" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square reddit" })] - RedditSquare = 0xF1A2, - - /// - /// The Font Awesome "redhat" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "redhat", "linux", "operating system", "os" })] - Redhat = 0xF7BC, - /// /// The Font Awesome "arrow-rotate-right" icon unicode character. /// @@ -9054,13 +6855,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Media Playback" })] RedoAlt = 0xF2F9, - /// - /// The Font Awesome "red-river" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "red river" })] - RedRiver = 0xF3E3, - /// /// The Font Awesome "registered" icon unicode character. /// @@ -9075,13 +6869,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Text Formatting" })] RemoveFormat = 0xF87D, - /// - /// The Font Awesome "renren" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "renren" })] - Renren = 0xF18B, - /// /// The Font Awesome "repeat" icon unicode character. /// @@ -9103,13 +6890,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows" })] ReplyAll = 0xF122, - /// - /// The Font Awesome "replyd" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "replyd" })] - Replyd = 0xF3E6, - /// /// The Font Awesome "republican" icon unicode character. /// @@ -9117,20 +6897,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Political" })] Republican = 0xF75E, - /// - /// The Font Awesome "researchgate" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "researchgate" })] - Researchgate = 0xF4F8, - - /// - /// The Font Awesome "resolving" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "resolving" })] - Resolving = 0xF3E7, - /// /// The Font Awesome "restroom" icon unicode character. /// @@ -9145,13 +6911,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Social" })] Retweet = 0xF079, - /// - /// The Font Awesome "rev" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "rev" })] - Rev = 0xF5B2, - /// /// The Font Awesome "ribbon" icon unicode character. /// @@ -9236,20 +6995,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps", "Science Fiction", "Transportation" })] Rocket = 0xF135, - /// - /// The Font Awesome "rocketchat" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "rocketchat" })] - Rocketchat = 0xF3E8, - - /// - /// The Font Awesome "rockrms" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "rockrms" })] - Rockrms = 0xF3E9, - /// /// The Font Awesome "route" icon unicode character. /// @@ -9257,13 +7002,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Camping", "Maps", "Moving" })] Route = 0xF4D7, - /// - /// The Font Awesome "r-project" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "r project" })] - RProject = 0xF4F7, - /// /// The Font Awesome "rss" icon unicode character. /// @@ -9369,13 +7107,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Emoji" })] SadTear = 0xF5B4, - /// - /// The Font Awesome "safari" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "safari", "browser" })] - Safari = 0xF267, - /// /// The Font Awesome "sailboat" icon unicode character. /// @@ -9383,20 +7114,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Logistics", "Maritime", "Transportation" })] Sailboat = 0xE445, - /// - /// The Font Awesome "salesforce" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "salesforce" })] - Salesforce = 0xF83B, - - /// - /// The Font Awesome "sass" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sass" })] - Sass = 0xF41E, - /// /// The Font Awesome "satellite" icon unicode character. /// @@ -9418,13 +7135,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Design", "Devices + Hardware", "Files" })] Save = 0xF0C7, - /// - /// The Font Awesome "schlix" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "schlix" })] - Schlix = 0xF3EA, - /// /// The Font Awesome "school" icon unicode character. /// @@ -9474,13 +7184,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Construction" })] Screwdriver = 0xF54A, - /// - /// The Font Awesome "scribd" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "scribd" })] - Scribd = 0xF28A, - /// /// The Font Awesome "scroll" icon unicode character. /// @@ -9509,13 +7212,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Marketing" })] SearchDollar = 0xF688, - /// - /// The Font Awesome "searchengin" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "searchengin" })] - Searchengin = 0xF3EB, - /// /// The Font Awesome "magnifying-glass-location" icon unicode character. /// @@ -9551,20 +7247,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Charity", "Energy", "Food + Beverage", "Fruits + Vegetables", "Humanitarian", "Nature", "Science" })] Seedling = 0xF4D8, - /// - /// The Font Awesome "sellcast" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sellcast", "eercast" })] - Sellcast = 0xF2DA, - - /// - /// The Font Awesome "sellsy" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sellsy" })] - Sellsy = 0xF213, - /// /// The Font Awesome "server" icon unicode character. /// @@ -9572,13 +7254,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Devices + Hardware" })] Server = 0xF233, - /// - /// The Font Awesome "servicestack" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "servicestack" })] - Servicestack = 0xF3EC, - /// /// The Font Awesome "shapes" icon unicode character. /// @@ -9684,13 +7359,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Logistics", "Shopping" })] ShippingFast = 0xF48B, - /// - /// The Font Awesome "shirtsinbulk" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "shirtsinbulk" })] - Shirtsinbulk = 0xF214, - /// /// The Font Awesome "shoe-prints" icon unicode character. /// @@ -9698,13 +7366,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Clothing + Fashion", "Maps", "Sports + Fitness" })] ShoePrints = 0xF54B, - /// - /// The Font Awesome "shopify" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "shopify" })] - Shopify = 0xF957, - /// /// The Font Awesome "shop-lock" icon unicode character. /// @@ -9740,13 +7401,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] ShopSlash = 0xE070, - /// - /// The Font Awesome "shopware" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "shopware" })] - Shopware = 0xF5B5, - /// /// The Font Awesome "shower" icon unicode character. /// @@ -9817,13 +7471,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Devices + Hardware" })] SimCard = 0xF7C4, - /// - /// The Font Awesome "simplybuilt" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "simplybuilt" })] - Simplybuilt = 0xF215, - /// /// The Font Awesome "sink" icon unicode character. /// @@ -9831,13 +7478,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Household" })] Sink = 0xE06D, - /// - /// The Font Awesome "sistrix" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sistrix" })] - Sistrix = 0xF3EE, - /// /// The Font Awesome "sitemap" icon unicode character. /// @@ -9845,13 +7485,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Business", "Coding" })] Sitemap = 0xF0E8, - /// - /// The Font Awesome "sith" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sith" })] - Sith = 0xF512, - /// /// The Font Awesome "person-skating" icon unicode character. /// @@ -9859,13 +7492,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Sports + Fitness", "Users + People" })] Skating = 0xF7C5, - /// - /// The Font Awesome "sketch" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sketch", "app", "design", "interface" })] - Sketch = 0xF7C6, - /// /// The Font Awesome "person-skiing" icon unicode character. /// @@ -9894,35 +7520,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Alert", "Gaming", "Halloween", "Humanitarian", "Medical + Health", "Science", "Security" })] SkullCrossbones = 0xF714, - /// - /// The Font Awesome "skyatlas" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "skyatlas" })] - Skyatlas = 0xF216, - - /// - /// The Font Awesome "skype" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "skype" })] - Skype = 0xF17E, - - /// - /// The Font Awesome "slack" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "slack", "anchor", "hash", "hashtag" })] - Slack = 0xF198, - - /// - /// The Font Awesome "slack" icon unicode character. - /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0xF198. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "slack", "anchor", "hash", "hashtag" })] - SlackHash = 0xF3EF, - /// /// The Font Awesome "slash" icon unicode character. /// @@ -9944,13 +7541,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Editing", "Media Playback", "Music + Audio", "Photos + Images" })] SlidersH = 0xF1DE, - /// - /// The Font Awesome "slideshare" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "slideshare" })] - Slideshare = 0xF1E7, - /// /// The Font Awesome "face-smile" icon unicode character. /// @@ -10000,28 +7590,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Communication" })] Sms = 0xF7CD, - /// - /// The Font Awesome "snapchat" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "snapchat" })] - Snapchat = 0xF2AB, - - /// - /// The Font Awesome "snapchat" icon unicode character. - /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0xF2AB. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "snapchat" })] - SnapchatGhost = 0xF2AC, - - /// - /// The Font Awesome "square-snapchat" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square snapchat" })] - SnapchatSquare = 0xF2AD, - /// /// The Font Awesome "person-snowboarding" icon unicode character. /// @@ -10176,21 +7744,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows" })] SortUp = 0xF0DE, - /// - /// The Font Awesome "soundcloud" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "soundcloud" })] - [FontAwesomeCategoriesAttribute(new[] { "Music + Audio" })] - Soundcloud = 0xF1BE, - - /// - /// The Font Awesome "sourcetree" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sourcetree" })] - Sourcetree = 0xF7D3, - /// /// The Font Awesome "spa" icon unicode character. /// @@ -10205,20 +7758,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Astronomy", "Transportation" })] SpaceShuttle = 0xF197, - /// - /// The Font Awesome "speakap" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "speakap" })] - Speakap = 0xF3F3, - - /// - /// The Font Awesome "speaker-deck" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "speaker deck" })] - SpeakerDeck = 0xF83C, - /// /// The Font Awesome "spell-check" icon unicode character. /// @@ -10247,14 +7786,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Design" })] Splotch = 0xF5BC, - /// - /// The Font Awesome "spotify" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "spotify" })] - [FontAwesomeCategoriesAttribute(new[] { "Music + Audio" })] - Spotify = 0xF1BC, - /// /// The Font Awesome "spray-can" icon unicode character. /// @@ -10304,13 +7835,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Mathematics" })] SquareRootAlt = 0xF698, - /// - /// The Font Awesome "squarespace" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "squarespace" })] - Squarespace = 0xF5BE, - /// /// The Font Awesome "square-virus" icon unicode character. /// @@ -10325,27 +7849,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Mathematics" })] SquareXmark = 0xF2D3, - /// - /// The Font Awesome "stack-exchange" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "stack exchange" })] - StackExchange = 0xF18D, - - /// - /// The Font Awesome "stack-overflow" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "stack overflow" })] - StackOverflow = 0xF16C, - - /// - /// The Font Awesome "stackpath" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "stackpath" })] - Stackpath = 0xF842, - /// /// The Font Awesome "staff-snake" icon unicode character. /// @@ -10416,37 +7919,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] StarOfLife = 0xF621, - /// - /// The Font Awesome "staylinked" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "staylinked" })] - Staylinked = 0xF3F5, - - /// - /// The Font Awesome "steam" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "steam" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - Steam = 0xF1B6, - - /// - /// The Font Awesome "square-steam" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square steam" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - SteamSquare = 0xF1B7, - - /// - /// The Font Awesome "steam-symbol" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "steam symbol" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - SteamSymbol = 0xF3F6, - /// /// The Font Awesome "backward-step" icon unicode character. /// @@ -10468,13 +7940,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Medical + Health" })] Stethoscope = 0xF0F1, - /// - /// The Font Awesome "sticker-mule" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "sticker mule" })] - StickerMule = 0xF3F7, - /// /// The Font Awesome "note-sticky" icon unicode character. /// @@ -10531,13 +7996,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] StoreSlash = 0xE071, - /// - /// The Font Awesome "strava" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "strava" })] - Strava = 0xF428, - /// /// The Font Awesome "bars-staggered" icon unicode character. /// @@ -10559,22 +8017,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Text Formatting" })] Strikethrough = 0xF0CC, - /// - /// The Font Awesome "stripe" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "stripe" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - Stripe = 0xF429, - - /// - /// The Font Awesome "stripe-s" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "stripe s" })] - [FontAwesomeCategoriesAttribute(new[] { "Shopping" })] - StripeS = 0xF42A, - /// /// The Font Awesome "stroopwafel" icon unicode character. /// @@ -10582,27 +8024,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage", "Spinners" })] Stroopwafel = 0xF551, - /// - /// The Font Awesome "studiovinari" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "studiovinari" })] - Studiovinari = 0xF3F8, - - /// - /// The Font Awesome "stumbleupon" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "stumbleupon" })] - Stumbleupon = 0xF1A4, - - /// - /// The Font Awesome "stumbleupon-circle" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "stumbleupon circle" })] - StumbleuponCircle = 0xF1A3, - /// /// The Font Awesome "subscript" icon unicode character. /// @@ -10645,13 +8066,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Disaster + Crisis", "Humanitarian", "Weather" })] SunPlantWilt = 0xE57A, - /// - /// The Font Awesome "superpowers" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "superpowers" })] - Superpowers = 0xF2DD, - /// /// The Font Awesome "superscript" icon unicode character. /// @@ -10659,13 +8073,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Mathematics", "Text Formatting" })] Superscript = 0xF12B, - /// - /// The Font Awesome "supple" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "supple" })] - Supple = 0xF3F9, - /// /// The Font Awesome "face-surprise" icon unicode character. /// @@ -10673,13 +8080,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Emoji" })] Surprise = 0xF5C2, - /// - /// The Font Awesome "suse" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "suse", "linux", "operating system", "os" })] - Suse = 0xF7D6, - /// /// The Font Awesome "swatchbook" icon unicode character. /// @@ -10687,13 +8087,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Design" })] Swatchbook = 0xF5C3, - /// - /// The Font Awesome "swift" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "swift" })] - Swift = 0xF8E1, - /// /// The Font Awesome "person-swimming" icon unicode character. /// @@ -10708,13 +8101,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Travel + Hotel" })] SwimmingPool = 0xF5C5, - /// - /// The Font Awesome "symfony" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "symfony" })] - Symfony = 0xF83D, - /// /// The Font Awesome "synagogue" icon unicode character. /// @@ -10842,13 +8228,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Automotive", "Maps", "Transportation", "Travel + Hotel" })] Taxi = 0xF1BA, - /// - /// The Font Awesome "teamspeak" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "teamspeak" })] - Teamspeak = 0xF4F9, - /// /// The Font Awesome "teeth" icon unicode character. /// @@ -10863,21 +8242,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] TeethOpen = 0xF62F, - /// - /// The Font Awesome "telegram" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "telegram" })] - Telegram = 0xF2C6, - - /// - /// The Font Awesome "telegram" icon unicode character. - /// Uses a legacy unicode value for backwards compatability. The current unicode value is 0xF2C6. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "telegram" })] - TelegramPlane = 0xF3FE, - /// /// The Font Awesome "temperature-arrow-down" icon unicode character. /// @@ -10906,13 +8270,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Science", "Weather" })] TemperatureLow = 0xF76B, - /// - /// The Font Awesome "tencent-weibo" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "tencent weibo" })] - TencentWeibo = 0xF1D5, - /// /// The Font Awesome "tenge-sign" icon unicode character. /// @@ -10997,27 +8354,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Education" })] TheaterMasks = 0xF630, - /// - /// The Font Awesome "themeco" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "themeco" })] - Themeco = 0xF5C6, - - /// - /// The Font Awesome "themeisle" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "themeisle" })] - Themeisle = 0xF2B2, - - /// - /// The Font Awesome "the-red-yeti" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "the red yeti" })] - TheRedYeti = 0xF69D, - /// /// The Font Awesome "thermometer" icon unicode character. /// @@ -11060,13 +8396,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Weather" })] ThermometerThreeQuarters = 0xF2C8, - /// - /// The Font Awesome "think-peaks" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "think peaks" })] - ThinkPeaks = 0xF731, - /// /// The Font Awesome "table-cells-large" icon unicode character. /// @@ -11270,13 +8599,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Transportation" })] Tractor = 0xF722, - /// - /// The Font Awesome "trade-federation" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "trade federation" })] - TradeFederation = 0xF513, - /// /// The Font Awesome "trademark" icon unicode character. /// @@ -11291,14 +8613,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Maps" })] TrafficLight = 0xF637, - /// - /// The Font Awesome "trailer" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "trailer", "carry", "haul", "moving", "travel" })] - [FontAwesomeCategoriesAttribute(new[] { "Automotive", "Camping", "Moving" })] - Trailer = 0xF941, - /// /// The Font Awesome "train" icon unicode character. /// @@ -11376,19 +8690,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Humanitarian", "Travel + Hotel" })] TreeCity = 0xE587, - /// - /// The Font Awesome "trello" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "trello", "atlassian" })] - Trello = 0xF181, - - /// - /// The Font Awesome "tripadvisor" icon unicode character. - /// - [Obsolete] - Tripadvisor = 0xF262, - /// /// The Font Awesome "trophy" icon unicode character. /// @@ -11501,20 +8802,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Accessibility", "Communication", "Maps" })] Tty = 0xF1E4, - /// - /// The Font Awesome "tumblr" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "tumblr" })] - Tumblr = 0xF173, - - /// - /// The Font Awesome "square-tumblr" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square tumblr" })] - TumblrSquare = 0xF174, - /// /// The Font Awesome "turkish-lira-sign" icon unicode character. /// @@ -11529,63 +8816,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Devices + Hardware", "Film + Video", "Household", "Travel + Hotel" })] Tv = 0xF26C, - /// - /// The Font Awesome "twitch" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "twitch" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - Twitch = 0xF1E8, - - /// - /// The Font Awesome "twitter" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "twitter", "social network", "tweet" })] - Twitter = 0xF099, - - /// - /// The Font Awesome "square-twitter" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square twitter", "social network", "tweet" })] - TwitterSquare = 0xF081, - - /// - /// The Font Awesome "typo3" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "typo3" })] - Typo3 = 0xF42B, - - /// - /// The Font Awesome "uber" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "uber" })] - Uber = 0xF402, - - /// - /// The Font Awesome "ubuntu" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ubuntu", "linux", "operating system", "os" })] - Ubuntu = 0xF7DF, - - /// - /// The Font Awesome "uikit" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "uikit" })] - Uikit = 0xF403, - - /// - /// The Font Awesome "umbraco" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "umbraco" })] - Umbraco = 0xF8E8, - /// /// The Font Awesome "umbrella" icon unicode character. /// @@ -11621,19 +8851,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Media Playback" })] UndoAlt = 0xF2EA, - /// - /// The Font Awesome "uniregistry" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "uniregistry" })] - Uniregistry = 0xF404, - - /// - /// The Font Awesome "unity" icon unicode character. - /// - [Obsolete] - Unity = 0xF949, - /// /// The Font Awesome "universal-access" icon unicode character. /// @@ -11669,13 +8886,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Security" })] UnlockAlt = 0xF13E, - /// - /// The Font Awesome "untappd" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "untappd" })] - Untappd = 0xF405, - /// /// The Font Awesome "upload" icon unicode character. /// @@ -11683,20 +8893,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Arrows", "Devices + Hardware" })] Upload = 0xF093, - /// - /// The Font Awesome "ups" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ups", "united parcel service", "package", "shipping" })] - Ups = 0xF7E0, - - /// - /// The Font Awesome "usb" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "usb" })] - Usb = 0xF287, - /// /// The Font Awesome "user" icon unicode character. /// @@ -11921,20 +9117,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Users + People" })] UserTimes = 0xF235, - /// - /// The Font Awesome "usps" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "usps", "american", "package", "shipping", "usa" })] - Usps = 0xF7E1, - - /// - /// The Font Awesome "ussunnah" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "ussunnah" })] - Ussunnah = 0xF407, - /// /// The Font Awesome "utensils" icon unicode character. /// @@ -11949,13 +9131,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Household", "Maps" })] UtensilSpoon = 0xF2E5, - /// - /// The Font Awesome "vaadin" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "vaadin" })] - Vaadin = 0xF408, - /// /// The Font Awesome "vault" icon unicode character. /// @@ -12005,27 +9180,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Clothing + Fashion", "Maps" })] VestPatches = 0xE086, - /// - /// The Font Awesome "viacoin" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "viacoin" })] - Viacoin = 0xF237, - - /// - /// The Font Awesome "viadeo" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "viadeo" })] - Viadeo = 0xF2A9, - - /// - /// The Font Awesome "square-viadeo" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square viadeo" })] - ViadeoSquare = 0xF2AA, - /// /// The Font Awesome "vial" icon unicode character. /// @@ -12054,13 +9208,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Humanitarian", "Medical + Health", "Science" })] VialVirus = 0xE597, - /// - /// The Font Awesome "viber" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "viber" })] - Viber = 0xF409, - /// /// The Font Awesome "video" icon unicode character. /// @@ -12082,34 +9229,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Buildings", "Humanitarian", "Religion" })] Vihara = 0xF6A7, - /// - /// The Font Awesome "vimeo" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "vimeo" })] - Vimeo = 0xF40A, - - /// - /// The Font Awesome "square-vimeo" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square vimeo" })] - VimeoSquare = 0xF194, - - /// - /// The Font Awesome "vimeo-v" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "vimeo v", "vimeo" })] - VimeoV = 0xF27D, - - /// - /// The Font Awesome "vine" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "vine" })] - Vine = 0xF1CA, - /// /// The Font Awesome "virus" icon unicode character. /// @@ -12145,20 +9264,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] VirusSlash = 0xE075, - /// - /// The Font Awesome "vk" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "vk" })] - Vk = 0xF189, - - /// - /// The Font Awesome "vnv" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "vnv" })] - Vnv = 0xF40B, - /// /// The Font Awesome "voicemail" icon unicode character. /// @@ -12222,13 +9327,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] VrCardboard = 0xF729, - /// - /// The Font Awesome "vuejs" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "vuejs" })] - Vuejs = 0xF41F, - /// /// The Font Awesome "walkie-talkie" icon unicode character. /// @@ -12285,27 +9383,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Mathematics" })] WaveSquare = 0xF83E, - /// - /// The Font Awesome "waze" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "waze" })] - Waze = 0xF83F, - - /// - /// The Font Awesome "weebly" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "weebly" })] - Weebly = 0xF5CC, - - /// - /// The Font Awesome "weibo" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "weibo" })] - Weibo = 0xF18A, - /// /// The Font Awesome "weight-scale" icon unicode character. /// @@ -12320,27 +9397,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Sports + Fitness" })] WeightHanging = 0xF5CD, - /// - /// The Font Awesome "weixin" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "weixin" })] - Weixin = 0xF1D7, - - /// - /// The Font Awesome "whatsapp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "whatsapp" })] - Whatsapp = 0xF232, - - /// - /// The Font Awesome "square-whatsapp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square whatsapp" })] - WhatsappSquare = 0xF40C, - /// /// The Font Awesome "wheat-awn" icon unicode character. /// @@ -12369,13 +9425,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Accessibility", "Humanitarian", "Maps", "Medical + Health", "Transportation", "Travel + Hotel", "Users + People" })] WheelchairMove = 0xE2CE, - /// - /// The Font Awesome "whmcs" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "whmcs" })] - Whmcs = 0xF40D, - /// /// The Font Awesome "wifi" icon unicode character. /// @@ -12383,13 +9432,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Connectivity", "Humanitarian", "Maps", "Toggle", "Travel + Hotel" })] Wifi = 0xF1EB, - /// - /// The Font Awesome "wikipedia-w" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wikipedia w" })] - WikipediaW = 0xF266, - /// /// The Font Awesome "wind" icon unicode character. /// @@ -12425,13 +9467,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Coding" })] WindowRestore = 0xF2D2, - /// - /// The Font Awesome "windows" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "windows", "microsoft", "operating system", "os" })] - Windows = 0xF17A, - /// /// The Font Awesome "wine-bottle" icon unicode character. /// @@ -12453,28 +9488,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Food + Beverage", "Travel + Hotel" })] WineGlassAlt = 0xF5CE, - /// - /// The Font Awesome "wix" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wix" })] - Wix = 0xF5CF, - - /// - /// The Font Awesome "wizards-of-the-coast" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wizards of the coast", "dungeons & dragons", "d&d", "dnd", "fantasy", "game", "gaming", "tabletop" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - WizardsOfTheCoast = 0xF730, - - /// - /// The Font Awesome "wolf-pack-battalion" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wolf pack battalion" })] - WolfPackBattalion = 0xF514, - /// /// The Font Awesome "won-sign" icon unicode character. /// @@ -12482,20 +9495,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Money" })] WonSign = 0xF159, - /// - /// The Font Awesome "wordpress" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wordpress" })] - Wordpress = 0xF19A, - - /// - /// The Font Awesome "wordpress-simple" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wordpress simple" })] - WordpressSimple = 0xF411, - /// /// The Font Awesome "worm" icon unicode character. /// @@ -12503,34 +9502,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Animals", "Disaster + Crisis", "Humanitarian", "Nature" })] Worm = 0xE599, - /// - /// The Font Awesome "wpbeginner" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wpbeginner" })] - Wpbeginner = 0xF297, - - /// - /// The Font Awesome "wpexplorer" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wpexplorer" })] - Wpexplorer = 0xF2DE, - - /// - /// The Font Awesome "wpforms" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wpforms" })] - Wpforms = 0xF298, - - /// - /// The Font Awesome "wpressr" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "wpressr", "rendact" })] - Wpressr = 0xF3E4, - /// /// The Font Awesome "wrench" icon unicode character. /// @@ -12538,28 +9509,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Construction", "Maps" })] Wrench = 0xF0AD, - /// - /// The Font Awesome "xbox" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "xbox" })] - [FontAwesomeCategoriesAttribute(new[] { "Gaming" })] - Xbox = 0xF412, - - /// - /// The Font Awesome "xing" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "xing" })] - Xing = 0xF168, - - /// - /// The Font Awesome "square-xing" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square xing" })] - XingSquare = 0xF169, - /// /// The Font Awesome "xmarks-lines" icon unicode character. /// @@ -12574,55 +9523,6 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Medical + Health" })] XRay = 0xF497, - /// - /// The Font Awesome "yahoo" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "yahoo" })] - Yahoo = 0xF19E, - - /// - /// The Font Awesome "yammer" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "yammer" })] - Yammer = 0xF840, - - /// - /// The Font Awesome "yandex" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "yandex" })] - Yandex = 0xF413, - - /// - /// The Font Awesome "yandex-international" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "yandex international" })] - YandexInternational = 0xF414, - - /// - /// The Font Awesome "yarn" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "yarn" })] - Yarn = 0xF7E3, - - /// - /// The Font Awesome "y-combinator" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "y combinator" })] - YCombinator = 0xF23B, - - /// - /// The Font Awesome "yelp" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "yelp" })] - Yelp = 0xF1E9, - /// /// The Font Awesome "yen-sign" icon unicode character. /// @@ -12637,33 +9537,4 @@ public enum FontAwesomeIcon [FontAwesomeCategoriesAttribute(new[] { "Religion", "Spinners" })] YinYang = 0xF6AD, - /// - /// The Font Awesome "yoast" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "yoast" })] - Yoast = 0xF2B1, - - /// - /// The Font Awesome "youtube" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "youtube", "film", "video", "youtube-play", "youtube-square" })] - [FontAwesomeCategoriesAttribute(new[] { "Film + Video" })] - Youtube = 0xF167, - - /// - /// The Font Awesome "square-youtube" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "square youtube" })] - YoutubeSquare = 0xF431, - - /// - /// The Font Awesome "zhihu" icon unicode character. - /// - [Obsolete] - [FontAwesomeSearchTerms(new[] { "zhihu" })] - Zhihu = 0xF63F, - } diff --git a/Dalamud/Interface/FontIdentifier/DalamudAssetFontAndFamilyId.cs b/Dalamud/Interface/FontIdentifier/DalamudAssetFontAndFamilyId.cs new file mode 100644 index 000000000..a6d40e4b7 --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/DalamudAssetFontAndFamilyId.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; + +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Storage.Assets; + +using ImGuiNET; + +using Newtonsoft.Json; + +using TerraFX.Interop.DirectX; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a font from Dalamud assets. +/// +public sealed class DalamudAssetFontAndFamilyId : IFontFamilyId, IFontId +{ + /// + /// Initializes a new instance of the class. + /// + /// The font asset. + public DalamudAssetFontAndFamilyId(DalamudAsset asset) + { + if (asset.GetPurpose() != DalamudAssetPurpose.Font) + throw new ArgumentOutOfRangeException(nameof(asset), asset, "The specified asset is not a font asset."); + this.Asset = asset; + } + + /// + /// Gets the font asset. + /// + [JsonProperty] + public DalamudAsset Asset { get; init; } + + /// + [JsonIgnore] + public string EnglishName => $"Dalamud: {this.Asset}"; + + /// + [JsonIgnore] + public IReadOnlyDictionary? LocaleNames => null; + + /// + [JsonIgnore] + public IReadOnlyList Fonts => new List { this }.AsReadOnly(); + + /// + [JsonIgnore] + public IFontFamilyId Family => this; + + /// + [JsonIgnore] + public int Weight => (int)DWRITE_FONT_WEIGHT.DWRITE_FONT_WEIGHT_NORMAL; + + /// + [JsonIgnore] + public int Stretch => (int)DWRITE_FONT_STRETCH.DWRITE_FONT_STRETCH_NORMAL; + + /// + [JsonIgnore] + public int Style => (int)DWRITE_FONT_STYLE.DWRITE_FONT_STYLE_NORMAL; + + public static bool operator ==(DalamudAssetFontAndFamilyId? left, DalamudAssetFontAndFamilyId? right) => + Equals(left, right); + + public static bool operator !=(DalamudAssetFontAndFamilyId? left, DalamudAssetFontAndFamilyId? right) => + !Equals(left, right); + + /// + public override bool Equals(object? obj) => obj is DalamudAssetFontAndFamilyId other && this.Equals(other); + + /// + public override int GetHashCode() => (int)this.Asset; + + /// + public override string ToString() => $"{nameof(DalamudAssetFontAndFamilyId)}:{this.Asset}"; + + /// + public int FindBestMatch(int weight, int stretch, int style) => 0; + + /// + public ImFontPtr AddToBuildToolkit(IFontAtlasBuildToolkitPreBuild tk, in SafeFontConfig config) => + tk.AddDalamudAssetFont(this.Asset, config); + + private bool Equals(DalamudAssetFontAndFamilyId other) => this.Asset == other.Asset; +} diff --git a/Dalamud/Interface/FontIdentifier/DalamudDefaultFontAndFamilyId.cs b/Dalamud/Interface/FontIdentifier/DalamudDefaultFontAndFamilyId.cs new file mode 100644 index 000000000..7c6a69622 --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/DalamudDefaultFontAndFamilyId.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; + +using Dalamud.Interface.ManagedFontAtlas; + +using ImGuiNET; + +using Newtonsoft.Json; + +using TerraFX.Interop.DirectX; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents the default Dalamud font. +/// +public sealed class DalamudDefaultFontAndFamilyId : IFontId, IFontFamilyId +{ + /// + /// The shared instance of . + /// + public static readonly DalamudDefaultFontAndFamilyId Instance = new(); + + private DalamudDefaultFontAndFamilyId() + { + } + + /// + [JsonIgnore] + public string EnglishName => "(Default)"; + + /// + [JsonIgnore] + public IReadOnlyDictionary? LocaleNames => null; + + /// + [JsonIgnore] + public IFontFamilyId Family => this; + + /// + [JsonIgnore] + public int Weight => (int)DWRITE_FONT_WEIGHT.DWRITE_FONT_WEIGHT_NORMAL; + + /// + [JsonIgnore] + public int Stretch => (int)DWRITE_FONT_STRETCH.DWRITE_FONT_STRETCH_NORMAL; + + /// + [JsonIgnore] + public int Style => (int)DWRITE_FONT_STYLE.DWRITE_FONT_STYLE_NORMAL; + + /// + [JsonIgnore] + public IReadOnlyList Fonts => new List { this }.AsReadOnly(); + + public static bool operator ==(DalamudDefaultFontAndFamilyId? left, DalamudDefaultFontAndFamilyId? right) => + left is null == right is null; + + public static bool operator !=(DalamudDefaultFontAndFamilyId? left, DalamudDefaultFontAndFamilyId? right) => + left is null != right is null; + + /// + public override bool Equals(object? obj) => obj is DalamudDefaultFontAndFamilyId; + + /// + public override int GetHashCode() => 12345678; + + /// + public override string ToString() => nameof(DalamudDefaultFontAndFamilyId); + + /// + public ImFontPtr AddToBuildToolkit(IFontAtlasBuildToolkitPreBuild tk, in SafeFontConfig config) + => tk.AddDalamudDefaultFont(config.SizePx, config.GlyphRanges); + // TODO: mergeFont + + /// + public int FindBestMatch(int weight, int stretch, int style) => 0; +} diff --git a/Dalamud/Interface/FontIdentifier/GameFontAndFamilyId.cs b/Dalamud/Interface/FontIdentifier/GameFontAndFamilyId.cs new file mode 100644 index 000000000..dd4ba0d66 --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/GameFontAndFamilyId.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; + +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas; + +using ImGuiNET; + +using Newtonsoft.Json; + +using TerraFX.Interop.DirectX; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a font from the game. +/// +public sealed class GameFontAndFamilyId : IFontId, IFontFamilyId +{ + /// + /// Initializes a new instance of the class. + /// + /// The game font family. + public GameFontAndFamilyId(GameFontFamily family) => this.GameFontFamily = family; + + /// + /// Gets the game font family. + /// + [JsonProperty] + public GameFontFamily GameFontFamily { get; init; } + + /// + [JsonIgnore] + public string EnglishName => $"Game: {Enum.GetName(this.GameFontFamily) ?? throw new NotSupportedException()}"; + + /// + [JsonIgnore] + public IReadOnlyDictionary? LocaleNames => null; + + /// + [JsonIgnore] + public IFontFamilyId Family => this; + + /// + [JsonIgnore] + public int Weight => (int)DWRITE_FONT_WEIGHT.DWRITE_FONT_WEIGHT_NORMAL; + + /// + [JsonIgnore] + public int Stretch => (int)DWRITE_FONT_STRETCH.DWRITE_FONT_STRETCH_NORMAL; + + /// + [JsonIgnore] + public int Style => (int)DWRITE_FONT_STYLE.DWRITE_FONT_STYLE_NORMAL; + + /// + [JsonIgnore] + public IReadOnlyList Fonts => new List { this }.AsReadOnly(); + + public static bool operator ==(GameFontAndFamilyId? left, GameFontAndFamilyId? right) => Equals(left, right); + + public static bool operator !=(GameFontAndFamilyId? left, GameFontAndFamilyId? right) => !Equals(left, right); + + /// + public override bool Equals(object? obj) => + ReferenceEquals(this, obj) || (obj is GameFontAndFamilyId other && this.Equals(other)); + + /// + public override int GetHashCode() => (int)this.GameFontFamily; + + /// + public int FindBestMatch(int weight, int stretch, int style) => 0; + + /// + public override string ToString() => $"{nameof(GameFontAndFamilyId)}:{this.GameFontFamily}"; + + /// + public ImFontPtr AddToBuildToolkit(IFontAtlasBuildToolkitPreBuild tk, in SafeFontConfig config) => + tk.AddGameGlyphs(new(this.GameFontFamily, config.SizePx), config.GlyphRanges, config.MergeFont); + + private bool Equals(GameFontAndFamilyId other) => this.GameFontFamily == other.GameFontFamily; +} diff --git a/Dalamud/Interface/FontIdentifier/IFontFamilyId.cs b/Dalamud/Interface/FontIdentifier/IFontFamilyId.cs new file mode 100644 index 000000000..991716f74 --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/IFontFamilyId.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; + +using Dalamud.Interface.GameFonts; +using Dalamud.Utility; + +using Newtonsoft.Json; + +using TerraFX.Interop.DirectX; +using TerraFX.Interop.Windows; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a font family identifier.
+/// Not intended for plugins to implement. +///
+public interface IFontFamilyId : IObjectWithLocalizableName +{ + /// + /// Gets the list of fonts under this family. + /// + [JsonIgnore] + IReadOnlyList Fonts { get; } + + /// + /// Finds the index of the font inside that best matches the given parameters. + /// + /// The weight of the font. + /// The stretch of the font. + /// The style of the font. + /// The index of the font. Guaranteed to be a valid index. + int FindBestMatch(int weight, int stretch, int style); + + /// + /// Gets the list of Dalamud-provided fonts. + /// + /// The list of fonts. + public static List ListDalamudFonts() => + new() + { + new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansJpMedium), + new DalamudAssetFontAndFamilyId(DalamudAsset.InconsolataRegular), + new DalamudAssetFontAndFamilyId(DalamudAsset.FontAwesomeFreeSolid), + }; + + /// + /// Gets the list of Game-provided fonts. + /// + /// The list of fonts. + public static List ListGameFonts() => new() + { + new GameFontAndFamilyId(GameFontFamily.Axis), + new GameFontAndFamilyId(GameFontFamily.Jupiter), + new GameFontAndFamilyId(GameFontFamily.JupiterNumeric), + new GameFontAndFamilyId(GameFontFamily.Meidinger), + new GameFontAndFamilyId(GameFontFamily.MiedingerMid), + new GameFontAndFamilyId(GameFontFamily.TrumpGothic), + }; + + /// + /// Gets the list of System-provided fonts. + /// + /// If true, try to refresh the list. + /// The list of fonts. + public static unsafe List ListSystemFonts(bool refresh) + { + using var dwf = default(ComPtr); + fixed (Guid* piid = &IID.IID_IDWriteFactory) + { + DirectX.DWriteCreateFactory( + DWRITE_FACTORY_TYPE.DWRITE_FACTORY_TYPE_SHARED, + piid, + (IUnknown**)dwf.GetAddressOf()).ThrowOnError(); + } + + using var sfc = default(ComPtr); + dwf.Get()->GetSystemFontCollection(sfc.GetAddressOf(), refresh).ThrowOnError(); + + var count = (int)sfc.Get()->GetFontFamilyCount(); + var result = new List(count); + for (var i = 0; i < count; i++) + { + using var ff = default(ComPtr); + if (sfc.Get()->GetFontFamily((uint)i, ff.GetAddressOf()).FAILED) + { + // Ignore errors, if any + continue; + } + + try + { + result.Add(SystemFontFamilyId.FromDWriteFamily(ff)); + } + catch + { + // ignore + } + } + + return result; + } +} diff --git a/Dalamud/Interface/FontIdentifier/IFontId.cs b/Dalamud/Interface/FontIdentifier/IFontId.cs new file mode 100644 index 000000000..4c611edf8 --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/IFontId.cs @@ -0,0 +1,40 @@ +using Dalamud.Interface.ManagedFontAtlas; + +using ImGuiNET; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a font identifier.
+/// Not intended for plugins to implement. +///
+public interface IFontId : IObjectWithLocalizableName +{ + /// + /// Gets the associated font family. + /// + IFontFamilyId Family { get; } + + /// + /// Gets the font weight, ranging from 1 to 999. + /// + int Weight { get; } + + /// + /// Gets the font stretch, ranging from 1 to 9. + /// + int Stretch { get; } + + /// + /// Gets the font style. Treat as an opaque value. + /// + int Style { get; } + + /// + /// Adds this font to the given font build toolkit. + /// + /// The font build toolkit. + /// The font configuration. Some parameters may be ignored. + /// The added font. + ImFontPtr AddToBuildToolkit(IFontAtlasBuildToolkitPreBuild tk, in SafeFontConfig config); +} diff --git a/Dalamud/Interface/FontIdentifier/IFontSpec.cs b/Dalamud/Interface/FontIdentifier/IFontSpec.cs new file mode 100644 index 000000000..4d0719d4e --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/IFontSpec.cs @@ -0,0 +1,52 @@ +using Dalamud.Interface.ManagedFontAtlas; + +using ImGuiNET; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a user's choice of font(s).
+/// Not intended for plugins to implement. +///
+public interface IFontSpec +{ + /// + /// Gets the font size in pixels. + /// + float SizePx { get; } + + /// + /// Gets the font size in points. + /// + float SizePt { get; } + + /// + /// Gets the line height in pixels. + /// + float LineHeightPx { get; } + + /// + /// Creates a font handle corresponding to this font specification. + /// + /// The atlas to bind this font handle to. + /// Optional callback to be called after creating the font handle. + /// The new font handle. + /// will be set when is invoked. + /// + IFontHandle CreateFontHandle(IFontAtlas atlas, FontAtlasBuildStepDelegate? callback = null); + + /// + /// Adds this font to the given font build toolkit. + /// + /// The font build toolkit. + /// The font to merge to. + /// The added font. + ImFontPtr AddToBuildToolkit(IFontAtlasBuildToolkitPreBuild tk, ImFontPtr mergeFont = default); + + /// + /// Represents this font specification, preferrably in the requested locale. + /// + /// The locale code. Must be in lowercase(invariant). + /// The value. + string ToLocalizedString(string localeCode); +} diff --git a/Dalamud/Interface/FontIdentifier/IObjectWithLocalizableName.cs b/Dalamud/Interface/FontIdentifier/IObjectWithLocalizableName.cs new file mode 100644 index 000000000..2b970a5fd --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/IObjectWithLocalizableName.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; + +using Dalamud.Utility; + +using TerraFX.Interop.DirectX; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents an object with localizable names. +/// +public interface IObjectWithLocalizableName +{ + /// + /// Gets the name, preferrably in English. + /// + string EnglishName { get; } + + /// + /// Gets the names per locales. + /// + IReadOnlyDictionary? LocaleNames { get; } + + /// + /// Gets the name in the requested locale if available; otherwise, . + /// + /// The locale code. Must be in lowercase(invariant). + /// The value. + string GetLocalizedName(string localeCode) + { + if (this.LocaleNames is null) + return this.EnglishName; + if (this.LocaleNames.TryGetValue(localeCode, out var v)) + return v; + foreach (var (a, b) in this.LocaleNames) + { + if (a.StartsWith(localeCode)) + return b; + } + + return this.EnglishName; + } + + /// + /// Resolves all names per locales. + /// + /// The names. + /// A new dictionary mapping from locale code to localized names. + internal static unsafe IReadOnlyDictionary GetLocaleNames(IDWriteLocalizedStrings* fn) + { + var count = fn->GetCount(); + var maxStrLen = 0u; + for (var i = 0u; i < count; i++) + { + var length = 0u; + fn->GetStringLength(i, &length).ThrowOnError(); + maxStrLen = Math.Max(maxStrLen, length); + fn->GetLocaleNameLength(i, &length).ThrowOnError(); + maxStrLen = Math.Max(maxStrLen, length); + } + + maxStrLen++; + var buf = stackalloc char[(int)maxStrLen]; + var result = new Dictionary((int)count); + for (var i = 0u; i < count; i++) + { + fn->GetLocaleName(i, (ushort*)buf, maxStrLen).ThrowOnError(); + var key = new string(buf); + fn->GetString(i, (ushort*)buf, maxStrLen).ThrowOnError(); + var value = new string(buf); + result[key.ToLowerInvariant()] = value; + } + + return result; + } +} diff --git a/Dalamud/Interface/FontIdentifier/SingleFontSpec.cs b/Dalamud/Interface/FontIdentifier/SingleFontSpec.cs new file mode 100644 index 000000000..946215b85 --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/SingleFontSpec.cs @@ -0,0 +1,154 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using System.Text; + +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility; + +using ImGuiNET; + +using Newtonsoft.Json; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a user's choice of a single font. +/// +[SuppressMessage( + "StyleCop.CSharp.OrderingRules", + "SA1206:Declaration keywords should follow order", + Justification = "public required")] +public record SingleFontSpec : IFontSpec +{ + /// + /// Gets the font id. + /// + [JsonProperty] + public required IFontId FontId { get; init; } + + /// + [JsonProperty] + public float SizePx { get; init; } = 16; + + /// + [JsonIgnore] + public float SizePt + { + get => (this.SizePx * 3) / 4; + init => this.SizePx = (value * 4) / 3; + } + + /// + [JsonIgnore] + public float LineHeightPx => MathF.Round(this.SizePx * this.LineHeight); + + /// + /// Gets the line height ratio to the font size. + /// + [JsonProperty] + public float LineHeight { get; init; } = 1f; + + /// + /// Gets the glyph offset in pixels. + /// + [JsonProperty] + public Vector2 GlyphOffset { get; init; } + + /// + /// Gets the letter spacing in pixels. + /// + [JsonProperty] + public float LetterSpacing { get; init; } + + /// + /// Gets the glyph ranges. + /// + [JsonProperty] + public ushort[]? GlyphRanges { get; init; } + + /// + public string ToLocalizedString(string localeCode) + { + var sb = new StringBuilder(); + sb.Append(this.FontId.Family.GetLocalizedName(localeCode)); + sb.Append($"({this.FontId.GetLocalizedName(localeCode)}, {this.SizePt}pt"); + if (Math.Abs(this.LineHeight - 1f) > 0.000001f) + sb.Append($", LH={this.LineHeight:0.##}"); + if (this.GlyphOffset != default) + sb.Append($", O={this.GlyphOffset.X:0.##},{this.GlyphOffset.Y:0.##}"); + if (this.LetterSpacing != 0f) + sb.Append($", LS={this.LetterSpacing:0.##}"); + sb.Append(')'); + return sb.ToString(); + } + + /// + public override string ToString() => this.ToLocalizedString("en"); + + /// + public IFontHandle CreateFontHandle(IFontAtlas atlas, FontAtlasBuildStepDelegate? callback = null) => + atlas.NewDelegateFontHandle(tk => + { + tk.OnPreBuild(e => e.Font = this.AddToBuildToolkit(e)); + callback?.Invoke(tk); + }); + + /// + public ImFontPtr AddToBuildToolkit(IFontAtlasBuildToolkitPreBuild tk, ImFontPtr mergeFont = default) + { + var font = this.FontId.AddToBuildToolkit( + tk, + new() + { + SizePx = this.SizePx, + GlyphRanges = this.GlyphRanges, + MergeFont = mergeFont, + }); + + tk.RegisterPostBuild( + () => + { + // Multiplication by scale will be done with global scale, outside of this handling. + var scale = tk.GetFontScaleMode(font) == FontScaleMode.UndoGlobalScale ? 1 / tk.Scale : 1; + var roundUnit = tk.GetFontScaleMode(font) == FontScaleMode.SkipHandling ? 1 : 1 / tk.Scale; + var newAscent = MathF.Round((font.Ascent * this.LineHeight) / roundUnit) * roundUnit; + var newFontSize = MathF.Round((font.FontSize * this.LineHeight) / roundUnit) * roundUnit; + var shiftDown = MathF.Round((newFontSize - font.FontSize) / 2f / roundUnit) * roundUnit; + + font.Ascent = newAscent; + font.FontSize = newFontSize; + font.Descent = newFontSize - font.Ascent; + + var lookup = new BitArray(ushort.MaxValue + 1, this.GlyphRanges is null); + if (this.GlyphRanges is not null) + { + for (var i = 0; i < this.GlyphRanges.Length && this.GlyphRanges[i] != 0; i += 2) + { + var to = (int)this.GlyphRanges[i + 1]; + for (var j = this.GlyphRanges[i]; j <= to; j++) + lookup[j] = true; + } + } + + var dax = MathF.Round((this.LetterSpacing * scale) / roundUnit) * roundUnit; + var dxy0 = this.GlyphOffset * scale; + dxy0 /= roundUnit; + dxy0 = new(MathF.Round(dxy0.X), MathF.Round(dxy0.Y)); + dxy0 *= roundUnit; + + dxy0.Y += shiftDown; + var dxy = new Vector4(dxy0, dxy0.X, dxy0.Y); + foreach (ref var glyphReal in font.GlyphsWrapped().DataSpan) + { + if (!lookup[glyphReal.Codepoint]) + continue; + + glyphReal.XY += dxy; + glyphReal.AdvanceX += dax; + } + }); + + return font; + } +} diff --git a/Dalamud/Interface/FontIdentifier/SystemFontFamilyId.cs b/Dalamud/Interface/FontIdentifier/SystemFontFamilyId.cs new file mode 100644 index 000000000..420ee77a4 --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/SystemFontFamilyId.cs @@ -0,0 +1,181 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using Dalamud.Utility; + +using Newtonsoft.Json; + +using TerraFX.Interop.DirectX; +using TerraFX.Interop.Windows; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a font from system. +/// +public sealed class SystemFontFamilyId : IFontFamilyId +{ + [JsonIgnore] + private IReadOnlyList? fontsLazy; + + /// + /// Initializes a new instance of the class. + /// + /// The font name in English. + /// The localized font name for display purposes. + [JsonConstructor] + internal SystemFontFamilyId(string englishName, IReadOnlyDictionary localeNames) + { + this.EnglishName = englishName; + this.LocaleNames = localeNames; + } + + /// + /// Initializes a new instance of the class. + /// + /// The localized font name for display purposes. + internal SystemFontFamilyId(IReadOnlyDictionary localeNames) + { + if (localeNames.TryGetValue("en-us", out var name)) + this.EnglishName = name; + else if (localeNames.TryGetValue("en", out name)) + this.EnglishName = name; + else + this.EnglishName = localeNames.Values.First(); + this.LocaleNames = localeNames; + } + + /// + [JsonProperty] + public string EnglishName { get; init; } + + /// + [JsonProperty] + public IReadOnlyDictionary? LocaleNames { get; } + + /// + [JsonIgnore] + public IReadOnlyList Fonts => this.fontsLazy ??= this.GetFonts(); + + public static bool operator ==(SystemFontFamilyId? left, SystemFontFamilyId? right) => Equals(left, right); + + public static bool operator !=(SystemFontFamilyId? left, SystemFontFamilyId? right) => !Equals(left, right); + + /// + public int FindBestMatch(int weight, int stretch, int style) + { + using var matchingFont = default(ComPtr); + + var candidates = this.Fonts.ToList(); + var minGap = int.MaxValue; + foreach (var c in candidates) + minGap = Math.Min(minGap, Math.Abs(c.Weight - weight)); + candidates.RemoveAll(c => Math.Abs(c.Weight - weight) != minGap); + + minGap = int.MaxValue; + foreach (var c in candidates) + minGap = Math.Min(minGap, Math.Abs(c.Stretch - stretch)); + candidates.RemoveAll(c => Math.Abs(c.Stretch - stretch) != minGap); + + if (candidates.Any(x => x.Style == style)) + candidates.RemoveAll(x => x.Style != style); + else if (candidates.Any(x => x.Style == (int)DWRITE_FONT_STYLE.DWRITE_FONT_STYLE_NORMAL)) + candidates.RemoveAll(x => x.Style != (int)DWRITE_FONT_STYLE.DWRITE_FONT_STYLE_NORMAL); + + if (!candidates.Any()) + return 0; + + for (var i = 0; i < this.Fonts.Count; i++) + { + if (Equals(this.Fonts[i], candidates[0])) + return i; + } + + return 0; + } + + /// + public override string ToString() => $"{nameof(SystemFontFamilyId)}:{this.EnglishName}"; + + /// + public override bool Equals(object? obj) => + ReferenceEquals(this, obj) || (obj is SystemFontFamilyId other && this.Equals(other)); + + /// + public override int GetHashCode() => this.EnglishName.GetHashCode(); + + /// + /// Create a new instance of from an . + /// + /// The family. + /// The new instance. + internal static unsafe SystemFontFamilyId FromDWriteFamily(ComPtr family) + { + using var fn = default(ComPtr); + family.Get()->GetFamilyNames(fn.GetAddressOf()).ThrowOnError(); + return new(IObjectWithLocalizableName.GetLocaleNames(fn)); + } + + private unsafe IReadOnlyList GetFonts() + { + using var dwf = default(ComPtr); + fixed (Guid* piid = &IID.IID_IDWriteFactory) + { + DirectX.DWriteCreateFactory( + DWRITE_FACTORY_TYPE.DWRITE_FACTORY_TYPE_SHARED, + piid, + (IUnknown**)dwf.GetAddressOf()).ThrowOnError(); + } + + using var sfc = default(ComPtr); + dwf.Get()->GetSystemFontCollection(sfc.GetAddressOf(), false).ThrowOnError(); + + var familyIndex = 0u; + BOOL exists = false; + fixed (void* pName = this.EnglishName) + sfc.Get()->FindFamilyName((ushort*)pName, &familyIndex, &exists).ThrowOnError(); + if (!exists) + throw new FileNotFoundException($"Font \"{this.EnglishName}\" not found."); + + using var family = default(ComPtr); + sfc.Get()->GetFontFamily(familyIndex, family.GetAddressOf()).ThrowOnError(); + + var fontCount = (int)family.Get()->GetFontCount(); + var fonts = new List(fontCount); + for (var i = 0; i < fontCount; i++) + { + using var font = default(ComPtr); + if (family.Get()->GetFont((uint)i, font.GetAddressOf()).FAILED) + { + // Ignore errors, if any + continue; + } + + if (font.Get()->GetSimulations() != DWRITE_FONT_SIMULATIONS.DWRITE_FONT_SIMULATIONS_NONE) + { + // No simulation support + continue; + } + + fonts.Add(new SystemFontId(this, font)); + } + + fonts.Sort( + (a, b) => + { + var comp = a.Weight.CompareTo(b.Weight); + if (comp != 0) + return comp; + + comp = a.Stretch.CompareTo(b.Stretch); + if (comp != 0) + return comp; + + return a.Style.CompareTo(b.Style); + }); + return fonts; + } + + private bool Equals(SystemFontFamilyId other) => this.EnglishName == other.EnglishName; +} diff --git a/Dalamud/Interface/FontIdentifier/SystemFontId.cs b/Dalamud/Interface/FontIdentifier/SystemFontId.cs new file mode 100644 index 000000000..0a350fc3a --- /dev/null +++ b/Dalamud/Interface/FontIdentifier/SystemFontId.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Utility; + +using ImGuiNET; + +using Newtonsoft.Json; + +using TerraFX.Interop.DirectX; +using TerraFX.Interop.Windows; + +namespace Dalamud.Interface.FontIdentifier; + +/// +/// Represents a font installed in the system. +/// +public sealed class SystemFontId : IFontId +{ + /// + /// Initializes a new instance of the class. + /// + /// The parent font family. + /// The font. + internal unsafe SystemFontId(SystemFontFamilyId family, ComPtr font) + { + this.Family = family; + this.Weight = (int)font.Get()->GetWeight(); + this.Stretch = (int)font.Get()->GetStretch(); + this.Style = (int)font.Get()->GetStyle(); + + using var fn = default(ComPtr); + font.Get()->GetFaceNames(fn.GetAddressOf()).ThrowOnError(); + this.LocaleNames = IObjectWithLocalizableName.GetLocaleNames(fn); + if (this.LocaleNames.TryGetValue("en-us", out var name)) + this.EnglishName = name; + else if (this.LocaleNames.TryGetValue("en", out name)) + this.EnglishName = name; + else + this.EnglishName = this.LocaleNames.Values.First(); + } + + [JsonConstructor] + private SystemFontId(string englishName, IReadOnlyDictionary localeNames, IFontFamilyId family) + { + this.EnglishName = englishName; + this.LocaleNames = localeNames; + this.Family = family; + } + + /// + [JsonProperty] + public string EnglishName { get; init; } + + /// + [JsonProperty] + public IReadOnlyDictionary? LocaleNames { get; } + + /// + [JsonProperty] + public IFontFamilyId Family { get; init; } + + /// + [JsonProperty] + public int Weight { get; init; } = (int)DWRITE_FONT_WEIGHT.DWRITE_FONT_WEIGHT_NORMAL; + + /// + [JsonProperty] + public int Stretch { get; init; } = (int)DWRITE_FONT_STRETCH.DWRITE_FONT_STRETCH_NORMAL; + + /// + [JsonProperty] + public int Style { get; init; } = (int)DWRITE_FONT_STYLE.DWRITE_FONT_STYLE_NORMAL; + + public static bool operator ==(SystemFontId? left, SystemFontId? right) => Equals(left, right); + + public static bool operator !=(SystemFontId? left, SystemFontId? right) => !Equals(left, right); + + /// + public override bool Equals(object? obj) => + ReferenceEquals(this, obj) || (obj is SystemFontId other && this.Equals(other)); + + /// + public override int GetHashCode() => HashCode.Combine(this.Family, this.Weight, this.Stretch, this.Style); + + /// + public override string ToString() => + $"{nameof(SystemFontId)}:{this.Weight}:{this.Stretch}:{this.Style}:{this.Family}"; + + /// + public ImFontPtr AddToBuildToolkit(IFontAtlasBuildToolkitPreBuild tk, in SafeFontConfig config) + { + var (path, index) = this.GetFileAndIndex(); + return tk.AddFontFromFile(path, config with { FontNo = index }); + } + + /// + /// Gets the file containing this font, and the font index within. + /// + /// The path and index. + public unsafe (string Path, int Index) GetFileAndIndex() + { + using var dwf = default(ComPtr); + fixed (Guid* piid = &IID.IID_IDWriteFactory) + { + DirectX.DWriteCreateFactory( + DWRITE_FACTORY_TYPE.DWRITE_FACTORY_TYPE_SHARED, + piid, + (IUnknown**)dwf.GetAddressOf()).ThrowOnError(); + } + + using var sfc = default(ComPtr); + dwf.Get()->GetSystemFontCollection(sfc.GetAddressOf(), false).ThrowOnError(); + + var familyIndex = 0u; + BOOL exists = false; + fixed (void* name = this.Family.EnglishName) + sfc.Get()->FindFamilyName((ushort*)name, &familyIndex, &exists).ThrowOnError(); + if (!exists) + throw new FileNotFoundException($"Font \"{this.Family.EnglishName}\" not found."); + + using var family = default(ComPtr); + sfc.Get()->GetFontFamily(familyIndex, family.GetAddressOf()).ThrowOnError(); + + using var font = default(ComPtr); + family.Get()->GetFirstMatchingFont( + (DWRITE_FONT_WEIGHT)this.Weight, + (DWRITE_FONT_STRETCH)this.Stretch, + (DWRITE_FONT_STYLE)this.Style, + font.GetAddressOf()).ThrowOnError(); + + using var fface = default(ComPtr); + font.Get()->CreateFontFace(fface.GetAddressOf()).ThrowOnError(); + var fileCount = 0; + fface.Get()->GetFiles((uint*)&fileCount, null).ThrowOnError(); + if (fileCount != 1) + throw new NotSupportedException(); + + using var ffile = default(ComPtr); + fface.Get()->GetFiles((uint*)&fileCount, ffile.GetAddressOf()).ThrowOnError(); + void* refKey; + var refKeySize = 0u; + ffile.Get()->GetReferenceKey(&refKey, &refKeySize).ThrowOnError(); + + using var floader = default(ComPtr); + ffile.Get()->GetLoader(floader.GetAddressOf()).ThrowOnError(); + + using var flocal = default(ComPtr); + floader.As(&flocal).ThrowOnError(); + + var pathSize = 0u; + flocal.Get()->GetFilePathLengthFromKey(refKey, refKeySize, &pathSize).ThrowOnError(); + + var path = stackalloc char[(int)pathSize + 1]; + flocal.Get()->GetFilePathFromKey(refKey, refKeySize, (ushort*)path, pathSize + 1).ThrowOnError(); + return (new(path, 0, (int)pathSize), (int)fface.Get()->GetIndex()); + } + + private bool Equals(SystemFontId other) => this.Family.Equals(other.Family) && this.Weight == other.Weight && + this.Stretch == other.Stretch && this.Style == other.Style; +} diff --git a/Dalamud/Interface/GameFonts/FdtFileView.cs b/Dalamud/Interface/GameFonts/FdtFileView.cs new file mode 100644 index 000000000..896a6dbb4 --- /dev/null +++ b/Dalamud/Interface/GameFonts/FdtFileView.cs @@ -0,0 +1,159 @@ +using System.Collections.Generic; +using System.IO; + +namespace Dalamud.Interface.GameFonts; + +/// +/// Reference member view of a .fdt file data. +/// +internal readonly unsafe struct FdtFileView +{ + private readonly byte* ptr; + + /// + /// Initializes a new instance of the struct. + /// + /// Pointer to the data. + /// Length of the data. + public FdtFileView(void* ptr, int length) + { + this.ptr = (byte*)ptr; + if (length < sizeof(FdtReader.FdtHeader)) + throw new InvalidDataException("Not enough space for a FdtHeader"); + + if (length < this.FileHeader.FontTableHeaderOffset + sizeof(FdtReader.FontTableHeader)) + throw new InvalidDataException("Not enough space for a FontTableHeader"); + if (length < this.FileHeader.FontTableHeaderOffset + sizeof(FdtReader.FontTableHeader) + + (sizeof(FdtReader.FontTableEntry) * this.FontHeader.FontTableEntryCount)) + throw new InvalidDataException("Not enough space for all the FontTableEntry"); + + if (length < this.FileHeader.KerningTableHeaderOffset + sizeof(FdtReader.KerningTableHeader)) + throw new InvalidDataException("Not enough space for a KerningTableHeader"); + if (length < this.FileHeader.KerningTableHeaderOffset + sizeof(FdtReader.KerningTableHeader) + + (sizeof(FdtReader.KerningTableEntry) * this.KerningEntryCount)) + throw new InvalidDataException("Not enough space for all the KerningTableEntry"); + } + + /// + /// Gets the file header. + /// + public ref FdtReader.FdtHeader FileHeader => ref *(FdtReader.FdtHeader*)this.ptr; + + /// + /// Gets the font header. + /// + public ref FdtReader.FontTableHeader FontHeader => + ref *(FdtReader.FontTableHeader*)((nint)this.ptr + this.FileHeader.FontTableHeaderOffset); + + /// + /// Gets the glyphs. + /// + public Span Glyphs => new(this.GlyphsUnsafe, this.FontHeader.FontTableEntryCount); + + /// + /// Gets the kerning header. + /// + public ref FdtReader.KerningTableHeader KerningHeader => + ref *(FdtReader.KerningTableHeader*)((nint)this.ptr + this.FileHeader.KerningTableHeaderOffset); + + /// + /// Gets the number of kerning entries. + /// + public int KerningEntryCount => Math.Min(this.FontHeader.KerningTableEntryCount, this.KerningHeader.Count); + + /// + /// Gets the kerning entries. + /// + public Span PairAdjustments => new( + this.ptr + this.FileHeader.KerningTableHeaderOffset + sizeof(FdtReader.KerningTableHeader), + this.KerningEntryCount); + + /// + /// Gets the maximum texture index. + /// + public int MaxTextureIndex + { + get + { + var i = 0; + foreach (ref var g in this.Glyphs) + { + if (g.TextureIndex > i) + i = g.TextureIndex; + } + + return i; + } + } + + private FdtReader.FontTableEntry* GlyphsUnsafe => + (FdtReader.FontTableEntry*)(this.ptr + this.FileHeader.FontTableHeaderOffset + + sizeof(FdtReader.FontTableHeader)); + + /// + /// Finds the glyph index for the corresponding codepoint. + /// + /// Unicode codepoint (UTF-32 value). + /// Corresponding index, or a negative number according to . + public int FindGlyphIndex(int codepoint) + { + var comp = FdtReader.CodePointToUtf8Int32(codepoint); + + var glyphs = this.GlyphsUnsafe; + var lo = 0; + var hi = this.FontHeader.FontTableEntryCount - 1; + while (lo <= hi) + { + var i = (int)(((uint)hi + (uint)lo) >> 1); + switch (comp.CompareTo(glyphs[i].CharUtf8)) + { + case 0: + return i; + case > 0: + lo = i + 1; + break; + default: + hi = i - 1; + break; + } + } + + return ~lo; + } + + /// + /// Create a glyph range for use with . + /// + /// Merge two ranges into one if distance is below the value specified in this parameter. + /// Glyph ranges. + public ushort[] ToGlyphRanges(int mergeDistance = 8) + { + var glyphs = this.Glyphs; + var ranges = new List(glyphs.Length) + { + checked((ushort)glyphs[0].CharInt), + checked((ushort)glyphs[0].CharInt), + }; + + foreach (ref var glyph in glyphs[1..]) + { + var c32 = glyph.CharInt; + if (c32 >= 0x10000) + break; + + var c16 = unchecked((ushort)c32); + if (ranges[^1] + mergeDistance >= c16 && c16 > ranges[^1]) + { + ranges[^1] = c16; + } + else if (ranges[^1] + 1 < c16) + { + ranges.Add(c16); + ranges.Add(c16); + } + } + + ranges.Add(0); + return ranges.ToArray(); + } +} diff --git a/Dalamud/Interface/GameFonts/FdtReader.cs b/Dalamud/Interface/GameFonts/FdtReader.cs index a68caba94..0e8f3fb59 100644 --- a/Dalamud/Interface/GameFonts/FdtReader.cs +++ b/Dalamud/Interface/GameFonts/FdtReader.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Runtime.InteropServices; @@ -22,7 +21,7 @@ public class FdtReader for (var i = 0; i < this.FontHeader.FontTableEntryCount; i++) this.Glyphs.Add(StructureFromByteArray(data, this.FileHeader.FontTableHeaderOffset + Marshal.SizeOf() + (Marshal.SizeOf() * i))); - for (int i = 0, i_ = Math.Min(this.FontHeader.KerningTableEntryCount, this.KerningHeader.Count); i < i_; i++) + for (int i = 0, to = Math.Min(this.FontHeader.KerningTableEntryCount, this.KerningHeader.Count); i < to; i++) this.Distances.Add(StructureFromByteArray(data, this.FileHeader.KerningTableHeaderOffset + Marshal.SizeOf() + (Marshal.SizeOf() * i))); } @@ -51,6 +50,14 @@ public class FdtReader ///
public List Distances { get; init; } = new(); + /// + /// Finds the glyph index for the corresponding codepoint. + /// + /// Unicode codepoint (UTF-32 value). + /// Corresponding index, or a negative number according to . + public int FindGlyphIndex(int codepoint) => + this.Glyphs.BinarySearch(new FontTableEntry { CharUtf8 = CodePointToUtf8Int32(codepoint) }); + /// /// Finds glyph definition for corresponding codepoint. /// @@ -58,7 +65,7 @@ public class FdtReader /// Corresponding FontTableEntry, or null if not found. public FontTableEntry? FindGlyph(int codepoint) { - var i = this.Glyphs.BinarySearch(new FontTableEntry { CharUtf8 = CodePointToUtf8Int32(codepoint) }); + var i = this.FindGlyphIndex(codepoint); if (i < 0 || i == this.Glyphs.Count) return null; return this.Glyphs[i]; @@ -91,17 +98,12 @@ public class FdtReader return this.Distances[i].RightOffset; } - private static unsafe T StructureFromByteArray(byte[] data, int offset) - { - var len = Marshal.SizeOf(); - if (offset + len > data.Length) - throw new Exception("Data too short"); - - fixed (byte* ptr = data) - return Marshal.PtrToStructure(new(ptr + offset)); - } - - private static int CodePointToUtf8Int32(int codepoint) + /// + /// Translates a UTF-32 codepoint to a containing a UTF-8 character. + /// + /// The codepoint. + /// The uint. + internal static int CodePointToUtf8Int32(int codepoint) { if (codepoint <= 0x7F) { @@ -131,6 +133,16 @@ public class FdtReader } } + private static unsafe T StructureFromByteArray(byte[] data, int offset) + { + var len = Marshal.SizeOf(); + if (offset + len > data.Length) + throw new Exception("Data too short"); + + fixed (byte* ptr = data) + return Marshal.PtrToStructure(new(ptr + offset)); + } + private static int Utf8Uint32ToCodePoint(int n) { if ((n & 0xFFFFFF80) == 0) @@ -252,7 +264,7 @@ public class FdtReader /// Glyph table entry. ///
[StructLayout(LayoutKind.Sequential)] - public unsafe struct FontTableEntry : IComparable + public struct FontTableEntry : IComparable { /// /// Mapping of texture channel index to byte index. @@ -367,7 +379,7 @@ public class FdtReader /// Kerning table entry. /// [StructLayout(LayoutKind.Sequential)] - public unsafe struct KerningTableEntry : IComparable + public struct KerningTableEntry : IComparable { /// /// Integer representation of a Unicode character in UTF-8 in reverse order, read in little endian, for the left character. diff --git a/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs b/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs index dd78baf87..6e66cf19b 100644 --- a/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs +++ b/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs @@ -3,7 +3,7 @@ namespace Dalamud.Interface.GameFonts; /// /// Enum of available game fonts in specific sizes. /// -public enum GameFontFamilyAndSize : int +public enum GameFontFamilyAndSize { /// /// Placeholder meaning unused. @@ -15,6 +15,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_96.fdt", "common/font/font{0}.tex", -1)] Axis96, /// @@ -22,6 +23,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_12.fdt", "common/font/font{0}.tex", -1)] Axis12, /// @@ -29,6 +31,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_14.fdt", "common/font/font{0}.tex", -1)] Axis14, /// @@ -36,6 +39,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_18.fdt", "common/font/font{0}.tex", -1)] Axis18, /// @@ -43,6 +47,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_36.fdt", "common/font/font{0}.tex", -4)] Axis36, /// @@ -50,6 +55,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_16.fdt", "common/font/font{0}.tex", -1)] Jupiter16, /// @@ -57,6 +63,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_20.fdt", "common/font/font{0}.tex", -1)] Jupiter20, /// @@ -64,6 +71,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_23.fdt", "common/font/font{0}.tex", -1)] Jupiter23, /// @@ -71,6 +79,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly numbers. Used in game for flying texts. /// + [GameFontFamilyAndSize("common/font/Jupiter_45.fdt", "common/font/font{0}.tex", -2)] Jupiter45, /// @@ -78,6 +87,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_46.fdt", "common/font/font{0}.tex", -2)] Jupiter46, /// @@ -85,6 +95,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly numbers. Used in game for flying texts. /// + [GameFontFamilyAndSize("common/font/Jupiter_90.fdt", "common/font/font{0}.tex", -4)] Jupiter90, /// @@ -92,6 +103,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly numbers. Used in game for HP/MP/IL stuff. /// + [GameFontFamilyAndSize("common/font/Meidinger_16.fdt", "common/font/font{0}.tex", -1)] Meidinger16, /// @@ -99,6 +111,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly numbers. Used in game for HP/MP/IL stuff. /// + [GameFontFamilyAndSize("common/font/Meidinger_20.fdt", "common/font/font{0}.tex", -1)] Meidinger20, /// @@ -106,6 +119,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly numbers. Used in game for HP/MP/IL stuff. /// + [GameFontFamilyAndSize("common/font/Meidinger_40.fdt", "common/font/font{0}.tex", -4)] Meidinger40, /// @@ -113,6 +127,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_10.fdt", "common/font/font{0}.tex", -1)] MiedingerMid10, /// @@ -120,6 +135,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_12.fdt", "common/font/font{0}.tex", -1)] MiedingerMid12, /// @@ -127,6 +143,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_14.fdt", "common/font/font{0}.tex", -1)] MiedingerMid14, /// @@ -134,6 +151,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_18.fdt", "common/font/font{0}.tex", -1)] MiedingerMid18, /// @@ -141,6 +159,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_36.fdt", "common/font/font{0}.tex", -2)] MiedingerMid36, /// @@ -148,6 +167,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_184.fdt", "common/font/font{0}.tex", -1)] TrumpGothic184, /// @@ -155,6 +175,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_23.fdt", "common/font/font{0}.tex", -1)] TrumpGothic23, /// @@ -162,6 +183,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_34.fdt", "common/font/font{0}.tex", -1)] TrumpGothic34, /// @@ -169,5 +191,6 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_68.fdt", "common/font/font{0}.tex", -3)] TrumpGothic68, } diff --git a/Dalamud/Interface/GameFonts/GameFontFamilyAndSizeAttribute.cs b/Dalamud/Interface/GameFonts/GameFontFamilyAndSizeAttribute.cs new file mode 100644 index 000000000..f5260e4bc --- /dev/null +++ b/Dalamud/Interface/GameFonts/GameFontFamilyAndSizeAttribute.cs @@ -0,0 +1,37 @@ +namespace Dalamud.Interface.GameFonts; + +/// +/// Marks the path for an enum value. +/// +[AttributeUsage(AttributeTargets.Field)] +internal class GameFontFamilyAndSizeAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// Inner path of the file. + /// the file path format for the relevant .tex files. + /// Horizontal offset of the corresponding font. + public GameFontFamilyAndSizeAttribute(string path, string texPathFormat, int horizontalOffset) + { + this.Path = path; + this.TexPathFormat = texPathFormat; + this.HorizontalOffset = horizontalOffset; + } + + /// + /// Gets the path. + /// + public string Path { get; } + + /// + /// Gets the file path format for the relevant .tex files.
+ /// Used for (, ). + ///
+ public string TexPathFormat { get; } + + /// + /// Gets the horizontal offset of the corresponding font. + /// + public int HorizontalOffset { get; } +} diff --git a/Dalamud/Interface/GameFonts/GameFontHandle.cs b/Dalamud/Interface/GameFonts/GameFontHandle.cs index d71e725c5..2594eea0e 100644 --- a/Dalamud/Interface/GameFonts/GameFontHandle.cs +++ b/Dalamud/Interface/GameFonts/GameFontHandle.cs @@ -1,75 +1,102 @@ -using System; using System.Numerics; +using System.Threading.Tasks; + +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Utility; using ImGuiNET; namespace Dalamud.Interface.GameFonts; /// -/// Prepare and keep game font loaded for use in OnDraw. +/// ABI-compatible wrapper for . /// -public class GameFontHandle : IDisposable +[Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] +public sealed class GameFontHandle : IFontHandle { - private readonly GameFontManager manager; - private readonly GameFontStyle fontStyle; + private readonly GamePrebakedFontHandle fontHandle; + private readonly FontAtlasFactory fontAtlasFactory; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class.
+ /// Ownership of is transferred. ///
- /// GameFontManager instance. - /// Font to use. - internal GameFontHandle(GameFontManager manager, GameFontStyle font) + /// The wrapped . + /// An instance of . + internal GameFontHandle(GamePrebakedFontHandle fontHandle, FontAtlasFactory fontAtlasFactory) { - this.manager = manager; - this.fontStyle = font; + this.fontHandle = fontHandle; + this.fontAtlasFactory = fontAtlasFactory; } - /// - /// Gets the font style. - /// - public GameFontStyle Style => this.fontStyle; - - /// - /// Gets a value indicating whether this font is ready for use. - /// - public bool Available + /// + public event IFontHandle.ImFontChangedDelegate ImFontChanged { - get - { - unsafe - { - return this.manager.GetFont(this.fontStyle).GetValueOrDefault(null).NativePtr != null; - } - } + add => this.fontHandle.ImFontChanged += value; + remove => this.fontHandle.ImFontChanged -= value; } - /// - /// Gets the font. - /// - public ImFontPtr ImFont => this.manager.GetFont(this.fontStyle).Value; + /// + public Exception? LoadException => this.fontHandle.LoadException; + + /// + public bool Available => this.fontHandle.Available; /// - /// Gets the FdtReader. + /// Gets the font.
+ /// Use of this properly is safe only from the UI thread.
+ /// Use if the intended purpose of this property is .
+ /// Futures changes may make simple not enough.
+ /// If you need to access a font outside the UI thread, use . ///
- public FdtReader FdtReader => this.manager.GetFdtReader(this.fontStyle.FamilyAndSize); + [Obsolete($"Use {nameof(Push)}-{nameof(ImGui.GetFont)} or {nameof(Lock)} instead.", false)] + public ImFontPtr ImFont => this.fontHandle.LockUntilPostFrame(); /// - /// Creates a new GameFontLayoutPlan.Builder. + /// Gets the font style. Only applicable for . + /// + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] + public GameFontStyle Style => ((GamePrebakedFontHandle)this.fontHandle).FontStyle; + + /// + /// Gets the relevant .
+ ///
+ /// Only applicable for game fonts. Otherwise it will throw. + ///
+ [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] + public FdtReader FdtReader => this.fontAtlasFactory.GetFdtReader(this.Style.FamilyAndSize)!; + + /// + public void Dispose() => this.fontHandle.Dispose(); + + /// + public ILockedImFont Lock() => this.fontHandle.Lock(); + + /// + public IDisposable Push() => this.fontHandle.Push(); + + /// + public void Pop() => this.fontHandle.Pop(); + + /// + public Task WaitAsync() => this.fontHandle.WaitAsync(); + + /// + /// Creates a new .
+ ///
+ /// Only applicable for game fonts. Otherwise it will throw. ///
/// Text. /// A new builder for GameFontLayoutPlan. - public GameFontLayoutPlan.Builder LayoutBuilder(string text) - { - return new GameFontLayoutPlan.Builder(this.ImFont, this.FdtReader, text); - } - - /// - public void Dispose() => this.manager.DecreaseFontRef(this.fontStyle); + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] + public GameFontLayoutPlan.Builder LayoutBuilder(string text) => new(this.ImFont, this.FdtReader, text); /// /// Draws text. /// /// Text to draw. + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] public void Text(string text) { if (!this.Available) @@ -93,6 +120,7 @@ public class GameFontHandle : IDisposable ///
/// Color. /// Text to draw. + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] public void TextColored(Vector4 col, string text) { ImGui.PushStyleColor(ImGuiCol.Text, col); @@ -104,6 +132,7 @@ public class GameFontHandle : IDisposable /// Draws disabled text. ///
/// Text to draw. + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] public void TextDisabled(string text) { unsafe diff --git a/Dalamud/Interface/GameFonts/GameFontManager.cs b/Dalamud/Interface/GameFonts/GameFontManager.cs deleted file mode 100644 index ad0e47273..000000000 --- a/Dalamud/Interface/GameFonts/GameFontManager.cs +++ /dev/null @@ -1,470 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Tasks; - -using Dalamud.Data; -using Dalamud.Game; -using Dalamud.Interface.Internal; -using Dalamud.Utility.Timing; -using ImGuiNET; -using Lumina.Data.Files; -using Serilog; - -using static Dalamud.Interface.ImGuiHelpers; - -namespace Dalamud.Interface.GameFonts; - -/// -/// Loads game font for use in ImGui. -/// -[ServiceManager.EarlyLoadedService] -internal class GameFontManager : IServiceType -{ - private static readonly string?[] FontNames = - { - null, - "AXIS_96", "AXIS_12", "AXIS_14", "AXIS_18", "AXIS_36", - "Jupiter_16", "Jupiter_20", "Jupiter_23", "Jupiter_45", "Jupiter_46", "Jupiter_90", - "Meidinger_16", "Meidinger_20", "Meidinger_40", - "MiedingerMid_10", "MiedingerMid_12", "MiedingerMid_14", "MiedingerMid_18", "MiedingerMid_36", - "TrumpGothic_184", "TrumpGothic_23", "TrumpGothic_34", "TrumpGothic_68", - }; - - private readonly object syncRoot = new(); - - private readonly FdtReader?[] fdts; - private readonly List texturePixels; - private readonly Dictionary fonts = new(); - private readonly Dictionary fontUseCounter = new(); - private readonly Dictionary>> glyphRectIds = new(); - -#pragma warning disable CS0414 - private bool isBetweenBuildFontsAndRightAfterImGuiIoFontsBuild = false; -#pragma warning restore CS0414 - - [ServiceManager.ServiceConstructor] - private GameFontManager(DataManager dataManager) - { - using (Timings.Start("Getting fdt data")) - { - this.fdts = FontNames.Select(fontName => fontName == null ? null : new FdtReader(dataManager.GetFile($"common/font/{fontName}.fdt")!.Data)).ToArray(); - } - - using (Timings.Start("Getting texture data")) - { - var texTasks = Enumerable - .Range(1, 1 + this.fdts - .Where(x => x != null) - .Select(x => x.Glyphs.Select(y => y.TextureFileIndex).Max()) - .Max()) - .Select(x => dataManager.GetFile($"common/font/font{x}.tex")!) - .Select(x => new Task(Timings.AttachTimingHandle(() => x.ImageData!))) - .ToArray(); - foreach (var task in texTasks) - task.Start(); - this.texturePixels = texTasks.Select(x => x.GetAwaiter().GetResult()).ToList(); - } - } - - /// - /// Describe font into a string. - /// - /// Font to describe. - /// A string in a form of "FontName (NNNpt)". - public static string DescribeFont(GameFontFamilyAndSize font) - { - return font switch - { - GameFontFamilyAndSize.Undefined => "-", - GameFontFamilyAndSize.Axis96 => "AXIS (9.6pt)", - GameFontFamilyAndSize.Axis12 => "AXIS (12pt)", - GameFontFamilyAndSize.Axis14 => "AXIS (14pt)", - GameFontFamilyAndSize.Axis18 => "AXIS (18pt)", - GameFontFamilyAndSize.Axis36 => "AXIS (36pt)", - GameFontFamilyAndSize.Jupiter16 => "Jupiter (16pt)", - GameFontFamilyAndSize.Jupiter20 => "Jupiter (20pt)", - GameFontFamilyAndSize.Jupiter23 => "Jupiter (23pt)", - GameFontFamilyAndSize.Jupiter45 => "Jupiter Numeric (45pt)", - GameFontFamilyAndSize.Jupiter46 => "Jupiter (46pt)", - GameFontFamilyAndSize.Jupiter90 => "Jupiter Numeric (90pt)", - GameFontFamilyAndSize.Meidinger16 => "Meidinger Numeric (16pt)", - GameFontFamilyAndSize.Meidinger20 => "Meidinger Numeric (20pt)", - GameFontFamilyAndSize.Meidinger40 => "Meidinger Numeric (40pt)", - GameFontFamilyAndSize.MiedingerMid10 => "MiedingerMid (10pt)", - GameFontFamilyAndSize.MiedingerMid12 => "MiedingerMid (12pt)", - GameFontFamilyAndSize.MiedingerMid14 => "MiedingerMid (14pt)", - GameFontFamilyAndSize.MiedingerMid18 => "MiedingerMid (18pt)", - GameFontFamilyAndSize.MiedingerMid36 => "MiedingerMid (36pt)", - GameFontFamilyAndSize.TrumpGothic184 => "Trump Gothic (18.4pt)", - GameFontFamilyAndSize.TrumpGothic23 => "Trump Gothic (23pt)", - GameFontFamilyAndSize.TrumpGothic34 => "Trump Gothic (34pt)", - GameFontFamilyAndSize.TrumpGothic68 => "Trump Gothic (68pt)", - _ => throw new ArgumentOutOfRangeException(nameof(font), font, "Invalid argument"), - }; - } - - /// - /// Determines whether a font should be able to display most of stuff. - /// - /// Font to check. - /// True if it can. - public static bool IsGenericPurposeFont(GameFontFamilyAndSize font) - { - return font switch - { - GameFontFamilyAndSize.Axis96 => true, - GameFontFamilyAndSize.Axis12 => true, - GameFontFamilyAndSize.Axis14 => true, - GameFontFamilyAndSize.Axis18 => true, - GameFontFamilyAndSize.Axis36 => true, - _ => false, - }; - } - - /// - /// Unscales fonts after they have been rendered onto atlas. - /// - /// Font to unscale. - /// Scale factor. - /// Whether to call target.BuildLookupTable(). - public static void UnscaleFont(ImFontPtr fontPtr, float fontScale, bool rebuildLookupTable = true) - { - if (fontScale == 1) - return; - - unsafe - { - var font = fontPtr.NativePtr; - for (int i = 0, i_ = font->IndexedHotData.Size; i < i_; ++i) - { - font->IndexedHotData.Ref(i).AdvanceX /= fontScale; - font->IndexedHotData.Ref(i).OccupiedWidth /= fontScale; - } - - font->FontSize /= fontScale; - font->Ascent /= fontScale; - font->Descent /= fontScale; - if (font->ConfigData != null) - font->ConfigData->SizePixels /= fontScale; - var glyphs = (ImFontGlyphReal*)font->Glyphs.Data; - for (int i = 0, i_ = font->Glyphs.Size; i < i_; i++) - { - var glyph = &glyphs[i]; - glyph->X0 /= fontScale; - glyph->X1 /= fontScale; - glyph->Y0 /= fontScale; - glyph->Y1 /= fontScale; - glyph->AdvanceX /= fontScale; - } - - for (int i = 0, i_ = font->KerningPairs.Size; i < i_; i++) - font->KerningPairs.Ref(i).AdvanceXAdjustment /= fontScale; - for (int i = 0, i_ = font->FrequentKerningPairs.Size; i < i_; i++) - font->FrequentKerningPairs.Ref(i) /= fontScale; - } - - if (rebuildLookupTable && fontPtr.Glyphs.Size > 0) - fontPtr.BuildLookupTable(); - } - - /// - /// Creates a new GameFontHandle, and increases internal font reference counter, and if it's first time use, then the font will be loaded on next font building process. - /// - /// Font to use. - /// Handle to game font that may or may not be ready yet. - public GameFontHandle NewFontRef(GameFontStyle style) - { - var interfaceManager = Service.Get(); - var needRebuild = false; - - lock (this.syncRoot) - { - this.fontUseCounter[style] = this.fontUseCounter.GetValueOrDefault(style, 0) + 1; - } - - needRebuild = !this.fonts.ContainsKey(style); - if (needRebuild) - { - Log.Information("[GameFontManager] NewFontRef: Queueing RebuildFonts because {0} has been requested.", style.ToString()); - Service.GetAsync() - .ContinueWith(task => task.Result.RunOnTick(() => interfaceManager.RebuildFonts())); - } - - return new(this, style); - } - - /// - /// Gets the font. - /// - /// Font to get. - /// Corresponding font or null. - public ImFontPtr? GetFont(GameFontStyle style) => this.fonts.GetValueOrDefault(style, null); - - /// - /// Gets the corresponding FdtReader. - /// - /// Font to get. - /// Corresponding FdtReader or null. - public FdtReader? GetFdtReader(GameFontFamilyAndSize family) => this.fdts[(int)family]; - - /// - /// Fills missing glyphs in target font from source font, if both are not null. - /// - /// Source font. - /// Target font. - /// Whether to copy missing glyphs only. - /// Whether to call target.BuildLookupTable(). - public void CopyGlyphsAcrossFonts(ImFontPtr? source, GameFontStyle target, bool missingOnly, bool rebuildLookupTable) - { - ImGuiHelpers.CopyGlyphsAcrossFonts(source, this.fonts[target], missingOnly, rebuildLookupTable); - } - - /// - /// Fills missing glyphs in target font from source font, if both are not null. - /// - /// Source font. - /// Target font. - /// Whether to copy missing glyphs only. - /// Whether to call target.BuildLookupTable(). - public void CopyGlyphsAcrossFonts(GameFontStyle source, ImFontPtr? target, bool missingOnly, bool rebuildLookupTable) - { - ImGuiHelpers.CopyGlyphsAcrossFonts(this.fonts[source], target, missingOnly, rebuildLookupTable); - } - - /// - /// Fills missing glyphs in target font from source font, if both are not null. - /// - /// Source font. - /// Target font. - /// Whether to copy missing glyphs only. - /// Whether to call target.BuildLookupTable(). - public void CopyGlyphsAcrossFonts(GameFontStyle source, GameFontStyle target, bool missingOnly, bool rebuildLookupTable) - { - ImGuiHelpers.CopyGlyphsAcrossFonts(this.fonts[source], this.fonts[target], missingOnly, rebuildLookupTable); - } - - /// - /// Build fonts before plugins do something more. To be called from InterfaceManager. - /// - public void BuildFonts() - { - this.isBetweenBuildFontsAndRightAfterImGuiIoFontsBuild = true; - - this.glyphRectIds.Clear(); - this.fonts.Clear(); - - lock (this.syncRoot) - { - foreach (var style in this.fontUseCounter.Keys) - this.EnsureFont(style); - } - } - - /// - /// Record that ImGui.GetIO().Fonts.Build() has been called. - /// - public void AfterIoFontsBuild() - { - this.isBetweenBuildFontsAndRightAfterImGuiIoFontsBuild = false; - } - - /// - /// Checks whether GameFontMamager owns an ImFont. - /// - /// ImFontPtr to check. - /// Whether it owns. - public bool OwnsFont(ImFontPtr fontPtr) => this.fonts.ContainsValue(fontPtr); - - /// - /// Post-build fonts before plugins do something more. To be called from InterfaceManager. - /// - public unsafe void AfterBuildFonts() - { - var interfaceManager = Service.Get(); - var ioFonts = ImGui.GetIO().Fonts; - var fontGamma = interfaceManager.FontGamma; - - var pixels8s = new byte*[ioFonts.Textures.Size]; - var pixels32s = new uint*[ioFonts.Textures.Size]; - var widths = new int[ioFonts.Textures.Size]; - var heights = new int[ioFonts.Textures.Size]; - for (var i = 0; i < pixels8s.Length; i++) - { - ioFonts.GetTexDataAsRGBA32(i, out pixels8s[i], out widths[i], out heights[i]); - pixels32s[i] = (uint*)pixels8s[i]; - } - - foreach (var (style, font) in this.fonts) - { - var fdt = this.fdts[(int)style.FamilyAndSize]; - var scale = style.SizePt / fdt.FontHeader.Size; - var fontPtr = font.NativePtr; - - Log.Verbose("[GameFontManager] AfterBuildFonts: Scaling {0} from {1}pt to {2}pt (scale: {3})", style.ToString(), fdt.FontHeader.Size, style.SizePt, scale); - - fontPtr->FontSize = fdt.FontHeader.Size * 4 / 3; - if (fontPtr->ConfigData != null) - fontPtr->ConfigData->SizePixels = fontPtr->FontSize; - fontPtr->Ascent = fdt.FontHeader.Ascent; - fontPtr->Descent = fdt.FontHeader.Descent; - fontPtr->EllipsisChar = '…'; - foreach (var fallbackCharCandidate in "〓?!") - { - var glyph = font.FindGlyphNoFallback(fallbackCharCandidate); - if ((IntPtr)glyph.NativePtr != IntPtr.Zero) - { - var ptr = font.NativePtr; - ptr->FallbackChar = fallbackCharCandidate; - ptr->FallbackGlyph = glyph.NativePtr; - ptr->FallbackHotData = (ImFontGlyphHotData*)ptr->IndexedHotData.Address(fallbackCharCandidate); - break; - } - } - - // I have no idea what's causing NPE, so just to be safe - try - { - if (font.NativePtr != null && font.NativePtr->ConfigData != null) - { - var nameBytes = Encoding.UTF8.GetBytes(style.ToString() + "\0"); - Marshal.Copy(nameBytes, 0, (IntPtr)font.ConfigData.Name.Data, Math.Min(nameBytes.Length, font.ConfigData.Name.Count)); - } - } - catch (NullReferenceException) - { - // do nothing - } - - foreach (var (c, (rectId, glyph)) in this.glyphRectIds[style]) - { - var rc = (ImFontAtlasCustomRectReal*)ioFonts.GetCustomRectByIndex(rectId).NativePtr; - var pixels8 = pixels8s[rc->TextureIndex]; - var pixels32 = pixels32s[rc->TextureIndex]; - var width = widths[rc->TextureIndex]; - var height = heights[rc->TextureIndex]; - var sourceBuffer = this.texturePixels[glyph.TextureFileIndex]; - var sourceBufferDelta = glyph.TextureChannelByteIndex; - var widthAdjustment = style.CalculateBaseWidthAdjustment(fdt, glyph); - if (widthAdjustment == 0) - { - for (var y = 0; y < glyph.BoundingHeight; y++) - { - for (var x = 0; x < glyph.BoundingWidth; x++) - { - var a = sourceBuffer[sourceBufferDelta + (4 * (((glyph.TextureOffsetY + y) * fdt.FontHeader.TextureWidth) + glyph.TextureOffsetX + x))]; - pixels32[((rc->Y + y) * width) + rc->X + x] = (uint)(a << 24) | 0xFFFFFFu; - } - } - } - else - { - for (var y = 0; y < glyph.BoundingHeight; y++) - { - for (var x = 0; x < glyph.BoundingWidth + widthAdjustment; x++) - pixels32[((rc->Y + y) * width) + rc->X + x] = 0xFFFFFFu; - } - - for (int xbold = 0, xbold_ = Math.Max(1, (int)Math.Ceiling(style.Weight + 1)); xbold < xbold_; xbold++) - { - var boldStrength = Math.Min(1f, style.Weight + 1 - xbold); - for (var y = 0; y < glyph.BoundingHeight; y++) - { - float xDelta = xbold; - if (style.BaseSkewStrength > 0) - xDelta += style.BaseSkewStrength * (fdt.FontHeader.LineHeight - glyph.CurrentOffsetY - y) / fdt.FontHeader.LineHeight; - else if (style.BaseSkewStrength < 0) - xDelta -= style.BaseSkewStrength * (glyph.CurrentOffsetY + y) / fdt.FontHeader.LineHeight; - var xDeltaInt = (int)Math.Floor(xDelta); - var xness = xDelta - xDeltaInt; - for (var x = 0; x < glyph.BoundingWidth; x++) - { - var sourcePixelIndex = ((glyph.TextureOffsetY + y) * fdt.FontHeader.TextureWidth) + glyph.TextureOffsetX + x; - var a1 = sourceBuffer[sourceBufferDelta + (4 * sourcePixelIndex)]; - var a2 = x == glyph.BoundingWidth - 1 ? 0 : sourceBuffer[sourceBufferDelta + (4 * (sourcePixelIndex + 1))]; - var n = (a1 * xness) + (a2 * (1 - xness)); - var targetOffset = ((rc->Y + y) * width) + rc->X + x + xDeltaInt; - pixels8[(targetOffset * 4) + 3] = Math.Max(pixels8[(targetOffset * 4) + 3], (byte)(boldStrength * n)); - } - } - } - } - - if (Math.Abs(fontGamma - 1.4f) >= 0.001) - { - // Gamma correction (stbtt/FreeType would output in linear space whereas most real world usages will apply 1.4 or 1.8 gamma; Windows/XIV prebaked uses 1.4) - for (int y = rc->Y, y_ = rc->Y + rc->Height; y < y_; y++) - { - for (int x = rc->X, x_ = rc->X + rc->Width; x < x_; x++) - { - var i = (((y * width) + x) * 4) + 3; - pixels8[i] = (byte)(Math.Pow(pixels8[i] / 255.0f, 1.4f / fontGamma) * 255.0f); - } - } - } - } - - UnscaleFont(font, 1 / scale, false); - } - } - - /// - /// Decrease font reference counter. - /// - /// Font to release. - internal void DecreaseFontRef(GameFontStyle style) - { - lock (this.syncRoot) - { - if (!this.fontUseCounter.ContainsKey(style)) - return; - - if ((this.fontUseCounter[style] -= 1) == 0) - this.fontUseCounter.Remove(style); - } - } - - private unsafe void EnsureFont(GameFontStyle style) - { - var rectIds = this.glyphRectIds[style] = new(); - - var fdt = this.fdts[(int)style.FamilyAndSize]; - if (fdt == null) - return; - - ImFontConfigPtr fontConfig = ImGuiNative.ImFontConfig_ImFontConfig(); - fontConfig.OversampleH = 1; - fontConfig.OversampleV = 1; - fontConfig.PixelSnapH = false; - - var io = ImGui.GetIO(); - var font = io.Fonts.AddFontDefault(fontConfig); - - fontConfig.Destroy(); - - this.fonts[style] = font; - foreach (var glyph in fdt.Glyphs) - { - var c = glyph.Char; - if (c < 32 || c >= 0xFFFF) - continue; - - var widthAdjustment = style.CalculateBaseWidthAdjustment(fdt, glyph); - rectIds[c] = Tuple.Create( - io.Fonts.AddCustomRectFontGlyph( - font, - c, - glyph.BoundingWidth + widthAdjustment, - glyph.BoundingHeight, - glyph.AdvanceWidth, - new Vector2(0, glyph.CurrentOffsetY)), - glyph); - } - - foreach (var kernPair in fdt.Distances) - font.AddKerningPair(kernPair.Left, kernPair.Right, kernPair.RightOffset); - } -} diff --git a/Dalamud/Interface/GameFonts/GameFontStyle.cs b/Dalamud/Interface/GameFonts/GameFontStyle.cs index 40b810161..fbaf9de07 100644 --- a/Dalamud/Interface/GameFonts/GameFontStyle.cs +++ b/Dalamud/Interface/GameFonts/GameFontStyle.cs @@ -1,5 +1,3 @@ -using System; - namespace Dalamud.Interface.GameFonts; /// @@ -66,7 +64,7 @@ public struct GameFontStyle /// public float SizePt { - get => this.SizePx * 3 / 4; + readonly get => this.SizePx * 3 / 4; set => this.SizePx = value * 4 / 3; } @@ -75,14 +73,14 @@ public struct GameFontStyle ///
public float BaseSkewStrength { - get => this.SkewStrength * this.BaseSizePx / this.SizePx; + readonly get => this.SkewStrength * this.BaseSizePx / this.SizePx; set => this.SkewStrength = value * this.SizePx / this.BaseSizePx; } /// /// Gets the font family. /// - public GameFontFamily Family => this.FamilyAndSize switch + public readonly GameFontFamily Family => this.FamilyAndSize switch { GameFontFamilyAndSize.Undefined => GameFontFamily.Undefined, GameFontFamilyAndSize.Axis96 => GameFontFamily.Axis, @@ -114,7 +112,7 @@ public struct GameFontStyle /// /// Gets the corresponding GameFontFamilyAndSize but with minimum possible font sizes. /// - public GameFontFamilyAndSize FamilyWithMinimumSize => this.Family switch + public readonly GameFontFamilyAndSize FamilyWithMinimumSize => this.Family switch { GameFontFamily.Axis => GameFontFamilyAndSize.Axis96, GameFontFamily.Jupiter => GameFontFamilyAndSize.Jupiter16, @@ -128,7 +126,7 @@ public struct GameFontStyle /// /// Gets the base font size in point unit. /// - public float BaseSizePt => this.FamilyAndSize switch + public readonly float BaseSizePt => this.FamilyAndSize switch { GameFontFamilyAndSize.Undefined => 0, GameFontFamilyAndSize.Axis96 => 9.6f, @@ -153,21 +151,21 @@ public struct GameFontStyle GameFontFamilyAndSize.TrumpGothic184 => 18.4f, GameFontFamilyAndSize.TrumpGothic23 => 23, GameFontFamilyAndSize.TrumpGothic34 => 34, - GameFontFamilyAndSize.TrumpGothic68 => 8, + GameFontFamilyAndSize.TrumpGothic68 => 68, _ => throw new InvalidOperationException(), }; /// /// Gets the base font size in pixel unit. /// - public float BaseSizePx => this.BaseSizePt * 4 / 3; + public readonly float BaseSizePx => this.BaseSizePt * 4 / 3; /// /// Gets or sets a value indicating whether this font is bold. /// public bool Bold { - get => this.Weight > 0f; + readonly get => this.Weight > 0f; set => this.Weight = value ? 1f : 0f; } @@ -176,8 +174,8 @@ public struct GameFontStyle ///
public bool Italic { - get => this.SkewStrength != 0; - set => this.SkewStrength = value ? this.SizePx / 7 : 0; + readonly get => this.SkewStrength != 0; + set => this.SkewStrength = value ? this.SizePx / 6 : 0; } /// @@ -186,77 +184,90 @@ public struct GameFontStyle /// Font family. /// Font size in points. /// Recommended GameFontFamilyAndSize. - public static GameFontFamilyAndSize GetRecommendedFamilyAndSize(GameFontFamily family, float size) - { - if (size <= 0) - return GameFontFamilyAndSize.Undefined; - - switch (family) + public static GameFontFamilyAndSize GetRecommendedFamilyAndSize(GameFontFamily family, float size) => + family switch { - case GameFontFamily.Undefined: - return GameFontFamilyAndSize.Undefined; + _ when size <= 0 => GameFontFamilyAndSize.Undefined, + GameFontFamily.Undefined => GameFontFamilyAndSize.Undefined, + GameFontFamily.Axis => size switch + { + <= ((int)((9.6f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Axis96, + <= ((int)((12f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Axis12, + <= ((int)((14f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Axis14, + <= ((int)((18f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Axis18, + _ => GameFontFamilyAndSize.Axis36, + }, + GameFontFamily.Jupiter => size switch + { + <= ((int)((16f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Jupiter16, + <= ((int)((20f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Jupiter20, + <= ((int)((23f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Jupiter23, + _ => GameFontFamilyAndSize.Jupiter46, + }, + GameFontFamily.JupiterNumeric => size switch + { + <= ((int)((45f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Jupiter45, + _ => GameFontFamilyAndSize.Jupiter90, + }, + GameFontFamily.Meidinger => size switch + { + <= ((int)((16f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Meidinger16, + <= ((int)((20f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.Meidinger20, + _ => GameFontFamilyAndSize.Meidinger40, + }, + GameFontFamily.MiedingerMid => size switch + { + <= ((int)((10f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.MiedingerMid10, + <= ((int)((12f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.MiedingerMid12, + <= ((int)((14f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.MiedingerMid14, + <= ((int)((18f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.MiedingerMid18, + _ => GameFontFamilyAndSize.MiedingerMid36, + }, + GameFontFamily.TrumpGothic => size switch + { + <= ((int)((18.4f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.TrumpGothic184, + <= ((int)((23f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.TrumpGothic23, + <= ((int)((34f * 4f / 3f) + 0.5f) * 3f / 4f) + 0.001f => GameFontFamilyAndSize.TrumpGothic34, + _ => GameFontFamilyAndSize.TrumpGothic68, + }, + _ => GameFontFamilyAndSize.Undefined, + }; - case GameFontFamily.Axis: - if (size <= 9.601) - return GameFontFamilyAndSize.Axis96; - else if (size <= 12.001) - return GameFontFamilyAndSize.Axis12; - else if (size <= 14.001) - return GameFontFamilyAndSize.Axis14; - else if (size <= 18.001) - return GameFontFamilyAndSize.Axis18; - else - return GameFontFamilyAndSize.Axis36; + /// + /// Creates a new scaled instance of struct. + /// + /// The scale. + /// The scaled instance. + public readonly GameFontStyle Scale(float scale) => new() + { + FamilyAndSize = GetRecommendedFamilyAndSize(this.Family, this.SizePt * scale), + SizePx = this.SizePx * scale, + Weight = this.Weight, + SkewStrength = this.SkewStrength * scale, + }; - case GameFontFamily.Jupiter: - if (size <= 16.001) - return GameFontFamilyAndSize.Jupiter16; - else if (size <= 20.001) - return GameFontFamilyAndSize.Jupiter20; - else if (size <= 23.001) - return GameFontFamilyAndSize.Jupiter23; - else - return GameFontFamilyAndSize.Jupiter46; - - case GameFontFamily.JupiterNumeric: - if (size <= 45.001) - return GameFontFamilyAndSize.Jupiter45; - else - return GameFontFamilyAndSize.Jupiter90; - - case GameFontFamily.Meidinger: - if (size <= 16.001) - return GameFontFamilyAndSize.Meidinger16; - else if (size <= 20.001) - return GameFontFamilyAndSize.Meidinger20; - else - return GameFontFamilyAndSize.Meidinger40; - - case GameFontFamily.MiedingerMid: - if (size <= 10.001) - return GameFontFamilyAndSize.MiedingerMid10; - else if (size <= 12.001) - return GameFontFamilyAndSize.MiedingerMid12; - else if (size <= 14.001) - return GameFontFamilyAndSize.MiedingerMid14; - else if (size <= 18.001) - return GameFontFamilyAndSize.MiedingerMid18; - else - return GameFontFamilyAndSize.MiedingerMid36; - - case GameFontFamily.TrumpGothic: - if (size <= 18.401) - return GameFontFamilyAndSize.TrumpGothic184; - else if (size <= 23.001) - return GameFontFamilyAndSize.TrumpGothic23; - else if (size <= 34.001) - return GameFontFamilyAndSize.TrumpGothic34; - else - return GameFontFamilyAndSize.TrumpGothic68; - - default: - return GameFontFamilyAndSize.Undefined; + /// + /// Calculates the adjustment to width resulting fron Weight and SkewStrength. + /// + /// Font header. + /// Glyph. + /// Width adjustment in pixel unit. + public readonly int CalculateBaseWidthAdjustment(in FdtReader.FontTableHeader header, in FdtReader.FontTableEntry glyph) + { + var widthDelta = this.Weight; + switch (this.BaseSkewStrength) + { + case > 0: + widthDelta += (1f * this.BaseSkewStrength * (header.LineHeight - glyph.CurrentOffsetY)) + / header.LineHeight; + break; + case < 0: + widthDelta -= (1f * this.BaseSkewStrength * (glyph.CurrentOffsetY + glyph.BoundingHeight)) + / header.LineHeight; + break; } + + return (int)MathF.Ceiling(widthDelta); } /// @@ -265,19 +276,11 @@ public struct GameFontStyle /// Font information. /// Glyph. /// Width adjustment in pixel unit. - public int CalculateBaseWidthAdjustment(FdtReader reader, FdtReader.FontTableEntry glyph) - { - var widthDelta = this.Weight; - if (this.BaseSkewStrength > 0) - widthDelta += 1f * this.BaseSkewStrength * (reader.FontHeader.LineHeight - glyph.CurrentOffsetY) / reader.FontHeader.LineHeight; - else if (this.BaseSkewStrength < 0) - widthDelta -= 1f * this.BaseSkewStrength * (glyph.CurrentOffsetY + glyph.BoundingHeight) / reader.FontHeader.LineHeight; - - return (int)Math.Ceiling(widthDelta); - } + public readonly int CalculateBaseWidthAdjustment(FdtReader reader, FdtReader.FontTableEntry glyph) => + this.CalculateBaseWidthAdjustment(reader.FontHeader, glyph); /// - public override string ToString() + public override readonly string ToString() { return $"GameFontStyle({this.FamilyAndSize}, {this.SizePt}pt, skew={this.SkewStrength}, weight={this.Weight})"; } diff --git a/Dalamud/Interface/ImGuiFileDialog/FileDialog.UI.cs b/Dalamud/Interface/ImGuiFileDialog/FileDialog.UI.cs index d3be8da95..0dd1410d5 100644 --- a/Dalamud/Interface/ImGuiFileDialog/FileDialog.UI.cs +++ b/Dalamud/Interface/ImGuiFileDialog/FileDialog.UI.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Numerics; +using Dalamud.Interface.Utility; using Dalamud.Utility; using ImGuiNET; diff --git a/Dalamud/Interface/ImGuiFileDialog/FileDialog.cs b/Dalamud/Interface/ImGuiFileDialog/FileDialog.cs index aec5e9af4..411f203cc 100644 --- a/Dalamud/Interface/ImGuiFileDialog/FileDialog.cs +++ b/Dalamud/Interface/ImGuiFileDialog/FileDialog.cs @@ -123,16 +123,6 @@ public partial class FileDialog return this.isOk; } - /// - /// Gets the result of the selection. - /// - /// The result of the selection (file or folder path). If multiple entries were selected, they are separated with commas. - [Obsolete("Use GetResults() instead.", true)] - public string GetResult() - { - return string.Join(',', this.GetResults()); - } - /// /// Gets the result of the selection. /// diff --git a/Dalamud/Interface/ImGuiFontChooserDialog/SingleFontChooserDialog.cs b/Dalamud/Interface/ImGuiFontChooserDialog/SingleFontChooserDialog.cs new file mode 100644 index 000000000..7636f22b6 --- /dev/null +++ b/Dalamud/Interface/ImGuiFontChooserDialog/SingleFontChooserDialog.cs @@ -0,0 +1,1312 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +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; + +using ImGuiNET; + +using TerraFX.Interop.DirectX; +using TerraFX.Interop.Windows; + +namespace Dalamud.Interface.ImGuiFontChooserDialog; + +/// +/// A dialog for choosing a font and its size. +/// +[SuppressMessage( + "StyleCop.CSharp.LayoutRules", + "SA1519:Braces should not be omitted from multi-line child statement", + Justification = "Multiple fixed blocks")] +public sealed class SingleFontChooserDialog : IDisposable +{ + private const float MinFontSizePt = 1; + + private const float MaxFontSizePt = 127; + + private static readonly List EmptyIFontList = new(); + + private static readonly (string Name, float Value)[] FontSizeList = + { + ("9.6", 9.6f), + ("10", 10f), + ("12", 12f), + ("14", 14f), + ("16", 16f), + ("18", 18f), + ("18.4", 18.4f), + ("20", 20), + ("23", 23), + ("34", 34), + ("36", 36), + ("40", 40), + ("45", 45), + ("46", 46), + ("68", 68), + ("90", 90), + }; + + private static int counterStatic; + + private readonly int counter; + private readonly byte[] fontPreviewText = new byte[2048]; + private readonly TaskCompletionSource tcs = new(); + private readonly IFontAtlas atlas; + + private string popupImGuiName; + private string title; + + private bool firstDraw = true; + private bool firstDrawAfterRefresh; + private int setFocusOn = -1; + + private bool useAdvancedOptions; + private AdvancedOptionsUiState advUiState; + + private Task>? fontFamilies; + private int selectedFamilyIndex = -1; + private int selectedFontIndex = -1; + private int selectedFontWeight = (int)DWRITE_FONT_WEIGHT.DWRITE_FONT_WEIGHT_NORMAL; + private int selectedFontStretch = (int)DWRITE_FONT_STRETCH.DWRITE_FONT_STRETCH_NORMAL; + private int selectedFontStyle = (int)DWRITE_FONT_STYLE.DWRITE_FONT_STYLE_NORMAL; + + private string familySearch = string.Empty; + private string fontSearch = string.Empty; + private string fontSizeSearch = "12"; + private IFontHandle? fontHandle; + private SingleFontSpec selectedFont; + + private bool popupPositionChanged; + private bool popupSizeChanged; + private Vector2 popupPosition = new(float.NaN); + private Vector2 popupSize = new(float.NaN); + + /// Initializes a new instance of the class. + /// A new instance of created using + /// as its auto-rebuild mode. + /// The passed instance of 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 for automatic + /// handling of font atlas derived from a , or even for automatic + /// registration and unregistration of event handler in addition to automatic disposal of this + /// class and the temporary font atlas for this font chooser dialog. + [Obsolete("See remarks, and use the other constructor.", false)] + [Api10ToDo("Make private.")] + public SingleFontChooserDialog(IFontAtlas newAsyncAtlas) + { + this.counter = Interlocked.Increment(ref counterStatic); + this.title = "Choose a font..."; + this.popupImGuiName = $"{this.title}##{nameof(SingleFontChooserDialog)}[{this.counter}]"; + this.atlas = newAsyncAtlas; + this.selectedFont = new() { FontId = DalamudDefaultFontAndFamilyId.Instance }; + 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 + + /// Initializes a new instance of the class. + /// The relevant instance of UiBuilder. + /// Whether the fonts in the atlas is global scaled. + /// Atlas name for debugging purposes. + /// + /// The passed is only used for creating a temporary font atlas. It will not + /// automatically register a hander for . + /// Consider using for automatic registration and unregistration of + /// event handler in addition to automatic disposal of this class and the temporary font atlas + /// for this font chooser dialog. + /// + public SingleFontChooserDialog(UiBuilder uiBuilder, bool isGlobalScaled = true, string? debugAtlasName = null) + : this(uiBuilder.CreateFontAtlas(FontAtlasAutoRebuildMode.Async, isGlobalScaled, debugAtlasName)) + { + } + + /// Initializes a new instance of the class. + /// An instance of . + /// The temporary atlas name. + 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 + + /// Called when the selected font spec has changed. + public event Action? SelectedFontSpecChanged; + + /// + /// Gets or sets the title of this font chooser dialog popup. + /// + public string Title + { + get => this.title; + set + { + this.title = value; + this.popupImGuiName = $"{this.title}##{nameof(SingleFontChooserDialog)}[{this.counter}]"; + } + } + + /// + /// Gets or sets the preview text. A text too long may be truncated on assignment. + /// + public string PreviewText + { + get + { + var n = this.fontPreviewText.AsSpan().IndexOf((byte)0); + return n < 0 + ? Encoding.UTF8.GetString(this.fontPreviewText) + : Encoding.UTF8.GetString(this.fontPreviewText, 0, n); + } + set => Encoding.UTF8.GetBytes(value, this.fontPreviewText); + } + + /// + /// Gets the task that resolves upon choosing a font or cancellation. + /// + public Task ResultTask => this.tcs.Task; + + /// + /// Gets or sets the selected family and font. + /// + public SingleFontSpec SelectedFont + { + get => this.selectedFont; + set + { + this.selectedFont = value; + + var familyName = value.FontId.Family.ToString() ?? string.Empty; + var fontName = value.FontId.ToString() ?? string.Empty; + this.familySearch = this.ExtractName(value.FontId.Family); + this.fontSearch = this.ExtractName(value.FontId); + if (this.fontFamilies?.IsCompletedSuccessfully is true) + this.UpdateSelectedFamilyAndFontIndices(this.fontFamilies.Result, familyName, fontName); + this.fontSizeSearch = $"{value.SizePt:0.##}"; + this.advUiState = new(value); + this.useAdvancedOptions |= Math.Abs(value.LineHeight - 1f) > 0.000001; + this.useAdvancedOptions |= value.GlyphOffset != default; + this.useAdvancedOptions |= value.LetterSpacing != 0f; + + this.SelectedFontSpecChanged?.Invoke(this.selectedFont); + } + } + + /// + /// Gets or sets the font family exclusion filter predicate. + /// + public Predicate? FontFamilyExcludeFilter { get; set; } + + /// + /// Gets or sets a value indicating whether to ignore the global scale on preview text input. + /// + public bool IgnorePreviewGlobalScale { get; set; } + + /// Gets or sets a value indicating whether this popup should be modal, blocking everything behind from + /// being interacted. + /// If true, then will be + /// used. Otherwise, will be used. + public bool IsModal { get; set; } = true; + + /// Gets or sets the window flags. + public ImGuiWindowFlags WindowFlags { get; set; } + + /// Gets or sets the popup window position. + /// + /// Setting the position only works before the first call to . + /// If any of the coordinates are , default position will be used. + /// The position will be clamped into the work area of the selected monitor. + /// + public Vector2 PopupPosition + { + get => this.popupPosition; + set + { + this.popupPositionChanged = true; + this.popupPosition = value; + } + } + + /// Gets or sets the popup window size. + /// + /// Setting the size only works before the first call to . + /// If any of the coordinates are , default size will be used. + /// The size will be clamped into the work area of the selected monitor. + /// + public Vector2 PopupSize + { + get => this.popupSize; + set + { + this.popupSizeChanged = true; + this.popupSize = value; + } + } + + /// Creates a new instance of that will automatically draw and + /// dispose itself as needed; calling and are handled automatically. + /// + /// An instance of . + /// The new instance of . + public static SingleFontChooserDialog CreateAuto(UiBuilder uiBuilder) + { + var fcd = new SingleFontChooserDialog(uiBuilder); + uiBuilder.Draw += fcd.Draw; + fcd.tcs.Task.ContinueWith( + r => + { + _ = r.Exception; + uiBuilder.Draw -= fcd.Draw; + fcd.Dispose(); + }); + + return fcd; + } + + /// Gets the default popup size before clamping to monitor work area. + /// The default popup size. + public static Vector2 GetDefaultPopupSizeNonClamped() + { + ThreadSafety.AssertMainThread(); + return new Vector2(40, 30) * ImGui.GetTextLineHeight(); + } + + /// + public void Dispose() + { + this.fontHandle?.Dispose(); + this.atlas.Dispose(); + } + + /// + /// Cancels this dialog. + /// + public void Cancel() + { + this.tcs.SetCanceled(); + ImGui.GetIO().WantCaptureKeyboard = false; + ImGui.GetIO().WantTextInput = false; + } + + /// Sets and to be at the center of the current window + /// being drawn. + /// The preferred popup size. + public void SetPopupPositionAndSizeToCurrentWindowCenter(Vector2 preferredPopupSize) + { + ThreadSafety.AssertMainThread(); + this.PopupSize = preferredPopupSize; + this.PopupPosition = ImGui.GetWindowPos() + ((ImGui.GetWindowSize() - preferredPopupSize) / 2); + } + + /// Sets and to be at the center of the current window + /// being drawn. + public void SetPopupPositionAndSizeToCurrentWindowCenter() => + this.SetPopupPositionAndSizeToCurrentWindowCenter(GetDefaultPopupSizeNonClamped()); + + /// + /// Draws this dialog. + /// + public void Draw() + { + const float popupMinWidth = 320; + const float popupMinHeight = 240; + + ImGui.GetIO().WantCaptureKeyboard = true; + ImGui.GetIO().WantTextInput = true; + if (ImGui.IsKeyPressed(ImGuiKey.Escape)) + { + this.Cancel(); + return; + } + + if (this.firstDraw) + { + 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; + var windowPad = ImGui.GetStyle().WindowPadding; + var baseOffset = ImGui.GetCursorPos() - windowPad; + + var actionSize = Vector2.Zero; + actionSize = Vector2.Max(actionSize, ImGui.CalcTextSize("OK")); + actionSize = Vector2.Max(actionSize, ImGui.CalcTextSize("Cancel")); + actionSize = Vector2.Max(actionSize, ImGui.CalcTextSize("Refresh")); + actionSize = Vector2.Max(actionSize, ImGui.CalcTextSize("Reset")); + actionSize += framePad * 2; + + var bodySize = ImGui.GetContentRegionAvail(); + ImGui.SetCursorPos(baseOffset + windowPad); + if (ImGui.BeginChild( + "##choicesBlock", + bodySize with { X = bodySize.X - windowPad.X - actionSize.X }, + false, + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + this.DrawChoices(); + } + + ImGui.EndChild(); + + ImGui.SetCursorPos(baseOffset + windowPad + new Vector2(bodySize.X - actionSize.X, 0)); + + if (ImGui.BeginChild("##actionsBlock", bodySize with { X = actionSize.X })) + { + this.DrawActionButtons(actionSize); + } + + ImGui.EndChild(); + + 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(); + var previewHeight = (ImGui.GetFrameHeightWithSpacing() - lineHeight) + + Math.Max(lineHeight, this.selectedFont.LineHeightPx * 2); + + var advancedOptionsHeight = ImGui.GetFrameHeightWithSpacing() * (this.useAdvancedOptions ? 4 : 1); + + var tableSize = ImGui.GetContentRegionAvail() - + new Vector2(0, ImGui.GetStyle().WindowPadding.Y + previewHeight + advancedOptionsHeight); + if (ImGui.BeginChild( + "##tableContainer", + tableSize, + false, + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) + && ImGui.BeginTable("##table", 3, ImGuiTableFlags.None)) + { + ImGui.PushStyleColor(ImGuiCol.TableHeaderBg, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, Vector4.Zero); + ImGui.TableSetupColumn( + "Font:##familyColumn", + ImGuiTableColumnFlags.WidthStretch, + 0.4f); + ImGui.TableSetupColumn( + "Style:##fontColumn", + ImGuiTableColumnFlags.WidthStretch, + 0.4f); + ImGui.TableSetupColumn( + "Size:##sizeColumn", + ImGuiTableColumnFlags.WidthStretch, + 0.2f); + ImGui.TableHeadersRow(); + ImGui.PopStyleColor(3); + + ImGui.TableNextRow(); + + var pad = (int)MathF.Round(8 * ImGuiHelpers.GlobalScale); + ImGui.PushStyleVar(ImGuiStyleVar.CellPadding, new Vector2(pad)); + ImGui.TableNextColumn(); + var changed = this.DrawFamilyListColumn(); + + ImGui.TableNextColumn(); + changed |= this.DrawFontListColumn(changed); + + ImGui.TableNextColumn(); + changed |= this.DrawSizeListColumn(); + + if (changed) + { + this.fontHandle?.Dispose(); + this.fontHandle = null; + } + + ImGui.PopStyleVar(); + + ImGui.EndTable(); + } + + ImGui.EndChild(); + + ImGui.Checkbox("Show advanced options", ref this.useAdvancedOptions); + if (this.useAdvancedOptions) + { + if (this.DrawAdvancedOptions()) + { + this.fontHandle?.Dispose(); + this.fontHandle = null; + } + } + + if (this.fontHandle is null) + { + 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) + { + ImGui.SetCursorPos(ImGui.GetCursorPos() + ImGui.GetStyle().FramePadding); + ImGui.TextUnformatted("Select a font."); + } + else if (this.fontHandle.LoadException is { } loadException) + { + ImGui.SetCursorPos(ImGui.GetCursorPos() + ImGui.GetStyle().FramePadding); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + ImGui.TextUnformatted(loadException.Message); + ImGui.PopStyleColor(); + } + else if (!this.fontHandle.Available) + { + ImGui.SetCursorPos(ImGui.GetCursorPos() + ImGui.GetStyle().FramePadding); + ImGui.TextUnformatted("Loading font..."); + } + else + { + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + using (this.fontHandle?.Push()) + { + unsafe + { + fixed (byte* buf = this.fontPreviewText) + fixed (byte* label = "##fontPreviewText"u8) + { + ImGuiNative.igInputTextMultiline( + label, + buf, + (uint)this.fontPreviewText.Length, + ImGui.GetContentRegionAvail(), + ImGuiInputTextFlags.None, + null, + null); + } + } + } + } + } + + private unsafe bool DrawFamilyListColumn() + { + if (this.fontFamilies?.IsCompleted is not true) + { + ImGui.SetScrollY(0); + ImGui.TextUnformatted("Loading..."); + return false; + } + + if (!this.fontFamilies.IsCompletedSuccessfully) + { + ImGui.SetScrollY(0); + ImGui.TextUnformatted("Error: " + this.fontFamilies.Exception); + return false; + } + + var families = this.fontFamilies.Result; + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + + if (this.setFocusOn == 0) + { + this.setFocusOn = -1; + ImGui.SetKeyboardFocusHere(); + } + + var changed = false; + if (ImGui.InputText( + "##familySearch", + ref this.familySearch, + 255, + ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CallbackHistory, + data => + { + if (families.Count == 0) + return 0; + + var baseIndex = this.selectedFamilyIndex; + if (data->SelectionStart == 0 && data->SelectionEnd == data->BufTextLen) + { + switch (data->EventKey) + { + case ImGuiKey.DownArrow: + this.selectedFamilyIndex = (this.selectedFamilyIndex + 1) % families.Count; + changed = true; + break; + case ImGuiKey.UpArrow: + this.selectedFamilyIndex = + (this.selectedFamilyIndex + families.Count - 1) % families.Count; + changed = true; + break; + } + + if (changed) + { + ImGuiHelpers.SetTextFromCallback( + data, + this.ExtractName(families[this.selectedFamilyIndex])); + } + } + else + { + switch (data->EventKey) + { + case ImGuiKey.DownArrow: + this.selectedFamilyIndex = families.FindIndex( + baseIndex + 1, + x => this.TestName(x, this.familySearch)); + if (this.selectedFamilyIndex < 0) + { + this.selectedFamilyIndex = families.FindIndex( + 0, + baseIndex + 1, + x => this.TestName(x, this.familySearch)); + } + + changed = true; + break; + case ImGuiKey.UpArrow: + if (baseIndex > 0) + { + this.selectedFamilyIndex = families.FindLastIndex( + baseIndex - 1, + x => this.TestName(x, this.familySearch)); + } + + if (this.selectedFamilyIndex < 0) + { + if (baseIndex < 0) + baseIndex = 0; + this.selectedFamilyIndex = families.FindLastIndex( + families.Count - 1, + families.Count - baseIndex, + x => this.TestName(x, this.familySearch)); + } + + changed = true; + break; + } + } + + return 0; + })) + { + if (!string.IsNullOrWhiteSpace(this.familySearch) && !changed) + { + this.selectedFamilyIndex = families.FindIndex(x => this.TestName(x, this.familySearch)); + changed = true; + } + } + + if (ImGui.BeginChild("##familyList", ImGui.GetContentRegionAvail())) + { + var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + var lineHeight = ImGui.GetTextLineHeightWithSpacing(); + + if ((changed || this.firstDrawAfterRefresh) && this.selectedFamilyIndex != -1) + { + ImGui.SetScrollY( + (lineHeight * this.selectedFamilyIndex) - + ((ImGui.GetContentRegionAvail().Y - lineHeight) / 2)); + } + + clipper.Begin(families.Count, lineHeight); + while (clipper.Step()) + { + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + { + if (i < 0) + { + ImGui.TextUnformatted(" "); + continue; + } + + var selected = this.selectedFamilyIndex == i; + if (ImGui.Selectable( + this.ExtractName(families[i]), + ref selected, + ImGuiSelectableFlags.DontClosePopups)) + { + this.selectedFamilyIndex = families.IndexOf(families[i]); + this.familySearch = this.ExtractName(families[i]); + this.setFocusOn = 0; + changed = true; + } + } + } + + clipper.Destroy(); + } + + if (changed && this.selectedFamilyIndex >= 0) + { + var family = families[this.selectedFamilyIndex]; + using var matchingFont = default(ComPtr); + this.selectedFontIndex = family.FindBestMatch( + this.selectedFontWeight, + this.selectedFontStretch, + this.selectedFontStyle); + this.selectedFont = this.selectedFont with { FontId = family.Fonts[this.selectedFontIndex] }; + } + + ImGui.EndChild(); + return changed; + } + + private unsafe bool DrawFontListColumn(bool changed) + { + if (this.fontFamilies?.IsCompleted is not true) + { + ImGui.TextUnformatted("Loading..."); + return changed; + } + + if (!this.fontFamilies.IsCompletedSuccessfully) + { + ImGui.TextUnformatted("Error: " + this.fontFamilies.Exception); + return changed; + } + + var families = this.fontFamilies.Result; + var family = this.selectedFamilyIndex >= 0 + && this.selectedFamilyIndex < families.Count + ? families[this.selectedFamilyIndex] + : null; + var fonts = family?.Fonts ?? EmptyIFontList; + + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + + if (this.setFocusOn == 1) + { + this.setFocusOn = -1; + ImGui.SetKeyboardFocusHere(); + } + + if (ImGui.InputText( + "##fontSearch", + ref this.fontSearch, + 255, + ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CallbackHistory, + data => + { + if (fonts.Count == 0) + return 0; + + var baseIndex = this.selectedFontIndex; + if (data->SelectionStart == 0 && data->SelectionEnd == data->BufTextLen) + { + switch (data->EventKey) + { + case ImGuiKey.DownArrow: + this.selectedFontIndex = (this.selectedFontIndex + 1) % fonts.Count; + changed = true; + break; + case ImGuiKey.UpArrow: + this.selectedFontIndex = (this.selectedFontIndex + fonts.Count - 1) % fonts.Count; + changed = true; + break; + } + + if (changed) + { + ImGuiHelpers.SetTextFromCallback( + data, + this.ExtractName(fonts[this.selectedFontIndex])); + } + } + else + { + switch (data->EventKey) + { + case ImGuiKey.DownArrow: + this.selectedFontIndex = fonts.FindIndex( + baseIndex + 1, + x => this.TestName(x, this.fontSearch)); + if (this.selectedFontIndex < 0) + { + this.selectedFontIndex = fonts.FindIndex( + 0, + baseIndex + 1, + x => this.TestName(x, this.fontSearch)); + } + + changed = true; + break; + case ImGuiKey.UpArrow: + if (baseIndex > 0) + { + this.selectedFontIndex = fonts.FindLastIndex( + baseIndex - 1, + x => this.TestName(x, this.fontSearch)); + } + + if (this.selectedFontIndex < 0) + { + if (baseIndex < 0) + baseIndex = 0; + this.selectedFontIndex = fonts.FindLastIndex( + fonts.Count - 1, + fonts.Count - baseIndex, + x => this.TestName(x, this.fontSearch)); + } + + changed = true; + break; + } + } + + return 0; + })) + { + if (!string.IsNullOrWhiteSpace(this.fontSearch) && !changed) + { + this.selectedFontIndex = fonts.FindIndex(x => this.TestName(x, this.fontSearch)); + changed = true; + } + } + + if (ImGui.BeginChild("##fontList")) + { + var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + var lineHeight = ImGui.GetTextLineHeightWithSpacing(); + + if ((changed || this.firstDrawAfterRefresh) && this.selectedFontIndex != -1) + { + ImGui.SetScrollY( + (lineHeight * this.selectedFontIndex) - + ((ImGui.GetContentRegionAvail().Y - lineHeight) / 2)); + } + + clipper.Begin(fonts.Count, lineHeight); + while (clipper.Step()) + { + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + { + if (i < 0) + { + ImGui.TextUnformatted(" "); + continue; + } + + var selected = this.selectedFontIndex == i; + if (ImGui.Selectable( + this.ExtractName(fonts[i]), + ref selected, + ImGuiSelectableFlags.DontClosePopups)) + { + this.selectedFontIndex = fonts.IndexOf(fonts[i]); + this.fontSearch = this.ExtractName(fonts[i]); + this.setFocusOn = 1; + changed = true; + } + } + } + + clipper.Destroy(); + } + + ImGui.EndChild(); + + if (changed && family is not null && this.selectedFontIndex >= 0) + { + var font = family.Fonts[this.selectedFontIndex]; + this.selectedFontWeight = font.Weight; + this.selectedFontStretch = font.Stretch; + this.selectedFontStyle = font.Style; + this.selectedFont = this.selectedFont with { FontId = font }; + } + + return changed; + } + + private unsafe bool DrawSizeListColumn() + { + var changed = false; + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + + if (this.setFocusOn == 2) + { + this.setFocusOn = -1; + ImGui.SetKeyboardFocusHere(); + } + + if (ImGui.InputText( + "##fontSizeSearch", + ref this.fontSizeSearch, + 255, + ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CallbackHistory | + ImGuiInputTextFlags.CharsDecimal, + data => + { + switch (data->EventKey) + { + case ImGuiKey.DownArrow: + this.selectedFont = this.selectedFont with + { + SizePt = Math.Min(MaxFontSizePt, MathF.Floor(this.selectedFont.SizePt) + 1), + }; + changed = true; + break; + case ImGuiKey.UpArrow: + this.selectedFont = this.selectedFont with + { + SizePt = Math.Max(MinFontSizePt, MathF.Ceiling(this.selectedFont.SizePt) - 1), + }; + changed = true; + break; + } + + if (changed) + ImGuiHelpers.SetTextFromCallback(data, $"{this.selectedFont.SizePt:0.##}"); + + return 0; + })) + { + if (float.TryParse(this.fontSizeSearch, out var fontSizePt1)) + { + this.selectedFont = this.selectedFont with { SizePt = fontSizePt1 }; + changed = true; + } + } + + if (ImGui.BeginChild("##fontSizeList")) + { + var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + var lineHeight = ImGui.GetTextLineHeightWithSpacing(); + + if (changed && this.selectedFontIndex != -1) + { + ImGui.SetScrollY( + (lineHeight * this.selectedFontIndex) - + ((ImGui.GetContentRegionAvail().Y - lineHeight) / 2)); + } + + clipper.Begin(FontSizeList.Length, lineHeight); + while (clipper.Step()) + { + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + { + if (i < 0) + { + ImGui.TextUnformatted(" "); + continue; + } + + var selected = Equals(FontSizeList[i].Value, this.selectedFont.SizePt); + if (ImGui.Selectable( + FontSizeList[i].Name, + ref selected, + ImGuiSelectableFlags.DontClosePopups)) + { + this.selectedFont = this.selectedFont with { SizePt = FontSizeList[i].Value }; + this.setFocusOn = 2; + changed = true; + } + } + } + + clipper.Destroy(); + } + + ImGui.EndChild(); + + if (this.selectedFont.SizePt < MinFontSizePt) + { + this.selectedFont = this.selectedFont with { SizePt = MinFontSizePt }; + changed = true; + } + + if (this.selectedFont.SizePt > MaxFontSizePt) + { + this.selectedFont = this.selectedFont with { SizePt = MaxFontSizePt }; + changed = true; + } + + if (changed) + this.fontSizeSearch = $"{this.selectedFont.SizePt:0.##}"; + + return changed; + } + + private bool DrawAdvancedOptions() + { + var changed = false; + + if (!ImGui.BeginTable("##advancedOptions", 4)) + return false; + + var labelWidth = ImGui.CalcTextSize("Letter Spacing:").X; + labelWidth = Math.Max(labelWidth, ImGui.CalcTextSize("Offset:").X); + labelWidth = Math.Max(labelWidth, ImGui.CalcTextSize("Line Height:").X); + labelWidth += ImGui.GetStyle().FramePadding.X; + + var inputWidth = ImGui.CalcTextSize("000.000").X + (ImGui.GetStyle().FramePadding.X * 2); + ImGui.TableSetupColumn( + "##inputLabelColumn", + ImGuiTableColumnFlags.WidthFixed, + labelWidth); + ImGui.TableSetupColumn( + "##input1Column", + ImGuiTableColumnFlags.WidthFixed, + inputWidth); + ImGui.TableSetupColumn( + "##input2Column", + ImGuiTableColumnFlags.WidthFixed, + inputWidth); + ImGui.TableSetupColumn( + "##fillerColumn", + ImGuiTableColumnFlags.WidthStretch, + 1f); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Offset:"); + + ImGui.TableNextColumn(); + if (FloatInputText( + "##glyphOffsetXInput", + ref this.advUiState.OffsetXText, + this.selectedFont.GlyphOffset.X) is { } newGlyphOffsetX) + { + changed = true; + this.selectedFont = this.selectedFont with + { + GlyphOffset = this.selectedFont.GlyphOffset with { X = newGlyphOffsetX }, + }; + } + + ImGui.TableNextColumn(); + if (FloatInputText( + "##glyphOffsetYInput", + ref this.advUiState.OffsetYText, + this.selectedFont.GlyphOffset.Y) is { } newGlyphOffsetY) + { + changed = true; + this.selectedFont = this.selectedFont with + { + GlyphOffset = this.selectedFont.GlyphOffset with { Y = newGlyphOffsetY }, + }; + } + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Letter Spacing:"); + + ImGui.TableNextColumn(); + if (FloatInputText( + "##letterSpacingXInput", + ref this.advUiState.LetterSpacingText, + this.selectedFont.LetterSpacing) is { } newLetterSpacing) + { + changed = true; + this.selectedFont = this.selectedFont with { LetterSpacing = newLetterSpacing }; + } + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Line Height:"); + + ImGui.TableNextColumn(); + if (FloatInputText( + "##lineHeightInput", + ref this.advUiState.LineHeightText, + this.selectedFont.LineHeight, + 0.05f, + 0.1f, + 3f) is { } newLineHeight) + { + changed = true; + this.selectedFont = this.selectedFont with { LineHeight = newLineHeight }; + } + + ImGui.EndTable(); + return changed; + + static unsafe float? FloatInputText( + string label, ref string buf, float value, float step = 1f, float min = -127, float max = 127) + { + var stylePushed = value < min || value > max || !float.TryParse(buf, out _); + if (stylePushed) + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + + var changed2 = false; + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + var changed1 = ImGui.InputText( + label, + ref buf, + 255, + ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.CallbackHistory | + ImGuiInputTextFlags.CharsDecimal, + data => + { + switch (data->EventKey) + { + case ImGuiKey.DownArrow: + changed2 = true; + value = Math.Min(max, (MathF.Round(value / step) * step) + step); + ImGuiHelpers.SetTextFromCallback(data, $"{value:0.##}"); + break; + case ImGuiKey.UpArrow: + changed2 = true; + value = Math.Max(min, (MathF.Round(value / step) * step) - step); + ImGuiHelpers.SetTextFromCallback(data, $"{value:0.##}"); + break; + } + + return 0; + }); + + if (stylePushed) + ImGui.PopStyleColor(); + + if (!changed1 && !changed2) + return null; + + if (!float.TryParse(buf, out var parsed)) + return null; + + if (min > parsed || parsed > max) + return null; + + return parsed; + } + } + + private void DrawActionButtons(Vector2 buttonSize) + { + if (this.fontHandle?.Available is not true + || this.FontFamilyExcludeFilter?.Invoke(this.selectedFont.FontId.Family) is true) + { + ImGui.BeginDisabled(); + ImGui.Button("OK", buttonSize); + ImGui.EndDisabled(); + } + else if (ImGui.Button("OK", buttonSize)) + { + this.tcs.SetResult(this.selectedFont); + } + + if (ImGui.Button("Cancel", buttonSize)) + { + this.Cancel(); + } + + var doRefresh = false; + var isFirst = false; + if (this.fontFamilies?.IsCompleted is not true) + { + isFirst = doRefresh = this.fontFamilies is null; + ImGui.BeginDisabled(); + ImGui.Button("Refresh", buttonSize); + ImGui.EndDisabled(); + } + else if (ImGui.Button("Refresh", buttonSize)) + { + doRefresh = true; + } + + if (doRefresh) + { + this.fontFamilies = + this.fontFamilies?.ContinueWith(_ => RefreshBody()) + ?? Task.Run(RefreshBody); + this.fontFamilies.ContinueWith(_ => this.firstDrawAfterRefresh = true); + + List RefreshBody() + { + var familyName = this.selectedFont.FontId.Family.ToString() ?? string.Empty; + var fontName = this.selectedFont.FontId.ToString() ?? string.Empty; + + var newFonts = new List { DalamudDefaultFontAndFamilyId.Instance }; + newFonts.AddRange(IFontFamilyId.ListDalamudFonts()); + newFonts.AddRange(IFontFamilyId.ListGameFonts()); + var systemFonts = IFontFamilyId.ListSystemFonts(!isFirst); + systemFonts.Sort( + (a, b) => string.Compare( + this.ExtractName(a), + this.ExtractName(b), + StringComparison.CurrentCultureIgnoreCase)); + newFonts.AddRange(systemFonts); + if (this.FontFamilyExcludeFilter is not null) + newFonts.RemoveAll(this.FontFamilyExcludeFilter); + + this.UpdateSelectedFamilyAndFontIndices(newFonts, familyName, fontName); + return newFonts; + } + } + + if (this.useAdvancedOptions) + { + if (ImGui.Button("Reset", buttonSize)) + { + this.selectedFont = this.selectedFont with + { + LineHeight = 1f, + GlyphOffset = default, + LetterSpacing = default, + }; + + this.advUiState = new(this.selectedFont); + this.fontHandle?.Dispose(); + this.fontHandle = null; + } + } + } + + private void UpdateSelectedFamilyAndFontIndices( + IReadOnlyList fonts, + string familyName, + string fontName) + { + this.selectedFamilyIndex = fonts.FindIndex(x => x.ToString() == familyName); + if (this.selectedFamilyIndex == -1) + { + this.selectedFontIndex = -1; + } + else + { + this.selectedFontIndex = -1; + var family = fonts[this.selectedFamilyIndex]; + for (var i = 0; i < family.Fonts.Count; i++) + { + if (family.Fonts[i].ToString() == fontName) + { + this.selectedFontIndex = i; + break; + } + } + + if (this.selectedFontIndex == -1) + this.selectedFontIndex = 0; + this.selectedFont = this.selectedFont with + { + FontId = fonts[this.selectedFamilyIndex].Fonts[this.selectedFontIndex], + }; + } + } + + private string ExtractName(IObjectWithLocalizableName what) => + what.GetLocalizedName(Service.Get().EffectiveLanguage); + // Note: EffectiveLanguage can be incorrect but close enough for now + + private bool TestName(IObjectWithLocalizableName what, string search) => + this.ExtractName(what).Contains(search, StringComparison.CurrentCultureIgnoreCase); + + private struct AdvancedOptionsUiState + { + public string OffsetXText; + public string OffsetYText; + public string LetterSpacingText; + public string LineHeightText; + + public AdvancedOptionsUiState(SingleFontSpec spec) + { + this.OffsetXText = $"{spec.GlyphOffset.X:0.##}"; + this.OffsetYText = $"{spec.GlyphOffset.Y:0.##}"; + this.LetterSpacingText = $"{spec.LetterSpacing:0.##}"; + this.LineHeightText = $"{spec.LineHeight:0.##}"; + } + } +} diff --git a/Dalamud/Interface/ImGuiHelpers.cs b/Dalamud/Interface/ImGuiHelpers.cs deleted file mode 100644 index 2356d90e2..000000000 --- a/Dalamud/Interface/ImGuiHelpers.cs +++ /dev/null @@ -1,428 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Numerics; - -using Dalamud.Game.ClientState.Keys; -using Dalamud.Interface.Raii; -using ImGuiNET; -using ImGuiScene; - -namespace Dalamud.Interface; - -/// -/// Class containing various helper methods for use with ImGui inside Dalamud. -/// -public static class ImGuiHelpers -{ - /// - /// Gets the main viewport. - /// - public static ImGuiViewportPtr MainViewport { get; internal set; } - - /// - /// Gets the global Dalamud scale. - /// - public static float GlobalScale { get; private set; } - - /// - /// Gets a that is pre-scaled with the multiplier. - /// - /// Vector2 X/Y parameter. - /// A scaled Vector2. - public static Vector2 ScaledVector2(float x) => new Vector2(x, x) * GlobalScale; - - /// - /// Gets a that is pre-scaled with the multiplier. - /// - /// Vector2 X parameter. - /// Vector2 Y parameter. - /// A scaled Vector2. - public static Vector2 ScaledVector2(float x, float y) => new Vector2(x, y) * GlobalScale; - - /// - /// Gets a that is pre-scaled with the multiplier. - /// - /// Vector4 X parameter. - /// Vector4 Y parameter. - /// Vector4 Z parameter. - /// Vector4 W parameter. - /// A scaled Vector2. - public static Vector4 ScaledVector4(float x, float y, float z, float w) => new Vector4(x, y, z, w) * GlobalScale; - - /// - /// Force the next ImGui window to stay inside the main game window. - /// - public static void ForceNextWindowMainViewport() => ImGui.SetNextWindowViewport(MainViewport.ID); - - /// - /// Create a dummy scaled by the global Dalamud scale. - /// - /// The size of the dummy. - public static void ScaledDummy(float size) => ScaledDummy(size, size); - - /// - /// Create a dummy scaled by the global Dalamud scale. - /// - /// Vector2 X parameter. - /// Vector2 Y parameter. - public static void ScaledDummy(float x, float y) => ScaledDummy(new Vector2(x, y)); - - /// - /// Create a dummy scaled by the global Dalamud scale. - /// - /// The size of the dummy. - public static void ScaledDummy(Vector2 size) => ImGui.Dummy(size * GlobalScale); - - /// - /// Create an indent scaled by the global Dalamud scale. - /// - /// The size of the indent. - public static void ScaledIndent(float size) => ImGui.Indent(size * GlobalScale); - - /// - /// Use a relative ImGui.SameLine() from your current cursor position, scaled by the Dalamud global scale. - /// - /// The offset from your current cursor position. - /// The spacing to use. - public static void ScaledRelativeSameLine(float offset, float spacing = -1.0f) - => ImGui.SameLine(ImGui.GetCursorPosX() + (offset * GlobalScale), spacing); - - /// - /// Set the position of the next window relative to the main viewport. - /// - /// The position of the next window. - /// When to set the position. - /// The pivot to set the position around. - public static void SetNextWindowPosRelativeMainViewport(Vector2 position, ImGuiCond condition = ImGuiCond.None, Vector2 pivot = default) - => ImGui.SetNextWindowPos(position + MainViewport.Pos, condition, pivot); - - /// - /// Set the position of a window relative to the main viewport. - /// - /// The name/ID of the window. - /// The position of the window. - /// When to set the position. - public static void SetWindowPosRelativeMainViewport(string name, Vector2 position, ImGuiCond condition = ImGuiCond.None) - => ImGui.SetWindowPos(name, position + MainViewport.Pos, condition); - - /// - /// Creates default color palette for use with color pickers. - /// - /// The total number of swatches to use. - /// Default color palette. - public static List DefaultColorPalette(int swatchCount = 32) - { - var colorPalette = new List(); - for (var i = 0; i < swatchCount; i++) - { - ImGui.ColorConvertHSVtoRGB(i / 31.0f, 0.7f, 0.8f, out var r, out var g, out var b); - colorPalette.Add(new Vector4(r, g, b, 1.0f)); - } - - return colorPalette; - } - - /// - /// Get the size of a button considering the default frame padding. - /// - /// Text in the button. - /// with the size of the button. - public static Vector2 GetButtonSize(string text) => ImGui.CalcTextSize(text) + (ImGui.GetStyle().FramePadding * 2); - - /// - /// Print out text that can be copied when clicked. - /// - /// The text to show. - /// The text to copy when clicked. - public static void ClickToCopyText(string text, string? textCopy = null) - { - textCopy ??= text; - ImGui.Text($"{text}"); - if (ImGui.IsItemHovered()) - { - ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); - if (textCopy != text) ImGui.SetTooltip(textCopy); - } - - if (ImGui.IsItemClicked()) ImGui.SetClipboardText($"{textCopy}"); - } - - /// - /// Write unformatted text wrapped. - /// - /// The text to write. - public static void SafeTextWrapped(string text) => ImGui.TextWrapped(text.Replace("%", "%%")); - - /// - /// Write unformatted text wrapped. - /// - /// The color of the text. - /// The text to write. - public static void SafeTextColoredWrapped(Vector4 color, string text) - { - using (ImRaii.PushColor(ImGuiCol.Text, color)) - { - ImGui.TextWrapped(text.Replace("%", "%%")); - } - } - - /// - /// Fills missing glyphs in target font from source font, if both are not null. - /// - /// Source font. - /// Target font. - /// Whether to copy missing glyphs only. - /// Whether to call target.BuildLookupTable(). - /// Low codepoint range to copy. - /// High codepoing range to copy. - public static void CopyGlyphsAcrossFonts(ImFontPtr? source, ImFontPtr? target, bool missingOnly, bool rebuildLookupTable, int rangeLow = 32, int rangeHigh = 0xFFFE) - { - if (!source.HasValue || !target.HasValue) - return; - - var scale = target.Value!.FontSize / source.Value!.FontSize; - var addedCodepoints = new HashSet(); - unsafe - { - var glyphs = (ImFontGlyphReal*)source.Value!.Glyphs.Data; - for (int j = 0, k = source.Value!.Glyphs.Size; j < k; j++) - { - Debug.Assert(glyphs != null, nameof(glyphs) + " != null"); - - var glyph = &glyphs[j]; - if (glyph->Codepoint < rangeLow || glyph->Codepoint > rangeHigh) - continue; - - var prevGlyphPtr = (ImFontGlyphReal*)target.Value!.FindGlyphNoFallback((ushort)glyph->Codepoint).NativePtr; - if ((IntPtr)prevGlyphPtr == IntPtr.Zero) - { - addedCodepoints.Add(glyph->Codepoint); - target.Value!.AddGlyph( - target.Value!.ConfigData, - (ushort)glyph->Codepoint, - glyph->TextureIndex, - glyph->X0 * scale, - ((glyph->Y0 - source.Value!.Ascent) * scale) + target.Value!.Ascent, - glyph->X1 * scale, - ((glyph->Y1 - source.Value!.Ascent) * scale) + target.Value!.Ascent, - glyph->U0, - glyph->V0, - glyph->U1, - glyph->V1, - glyph->AdvanceX * scale); - } - else if (!missingOnly) - { - addedCodepoints.Add(glyph->Codepoint); - prevGlyphPtr->TextureIndex = glyph->TextureIndex; - prevGlyphPtr->X0 = glyph->X0 * scale; - prevGlyphPtr->Y0 = ((glyph->Y0 - source.Value!.Ascent) * scale) + target.Value!.Ascent; - prevGlyphPtr->X1 = glyph->X1 * scale; - prevGlyphPtr->Y1 = ((glyph->Y1 - source.Value!.Ascent) * scale) + target.Value!.Ascent; - prevGlyphPtr->U0 = glyph->U0; - prevGlyphPtr->V0 = glyph->V0; - prevGlyphPtr->U1 = glyph->U1; - prevGlyphPtr->V1 = glyph->V1; - prevGlyphPtr->AdvanceX = glyph->AdvanceX * scale; - } - } - - var kernPairs = source.Value!.KerningPairs; - for (int j = 0, k = kernPairs.Size; j < k; j++) - { - if (!addedCodepoints.Contains(kernPairs[j].Left)) - continue; - if (!addedCodepoints.Contains(kernPairs[j].Right)) - continue; - target.Value.AddKerningPair(kernPairs[j].Left, kernPairs[j].Right, kernPairs[j].AdvanceXAdjustment); - } - } - - if (rebuildLookupTable && target.Value!.Glyphs.Size > 0) - target.Value!.BuildLookupTable(); - } - - /// - /// Map a VirtualKey keycode to an ImGuiKey enum value. - /// - /// The VirtualKey value to retrieve the ImGuiKey counterpart for. - /// The ImGuiKey that corresponds to this VirtualKey, or ImGuiKey.None otherwise. - public static ImGuiKey VirtualKeyToImGuiKey(VirtualKey key) - { - return ImGui_Input_Impl_Direct.VirtualKeyToImGuiKey((int)key); - } - - /// - /// Map an ImGuiKey enum value to a VirtualKey code. - /// - /// The ImGuiKey value to retrieve the VirtualKey counterpart for. - /// The VirtualKey that corresponds to this ImGuiKey, or VirtualKey.NO_KEY otherwise. - public static VirtualKey ImGuiKeyToVirtualKey(ImGuiKey key) - { - return (VirtualKey)ImGui_Input_Impl_Direct.ImGuiKeyToVirtualKey(key); - } - - /// - /// Show centered text. - /// - /// Text to show. - public static void CenteredText(string text) - { - CenterCursorForText(text); - ImGui.TextUnformatted(text); - } - - /// - /// Center the ImGui cursor for a certain text. - /// - /// The text to center for. - public static void CenterCursorForText(string text) - { - var textWidth = ImGui.CalcTextSize(text).X; - CenterCursorFor((int)textWidth); - } - - /// - /// Center the ImGui cursor for an item with a certain width. - /// - /// The width to center for. - public static void CenterCursorFor(int itemWidth) - { - var window = (int)ImGui.GetWindowWidth(); - ImGui.SetCursorPosX((window / 2) - (itemWidth / 2)); - } - - /// - /// Get data needed for each new frame. - /// - internal static void NewFrame() - { - GlobalScale = ImGui.GetIO().FontGlobalScale; - InterfaceHelpers.GlobalScale = GlobalScale; - } - - /// - /// ImFontGlyph the correct version. - /// - [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "ImGui internals")] - public struct ImFontGlyphReal - { - public uint ColoredVisibleTextureIndexCodepoint; - public float AdvanceX; - public float X0; - public float Y0; - public float X1; - public float Y1; - public float U0; - public float V0; - public float U1; - public float V1; - - private const uint ColoredMask /*****/ = 0b_00000000_00000000_00000000_00000001u; - private const uint VisibleMask /*****/ = 0b_00000000_00000000_00000000_00000010u; - private const uint TextureMask /*****/ = 0b_00000000_00000000_00000111_11111100u; - private const uint CodepointMask /***/ = 0b_11111111_11111111_11111000_00000000u; - - private const int ColoredShift = 0; - private const int VisibleShift = 1; - private const int TextureShift = 2; - private const int CodepointShift = 11; - - public bool Colored - { - get => (int)((this.ColoredVisibleTextureIndexCodepoint & ColoredMask) >> ColoredShift) != 0; - set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~ColoredMask) | (value ? 1u << ColoredShift : 0u); - } - - public bool Visible - { - get => (int)((this.ColoredVisibleTextureIndexCodepoint & VisibleMask) >> VisibleShift) != 0; - set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~VisibleMask) | (value ? 1u << VisibleShift : 0u); - } - - public int TextureIndex - { - get => (int)(this.ColoredVisibleTextureIndexCodepoint & TextureMask) >> TextureShift; - set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~TextureMask) | ((uint)value << TextureShift); - } - - public int Codepoint - { - get => (int)(this.ColoredVisibleTextureIndexCodepoint & CodepointMask) >> CodepointShift; - set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~CodepointMask) | ((uint)value << CodepointShift); - } - } - - /// - /// ImFontGlyphHotData the correct version. - /// - [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "ImGui internals")] - public struct ImFontGlyphHotDataReal - { - public float AdvanceX; - public float OccupiedWidth; - public uint KerningPairInfo; - - private const uint UseBisectMask /***/ = 0b_00000000_00000000_00000000_00000001u; - private const uint OffsetMask /******/ = 0b_00000000_00001111_11111111_11111110u; - private const uint CountMask /*******/ = 0b_11111111_11110000_00000111_11111100u; - - private const int UseBisectShift = 0; - private const int OffsetShift = 1; - private const int CountShift = 20; - - public bool UseBisect - { - get => (int)((this.KerningPairInfo & UseBisectMask) >> UseBisectShift) != 0; - set => this.KerningPairInfo = (this.KerningPairInfo & ~UseBisectMask) | (value ? 1u << UseBisectShift : 0u); - } - - public bool Offset - { - get => (int)((this.KerningPairInfo & OffsetMask) >> OffsetShift) != 0; - set => this.KerningPairInfo = (this.KerningPairInfo & ~OffsetMask) | (value ? 1u << OffsetShift : 0u); - } - - public int Count - { - get => (int)(this.KerningPairInfo & CountMask) >> CountShift; - set => this.KerningPairInfo = (this.KerningPairInfo & ~CountMask) | ((uint)value << CountShift); - } - } - - /// - /// ImFontAtlasCustomRect the correct version. - /// - [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "ImGui internals")] - public unsafe struct ImFontAtlasCustomRectReal - { - public ushort Width; - public ushort Height; - public ushort X; - public ushort Y; - public uint TextureIndexAndGlyphId; - public float GlyphAdvanceX; - public Vector2 GlyphOffset; - public ImFont* Font; - - private const uint TextureIndexMask /***/ = 0b_00000000_00000000_00000111_11111100u; - private const uint GlyphIDMask /********/ = 0b_11111111_11111111_11111000_00000000u; - - private const int TextureIndexShift = 2; - private const int GlyphIDShift = 11; - - public int TextureIndex - { - get => (int)(this.TextureIndexAndGlyphId & TextureIndexMask) >> TextureIndexShift; - set => this.TextureIndexAndGlyphId = (this.TextureIndexAndGlyphId & ~TextureIndexMask) | ((uint)value << TextureIndexShift); - } - - public int GlyphId - { - get => (int)(this.TextureIndexAndGlyphId & GlyphIDMask) >> GlyphIDShift; - set => this.TextureIndexAndGlyphId = (this.TextureIndexAndGlyphId & ~GlyphIDMask) | ((uint)value << GlyphIDShift); - } - } -} diff --git a/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationClickArgs.cs b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationClickArgs.cs new file mode 100644 index 000000000..b85a96004 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationClickArgs.cs @@ -0,0 +1,9 @@ +namespace Dalamud.Interface.ImGuiNotification.EventArgs; + +/// Arguments for use with . +/// Not to be implemented by plugins. +public interface INotificationClickArgs +{ + /// Gets the notification being clicked. + IActiveNotification Notification { get; } +} diff --git a/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDismissArgs.cs b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDismissArgs.cs new file mode 100644 index 000000000..7f664efa1 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDismissArgs.cs @@ -0,0 +1,12 @@ +namespace Dalamud.Interface.ImGuiNotification.EventArgs; + +/// Arguments for use with . +/// Not to be implemented by plugins. +public interface INotificationDismissArgs +{ + /// Gets the notification being dismissed. + IActiveNotification Notification { get; } + + /// Gets the dismiss reason. + NotificationDismissReason Reason { get; } +} diff --git a/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDrawArgs.cs b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDrawArgs.cs new file mode 100644 index 000000000..221f769e0 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/EventArgs/INotificationDrawArgs.cs @@ -0,0 +1,19 @@ +using System.Numerics; + +namespace Dalamud.Interface.ImGuiNotification.EventArgs; + +/// Arguments for use with . +/// Not to be implemented by plugins. +public interface INotificationDrawArgs +{ + /// Gets the notification being drawn. + IActiveNotification Notification { get; } + + /// Gets the top left coordinates of the area being drawn. + Vector2 MinCoord { get; } + + /// Gets the bottom right coordinates of the area being drawn. + /// Note that can be , in which case there is no + /// vertical limits to the drawing region. + Vector2 MaxCoord { get; } +} diff --git a/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs new file mode 100644 index 000000000..e677471b4 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/IActiveNotification.cs @@ -0,0 +1,83 @@ +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.ImGuiNotification.EventArgs; +using Dalamud.Interface.Internal; + +namespace Dalamud.Interface.ImGuiNotification; + +/// Represents an active notification. +/// Not to be implemented by plugins. +public interface IActiveNotification : INotification +{ + /// The counter for field. + private static long idCounter; + + /// Invoked upon dismissing the notification. + /// The event callback will not be called, if it gets dismissed after plugin unload. + event Action Dismiss; + + /// Invoked upon clicking on the notification. + /// Note that this function may be called even after has been invoked. + event Action Click; + + /// Invoked upon drawing the action bar of the notification. + /// Note that this function may be called even after has been invoked. + event Action DrawActions; + + /// Gets the ID of this notification. + /// This value does not change. + long Id { get; } + + /// Gets the time of creating this notification. + /// This value does not change. + DateTime CreatedAt { get; } + + /// Gets the effective expiry time. + /// Contains if the notification does not expire. + /// This value will change depending on property changes and user interactions. + DateTime EffectiveExpiry { get; } + + /// Gets the reason how this notification got dismissed. null if not dismissed. + /// This includes when the hide animation is being played. + NotificationDismissReason? DismissReason { get; } + + /// Dismisses this notification. + /// If the notification has already been dismissed, this function does nothing. + void DismissNow(); + + /// Extends this notifiation. + /// The extension time. + /// This does not override . + void ExtendBy(TimeSpan extension); + + /// Sets the icon from , overriding the icon. + /// The new texture wrap to use, or null to clear and revert back to the icon specified + /// from . + /// + /// The texture passed will be disposed when the notification is dismissed or a new different texture is set + /// via another call to this function. You do not have to dispose it yourself. + /// If is not null, then calling this function will simply dispose the + /// passed without actually updating the icon. + /// + void SetIconTexture(IDalamudTextureWrap? textureWrap); + + /// Sets the icon from , overriding the icon, once the given task + /// completes. + /// The task that will result in a new texture wrap to use, or null to clear and + /// revert back to the icon specified from . + /// + /// The texture resulted from the passed will be disposed when the notification + /// is dismissed or a new different texture is set via another call to this function. You do not have to dispose the + /// resulted instance of yourself. + /// If the task fails for any reason, the exception will be silently ignored and the icon specified from + /// will be used instead. + /// If is not null, then calling this function will simply dispose the + /// result of the passed without actually updating the icon. + /// + void SetIconTexture(Task? textureWrapTask); + + /// Generates a new value to use for . + /// The new value. + internal static long CreateNewId() => Interlocked.Increment(ref idCounter); +} diff --git a/Dalamud/Interface/ImGuiNotification/INotification.cs b/Dalamud/Interface/ImGuiNotification/INotification.cs new file mode 100644 index 000000000..f9a043c0b --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/INotification.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; + +using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Plugin.Services; + +namespace Dalamud.Interface.ImGuiNotification; + +/// Represents a notification. +/// Not to be implemented by plugins. +public interface INotification +{ + /// Gets or sets the content body of the notification. + string Content { get; set; } + + /// Gets or sets the title of the notification. + string? Title { get; set; } + + /// Gets or sets the text to display when the notification is minimized. + string? MinimizedText { get; set; } + + /// Gets or sets the type of the notification. + NotificationType Type { get; set; } + + /// Gets or sets the icon source. + /// Use or + /// to use a texture, after calling + /// . Call either of those functions with null to revert + /// the effective icon back to this property. + INotificationIcon? Icon { get; set; } + + /// Gets or sets the hard expiry. + /// + /// Setting this value will override and , in that + /// the notification will be dismissed when this expiry expires.
+ /// Set to to make only take effect.
+ /// If neither nor is not MaxValue, then the notification + /// will not expire after a set time. It must be explicitly dismissed by the user of via calling + /// .
+ /// Updating this value will reset the dismiss timer. + ///
+ DateTime HardExpiry { get; set; } + + /// Gets or sets the initial duration. + /// Set to to make only take effect. + /// Updating this value will reset the dismiss timer, but the remaining duration will still be calculated + /// based on . + TimeSpan InitialDuration { get; set; } + + /// Gets or sets the new duration for this notification once the mouse cursor leaves the window and the + /// window is no longer focused. + /// + /// If set to or less, then this feature is turned off, and hovering the mouse on the + /// notification or focusing on it will not make the notification stay.
+ /// Updating this value will reset the dismiss timer. + ///
+ TimeSpan ExtensionDurationSinceLastInterest { get; set; } + + /// Gets or sets a value indicating whether to show an indeterminate expiration animation if + /// is set to . + bool ShowIndeterminateIfNoExpiry { get; set; } + + /// Gets or sets a value indicating whether to respect the current UI visibility state. + bool RespectUiHidden { get; set; } + + /// Gets or sets a value indicating whether the notification has been minimized. + bool Minimized { get; set; } + + /// Gets or sets a value indicating whether the user can dismiss the notification by themselves. + /// Consider adding a cancel button to . + bool UserDismissable { get; set; } + + /// Gets or sets the progress for the background progress bar of the notification. + /// The progress should be in the range between 0 and 1. + float Progress { get; set; } +} diff --git a/Dalamud/Interface/ImGuiNotification/INotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/INotificationIcon.cs new file mode 100644 index 000000000..94c746b4f --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/INotificationIcon.cs @@ -0,0 +1,54 @@ +using System.Numerics; +using System.Runtime.CompilerServices; + +using Dalamud.Game.Text; +using Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon; + +namespace Dalamud.Interface.ImGuiNotification; + +/// Icon source for . +/// Plugins implementing this interface are left to their own on managing the resources contained by the +/// instance of their implementation of . In other words, they should not expect to have +/// called if their implementation is an . Dalamud will not +/// call on any instance of . On plugin unloads, the +/// icon may be reverted back to the default, if the instance of is not provided by +/// Dalamud. +public interface INotificationIcon +{ + /// Gets a new instance of that will source the icon from an + /// . + /// The icon character. + /// A new instance of that should be disposed after use. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static INotificationIcon From(SeIconChar iconChar) => new SeIconCharNotificationIcon(iconChar); + + /// Gets a new instance of that will source the icon from an + /// . + /// The icon character. + /// A new instance of that should be disposed after use. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static INotificationIcon From(FontAwesomeIcon iconChar) => new FontAwesomeIconNotificationIcon(iconChar); + + /// Gets a new instance of that will source the icon from a texture + /// file shipped as a part of the game resources. + /// The path to a texture file in the game virtual file system. + /// A new instance of that should be disposed after use. + /// If any errors are thrown, the default icon will be displayed instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static INotificationIcon FromGame(string gamePath) => new GamePathNotificationIcon(gamePath); + + /// Gets a new instance of that will source the icon from an image + /// file from the file system. + /// The path to an image file in the file system. + /// A new instance of that should be disposed after use. + /// If any errors are thrown, the default icon will be displayed instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static INotificationIcon FromFile(string filePath) => new FilePathNotificationIcon(filePath); + + /// Draws the icon. + /// The coordinates of the top left of the icon area. + /// The coordinates of the bottom right of the icon area. + /// The foreground color. + /// true if anything has been drawn. + bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color); +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.EventArgs.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.EventArgs.cs new file mode 100644 index 000000000..428d9103f --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.EventArgs.cs @@ -0,0 +1,87 @@ +using System.Numerics; + +using Dalamud.Interface.ImGuiNotification.EventArgs; + +namespace Dalamud.Interface.ImGuiNotification.Internal; + +/// Represents an active notification. +internal sealed partial class ActiveNotification : INotificationDismissArgs +{ + /// + public event Action? Dismiss; + + /// + IActiveNotification INotificationDismissArgs.Notification => this; + + /// + NotificationDismissReason INotificationDismissArgs.Reason => + this.DismissReason + ?? throw new InvalidOperationException("DismissReason must be set before using INotificationDismissArgs"); + + private void InvokeDismiss() + { + try + { + this.Dismiss?.Invoke(this); + } + catch (Exception e) + { + this.LogEventInvokeError(e, $"{nameof(this.Dismiss)} error"); + } + } +} + +/// Represents an active notification. +internal sealed partial class ActiveNotification : INotificationClickArgs +{ + /// + public event Action? Click; + + /// + IActiveNotification INotificationClickArgs.Notification => this; + + private void InvokeClick() + { + try + { + this.Click?.Invoke(this); + } + catch (Exception e) + { + this.LogEventInvokeError(e, $"{nameof(this.Click)} error"); + } + } +} + +/// Represents an active notification. +internal sealed partial class ActiveNotification : INotificationDrawArgs +{ + private Vector2 drawActionArgMinCoord; + private Vector2 drawActionArgMaxCoord; + + /// + public event Action? DrawActions; + + /// + IActiveNotification INotificationDrawArgs.Notification => this; + + /// + Vector2 INotificationDrawArgs.MinCoord => this.drawActionArgMinCoord; + + /// + Vector2 INotificationDrawArgs.MaxCoord => this.drawActionArgMaxCoord; + + private void InvokeDrawActions(Vector2 minCoord, Vector2 maxCoord) + { + this.drawActionArgMinCoord = minCoord; + this.drawActionArgMaxCoord = maxCoord; + try + { + this.DrawActions?.Invoke(this); + } + catch (Exception e) + { + this.LogEventInvokeError(e, $"{nameof(this.DrawActions)} error; event registration cancelled"); + } + } +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs new file mode 100644 index 000000000..d4a08ff69 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.ImGui.cs @@ -0,0 +1,500 @@ +using System.Numerics; + +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ImGuiNotification.Internal; + +/// Represents an active notification. +internal sealed partial class ActiveNotification +{ + /// Draws this notification. + /// The maximum width of the notification window. + /// The offset from the bottom. + /// The height of the notification. + public float Draw(float width, float offsetY) + { + var opacity = + Math.Clamp( + (float)(this.hideEasing.IsRunning + ? (this.hideEasing.IsDone ? 0 : 1f - this.hideEasing.Value) + : (this.showEasing.IsDone ? 1 : this.showEasing.Value)), + 0f, + 1f); + if (opacity <= 0) + return 0; + + var actionWindowHeight = + // Content + ImGui.GetTextLineHeight() + + // Top and bottom padding + (NotificationConstants.ScaledWindowPadding * 2); + + var viewport = ImGuiHelpers.MainViewport; + var viewportPos = viewport.WorkPos; + var viewportSize = viewport.WorkSize; + + ImGui.PushID(this.Id.GetHashCode()); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0f); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(NotificationConstants.ScaledWindowPadding)); + unsafe + { + ImGui.PushStyleColor( + ImGuiCol.WindowBg, + *ImGui.GetStyleColorVec4(ImGuiCol.WindowBg) * new Vector4( + 1f, + 1f, + 1f, + NotificationConstants.BackgroundOpacity)); + } + + ImGuiHelpers.ForceNextWindowMainViewport(); + ImGui.SetNextWindowPos( + (viewportPos + viewportSize) - + new Vector2(NotificationConstants.ScaledViewportEdgeMargin) - + new Vector2(0, offsetY), + ImGuiCond.Always, + Vector2.One); + ImGui.SetNextWindowSizeConstraints( + new(width, actionWindowHeight), + new( + width, + !this.underlyingNotification.Minimized || this.expandoEasing.IsRunning + ? float.MaxValue + : actionWindowHeight)); + ImGui.Begin( + $"##NotifyMainWindow{this.Id}", + ImGuiWindowFlags.AlwaysAutoResize | + ImGuiWindowFlags.NoDecoration | + ImGuiWindowFlags.NoNav | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoDocking); + + var isFocused = ImGui.IsWindowFocused(); + var isHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + var isTakingKeyboardInput = isFocused && ImGui.GetIO().WantTextInput; + var warrantsExtension = + this.ExtensionDurationSinceLastInterest > TimeSpan.Zero + && (isHovered || isTakingKeyboardInput); + + this.EffectiveExpiry = this.CalculateEffectiveExpiry(ref warrantsExtension); + + if (!isTakingKeyboardInput && !isHovered && isFocused) + { + ImGui.SetWindowFocus(null); + isFocused = false; + } + + if (DateTime.Now > this.EffectiveExpiry) + this.DismissNow(NotificationDismissReason.Timeout); + + if (this.ExtensionDurationSinceLastInterest > TimeSpan.Zero && warrantsExtension) + this.lastInterestTime = DateTime.Now; + + this.DrawWindowBackgroundProgressBar(); + this.DrawTopBar(width, actionWindowHeight, isHovered); + if (!this.underlyingNotification.Minimized && !this.expandoEasing.IsRunning) + { + this.DrawContentAndActions(width, actionWindowHeight); + } + else if (this.expandoEasing.IsRunning) + { + if (this.underlyingNotification.Minimized) + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * (1f - (float)this.expandoEasing.Value)); + else + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, opacity * (float)this.expandoEasing.Value); + this.DrawContentAndActions(width, actionWindowHeight); + ImGui.PopStyleVar(); + } + + if (isFocused) + this.DrawFocusIndicator(); + this.DrawExpiryBar(this.EffectiveExpiry, warrantsExtension); + + if (ImGui.IsWindowHovered()) + { + if (this.Click is null) + { + if (this.UserDismissable && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + this.DismissNow(NotificationDismissReason.Manual); + } + else + { + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left) + || ImGui.IsMouseClicked(ImGuiMouseButton.Right) + || ImGui.IsMouseClicked(ImGuiMouseButton.Middle)) + this.InvokeClick(); + } + } + + var windowSize = ImGui.GetWindowSize(); + ImGui.End(); + + ImGui.PopStyleColor(); + ImGui.PopStyleVar(3); + ImGui.PopID(); + + return windowSize.Y; + } + + /// Calculates the effective expiry, taking ImGui window state into account. + /// Notification will not dismiss while this paramter is true. + /// The calculated effective expiry. + /// Expected to be called BETWEEN and . + private DateTime CalculateEffectiveExpiry(ref bool warrantsExtension) + { + DateTime expiry; + var initialDuration = this.InitialDuration; + var expiryInitial = + initialDuration == TimeSpan.MaxValue + ? DateTime.MaxValue + : this.CreatedAt + initialDuration; + + var extendDuration = this.ExtensionDurationSinceLastInterest; + if (warrantsExtension) + { + expiry = DateTime.MaxValue; + } + else + { + var expiryExtend = + extendDuration == TimeSpan.MaxValue + ? DateTime.MaxValue + : this.lastInterestTime + extendDuration; + + expiry = expiryInitial > expiryExtend ? expiryInitial : expiryExtend; + if (expiry < this.extendedExpiry) + expiry = this.extendedExpiry; + } + + var he = this.HardExpiry; + if (he < expiry) + { + expiry = he; + warrantsExtension = false; + } + + return expiry; + } + + private void DrawWindowBackgroundProgressBar() + { + var elapsed = (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds % + NotificationConstants.ProgressWaveLoopDuration) / + NotificationConstants.ProgressWaveLoopDuration); + elapsed /= NotificationConstants.ProgressWaveIdleTimeRatio; + + var colorElapsed = + elapsed < NotificationConstants.ProgressWaveLoopMaxColorTimeRatio + ? elapsed / NotificationConstants.ProgressWaveLoopMaxColorTimeRatio + : ((NotificationConstants.ProgressWaveLoopMaxColorTimeRatio * 2) - elapsed) / + NotificationConstants.ProgressWaveLoopMaxColorTimeRatio; + + elapsed = Math.Clamp(elapsed, 0f, 1f); + colorElapsed = Math.Clamp(colorElapsed, 0f, 1f); + colorElapsed = MathF.Sin(colorElapsed * (MathF.PI / 2f)); + + var progress = Math.Clamp(this.ProgressEased, 0f, 1f); + if (progress >= 1f) + elapsed = colorElapsed = 0f; + + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + var rb = windowPos + windowSize; + var midp = windowPos + windowSize with { X = windowSize.X * progress * elapsed }; + var rp = windowPos + windowSize with { X = windowSize.X * progress }; + + ImGui.PushClipRect(windowPos, rb, false); + ImGui.GetWindowDrawList().AddRectFilled( + windowPos, + midp, + ImGui.GetColorU32( + Vector4.Lerp( + NotificationConstants.BackgroundProgressColorMin, + NotificationConstants.BackgroundProgressColorMax, + colorElapsed))); + ImGui.GetWindowDrawList().AddRectFilled( + midp with { Y = 0 }, + rp, + ImGui.GetColorU32(NotificationConstants.BackgroundProgressColorMin)); + ImGui.PopClipRect(); + } + + private void DrawFocusIndicator() + { + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + ImGui.PushClipRect(windowPos, windowPos + windowSize, false); + ImGui.GetWindowDrawList().AddRect( + windowPos, + windowPos + windowSize, + ImGui.GetColorU32(NotificationConstants.FocusBorderColor * new Vector4(1f, 1f, 1f, ImGui.GetStyle().Alpha)), + 0f, + ImDrawFlags.None, + NotificationConstants.FocusIndicatorThickness); + ImGui.PopClipRect(); + } + + private void DrawTopBar(float width, float height, bool drawActionButtons) + { + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + + var rtOffset = new Vector2(width, 0); + using (Service.Get().IconFontHandle?.Push()) + { + ImGui.PushClipRect(windowPos, windowPos + windowSize with { Y = height }, false); + if (this.UserDismissable) + { + if (this.DrawIconButton(FontAwesomeIcon.Times, rtOffset, height, drawActionButtons)) + this.DismissNow(NotificationDismissReason.Manual); + rtOffset.X -= height; + } + + if (this.underlyingNotification.Minimized) + { + if (this.DrawIconButton(FontAwesomeIcon.ChevronDown, rtOffset, height, drawActionButtons)) + this.Minimized = false; + } + else + { + if (this.DrawIconButton(FontAwesomeIcon.ChevronUp, rtOffset, height, drawActionButtons)) + this.Minimized = true; + } + + rtOffset.X -= height; + ImGui.PopClipRect(); + } + + float relativeOpacity; + if (this.expandoEasing.IsRunning) + { + relativeOpacity = + this.underlyingNotification.Minimized + ? 1f - (float)this.expandoEasing.Value + : (float)this.expandoEasing.Value; + } + else + { + relativeOpacity = this.underlyingNotification.Minimized ? 0f : 1f; + } + + if (drawActionButtons) + ImGui.PushClipRect(windowPos, windowPos + rtOffset with { Y = height }, false); + else + ImGui.PushClipRect(windowPos, windowPos + windowSize with { Y = height }, false); + + if (relativeOpacity > 0) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * relativeOpacity); + ImGui.SetCursorPos(new(NotificationConstants.ScaledWindowPadding)); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.WhenTextColor); + ImGui.TextUnformatted( + ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) + ? this.CreatedAt.LocAbsolute() + : this.CreatedAt.LocRelativePastLong()); + ImGui.PopStyleColor(); + ImGui.PopStyleVar(); + } + + if (relativeOpacity < 1) + { + rtOffset = new(width - NotificationConstants.ScaledWindowPadding, 0); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * (1f - relativeOpacity)); + + var ltOffset = new Vector2(NotificationConstants.ScaledWindowPadding); + this.DrawIcon(ltOffset, new(height - (2 * NotificationConstants.ScaledWindowPadding))); + + ltOffset.X = height; + + var agoText = this.CreatedAt.LocRelativePastShort(); + var agoSize = ImGui.CalcTextSize(agoText); + rtOffset.X -= agoSize.X; + ImGui.SetCursorPos(rtOffset with { Y = NotificationConstants.ScaledWindowPadding }); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.WhenTextColor); + ImGui.TextUnformatted(agoText); + ImGui.PopStyleColor(); + + rtOffset.X -= NotificationConstants.ScaledWindowPadding; + + ImGui.PushClipRect( + windowPos + ltOffset with { Y = 0 }, + windowPos + rtOffset with { Y = height }, + true); + ImGui.SetCursorPos(ltOffset with { Y = NotificationConstants.ScaledWindowPadding }); + ImGui.TextUnformatted(this.EffectiveMinimizedText); + ImGui.PopClipRect(); + + ImGui.PopStyleVar(); + } + + ImGui.PopClipRect(); + } + + private bool DrawIconButton(FontAwesomeIcon icon, Vector2 rt, float size, bool drawActionButtons) + { + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, Vector2.Zero); + if (!drawActionButtons) + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0f); + ImGui.PushStyleColor(ImGuiCol.Button, 0); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.CloseTextColor); + + ImGui.SetCursorPos(rt - new Vector2(size, 0)); + var r = ImGui.Button(icon.ToIconString(), new(size)); + + ImGui.PopStyleColor(2); + if (!drawActionButtons) + ImGui.PopStyleVar(); + ImGui.PopStyleVar(); + return r; + } + + private void DrawContentAndActions(float width, float actionWindowHeight) + { + var textColumnX = (NotificationConstants.ScaledWindowPadding * 2) + NotificationConstants.ScaledIconSize; + var textColumnWidth = width - textColumnX - NotificationConstants.ScaledWindowPadding; + var textColumnOffset = new Vector2(textColumnX, actionWindowHeight); + + this.DrawIcon( + new(NotificationConstants.ScaledWindowPadding, actionWindowHeight), + new(NotificationConstants.ScaledIconSize)); + + textColumnOffset.Y += this.DrawTitle(textColumnOffset, textColumnWidth); + textColumnOffset.Y += NotificationConstants.ScaledComponentGap; + + this.DrawContentBody(textColumnOffset, textColumnWidth); + + if (this.DrawActions is null) + return; + + var userActionOffset = new Vector2( + NotificationConstants.ScaledWindowPadding, + ImGui.GetCursorPosY() + NotificationConstants.ScaledComponentGap); + ImGui.SetCursorPos(userActionOffset); + this.InvokeDrawActions( + userActionOffset, + new(width - NotificationConstants.ScaledWindowPadding, float.MaxValue)); + } + + private void DrawIcon(Vector2 minCoord, Vector2 size) + { + var maxCoord = minCoord + size; + var iconColor = this.Type.ToColor(); + + if (NotificationUtilities.DrawIconFrom(minCoord, maxCoord, this.iconTextureWrap)) + return; + + if (this.Icon?.DrawIcon(minCoord, maxCoord, iconColor) is true) + return; + + if (NotificationUtilities.DrawIconFrom( + minCoord, + maxCoord, + this.Type.ToChar(), + Service.Get().IconFontAwesomeFontHandle, + iconColor)) + return; + + if (NotificationUtilities.DrawIconFrom(minCoord, maxCoord, this.initiatorPlugin)) + return; + + NotificationUtilities.DrawIconFromDalamudLogo(minCoord, maxCoord); + } + + private float DrawTitle(Vector2 minCoord, float width) + { + ImGui.PushTextWrapPos(minCoord.X + width); + + ImGui.SetCursorPos(minCoord); + if ((this.Title ?? this.Type.ToTitle()) is { } title) + { + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.TitleTextColor); + ImGui.TextUnformatted(title); + ImGui.PopStyleColor(); + } + + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.BlameTextColor); + ImGui.SetCursorPos(minCoord with { Y = ImGui.GetCursorPosY() }); + ImGui.TextUnformatted(this.InitiatorString); + ImGui.PopStyleColor(); + + ImGui.PopTextWrapPos(); + return ImGui.GetCursorPosY() - minCoord.Y; + } + + private void DrawContentBody(Vector2 minCoord, float width) + { + ImGui.SetCursorPos(minCoord); + ImGui.PushTextWrapPos(minCoord.X + width); + ImGui.PushStyleColor(ImGuiCol.Text, NotificationConstants.BodyTextColor); + ImGui.TextUnformatted(this.Content); + ImGui.PopStyleColor(); + ImGui.PopTextWrapPos(); + } + + private void DrawExpiryBar(DateTime effectiveExpiry, bool warrantsExtension) + { + float barL, barR; + if (this.DismissReason is not null) + { + var v = this.hideEasing.IsDone ? 0f : 1f - (float)this.hideEasing.Value; + var midpoint = (this.prevProgressL + this.prevProgressR) / 2f; + var length = (this.prevProgressR - this.prevProgressL) / 2f; + barL = midpoint - (length * v); + barR = midpoint + (length * v); + } + else if (warrantsExtension) + { + barL = 0f; + barR = 1f; + this.prevProgressL = barL; + this.prevProgressR = barR; + } + else if (effectiveExpiry == DateTime.MaxValue) + { + if (this.ShowIndeterminateIfNoExpiry) + { + var elapsed = (float)(((DateTime.Now - this.CreatedAt).TotalMilliseconds % + NotificationConstants.IndeterminateProgressbarLoopDuration) / + NotificationConstants.IndeterminateProgressbarLoopDuration); + barL = Math.Max(elapsed - (1f / 3), 0f) / (2f / 3); + barR = Math.Min(elapsed, 2f / 3) / (2f / 3); + barL = MathF.Pow(barL, 3); + barR = 1f - MathF.Pow(1f - barR, 3); + this.prevProgressL = barL; + this.prevProgressR = barR; + } + else + { + this.prevProgressL = barL = 0f; + this.prevProgressR = barR = 1f; + } + } + else + { + barL = 1f - (float)((effectiveExpiry - DateTime.Now).TotalMilliseconds / + (effectiveExpiry - this.lastInterestTime).TotalMilliseconds); + barR = 1f; + this.prevProgressL = barL; + this.prevProgressR = barR; + } + + barR = Math.Clamp(barR, 0f, 1f); + + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + ImGui.PushClipRect(windowPos, windowPos + windowSize, false); + ImGui.GetWindowDrawList().AddRectFilled( + windowPos + new Vector2( + windowSize.X * barL, + windowSize.Y - NotificationConstants.ScaledExpiryProgressBarHeight), + windowPos + windowSize with { X = windowSize.X * barR }, + ImGui.GetColorU32(this.Type.ToColor())); + ImGui.PopClipRect(); + } +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs new file mode 100644 index 000000000..3bc7c3837 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/ActiveNotification.cs @@ -0,0 +1,370 @@ +using System.Runtime.Loader; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.Animation; +using Dalamud.Interface.Animation.EasingFunctions; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Utility; + +using Serilog; + +namespace Dalamud.Interface.ImGuiNotification.Internal; + +/// Represents an active notification. +internal sealed partial class ActiveNotification : IActiveNotification +{ + private readonly Notification underlyingNotification; + + private readonly Easing showEasing; + private readonly Easing hideEasing; + private readonly Easing progressEasing; + private readonly Easing expandoEasing; + + /// Gets the time of starting to count the timer for the expiration. + private DateTime lastInterestTime; + + /// Gets the extended expiration time from . + private DateTime extendedExpiry; + + /// The icon texture to use if specified; otherwise, icon will be used from . + private Task? iconTextureWrap; + + /// The plugin that initiated this notification. + private LocalPlugin? initiatorPlugin; + + /// Whether has been unloaded. + private bool isInitiatorUnloaded; + + /// The progress before for the progress bar animation with . + private float progressBefore; + + /// Used for calculating correct dismissal progressbar animation (left edge). + private float prevProgressL; + + /// Used for calculating correct dismissal progressbar animation (right edge). + private float prevProgressR; + + /// New progress value to be updated on next call to . + private float? newProgress; + + /// New minimized value to be updated on next call to . + private bool? newMinimized; + + /// Initializes a new instance of the class. + /// The underlying notification. + /// The initiator plugin. Use null if originated by Dalamud. + public ActiveNotification(Notification underlyingNotification, LocalPlugin? initiatorPlugin) + { + this.underlyingNotification = underlyingNotification with { }; + this.initiatorPlugin = initiatorPlugin; + this.showEasing = new InCubic(NotificationConstants.ShowAnimationDuration); + this.hideEasing = new OutCubic(NotificationConstants.HideAnimationDuration); + this.progressEasing = new InOutCubic(NotificationConstants.ProgressChangeAnimationDuration); + this.expandoEasing = new InOutCubic(NotificationConstants.ExpandoAnimationDuration); + this.CreatedAt = this.lastInterestTime = this.extendedExpiry = DateTime.Now; + + this.showEasing.Start(); + this.progressEasing.Start(); + } + + /// + public long Id { get; } = IActiveNotification.CreateNewId(); + + /// + public DateTime CreatedAt { get; } + + /// + public string Content + { + get => this.underlyingNotification.Content; + set => this.underlyingNotification.Content = value; + } + + /// + public string? Title + { + get => this.underlyingNotification.Title; + set => this.underlyingNotification.Title = value; + } + + /// + public bool RespectUiHidden + { + get => this.underlyingNotification.RespectUiHidden; + set => this.underlyingNotification.RespectUiHidden = value; + } + + /// + public string? MinimizedText + { + get => this.underlyingNotification.MinimizedText; + set => this.underlyingNotification.MinimizedText = value; + } + + /// + public NotificationType Type + { + get => this.underlyingNotification.Type; + set => this.underlyingNotification.Type = value; + } + + /// + public INotificationIcon? Icon + { + get => this.underlyingNotification.Icon; + set => this.underlyingNotification.Icon = value; + } + + /// + public DateTime HardExpiry + { + get => this.underlyingNotification.HardExpiry; + set + { + if (this.underlyingNotification.HardExpiry == value) + return; + this.underlyingNotification.HardExpiry = value; + this.lastInterestTime = DateTime.Now; + } + } + + /// + public TimeSpan InitialDuration + { + get => this.underlyingNotification.InitialDuration; + set + { + this.underlyingNotification.InitialDuration = value; + this.lastInterestTime = DateTime.Now; + } + } + + /// + public TimeSpan ExtensionDurationSinceLastInterest + { + get => this.underlyingNotification.ExtensionDurationSinceLastInterest; + set + { + this.underlyingNotification.ExtensionDurationSinceLastInterest = value; + this.lastInterestTime = DateTime.Now; + } + } + + /// + public DateTime EffectiveExpiry { get; private set; } + + /// + public NotificationDismissReason? DismissReason { get; private set; } + + /// + public bool ShowIndeterminateIfNoExpiry + { + get => this.underlyingNotification.ShowIndeterminateIfNoExpiry; + set => this.underlyingNotification.ShowIndeterminateIfNoExpiry = value; + } + + /// + public bool Minimized + { + get => this.newMinimized ?? this.underlyingNotification.Minimized; + set => this.newMinimized = value; + } + + /// + public bool UserDismissable + { + get => this.underlyingNotification.UserDismissable; + set => this.underlyingNotification.UserDismissable = value; + } + + /// + public float Progress + { + get => this.newProgress ?? this.underlyingNotification.Progress; + set => this.newProgress = value; + } + + /// Gets the eased progress. + private float ProgressEased + { + get + { + var underlyingProgress = this.underlyingNotification.Progress; + if (Math.Abs(underlyingProgress - this.progressBefore) < 0.000001f || this.progressEasing.IsDone) + return underlyingProgress; + + var state = Math.Clamp((float)this.progressEasing.Value, 0f, 1f); + return this.progressBefore + (state * (underlyingProgress - this.progressBefore)); + } + } + + /// Gets the string for the initiator field. + private string InitiatorString => + this.initiatorPlugin is not { } plugin + ? NotificationConstants.DefaultInitiator + : this.isInitiatorUnloaded + ? NotificationConstants.UnloadedInitiatorNameFormat.Format(plugin.Name) + : plugin.Name; + + /// Gets the effective text to display when minimized. + private string EffectiveMinimizedText => (this.MinimizedText ?? this.Content).ReplaceLineEndings(" "); + + /// + public void DismissNow() => this.DismissNow(NotificationDismissReason.Programmatical); + + /// Dismisses this notification. Multiple calls will be ignored. + /// The reason of dismissal. + public void DismissNow(NotificationDismissReason reason) + { + if (this.DismissReason is not null) + return; + + this.DismissReason = reason; + this.hideEasing.Start(); + this.InvokeDismiss(); + } + + /// + public void ExtendBy(TimeSpan extension) + { + var newExpiry = DateTime.Now + extension; + if (this.extendedExpiry < newExpiry) + this.extendedExpiry = newExpiry; + } + + /// + public void SetIconTexture(IDalamudTextureWrap? textureWrap) + { + this.SetIconTexture(textureWrap is null ? null : Task.FromResult(textureWrap)); + } + + /// + public void SetIconTexture(Task? textureWrapTask) + { + if (this.DismissReason is not null) + { + textureWrapTask?.ToContentDisposedTask(true); + return; + } + + // After replacing, if the old texture is not the old texture, then dispose the old texture. + if (Interlocked.Exchange(ref this.iconTextureWrap, textureWrapTask) is { } wrapTaskToDispose && + wrapTaskToDispose != textureWrapTask) + { + wrapTaskToDispose.ToContentDisposedTask(true); + } + } + + /// Removes non-Dalamud invocation targets from events. + /// + /// This is done to prevent references of plugins being unloaded from outliving the plugin itself. + /// Anything that can contain plugin-provided types and functions count, which effectively means that events and + /// interface/object-typed fields need to be scrubbed. + /// As a notification can be marked as non-user-dismissable, in which case after removing event handlers there will + /// be no way to remove the notification, we force the notification to become user-dismissable, and reset the expiry + /// to the default duration on unload. + /// + internal void RemoveNonDalamudInvocations() + { + var dalamudContext = AssemblyLoadContext.GetLoadContext(typeof(NotificationManager).Assembly); + this.Dismiss = RemoveNonDalamudInvocationsCore(this.Dismiss); + this.Click = RemoveNonDalamudInvocationsCore(this.Click); + this.DrawActions = RemoveNonDalamudInvocationsCore(this.DrawActions); + + if (this.Icon is { } previousIcon && !IsOwnedByDalamud(previousIcon.GetType())) + this.Icon = null; + + this.isInitiatorUnloaded = true; + this.UserDismissable = true; + this.ExtensionDurationSinceLastInterest = NotificationConstants.DefaultDuration; + + var newMaxExpiry = DateTime.Now + NotificationConstants.DefaultDuration; + if (this.EffectiveExpiry > newMaxExpiry) + this.HardExpiry = newMaxExpiry; + + return; + + bool IsOwnedByDalamud(Type t) => AssemblyLoadContext.GetLoadContext(t.Assembly) == dalamudContext; + + T? RemoveNonDalamudInvocationsCore(T? @delegate) where T : Delegate + { + if (@delegate is null) + return null; + + foreach (var il in @delegate.GetInvocationList()) + { + if (il.Target is { } target && !IsOwnedByDalamud(target.GetType())) + @delegate = (T)Delegate.Remove(@delegate, il); + } + + return @delegate; + } + } + + /// Updates the state of this notification, and release the relevant resource if this notification is no + /// longer in use. + /// true if the notification is over and relevant resources are released. + /// Intended to be called from the main thread only. + internal bool UpdateOrDisposeInternal() + { + this.showEasing.Update(); + this.hideEasing.Update(); + this.progressEasing.Update(); + if (this.expandoEasing.IsRunning) + { + this.expandoEasing.Update(); + if (this.expandoEasing.IsDone) + this.expandoEasing.Stop(); + } + + if (this.newProgress is { } newProgressValue) + { + if (Math.Abs(this.underlyingNotification.Progress - newProgressValue) > float.Epsilon) + { + this.progressBefore = this.ProgressEased; + this.underlyingNotification.Progress = newProgressValue; + this.progressEasing.Restart(); + this.progressEasing.Update(); + } + + this.newProgress = null; + } + + if (this.newMinimized is { } newMinimizedValue) + { + if (this.underlyingNotification.Minimized != newMinimizedValue) + { + this.underlyingNotification.Minimized = newMinimizedValue; + this.expandoEasing.Restart(); + this.expandoEasing.Update(); + } + + this.newMinimized = null; + } + + if (!this.hideEasing.IsRunning || !this.hideEasing.IsDone) + return false; + + this.DisposeInternal(); + return true; + } + + /// Clears the resources associated with this instance of . + internal void DisposeInternal() + { + if (Interlocked.Exchange(ref this.iconTextureWrap, null) is { } wrapTaskToDispose) + wrapTaskToDispose.ToContentDisposedTask(true); + this.Dismiss = null; + this.Click = null; + this.DrawActions = null; + this.initiatorPlugin = null; + } + + private void LogEventInvokeError(Exception exception, string message) => + Log.Error( + exception, + $"[{nameof(ActiveNotification)}:{this.initiatorPlugin?.Name ?? NotificationConstants.DefaultInitiator}] {message}"); +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs new file mode 100644 index 000000000..de212160c --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationConstants.cs @@ -0,0 +1,161 @@ +using System.Numerics; + +using CheapLoc; + +using Dalamud.Interface.Colors; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.Utility; + +namespace Dalamud.Interface.ImGuiNotification.Internal; + +/// Constants for drawing notification windows. +internal static class NotificationConstants +{ + // .............................[..] + // ..when.......................[XX] + // .. .. + // ..[i]..title title title title .. + // .. by this_plugin .. + // .. .. + // .. body body body body .. + // .. some more wrapped body .. + // .. .. + // .. action buttons .. + // ................................. + + /// The string to measure size of, to decide the width of notification windows. + /// Probably not worth localizing. + public const string NotificationWidthMeasurementString = + "The width of this text will decide the width\n" + + "of the notification window."; + + /// The ratio of maximum notification window width w.r.t. main viewport width. + public const float MaxNotificationWindowWidthWrtMainViewportWidth = 2f / 3; + + /// The size of the icon. + public const float IconSize = 32; + + /// The background opacity of a notification window. + public const float BackgroundOpacity = 0.82f; + + /// The duration of indeterminate progress bar loop in milliseconds. + public const float IndeterminateProgressbarLoopDuration = 2000f; + + /// The duration of the progress wave animation in milliseconds. + public const float ProgressWaveLoopDuration = 2000f; + + /// The time ratio of a progress wave loop where the animation is idle. + public const float ProgressWaveIdleTimeRatio = 0.5f; + + /// The time ratio of a non-idle portion of the progress wave loop where the color is the most opaque. + /// + public const float ProgressWaveLoopMaxColorTimeRatio = 0.7f; + + /// Default duration of the notification. + public static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(3); + + /// Duration of show animation. + public static readonly TimeSpan ShowAnimationDuration = TimeSpan.FromMilliseconds(300); + + /// Duration of hide animation. + public static readonly TimeSpan HideAnimationDuration = TimeSpan.FromMilliseconds(300); + + /// Duration of progress change animation. + public static readonly TimeSpan ProgressChangeAnimationDuration = TimeSpan.FromMilliseconds(200); + + /// Duration of expando animation. + public static readonly TimeSpan ExpandoAnimationDuration = TimeSpan.FromMilliseconds(300); + + /// Text color for the rectangular border when the notification is focused. + public static readonly Vector4 FocusBorderColor = new(0.4f, 0.4f, 0.4f, 1f); + + /// Text color for the when. + public static readonly Vector4 WhenTextColor = new(0.8f, 0.8f, 0.8f, 1f); + + /// Text color for the close button [X]. + public static readonly Vector4 CloseTextColor = new(0.8f, 0.8f, 0.8f, 1f); + + /// Text color for the title. + public static readonly Vector4 TitleTextColor = new(1f, 1f, 1f, 1f); + + /// Text color for the name of the initiator. + public static readonly Vector4 BlameTextColor = new(0.8f, 0.8f, 0.8f, 1f); + + /// Text color for the body. + public static readonly Vector4 BodyTextColor = new(0.9f, 0.9f, 0.9f, 1f); + + /// Color for the background progress bar (determinate progress only). + public static readonly Vector4 BackgroundProgressColorMax = new(1f, 1f, 1f, 0.1f); + + /// Color for the background progress bar (determinate progress only). + public static readonly Vector4 BackgroundProgressColorMin = new(1f, 1f, 1f, 0.05f); + + /// Gets the scaled padding of the window (dot(.) in the above diagram). + public static float ScaledWindowPadding => MathF.Round(16 * ImGuiHelpers.GlobalScale); + + /// Gets the distance from the right bottom border of the viewport + /// to the right bottom border of a notification window. + /// + public static float ScaledViewportEdgeMargin => MathF.Round(20 * ImGuiHelpers.GlobalScale); + + /// Gets the scaled gap between two notification windows. + public static float ScaledWindowGap => MathF.Round(10 * ImGuiHelpers.GlobalScale); + + /// Gets the scaled gap between components. + public static float ScaledComponentGap => MathF.Round(5 * ImGuiHelpers.GlobalScale); + + /// Gets the scaled size of the icon. + public static float ScaledIconSize => MathF.Round(IconSize * ImGuiHelpers.GlobalScale); + + /// Gets the height of the expiry progress bar. + public static float ScaledExpiryProgressBarHeight => MathF.Round(3 * ImGuiHelpers.GlobalScale); + + /// Gets the thickness of the focus indicator rectangle. + public static float FocusIndicatorThickness => MathF.Round(3 * ImGuiHelpers.GlobalScale); + + /// Gets the string to show in place of this_plugin if the notification is shown by Dalamud. + public static string DefaultInitiator => Loc.Localize("NotificationConstants.DefaultInitiator", "Dalamud"); + + /// Gets the string format of the initiator name field, if the initiator is unloaded. + public static string UnloadedInitiatorNameFormat => + Loc.Localize("NotificationConstants.UnloadedInitiatorNameFormat", "{0} (unloaded)"); + + /// Gets the color corresponding to the notification type. + /// The notification type. + /// The corresponding color. + public static Vector4 ToColor(this NotificationType type) => type switch + { + NotificationType.None => ImGuiColors.DalamudWhite, + NotificationType.Success => ImGuiColors.HealerGreen, + NotificationType.Warning => ImGuiColors.DalamudOrange, + NotificationType.Error => ImGuiColors.DalamudRed, + NotificationType.Info => ImGuiColors.TankBlue, + _ => ImGuiColors.DalamudWhite, + }; + + /// Gets the char value corresponding to the notification type. + /// The notification type. + /// The corresponding char, or null. + public static char ToChar(this NotificationType type) => type switch + { + NotificationType.None => '\0', + NotificationType.Success => FontAwesomeIcon.CheckCircle.ToIconChar(), + NotificationType.Warning => FontAwesomeIcon.ExclamationCircle.ToIconChar(), + NotificationType.Error => FontAwesomeIcon.TimesCircle.ToIconChar(), + NotificationType.Info => FontAwesomeIcon.InfoCircle.ToIconChar(), + _ => '\0', + }; + + /// Gets the localized title string corresponding to the notification type. + /// The notification type. + /// The corresponding title. + public static string? ToTitle(this NotificationType type) => type switch + { + NotificationType.None => null, + NotificationType.Success => Loc.Localize("NotificationConstants.Title.Success", "Success"), + NotificationType.Warning => Loc.Localize("NotificationConstants.Title.Warning", "Warning"), + NotificationType.Error => Loc.Localize("NotificationConstants.Title.Error", "Error"), + NotificationType.Info => Loc.Localize("NotificationConstants.Title.Info", "Info"), + _ => null, + }; +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FilePathNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FilePathNotificationIcon.cs new file mode 100644 index 000000000..3aa712160 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FilePathNotificationIcon.cs @@ -0,0 +1,34 @@ +using System.IO; +using System.Numerics; + +using Dalamud.Interface.Internal; + +namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon; + +/// Represents the use of a texture from a file as the icon of a notification. +/// If there was no texture loaded for any reason, the plugin icon will be displayed instead. +internal class FilePathNotificationIcon : INotificationIcon +{ + private readonly FileInfo fileInfo; + + /// Initializes a new instance of the class. + /// The path to a .tex file inside the game resources. + public FilePathNotificationIcon(string filePath) => this.fileInfo = new(filePath); + + /// + public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) => + NotificationUtilities.DrawIconFrom( + minCoord, + maxCoord, + Service.Get().GetTextureFromFile(this.fileInfo)); + + /// + public override bool Equals(object? obj) => + obj is FilePathNotificationIcon r && r.fileInfo.FullName == this.fileInfo.FullName; + + /// + public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.fileInfo.FullName); + + /// + public override string ToString() => $"{nameof(FilePathNotificationIcon)}({this.fileInfo.FullName})"; +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FontAwesomeIconNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FontAwesomeIconNotificationIcon.cs new file mode 100644 index 000000000..0acfdee4c --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/FontAwesomeIconNotificationIcon.cs @@ -0,0 +1,31 @@ +using System.Numerics; + +namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon; + +/// Represents the use of as the icon of a notification. +internal class FontAwesomeIconNotificationIcon : INotificationIcon +{ + private readonly char iconChar; + + /// Initializes a new instance of the class. + /// The character. + public FontAwesomeIconNotificationIcon(FontAwesomeIcon iconChar) => this.iconChar = (char)iconChar; + + /// + public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) => + NotificationUtilities.DrawIconFrom( + minCoord, + maxCoord, + this.iconChar, + Service.Get().IconFontAwesomeFontHandle, + color); + + /// + public override bool Equals(object? obj) => obj is FontAwesomeIconNotificationIcon r && r.iconChar == this.iconChar; + + /// + public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.iconChar); + + /// + public override string ToString() => $"{nameof(FontAwesomeIconNotificationIcon)}({this.iconChar})"; +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/GamePathNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/GamePathNotificationIcon.cs new file mode 100644 index 000000000..e0699e1b6 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/GamePathNotificationIcon.cs @@ -0,0 +1,34 @@ +using System.Numerics; + +using Dalamud.Interface.Internal; +using Dalamud.Plugin.Services; + +namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon; + +/// Represents the use of a game-shipped texture as the icon of a notification. +/// If there was no texture loaded for any reason, the plugin icon will be displayed instead. +internal class GamePathNotificationIcon : INotificationIcon +{ + private readonly string gamePath; + + /// Initializes a new instance of the class. + /// The path to a .tex file inside the game resources. + /// Use to get the game path from icon IDs. + public GamePathNotificationIcon(string gamePath) => this.gamePath = gamePath; + + /// + public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) => + NotificationUtilities.DrawIconFrom( + minCoord, + maxCoord, + Service.Get().GetTextureFromGame(this.gamePath)); + + /// + public override bool Equals(object? obj) => obj is GamePathNotificationIcon r && r.gamePath == this.gamePath; + + /// + public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.gamePath); + + /// + public override string ToString() => $"{nameof(GamePathNotificationIcon)}({this.gamePath})"; +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/SeIconCharNotificationIcon.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/SeIconCharNotificationIcon.cs new file mode 100644 index 000000000..3bbd8dd81 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationIcon/SeIconCharNotificationIcon.cs @@ -0,0 +1,33 @@ +using System.Numerics; + +using Dalamud.Game.Text; + +namespace Dalamud.Interface.ImGuiNotification.Internal.NotificationIcon; + +/// Represents the use of as the icon of a notification. +internal class SeIconCharNotificationIcon : INotificationIcon +{ + private readonly SeIconChar iconChar; + + /// Initializes a new instance of the class. + /// The character. + public SeIconCharNotificationIcon(SeIconChar c) => this.iconChar = c; + + /// + public bool DrawIcon(Vector2 minCoord, Vector2 maxCoord, Vector4 color) => + NotificationUtilities.DrawIconFrom( + minCoord, + maxCoord, + (char)this.iconChar, + Service.Get().IconAxisFontHandle, + color); + + /// + public override bool Equals(object? obj) => obj is SeIconCharNotificationIcon r && r.iconChar == this.iconChar; + + /// + public override int GetHashCode() => HashCode.Combine(this.GetType().GetHashCode(), this.iconChar); + + /// + public override string ToString() => $"{nameof(SeIconCharNotificationIcon)}({this.iconChar})"; +} diff --git a/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs b/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs new file mode 100644 index 000000000..42aad2c45 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Internal/NotificationManager.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; + +using Dalamud.Game.Gui; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; + +using ImGuiNET; + +namespace Dalamud.Interface.ImGuiNotification.Internal; + +/// Class handling notifications/toasts in ImGui. +[InterfaceVersion("1.0")] +[ServiceManager.EarlyLoadedService] +internal class NotificationManager : INotificationManager, IInternalDisposableService +{ + [ServiceManager.ServiceDependency] + private readonly GameGui gameGui = Service.Get(); + + private readonly List notifications = new(); + private readonly ConcurrentBag pendingNotifications = new(); + + [ServiceManager.ServiceConstructor] + private NotificationManager(FontAtlasFactory fontAtlasFactory) + { + this.PrivateAtlas = fontAtlasFactory.CreateFontAtlas( + nameof(NotificationManager), + FontAtlasAutoRebuildMode.Async); + this.IconAxisFontHandle = + this.PrivateAtlas.NewGameFontHandle(new(GameFontFamily.Axis, NotificationConstants.IconSize)); + this.IconFontAwesomeFontHandle = + this.PrivateAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + tk => tk.AddFontAwesomeIconFont(new() { SizePx = NotificationConstants.IconSize }))); + } + + /// Gets the handle to AXIS fonts, sized for use as an icon. + public IFontHandle IconAxisFontHandle { get; } + + /// Gets the handle to FontAwesome fonts, sized for use as an icon. + public IFontHandle IconFontAwesomeFontHandle { get; } + + /// Gets the private atlas for use with notification windows. + private IFontAtlas PrivateAtlas { get; } + + /// + public void DisposeService() + { + this.PrivateAtlas.Dispose(); + foreach (var n in this.pendingNotifications) + n.DisposeInternal(); + foreach (var n in this.notifications) + n.DisposeInternal(); + this.pendingNotifications.Clear(); + this.notifications.Clear(); + } + + /// + public IActiveNotification AddNotification(Notification notification) + { + var an = new ActiveNotification(notification, null); + this.pendingNotifications.Add(an); + return an; + } + + /// Adds a notification originating from a plugin. + /// The notification. + /// The source plugin. + /// The added notification. + public IActiveNotification AddNotification(Notification notification, LocalPlugin plugin) + { + var an = new ActiveNotification(notification, plugin); + this.pendingNotifications.Add(an); + return an; + } + + /// Add a notification to the notification queue. + /// The content of the notification. + /// The title of the notification. + /// The type of the notification. + public void AddNotification( + string content, + string? title = null, + NotificationType type = NotificationType.None) => + this.AddNotification( + new() + { + Content = content, + Title = title, + Type = type, + }); + + /// Draw all currently queued notifications. + public void Draw() + { + var viewportSize = ImGuiHelpers.MainViewport.WorkSize; + var height = 0f; + var uiHidden = this.gameGui.GameUiHidden; + + while (this.pendingNotifications.TryTake(out var newNotification)) + this.notifications.Add(newNotification); + + var width = ImGui.CalcTextSize(NotificationConstants.NotificationWidthMeasurementString).X; + width += NotificationConstants.ScaledWindowPadding * 3; + width += NotificationConstants.ScaledIconSize; + width = Math.Min(width, viewportSize.X * NotificationConstants.MaxNotificationWindowWidthWrtMainViewportWidth); + + this.notifications.RemoveAll(static x => x.UpdateOrDisposeInternal()); + foreach (var tn in this.notifications) + { + if (uiHidden && tn.RespectUiHidden) + continue; + height += tn.Draw(width, height) + NotificationConstants.ScaledWindowGap; + } + } +} + +/// Plugin-scoped version of a service. +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class NotificationManagerPluginScoped : INotificationManager, IInternalDisposableService +{ + private readonly LocalPlugin localPlugin; + private readonly ConcurrentDictionary notifications = new(); + + [ServiceManager.ServiceDependency] + private readonly NotificationManager notificationManagerService = Service.Get(); + + [ServiceManager.ServiceConstructor] + private NotificationManagerPluginScoped(LocalPlugin localPlugin) => + this.localPlugin = localPlugin; + + /// + public IActiveNotification AddNotification(Notification notification) + { + var an = this.notificationManagerService.AddNotification(notification, this.localPlugin); + _ = this.notifications.TryAdd(an, 0); + an.Dismiss += a => this.notifications.TryRemove(a.Notification, out _); + return an; + } + + /// + public void DisposeService() + { + while (!this.notifications.IsEmpty) + { + foreach (var n in this.notifications.Keys) + { + this.notifications.TryRemove(n, out _); + ((ActiveNotification)n).RemoveNonDalamudInvocations(); + } + } + } +} diff --git a/Dalamud/Interface/ImGuiNotification/Notification.cs b/Dalamud/Interface/ImGuiNotification/Notification.cs new file mode 100644 index 000000000..5175985c7 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/Notification.cs @@ -0,0 +1,52 @@ +using Dalamud.Interface.ImGuiNotification.Internal; +using Dalamud.Interface.Internal.Notifications; + +namespace Dalamud.Interface.ImGuiNotification; + +/// Represents a blueprint for a notification. +public sealed record Notification : INotification +{ + /// + /// Gets the default value for and . + /// + public static TimeSpan DefaultDuration => NotificationConstants.DefaultDuration; + + /// + public string Content { get; set; } = string.Empty; + + /// + public string? Title { get; set; } + + /// + public string? MinimizedText { get; set; } + + /// + public NotificationType Type { get; set; } = NotificationType.None; + + /// + public INotificationIcon? Icon { get; set; } + + /// + public DateTime HardExpiry { get; set; } = DateTime.MaxValue; + + /// + public TimeSpan InitialDuration { get; set; } = DefaultDuration; + + /// + public TimeSpan ExtensionDurationSinceLastInterest { get; set; } = DefaultDuration; + + /// + public bool ShowIndeterminateIfNoExpiry { get; set; } = true; + + /// + public bool RespectUiHidden { get; set; } = true; + + /// + public bool Minimized { get; set; } = true; + + /// + public bool UserDismissable { get; set; } = true; + + /// + public float Progress { get; set; } = 1f; +} diff --git a/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs b/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs new file mode 100644 index 000000000..2c9d6d2a4 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/NotificationDismissReason.cs @@ -0,0 +1,16 @@ +namespace Dalamud.Interface.ImGuiNotification; + +/// Specifies the reason of dismissal for a notification. +public enum NotificationDismissReason +{ + /// The notification is dismissed because the expiry specified from is + /// met. + Timeout = 1, + + /// The notification is dismissed because the user clicked on the close button on a notification window. + /// + Manual = 2, + + /// The notification is dismissed from calling . + Programmatical = 3, +} diff --git a/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs b/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs new file mode 100644 index 000000000..631263f95 --- /dev/null +++ b/Dalamud/Interface/ImGuiNotification/NotificationUtilities.cs @@ -0,0 +1,149 @@ +using System.IO; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +using Dalamud.Game.Text; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.Windows; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Storage.Assets; + +using ImGuiNET; + +namespace Dalamud.Interface.ImGuiNotification; + +/// Utilities for implementing stuff under . +public static class NotificationUtilities +{ + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static INotificationIcon ToNotificationIcon(this SeIconChar iconChar) => + INotificationIcon.From(iconChar); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static INotificationIcon ToNotificationIcon(this FontAwesomeIcon iconChar) => + INotificationIcon.From(iconChar); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static INotificationIcon ToNotificationIcon(this FileInfo fileInfo) => + INotificationIcon.FromFile(fileInfo.FullName); + + /// Draws an icon from an and a . + /// The coordinates of the top left of the icon area. + /// The coordinates of the bottom right of the icon area. + /// The icon character. + /// The font handle to use. + /// The foreground color. + /// true if anything has been drawn. + internal static unsafe bool DrawIconFrom( + Vector2 minCoord, + Vector2 maxCoord, + char c, + IFontHandle fontHandle, + Vector4 color) + { + if (c is '\0' or char.MaxValue) + return false; + + var smallerDim = Math.Max(maxCoord.Y - minCoord.Y, maxCoord.X - minCoord.X); + using (fontHandle.Push()) + { + var font = ImGui.GetFont(); + var glyphPtr = (ImGuiHelpers.ImFontGlyphReal*)font.FindGlyphNoFallback(c).NativePtr; + if (glyphPtr is null) + return false; + + ref readonly var glyph = ref *glyphPtr; + var size = glyph.XY1 - glyph.XY0; + var smallerSizeDim = Math.Min(size.X, size.Y); + var scale = smallerSizeDim > smallerDim ? smallerDim / smallerSizeDim : 1f; + size *= scale; + var pos = ((minCoord + maxCoord) - size) / 2; + pos += ImGui.GetWindowPos(); + ImGui.GetWindowDrawList().AddImage( + font.ContainerAtlas.Textures[glyph.TextureIndex].TexID, + pos, + pos + size, + glyph.UV0, + glyph.UV1, + ImGui.GetColorU32(color with { W = color.W * ImGui.GetStyle().Alpha })); + } + + return true; + } + + /// Draws an icon from an instance of . + /// The coordinates of the top left of the icon area. + /// The coordinates of the bottom right of the icon area. + /// The texture. + /// true if anything has been drawn. + internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, IDalamudTextureWrap? texture) + { + if (texture is null) + return false; + try + { + var handle = texture.ImGuiHandle; + var size = texture.Size; + if (size.X > maxCoord.X - minCoord.X) + size *= (maxCoord.X - minCoord.X) / size.X; + if (size.Y > maxCoord.Y - minCoord.Y) + size *= (maxCoord.Y - minCoord.Y) / size.Y; + ImGui.SetCursorPos(((minCoord + maxCoord) - size) / 2); + ImGui.Image(handle, size); + return true; + } + catch + { + return false; + } + } + + /// Draws an icon from an instance of that results in an + /// . + /// The coordinates of the top left of the icon area. + /// The coordinates of the bottom right of the icon area. + /// The task that results in a texture. + /// true if anything has been drawn. + /// Exceptions from the task will be treated as if no texture is provided. + internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, Task? textureTask) => + textureTask?.IsCompletedSuccessfully is true && DrawIconFrom(minCoord, maxCoord, textureTask.Result); + + /// Draws an icon from an instance of . + /// The coordinates of the top left of the icon area. + /// The coordinates of the bottom right of the icon area. + /// The plugin. Dalamud icon will be drawn if null is given. + /// true if anything has been drawn. + internal static bool DrawIconFrom(Vector2 minCoord, Vector2 maxCoord, LocalPlugin? plugin) + { + var dam = Service.Get(); + if (plugin is null) + return false; + + if (!Service.Get().TryGetIcon( + plugin, + plugin.Manifest, + plugin.IsThirdParty, + out var texture) || texture is null) + { + texture = dam.GetDalamudTextureWrap(DalamudAsset.DefaultIcon); + } + + return DrawIconFrom(minCoord, maxCoord, texture); + } + + /// Draws the Dalamud logo as an icon. + /// The coordinates of the top left of the icon area. + /// The coordinates of the bottom right of the icon area. + internal static void DrawIconFromDalamudLogo(Vector2 minCoord, Vector2 maxCoord) + { + var dam = Service.Get(); + var texture = dam.GetDalamudTextureWrap(DalamudAsset.LogoSmall); + DrawIconFrom(minCoord, maxCoord, texture); + } +} diff --git a/Dalamud/Interface/Internal/DalamudCommands.cs b/Dalamud/Interface/Internal/DalamudCommands.cs index 307f79436..b64df8f19 100644 --- a/Dalamud/Interface/Internal/DalamudCommands.cs +++ b/Dalamud/Interface/Internal/DalamudCommands.cs @@ -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"), @@ -141,6 +135,13 @@ internal class DalamudCommands : IServiceType "Toggle Dalamud UI display modes. Native UI modifications may also be affected by this, but that depends on the plugin."), }); + commandManager.AddHandler("/xlprofiler", new CommandInfo(this.OnOpenProfilerCommand) + { + HelpMessage = Loc.Localize( + "DalamudProfilerHelp", + "Open Dalamud's startup timing profiler."), + }); + commandManager.AddHandler("/imdebug", new CommandInfo(this.OnDebugImInfoCommand) { HelpMessage = "ImGui DEBUG", @@ -301,11 +302,6 @@ internal class DalamudCommands : IServiceType dalamudInterface.ToggleDataWindow(arguments); } - private void OnDebugDrawIMEPanel(string command, string arguments) - { - Service.Get().OpenImeWindow(); - } - private void OnOpenLog(string command, string arguments) { Service.Get().ToggleLogWindow(); @@ -351,7 +347,8 @@ internal class DalamudCommands : IServiceType private void OnOpenInstallerCommand(string command, string arguments) { - Service.Get().TogglePluginInstallerWindow(); + var configuration = Service.Get(); + Service.Get().TogglePluginInstallerWindowTo(configuration.PluginInstallerOpen); } private void OnSetLanguageCommand(string command, string arguments) @@ -408,4 +405,9 @@ internal class DalamudCommands : IServiceType } } } + + private void OnOpenProfilerCommand(string command, string arguments) + { + Service.Get().ToggleProfilerWindow(); + } } diff --git a/Dalamud/Interface/Internal/DalamudIme.cs b/Dalamud/Interface/Internal/DalamudIme.cs new file mode 100644 index 000000000..64040011e --- /dev/null +++ b/Dalamud/Interface/Internal/DalamudIme.cs @@ -0,0 +1,1085 @@ +// #define IMEDEBUG + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Unicode; + +using Dalamud.Game.Text; +using Dalamud.Hooking.WndProcHook; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal.ManagedAsserts; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; + +using ImGuiNET; + +#if IMEDEBUG +using Serilog; +#endif + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Interface.Internal; + +/// +/// This class handles CJK IME. +/// +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class DalamudIme : IInternalDisposableService +{ + private const int CImGuiStbTextCreateUndoOffset = 0xB57A0; + private const int CImGuiStbTextUndoOffset = 0xB59C0; + + private const int ImePageSize = 9; + + private static readonly Dictionary WmNames = + typeof(WM).GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(x => x.IsLiteral && !x.IsInitOnly && x.FieldType == typeof(int)) + .Select(x => ((int)x.GetRawConstantValue()!, x.Name)) + .DistinctBy(x => x.Item1) + .ToDictionary(x => x.Item1, x => x.Name); + + private static readonly UnicodeRange[] HanRange = + { + UnicodeRanges.CjkRadicalsSupplement, + UnicodeRanges.CjkSymbolsandPunctuation, + UnicodeRanges.CjkUnifiedIdeographsExtensionA, + UnicodeRanges.CjkUnifiedIdeographs, + UnicodeRanges.CjkCompatibilityIdeographs, + UnicodeRanges.CjkCompatibilityForms, + // No more; Extension B~ are outside BMP range + }; + + private static readonly UnicodeRange[] HangulRange = + { + UnicodeRanges.HangulJamo, + UnicodeRanges.HangulSyllables, + UnicodeRanges.HangulCompatibilityJamo, + UnicodeRanges.HangulJamoExtendedA, + UnicodeRanges.HangulJamoExtendedB, + }; + + private static readonly delegate* unmanaged + StbTextMakeUndoReplace; + + private static readonly delegate* unmanaged StbTextUndo; + + [ServiceManager.ServiceDependency] + private readonly WndProcHookManager wndProcHookManager = Service.Get(); + + private readonly InterfaceManager interfaceManager; + + private readonly ImGuiSetPlatformImeDataDelegate setPlatformImeDataDelegate; + + /// The candidates. + private readonly List candidateStrings = new(); + + /// The selected imm component. + private string compositionString = string.Empty; + + /// The cursor position in screen coordinates. + private Vector2 cursorScreenPos; + + /// The associated viewport. + private ImGuiViewportPtr associatedViewport; + + /// The index of the first imm candidate in relation to the full list. + private CANDIDATELIST immCandNative; + + /// The partial conversion from-range. + private int partialConversionFrom; + + /// The partial conversion to-range. + private int partialConversionTo; + + /// The cursor offset in the composition string. + private int compositionCursorOffset; + + /// The input mode icon from . + private char inputModeIcon; + + /// Undo range for modifying the buffer while composition is in progress. + private (int Start, int End, int Cursor)? temporaryUndoSelection; + + private bool updateInputLanguage = true; + private bool updateImeStatusAgain; + + [SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1003:Symbols should be spaced correctly", Justification = ".")] + static DalamudIme() + { + nint cimgui; + try + { + _ = ImGui.GetCurrentContext(); + + cimgui = Process.GetCurrentProcess().Modules.Cast() + .First(x => x.ModuleName == "cimgui.dll") + .BaseAddress; + } + catch + { + return; + } + + StbTextMakeUndoReplace = + (delegate* unmanaged) + (cimgui + CImGuiStbTextCreateUndoOffset); + StbTextUndo = + (delegate* unmanaged) + (cimgui + CImGuiStbTextUndoOffset); + } + + [ServiceManager.ServiceConstructor] + private DalamudIme(InterfaceManager.InterfaceManagerWithScene imws) + { + Debug.Assert(ImGuiHelpers.IsImGuiInitialized, "IMWS initialized but IsImGuiInitialized is false?"); + + this.interfaceManager = imws.Manager; + this.setPlatformImeDataDelegate = this.ImGuiSetPlatformImeData; + + ImGui.GetIO().SetPlatformImeDataFn = Marshal.GetFunctionPointerForDelegate(this.setPlatformImeDataDelegate); + this.interfaceManager.Draw += this.Draw; + this.wndProcHookManager.PreWndProc += this.WndProcHookManagerOnPreWndProc; + } + + /// + /// Finalizes an instance of the class. + /// + ~DalamudIme() => this.ReleaseUnmanagedResources(); + + private delegate void ImGuiSetPlatformImeDataDelegate(ImGuiViewportPtr viewport, ImGuiPlatformImeDataPtr data); + + /// + /// Gets a value indicating whether Han(Chinese) input has been detected. + /// + public bool EncounteredHan { get; private set; } + + /// + /// Gets a value indicating whether Hangul(Korean) input has been detected. + /// + public bool EncounteredHangul { get; private set; } + + /// + /// Gets a value indicating whether to display the cursor in input text. This also deals with blinking. + /// + private static bool ShowCursorInInputText + { + get + { + if (!ImGuiHelpers.IsImGuiInitialized) + return true; + if (!ImGui.GetIO().ConfigInputTextCursorBlink) + return true; + var textState = TextState; + if (textState->Id == 0 || (textState->Flags & ImGuiInputTextFlags.ReadOnly) != 0) + return true; + if (textState->CursorAnim <= 0) + return true; + return textState->CursorAnim % 1.2f <= 0.8f; + } + } + + private static ImGuiInputTextState* TextState => + (ImGuiInputTextState*)(ImGui.GetCurrentContext() + ImGuiContextOffsets.TextStateOffset); + + /// Gets a value indicating whether to display partial conversion status. + private bool ShowPartialConversion => this.partialConversionFrom != 0 || + this.partialConversionTo != this.compositionString.Length; + + /// Gets a value indicating whether to draw. + private bool ShouldDraw => + this.candidateStrings.Count != 0 || this.ShowPartialConversion || this.inputModeIcon != default; + + /// + void IInternalDisposableService.DisposeService() + { + this.interfaceManager.Draw -= this.Draw; + this.ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + /// + /// Looks for the characters inside and enables fonts accordingly. + /// + /// The string. + public void ReflectCharacterEncounters(string str) + { + foreach (var chr in str) + { + if (!this.EncounteredHan) + { + if (HanRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) + { + if (Service.Get() + ?.GetFdtReader(GameFontFamilyAndSize.Axis12) + .FindGlyph(chr) is null) + { + this.EncounteredHan = true; + Service.Get().RebuildFonts(); + } + } + } + + if (!this.EncounteredHangul) + { + if (HangulRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) + { + this.EncounteredHangul = true; + Service.Get().RebuildFonts(); + } + } + } + } + + private static string ImmGetCompositionString(HIMC hImc, uint comp) + { + var numBytes = ImmGetCompositionStringW(hImc, comp, null, 0); + if (numBytes == 0) + return string.Empty; + + var data = stackalloc char[numBytes / 2]; + _ = ImmGetCompositionStringW(hImc, comp, data, (uint)numBytes); + return new(data, 0, numBytes / 2); + } + + private void ReleaseUnmanagedResources() + { + if (ImGuiHelpers.IsImGuiInitialized) + ImGui.GetIO().SetPlatformImeDataFn = nint.Zero; + } + + private void WndProcHookManagerOnPreWndProc(WndProcEventArgs args) + { + if (!ImGuiHelpers.IsImGuiInitialized) + { + this.updateInputLanguage = true; + return; + } + + // Are we not the target of text input? + if (!ImGui.GetIO().WantTextInput) + { + this.updateInputLanguage = true; + return; + } + + var hImc = ImmGetContext(args.Hwnd); + if (hImc == nint.Zero) + { + this.updateInputLanguage = true; + return; + } + + try + { + var invalidTarget = TextState->Id == 0 || (TextState->Flags & ImGuiInputTextFlags.ReadOnly) != 0; + +#if IMEDEBUG + switch (args.Message) + { + case WM.WM_IME_NOTIFY: + Log.Verbose($"{nameof(WM.WM_IME_NOTIFY)}({ImeDebug.ImnName((int)args.WParam)}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_CONTROL: + Log.Verbose( + $"{nameof(WM.WM_IME_CONTROL)}({ImeDebug.ImcName((int)args.WParam)}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_REQUEST: + Log.Verbose( + $"{nameof(WM.WM_IME_REQUEST)}({ImeDebug.ImrName((int)args.WParam)}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_SELECT: + Log.Verbose($"{nameof(WM.WM_IME_SELECT)}({(int)args.WParam != 0}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_STARTCOMPOSITION: + Log.Verbose($"{nameof(WM.WM_IME_STARTCOMPOSITION)}()"); + break; + case WM.WM_IME_COMPOSITION: + Log.Verbose( + $"{nameof(WM.WM_IME_COMPOSITION)}({(char)args.WParam}, {ImeDebug.GcsName((int)args.LParam)})"); + break; + case WM.WM_IME_COMPOSITIONFULL: + Log.Verbose($"{nameof(WM.WM_IME_COMPOSITIONFULL)}()"); + break; + case WM.WM_IME_ENDCOMPOSITION: + Log.Verbose($"{nameof(WM.WM_IME_ENDCOMPOSITION)}()"); + break; + case WM.WM_IME_CHAR: + Log.Verbose($"{nameof(WM.WM_IME_CHAR)}({(char)args.WParam}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_KEYDOWN: + Log.Verbose($"{nameof(WM.WM_IME_KEYDOWN)}({(char)args.WParam}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_KEYUP: + Log.Verbose($"{nameof(WM.WM_IME_KEYUP)}({(char)args.WParam}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_SETCONTEXT: + Log.Verbose($"{nameof(WM.WM_IME_SETCONTEXT)}({(int)args.WParam != 0}, 0x{args.LParam:X})"); + break; + } +#endif + if (this.updateInputLanguage + || (args.Message == WM.WM_IME_NOTIFY + && (int)args.WParam + is IMN.IMN_SETCONVERSIONMODE + or IMN.IMN_OPENSTATUSWINDOW + or IMN.IMN_CLOSESTATUSWINDOW)) + { + this.UpdateInputLanguage(hImc); + this.updateInputLanguage = false; + } + + if (this.updateImeStatusAgain) + { + this.ReplaceCompositionString(hImc, false); + this.UpdateCandidates(hImc); + this.updateImeStatusAgain = false; + } + + switch (args.Message) + { + case WM.WM_IME_NOTIFY + when (nint)args.WParam is IMN.IMN_OPENCANDIDATE or IMN.IMN_CLOSECANDIDATE + or IMN.IMN_CHANGECANDIDATE: + this.UpdateCandidates(hImc); + this.updateImeStatusAgain = true; + args.SuppressWithValue(0); + break; + + case WM.WM_IME_STARTCOMPOSITION: + this.updateImeStatusAgain = true; + args.SuppressWithValue(0); + break; + + case WM.WM_IME_COMPOSITION: + if (invalidTarget) + ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); + else + this.ReplaceCompositionString(hImc, ((int)args.LParam & GCS.GCS_RESULTSTR) != 0); + + this.updateImeStatusAgain = true; + args.SuppressWithValue(0); + break; + + case WM.WM_IME_ENDCOMPOSITION: + this.ClearState(hImc, false); + this.updateImeStatusAgain = true; + args.SuppressWithValue(0); + break; + + case WM.WM_IME_CHAR: + case WM.WM_IME_KEYDOWN: + case WM.WM_IME_KEYUP: + case WM.WM_IME_CONTROL: + case WM.WM_IME_REQUEST: + this.updateImeStatusAgain = true; + args.SuppressWithValue(0); + break; + + case WM.WM_IME_SETCONTEXT: + // Hide candidate and composition windows. + args.LParam = (LPARAM)((nint)args.LParam & ~(ISC_SHOWUICOMPOSITIONWINDOW | 0xF)); + + this.updateImeStatusAgain = true; + args.SuppressWithDefault(); + break; + + case WM.WM_IME_NOTIFY: + case WM.WM_IME_COMPOSITIONFULL: + case WM.WM_IME_SELECT: + this.updateImeStatusAgain = true; + break; + + case WM.WM_KEYDOWN when (int)args.WParam is + VK.VK_TAB + or VK.VK_PRIOR + or VK.VK_NEXT + or VK.VK_END + or VK.VK_HOME + or VK.VK_LEFT + or VK.VK_UP + or VK.VK_RIGHT + or VK.VK_DOWN + or VK.VK_RETURN: + if (this.candidateStrings.Count != 0) + { + this.ClearState(hImc); + args.WParam = VK.VK_PROCESSKEY; + } + + this.UpdateCandidates(hImc); + break; + + case WM.WM_KEYDOWN when (int)args.WParam is VK.VK_ESCAPE && this.candidateStrings.Count != 0: + this.ClearState(hImc); + args.SuppressWithDefault(); + break; + + case WM.WM_LBUTTONDOWN: + case WM.WM_RBUTTONDOWN: + case WM.WM_MBUTTONDOWN: + case WM.WM_XBUTTONDOWN: + ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_COMPLETE, 0); + break; + } + } + finally + { + ImmReleaseContext(args.Hwnd, hImc); + } + } + + private void UpdateInputLanguage(HIMC hImc) + { + uint conv, sent; + ImmGetConversionStatus(hImc, &conv, &sent); + var lang = GetKeyboardLayout(0); + var open = ImmGetOpenStatus(hImc) != false; + + var native = (conv & 1) != 0; + var katakana = (conv & 2) != 0; + var fullwidth = (conv & 8) != 0; + switch (lang & 0x3F) + { + case LANG.LANG_KOREAN: + if (native) + this.inputModeIcon = (char)SeIconChar.ImeKoreanHangul; + else if (fullwidth) + this.inputModeIcon = (char)SeIconChar.ImeAlphanumeric; + else + this.inputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; + break; + + case LANG.LANG_JAPANESE: + // wtf + // see the function called from: 48 8b 0d ?? ?? ?? ?? e8 ?? ?? ?? ?? 8b d8 e9 ?? 00 00 0 + if (open && native && katakana && fullwidth) + this.inputModeIcon = (char)SeIconChar.ImeKatakana; + else if (open && native && katakana) + this.inputModeIcon = (char)SeIconChar.ImeKatakanaHalfWidth; + else if (open && native) + this.inputModeIcon = (char)SeIconChar.ImeHiragana; + else if (open && fullwidth) + this.inputModeIcon = (char)SeIconChar.ImeAlphanumeric; + else + this.inputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; + break; + + case LANG.LANG_CHINESE: + if (native) + this.inputModeIcon = (char)SeIconChar.ImeChineseHan; + else + this.inputModeIcon = (char)SeIconChar.ImeChineseLatin; + break; + + default: + this.inputModeIcon = default; + break; + } + } + + private void ReplaceCompositionString(HIMC hImc, bool finalCommit) + { + var newString = finalCommit + ? ImmGetCompositionString(hImc, GCS.GCS_RESULTSTR) + : ImmGetCompositionString(hImc, GCS.GCS_COMPSTR); + +#if IMEDEBUG + Log.Verbose($"{nameof(this.ReplaceCompositionString)}({newString})"); +#endif + + this.ReflectCharacterEncounters(newString); + + if (this.temporaryUndoSelection is not null) + { + TextState->Undo(); + TextState->SelectionTuple = this.temporaryUndoSelection.Value; + this.temporaryUndoSelection = null; + } + + TextState->SanitizeSelectionRange(); + if (TextState->ReplaceSelectionAndPushUndo(newString)) + this.temporaryUndoSelection = TextState->SelectionTuple; + + // Put the cursor at the beginning, so that the candidate window appears aligned with the text. + TextState->SetSelectionRange(TextState->SelectionTuple.Start, newString.Length, 0); + + if (finalCommit) + { + this.ClearState(hImc, false); + newString = string.Empty; + } + + this.compositionString = newString; + this.compositionCursorOffset = ImmGetCompositionStringW(hImc, GCS.GCS_CURSORPOS, null, 0); + + var attrLength = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, null, 0); + if (attrLength > 0) + { + var attrPtr = stackalloc byte[attrLength]; + var attr = new Span(attrPtr, Math.Min(this.compositionString.Length, attrLength)); + _ = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, attrPtr, (uint)attrLength); + var l = 0; + while (l < attr.Length && attr[l] is not ATTR_TARGET_CONVERTED and not ATTR_TARGET_NOTCONVERTED) + l++; + + var r = l; + while (r < attr.Length && attr[r] is ATTR_TARGET_CONVERTED or ATTR_TARGET_NOTCONVERTED) + r++; + + if (r == 0 || l == this.compositionString.Length) + (l, r) = (0, this.compositionString.Length); + + (this.partialConversionFrom, this.partialConversionTo) = (l, r); + } + else + { + this.partialConversionFrom = 0; + this.partialConversionTo = this.compositionString.Length; + } + + this.UpdateCandidates(hImc); + } + + private void ClearState(HIMC hImc, bool invokeCancel = true) + { + this.compositionString = string.Empty; + this.partialConversionFrom = this.partialConversionTo = 0; + this.compositionCursorOffset = 0; + this.temporaryUndoSelection = null; + TextState->Stb.SelectStart = TextState->Stb.Cursor = TextState->Stb.SelectEnd; + this.candidateStrings.Clear(); + this.immCandNative = default; + if (invokeCancel) + ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); + +#if IMEDEBUG + Log.Information($"{nameof(this.ClearState)}({invokeCancel})"); +#endif + } + + private void UpdateCandidates(HIMC hImc) + { + this.candidateStrings.Clear(); + this.immCandNative = default; + + if (hImc == default) + return; + + var size = (int)ImmGetCandidateListW(hImc, 0, null, 0); + if (size == 0) + return; + + var pStorage = stackalloc byte[size]; + if (size != ImmGetCandidateListW(hImc, 0, (CANDIDATELIST*)pStorage, (uint)size)) + return; + + ref var candlist = ref *(CANDIDATELIST*)pStorage; + this.immCandNative = candlist; + + if (candlist.dwPageSize == 0 || candlist.dwCount == 0) + return; + + foreach (var i in Enumerable.Range( + (int)candlist.dwPageStart, + (int)Math.Min(candlist.dwCount - candlist.dwPageStart, candlist.dwPageSize))) + { + this.candidateStrings.Add(new((char*)(pStorage + candlist.dwOffset[i]))); + this.ReflectCharacterEncounters(this.candidateStrings[^1]); + } + } + + private void ImGuiSetPlatformImeData(ImGuiViewportPtr viewport, ImGuiPlatformImeDataPtr data) + { + this.cursorScreenPos = data.InputPos; + this.associatedViewport = data.WantVisible ? viewport : default; + } + + private void Draw() + { + if (!this.ShouldDraw) + return; + + if (Service.GetNullable() is not { } ime) + return; + + var viewport = ime.associatedViewport; + if (viewport.NativePtr is null) + return; + + var drawCand = ime.candidateStrings.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.compositionString == string.Empty ? " " : ime.compositionString); + + 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.candidateStrings.Count; i++) + { + var textSize = ImGui.CalcTextSize($"{i + 1}. {ime.candidateStrings[i]}"); + maxTextWidth = maxTextWidth > textSize.X ? maxTextWidth : textSize.X; + } + + maxTextWidth = maxTextWidth > ImGui.CalcTextSize(pageInfo).X ? maxTextWidth : ImGui.CalcTextSize(pageInfo).X; + maxTextWidth = maxTextWidth > ImGui.CalcTextSize(ime.compositionString).X + ? maxTextWidth + : ImGui.CalcTextSize(ime.compositionString).X; + + var numEntries = (drawCand ? ime.candidateStrings.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.cursorScreenPos.Y + windowSize.Y > viewport.WorkPos.Y + viewport.WorkSize.Y; + var windowPos = ime.cursorScreenPos - 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.candidateStrings.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.candidateStrings[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.compositionString.Length) + { + var part1 = ime.compositionString[..ime.partialConversionFrom]; + var part2 = ime.compositionString[..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.compositionString); + + // Draw the caret inside the composition string. + if (DalamudIme.ShowCursorInInputText) + { + var partBeforeCaret = ime.compositionString[..ime.compositionCursorOffset]; + var sizeBeforeCaret = ImGui.CalcTextSize(partBeforeCaret); + drawList.AddLine( + cursor + sizeBeforeCaret with { Y = 0 }, + cursor + sizeBeforeCaret, + ImGui.GetColorU32(ImGuiCol.Text)); + } + } + } + + /// + /// Ported from imstb_textedit.h. + /// + [StructLayout(LayoutKind.Sequential, Size = 0xE2C)] + private struct StbTextEditState + { + /// + /// Position of the text cursor within the string. + /// + public int Cursor; + + /// + /// Selection start point. + /// + public int SelectStart; + + /// + /// selection start and end point in characters; if equal, no selection. + /// + /// + /// Note that start may be less than or greater than end (e.g. when dragging the mouse, + /// start is where the initial click was, and you can drag in either direction.) + /// + public int SelectEnd; + + /// + /// Each text field keeps its own insert mode state. + /// To keep an app-wide insert mode, copy this value in/out of the app state. + /// + public byte InsertMode; + + /// + /// Page size in number of row. + /// This value MUST be set to >0 for pageup or pagedown in multilines documents. + /// + public int RowCountPerPage; + + // Remainder is stb-private data. + } + + [StructLayout(LayoutKind.Sequential)] + private struct ImGuiInputTextState + { + public uint Id; + public int CurLenW; + public int CurLenA; + public ImVector TextWRaw; + public ImVector TextARaw; + public ImVector InitialTextARaw; + public bool TextAIsValid; + public int BufCapacityA; + public float ScrollX; + public StbTextEditState Stb; + public float CursorAnim; + public bool CursorFollow; + public bool SelectedAllMouseLock; + public bool Edited; + public ImGuiInputTextFlags Flags; + + public ImVectorWrapper TextW => new((ImVector*)&this.ThisPtr->TextWRaw); + + public (int Start, int End, int Cursor) SelectionTuple + { + get => (this.Stb.SelectStart, this.Stb.SelectEnd, this.Stb.Cursor); + set => (this.Stb.SelectStart, this.Stb.SelectEnd, this.Stb.Cursor) = value; + } + + private ImGuiInputTextState* ThisPtr => (ImGuiInputTextState*)Unsafe.AsPointer(ref this); + + public void SetSelectionRange(int offset, int length, int relativeCursorOffset) + { + this.Stb.SelectStart = offset; + this.Stb.SelectEnd = offset + length; + if (relativeCursorOffset >= 0) + this.Stb.Cursor = this.Stb.SelectStart + relativeCursorOffset; + else + this.Stb.Cursor = this.Stb.SelectEnd + 1 + relativeCursorOffset; + this.SanitizeSelectionRange(); + } + + public void SanitizeSelectionRange() + { + ref var s = ref this.Stb.SelectStart; + ref var e = ref this.Stb.SelectEnd; + ref var c = ref this.Stb.Cursor; + s = Math.Clamp(s, 0, this.CurLenW); + e = Math.Clamp(e, 0, this.CurLenW); + c = Math.Clamp(c, 0, this.CurLenW); + if (s == e) + s = e = c; + if (s > e) + (s, e) = (e, s); + } + + public void Undo() => StbTextUndo(this.ThisPtr, &this.ThisPtr->Stb); + + public bool MakeUndoReplace(int offset, int oldLength, int newLength) + { + if (oldLength == 0 && newLength == 0) + return false; + + StbTextMakeUndoReplace(this.ThisPtr, &this.ThisPtr->Stb, offset, oldLength, newLength); + return true; + } + + public bool ReplaceSelectionAndPushUndo(ReadOnlySpan newText) + { + var off = this.Stb.SelectStart; + var len = this.Stb.SelectEnd - this.Stb.SelectStart; + return this.MakeUndoReplace(off, len, newText.Length) && this.ReplaceChars(off, len, newText); + } + + public bool ReplaceChars(int pos, int len, ReadOnlySpan newText) + { + this.DeleteChars(pos, len); + return this.InsertChars(pos, newText); + } + + // See imgui_widgets.cpp: STB_TEXTEDIT_DELETECHARS + public void DeleteChars(int pos, int n) + { + if (n == 0) + return; + + var dst = this.TextW.Data + pos; + + // We maintain our buffer length in both UTF-8 and wchar formats + this.Edited = true; + this.CurLenA -= Encoding.UTF8.GetByteCount(dst, n); + this.CurLenW -= n; + + // Offset remaining text (FIXME-OPT: Use memmove) + var src = this.TextW.Data + pos + n; + int i; + for (i = 0; src[i] != 0; i++) + dst[i] = src[i]; + dst[i] = '\0'; + } + + // See imgui_widgets.cpp: STB_TEXTEDIT_INSERTCHARS + public bool InsertChars(int pos, ReadOnlySpan newText) + { + if (newText.Length == 0) + return true; + + var isResizable = (this.Flags & ImGuiInputTextFlags.CallbackResize) != 0; + var textLen = this.CurLenW; + Debug.Assert(pos <= textLen, "pos <= text_len"); + + var newTextLenUtf8 = Encoding.UTF8.GetByteCount(newText); + if (!isResizable && newTextLenUtf8 + this.CurLenA + 1 > this.BufCapacityA) + return false; + + // Grow internal buffer if needed + if (newText.Length + textLen + 1 > this.TextW.Length) + { + if (!isResizable) + return false; + + Debug.Assert(textLen < this.TextW.Length, "text_len < this.TextW.Length"); + this.TextW.Resize(textLen + Math.Clamp(newText.Length * 4, 32, Math.Max(256, newText.Length)) + 1); + } + + var text = this.TextW.DataSpan; + if (pos != textLen) + text.Slice(pos, textLen - pos).CopyTo(text[(pos + newText.Length)..]); + newText.CopyTo(text[pos..]); + + this.Edited = true; + this.CurLenW += newText.Length; + this.CurLenA += newTextLenUtf8; + this.TextW[this.CurLenW] = '\0'; + + return true; + } + } + +#if IMEDEBUG + private static class ImeDebug + { + private static readonly (int Value, string Name)[] GcsFields = + { + (GCS.GCS_COMPREADSTR, nameof(GCS.GCS_COMPREADSTR)), + (GCS.GCS_COMPREADATTR, nameof(GCS.GCS_COMPREADATTR)), + (GCS.GCS_COMPREADCLAUSE, nameof(GCS.GCS_COMPREADCLAUSE)), + (GCS.GCS_COMPSTR, nameof(GCS.GCS_COMPSTR)), + (GCS.GCS_COMPATTR, nameof(GCS.GCS_COMPATTR)), + (GCS.GCS_COMPCLAUSE, nameof(GCS.GCS_COMPCLAUSE)), + (GCS.GCS_CURSORPOS, nameof(GCS.GCS_CURSORPOS)), + (GCS.GCS_DELTASTART, nameof(GCS.GCS_DELTASTART)), + (GCS.GCS_RESULTREADSTR, nameof(GCS.GCS_RESULTREADSTR)), + (GCS.GCS_RESULTREADCLAUSE, nameof(GCS.GCS_RESULTREADCLAUSE)), + (GCS.GCS_RESULTSTR, nameof(GCS.GCS_RESULTSTR)), + (GCS.GCS_RESULTCLAUSE, nameof(GCS.GCS_RESULTCLAUSE)), + }; + + private static readonly IReadOnlyDictionary ImnFields = + typeof(IMN) + .GetFields(BindingFlags.Static | BindingFlags.Public) + .Where(x => x.IsLiteral) + .ToDictionary(x => (int)x.GetRawConstantValue()!, x => x.Name); + + public static string GcsName(int val) + { + var sb = new StringBuilder(); + foreach (var (value, name) in GcsFields) + { + if ((val & value) != 0) + { + if (sb.Length != 0) + sb.Append(" | "); + sb.Append(name); + val &= ~value; + } + } + + if (val != 0) + { + if (sb.Length != 0) + sb.Append(" | "); + sb.Append($"0x{val:X}"); + } + + return sb.ToString(); + } + + public static string ImcName(int val) => ImnFields.TryGetValue(val, out var name) ? name : $"0x{val:X}"; + + public static string ImnName(int val) => ImnFields.TryGetValue(val, out var name) ? name : $"0x{val:X}"; + + public static string ImrName(int val) => val switch + { + IMR_CANDIDATEWINDOW => nameof(IMR_CANDIDATEWINDOW), + IMR_COMPOSITIONFONT => nameof(IMR_COMPOSITIONFONT), + IMR_COMPOSITIONWINDOW => nameof(IMR_COMPOSITIONWINDOW), + IMR_CONFIRMRECONVERTSTRING => nameof(IMR_CONFIRMRECONVERTSTRING), + IMR_DOCUMENTFEED => nameof(IMR_DOCUMENTFEED), + IMR_QUERYCHARPOSITION => nameof(IMR_QUERYCHARPOSITION), + IMR_RECONVERTSTRING => nameof(IMR_RECONVERTSTRING), + _ => $"0x{val:X}", + }; + } +#endif +} diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs index 479297c20..ec18fbb69 100644 --- a/Dalamud/Interface/Internal/DalamudInterface.cs +++ b/Dalamud/Interface/Internal/DalamudInterface.cs @@ -1,7 +1,5 @@ -using System; using System.Diagnostics; using System.Globalization; -using System.IO; using System.Linq; using System.Numerics; using System.Reflection; @@ -9,9 +7,12 @@ using System.Runtime.InteropServices; using CheapLoc; using Dalamud.Configuration.Internal; +using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Keys; using Dalamud.Game.Gui; using Dalamud.Game.Internal; +using Dalamud.Hooking; using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Colors; using Dalamud.Interface.Internal.ManagedAsserts; @@ -21,17 +22,20 @@ using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.SelfTest; using Dalamud.Interface.Internal.Windows.Settings; using Dalamud.Interface.Internal.Windows.StyleEditor; -using Dalamud.Interface.Raii; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Style; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; -using Dalamud.Logging; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal; +using Dalamud.Storage.Assets; using Dalamud.Utility; + using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.UI; using ImGuiNET; -using ImGuiScene; + using ImPlotNET; using PInvoke; using Serilog.Events; @@ -42,18 +46,21 @@ namespace Dalamud.Interface.Internal; /// This plugin implements all of the Dalamud interface separately, to allow for reloading of the interface and rapid prototyping. ///
[ServiceManager.EarlyLoadedService] -internal class DalamudInterface : IDisposable, IServiceType +internal class DalamudInterface : IInternalDisposableService { private const float CreditsDarkeningMaxAlpha = 0.8f; private static readonly ModuleLog Log = new("DUI"); + private readonly Dalamud dalamud; + private readonly DalamudConfiguration configuration; + private readonly InterfaceManager interfaceManager; + private readonly ChangelogWindow changelogWindow; private readonly ColorDemoWindow colorDemoWindow; private readonly ComponentDemoWindow componentDemoWindow; private readonly DataWindow dataWindow; private readonly GamepadModeNotifierWindow gamepadModeNotifierWindow; - private readonly ImeWindow imeWindow; private readonly ConsoleWindow consoleWindow; private readonly PluginStatWindow pluginStatWindow; private readonly PluginInstallerWindow pluginWindow; @@ -65,9 +72,6 @@ internal class DalamudInterface : IDisposable, IServiceType private readonly BranchSwitcherWindow branchSwitcherWindow; private readonly HitchSettingsWindow hitchSettingsWindow; - private readonly TextureWrap logoTexture; - private readonly TextureWrap tsmLogoTexture; - private bool isCreditsDarkening = false; private OutCubic creditsDarkeningAnimation = new(TimeSpan.FromSeconds(10)); @@ -85,30 +89,48 @@ internal class DalamudInterface : IDisposable, IServiceType private bool isImPlotDrawDemoWindow = false; private bool isImGuiTestWindowsInMonospace = false; private bool isImGuiDrawMetricsWindow = false; - + [ServiceManager.ServiceConstructor] private DalamudInterface( Dalamud dalamud, DalamudConfiguration configuration, - InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene, - PluginImageCache pluginImageCache) + FontAtlasFactory fontAtlasFactory, + InterfaceManager interfaceManager, + PluginImageCache pluginImageCache, + DalamudAssetManager dalamudAssetManager, + Game.Framework framework, + ClientState clientState, + TitleScreenMenu titleScreenMenu, + GameGui gameGui) { - var interfaceManager = interfaceManagerWithScene.Manager; - this.WindowSystem = new WindowSystem("DalamudCore"); + this.dalamud = dalamud; + this.configuration = configuration; + this.interfaceManager = interfaceManager; - this.changelogWindow = new ChangelogWindow() { IsOpen = false }; + this.WindowSystem = new WindowSystem("DalamudCore"); + this.colorDemoWindow = new ColorDemoWindow() { IsOpen = false }; this.componentDemoWindow = new ComponentDemoWindow() { IsOpen = false }; this.dataWindow = new DataWindow() { IsOpen = false }; this.gamepadModeNotifierWindow = new GamepadModeNotifierWindow() { IsOpen = false }; - this.imeWindow = new ImeWindow() { IsOpen = false }; - this.consoleWindow = new ConsoleWindow() { IsOpen = configuration.LogOpenAtStartup }; + this.consoleWindow = new ConsoleWindow(configuration) { IsOpen = configuration.LogOpenAtStartup }; this.pluginStatWindow = new PluginStatWindow() { IsOpen = false }; - this.pluginWindow = new PluginInstallerWindow(pluginImageCache) { IsOpen = false }; + this.pluginWindow = new PluginInstallerWindow(pluginImageCache, configuration) { IsOpen = false }; this.settingsWindow = new SettingsWindow() { IsOpen = false }; this.selfTestWindow = new SelfTestWindow() { IsOpen = false }; this.styleEditorWindow = new StyleEditorWindow() { IsOpen = false }; - this.titleScreenMenuWindow = new TitleScreenMenuWindow() { IsOpen = false }; + this.titleScreenMenuWindow = new TitleScreenMenuWindow( + clientState, + configuration, + dalamudAssetManager, + fontAtlasFactory, + framework, + gameGui, + titleScreenMenu) { IsOpen = false }; + this.changelogWindow = new ChangelogWindow( + this.titleScreenMenuWindow, + fontAtlasFactory, + dalamudAssetManager) { IsOpen = false }; this.profilerWindow = new ProfilerWindow() { IsOpen = false }; this.branchSwitcherWindow = new BranchSwitcherWindow() { IsOpen = false }; this.hitchSettingsWindow = new HitchSettingsWindow() { IsOpen = false }; @@ -118,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); @@ -133,34 +154,41 @@ internal class DalamudInterface : IDisposable, IServiceType ImGuiManagedAsserts.AssertsEnabled = configuration.AssertsEnabledAtStartup; this.isImGuiDrawDevMenu = this.isImGuiDrawDevMenu || configuration.DevBarOpenAtStartup; - interfaceManager.Draw += this.OnDraw; + this.interfaceManager.Draw += this.OnDraw; - var logoTex = - interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "logo.png")); - var tsmLogoTex = - interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "tsmLogo.png")); + Service.GetAsync().ContinueWith( + _ => + { + titleScreenMenu.AddEntryCore( + Loc.Localize("TSMDalamudPlugins", "Plugin Installer"), + dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall), + this.OpenPluginInstaller); + titleScreenMenu.AddEntryCore( + Loc.Localize("TSMDalamudSettings", "Dalamud Settings"), + dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall), + this.OpenSettings); - if (logoTex == null || tsmLogoTex == null) - { - throw new Exception("Failed to load logo textures"); - } + titleScreenMenu.AddEntryCore( + "Toggle Dev Menu", + dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall), + () => Service.GetNullable()?.ToggleDevMenu(), + VirtualKey.SHIFT); - this.logoTexture = logoTex; - this.tsmLogoTexture = tsmLogoTex; - - var tsm = Service.Get(); - tsm.AddEntryCore(Loc.Localize("TSMDalamudPlugins", "Plugin Installer"), this.tsmLogoTexture, this.OpenPluginInstaller); - tsm.AddEntryCore(Loc.Localize("TSMDalamudSettings", "Dalamud Settings"), this.tsmLogoTexture, this.OpenSettings); - - if (!configuration.DalamudBetaKind.IsNullOrEmpty()) - { - tsm.AddEntryCore(Loc.Localize("TSMDalamudDevMenu", "Developer Menu"), this.tsmLogoTexture, () => this.isImGuiDrawDevMenu = true); - } + if (!configuration.DalamudBetaKind.IsNullOrEmpty()) + { + titleScreenMenu.AddEntryCore( + Loc.Localize("TSMDalamudDevMenu", "Developer Menu"), + dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall), + () => this.isImGuiDrawDevMenu = true); + } + }); this.creditsDarkeningAnimation.Point1 = Vector2.Zero; this.creditsDarkeningAnimation.Point2 = new Vector2(CreditsDarkeningMaxAlpha); } - + + private delegate nint CrashDebugDelegate(nint self); + /// /// Gets the number of frames since Dalamud has loaded. /// @@ -181,19 +209,17 @@ internal class DalamudInterface : IDisposable, IServiceType } /// - public void Dispose() + void IInternalDisposableService.DisposeService() { - Service.Get().Draw -= this.OnDraw; + this.interfaceManager.Draw -= this.OnDraw; + this.WindowSystem.Windows.OfType().AggregateToDisposable().Dispose(); this.WindowSystem.RemoveAllWindows(); this.changelogWindow.Dispose(); this.consoleWindow.Dispose(); this.pluginWindow.Dispose(); this.titleScreenMenuWindow.Dispose(); - - this.logoTexture.Dispose(); - this.tsmLogoTexture.Dispose(); } #region Open @@ -236,11 +262,6 @@ internal class DalamudInterface : IDisposable, IServiceType ///
public void OpenGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.IsOpen = true; - /// - /// Opens the . - /// - public void OpenImeWindow() => this.imeWindow.IsOpen = true; - /// /// Opens the . /// @@ -258,31 +279,23 @@ internal class DalamudInterface : IDisposable, IServiceType this.pluginStatWindow.IsOpen = true; this.pluginStatWindow.BringToFront(); } - + /// - /// Opens the . + /// Opens the on the plugin installed. /// public void OpenPluginInstaller() { - this.pluginWindow.IsOpen = true; + this.pluginWindow.OpenTo(this.configuration.PluginInstallerOpen); this.pluginWindow.BringToFront(); } /// /// Opens the on the plugin installed. /// - public void OpenPluginInstallerPluginInstalled() + /// The page of the installer to open. + public void OpenPluginInstallerTo(PluginInstallerWindow.PluginInstallerOpenKind kind) { - this.pluginWindow.OpenInstalledPlugins(); - this.pluginWindow.BringToFront(); - } - - /// - /// Opens the on the plugin changelogs. - /// - public void OpenPluginInstallerPluginChangelogs() - { - this.pluginWindow.OpenPluginChangelogs(); + this.pluginWindow.OpenTo(kind); this.pluginWindow.BringToFront(); } @@ -344,11 +357,6 @@ internal class DalamudInterface : IDisposable, IServiceType #region Close - /// - /// Closes the . - /// - public void CloseImeWindow() => this.imeWindow.IsOpen = false; - /// /// Closes the . /// @@ -377,7 +385,7 @@ internal class DalamudInterface : IDisposable, IServiceType /// Toggles the . ///
/// The data kind to switch to after opening. - public void ToggleDataWindow(string dataKind = null) + public void ToggleDataWindow(string? dataKind = null) { this.dataWindow.Toggle(); if (dataKind != null && this.dataWindow.IsOpen) @@ -396,11 +404,6 @@ internal class DalamudInterface : IDisposable, IServiceType ///
public void ToggleGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.Toggle(); - /// - /// Toggles the . - /// - public void ToggleIMEWindow() => this.imeWindow.Toggle(); - /// /// Toggles the . /// @@ -414,7 +417,8 @@ internal class DalamudInterface : IDisposable, IServiceType /// /// Toggles the . /// - public void TogglePluginInstallerWindow() => this.pluginWindow.Toggle(); + /// The page of the installer to open. + public void TogglePluginInstallerWindowTo(PluginInstallerWindow.PluginInstallerOpenKind kind) => this.pluginWindow.ToggleTo(kind); /// /// Toggles the . @@ -524,7 +528,9 @@ internal class DalamudInterface : IDisposable, IServiceType private void DrawCreditsDarkeningAnimation() { - using var style = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 0f); + using var style1 = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 0f); + using var style2 = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 0f); + using var color = ImRaii.PushColor(ImGuiCol.WindowBg, new Vector4(0, 0, 0, 0)); ImGui.SetNextWindowPos(new Vector2(0, 0)); ImGui.SetNextWindowSize(ImGuiHelpers.MainViewport.Size); @@ -598,18 +604,16 @@ internal class DalamudInterface : IDisposable, IServiceType { if (ImGui.BeginMainMenuBar()) { - var dalamud = Service.Get(); - var configuration = Service.Get(); var pluginManager = Service.Get(); if (ImGui.BeginMenu("Dalamud")) { ImGui.MenuItem("Draw dev menu", string.Empty, ref this.isImGuiDrawDevMenu); - var devBarAtStartup = configuration.DevBarOpenAtStartup; + var devBarAtStartup = this.configuration.DevBarOpenAtStartup; if (ImGui.MenuItem("Draw dev menu at startup", string.Empty, ref devBarAtStartup)) { - configuration.DevBarOpenAtStartup ^= true; - configuration.QueueSave(); + this.configuration.DevBarOpenAtStartup ^= true; + this.configuration.QueueSave(); } ImGui.Separator(); @@ -626,31 +630,29 @@ internal class DalamudInterface : IDisposable, IServiceType if (ImGui.MenuItem(logLevel + "##logLevelSwitch", string.Empty, EntryPoint.LogLevelSwitch.MinimumLevel == logLevel)) { EntryPoint.LogLevelSwitch.MinimumLevel = logLevel; - configuration.LogLevel = logLevel; - configuration.QueueSave(); + this.configuration.LogLevel = logLevel; + this.configuration.QueueSave(); } } ImGui.EndMenu(); } - - var startInfo = Service.Get(); - - var logSynchronously = configuration.LogSynchronously; + + var logSynchronously = this.configuration.LogSynchronously; if (ImGui.MenuItem("Log Synchronously", null, ref logSynchronously)) { - configuration.LogSynchronously = logSynchronously; - configuration.QueueSave(); + this.configuration.LogSynchronously = logSynchronously; + this.configuration.QueueSave(); EntryPoint.InitLogging( - startInfo.LogPath!, - startInfo.BootShowConsole, - configuration.LogSynchronously, - startInfo.LogName); + this.dalamud.StartInfo.LogPath!, + this.dalamud.StartInfo.BootShowConsole, + this.configuration.LogSynchronously, + this.dalamud.StartInfo.LogName); } var antiDebug = Service.Get(); - if (ImGui.MenuItem("Enable AntiDebug", null, antiDebug.IsEnabled)) + if (ImGui.MenuItem("Disable Debugging Protections", null, antiDebug.IsEnabled)) { var newEnabled = !antiDebug.IsEnabled; if (newEnabled) @@ -658,8 +660,8 @@ internal class DalamudInterface : IDisposable, IServiceType else antiDebug.Disable(); - configuration.IsAntiAntiDebugEnabled = newEnabled; - configuration.QueueSave(); + this.configuration.IsAntiAntiDebugEnabled = newEnabled; + this.configuration.QueueSave(); } ImGui.Separator(); @@ -727,34 +729,54 @@ internal class DalamudInterface : IDisposable, IServiceType } ImGui.Separator(); - - if (ImGui.MenuItem("Access Violation")) + + if (ImGui.BeginMenu("Crash game")) { - Marshal.ReadByte(IntPtr.Zero); - } - - if (ImGui.MenuItem("Crash game (nullptr)")) - { - unsafe + if (ImGui.MenuItem("Access Violation")) { - var framework = Framework.Instance(); - framework->UIModule = (UIModule*)0; - } - } - - if (ImGui.MenuItem("Crash game (non-nullptr)")) - { - unsafe + Marshal.ReadByte(IntPtr.Zero); + } + + if (ImGui.MenuItem("Set UiModule to NULL")) { - var framework = Framework.Instance(); - framework->UIModule = (UIModule*)0x12345678; + unsafe + { + var framework = Framework.Instance(); + framework->UIModule = (UIModule*)0; + } } + + if (ImGui.MenuItem("Set UiModule to invalid ptr")) + { + unsafe + { + var framework = Framework.Instance(); + framework->UIModule = (UIModule*)0x12345678; + } + } + + if (ImGui.MenuItem("Deref nullptr in Hook")) + { + unsafe + { + var hook = Hook.FromAddress( + (nint)UIModule.StaticVTable.GetUIInputData, + self => + { + _ = *(byte*)0; + return (nint)UIModule.Instance()->GetUIInputData(); + }); + hook.Enable(); + } + } + + ImGui.EndMenu(); } - if (ImGui.MenuItem("Report crashes at shutdown", null, configuration.ReportShutdownCrashes)) + if (ImGui.MenuItem("Report crashes at shutdown", null, this.configuration.ReportShutdownCrashes)) { - configuration.ReportShutdownCrashes = !configuration.ReportShutdownCrashes; - configuration.QueueSave(); + this.configuration.ReportShutdownCrashes = !this.configuration.ReportShutdownCrashes; + this.configuration.QueueSave(); } ImGui.Separator(); @@ -765,7 +787,7 @@ internal class DalamudInterface : IDisposable, IServiceType } ImGui.MenuItem(Util.AssemblyVersion, false); - ImGui.MenuItem(startInfo.GameVersion?.ToString() ?? "Unknown version", false); + ImGui.MenuItem(this.dalamud.StartInfo.GameVersion?.ToString() ?? "Unknown version", false); ImGui.MenuItem($"D: {Util.GetGitHash()}[{Util.GetGitCommitCount()}] CS: {Util.GetGitHashClientStructs()}[{FFXIVClientStructs.Interop.Resolver.Version}]", false); ImGui.MenuItem($"CLR: {Environment.Version}", false); @@ -787,10 +809,10 @@ internal class DalamudInterface : IDisposable, IServiceType ImGuiManagedAsserts.AssertsEnabled = val; } - if (ImGui.MenuItem("Enable asserts at startup", null, configuration.AssertsEnabledAtStartup)) + if (ImGui.MenuItem("Enable asserts at startup", null, this.configuration.AssertsEnabledAtStartup)) { - configuration.AssertsEnabledAtStartup = !configuration.AssertsEnabledAtStartup; - configuration.QueueSave(); + this.configuration.AssertsEnabledAtStartup = !this.configuration.AssertsEnabledAtStartup; + this.configuration.QueueSave(); } if (ImGui.MenuItem("Clear focus")) @@ -800,7 +822,7 @@ internal class DalamudInterface : IDisposable, IServiceType if (ImGui.MenuItem("Clear stacks")) { - Service.Get().ClearStacks(); + this.interfaceManager.ClearStacks(); } if (ImGui.MenuItem("Dump style")) @@ -813,7 +835,7 @@ internal class DalamudInterface : IDisposable, IServiceType { if (propertyInfo.PropertyType == typeof(Vector2)) { - var vec2 = (Vector2)propertyInfo.GetValue(style); + var vec2 = (Vector2)propertyInfo.GetValue(style)!; info += $"{propertyInfo.Name} = new Vector2({vec2.X.ToString(enCulture)}f, {vec2.Y.ToString(enCulture)}f),\n"; } else @@ -836,9 +858,9 @@ internal class DalamudInterface : IDisposable, IServiceType Log.Information(info); } - if (ImGui.MenuItem("Show dev bar info", null, configuration.ShowDevBarInfo)) + if (ImGui.MenuItem("Show dev bar info", null, this.configuration.ShowDevBarInfo)) { - configuration.ShowDevBarInfo = !configuration.ShowDevBarInfo; + this.configuration.ShowDevBarInfo = !this.configuration.ShowDevBarInfo; } ImGui.EndMenu(); @@ -846,9 +868,19 @@ internal class DalamudInterface : IDisposable, IServiceType if (ImGui.BeginMenu("Game")) { - if (ImGui.MenuItem("Replace ExceptionHandler")) + if (ImGui.MenuItem("Use in-game default ExceptionHandler")) { - dalamud.ReplaceExceptionHandler(); + this.dalamud.UseDefaultExceptionHandler(); + } + + if (ImGui.MenuItem("Use in-game debug ExceptionHandler")) + { + this.dalamud.UseDebugExceptionHandler(); + } + + if (ImGui.MenuItem("Disable in-game ExceptionHandler")) + { + this.dalamud.UseNoExceptionHandler(); } ImGui.EndMenu(); @@ -943,7 +975,7 @@ internal class DalamudInterface : IDisposable, IServiceType if (Service.Get().GameUiHidden) ImGui.BeginMenu("UI is hidden...", false); - if (configuration.ShowDevBarInfo) + if (this.configuration.ShowDevBarInfo) { ImGui.PushFont(InterfaceManager.MonoFont); @@ -952,9 +984,9 @@ internal class DalamudInterface : IDisposable, IServiceType ImGui.BeginMenu(ImGui.GetIO().Framerate.ToString("000"), false); ImGui.BeginMenu($"W:{Util.FormatBytes(GC.GetTotalMemory(false))}", false); - var videoMem = Service.Get().GetD3dMemoryInfo(); + var videoMem = this.interfaceManager.GetD3dMemoryInfo(); ImGui.BeginMenu( - !videoMem.HasValue ? $"V:???" : $"V:{Util.FormatBytes(videoMem.Value.Used)}", + !videoMem.HasValue ? "V:???" : $"V:{Util.FormatBytes(videoMem.Value.Used)}", false); ImGui.PopFont(); diff --git a/Dalamud/Interface/Internal/DalamudTextureWrap.cs b/Dalamud/Interface/Internal/DalamudTextureWrap.cs index 039873f1f..b49c6f07b 100644 --- a/Dalamud/Interface/Internal/DalamudTextureWrap.cs +++ b/Dalamud/Interface/Internal/DalamudTextureWrap.cs @@ -1,22 +1,14 @@ -using System; +using Dalamud.Utility; using ImGuiScene; namespace Dalamud.Interface.Internal; -/// -/// Base TextureWrap interface for all Dalamud-owned texture wraps. -/// Used to avoid referencing ImGuiScene. -/// -public interface IDalamudTextureWrap : TextureWrap -{ -} - /// /// Safety harness for ImGuiScene textures that will defer destruction until /// the end of the frame. /// -public class DalamudTextureWrap : IDalamudTextureWrap +public class DalamudTextureWrap : IDalamudTextureWrap, IDeferredDisposable { private readonly TextureWrap wrappedWrap; @@ -64,7 +56,7 @@ public class DalamudTextureWrap : IDalamudTextureWrap /// /// Actually dispose the wrapped texture. /// - internal void RealDispose() + void IDeferredDisposable.RealDispose() { this.wrappedWrap.Dispose(); } diff --git a/Dalamud/Interface/Internal/IDalamudTextureWrap.cs b/Dalamud/Interface/Internal/IDalamudTextureWrap.cs new file mode 100644 index 000000000..8e2e56c26 --- /dev/null +++ b/Dalamud/Interface/Internal/IDalamudTextureWrap.cs @@ -0,0 +1,55 @@ +using System.Numerics; + +using TerraFX.Interop.Windows; + +namespace Dalamud.Interface.Internal; + +/// +/// Base TextureWrap interface for all Dalamud-owned texture wraps. +/// Used to avoid referencing ImGuiScene. +/// +public interface IDalamudTextureWrap : IDisposable +{ + /// + /// Gets a texture handle suitable for direct use with ImGui functions. + /// + IntPtr ImGuiHandle { get; } + + /// + /// Gets the width of the texture. + /// + int Width { get; } + + /// + /// Gets the height of the texture. + /// + int Height { get; } + + /// + /// Gets the size vector of the texture using Width, Height. + /// + Vector2 Size => new(this.Width, this.Height); + + /// + /// Creates a new reference to the resource being pointed by this instance of . + /// + /// The new reference to this texture wrap. + /// + /// On calling this function, a new instance of will be returned, but with + /// the same . The new instance must be d, as the backing + /// resource will stay alive until all the references are released. The old instance may be disposed as needed, + /// once this function returns; the new instance will stay alive regardless of whether the old instance has been + /// disposed.
+ /// Primary purpose of this function is to share textures across plugin boundaries. When texture wraps get passed + /// across plugin boundaries for use for an indeterminate duration, the receiver should call this function to + /// obtain a new reference to the texture received, so that it gets its own "copy" of the texture and the caller + /// may dispose the texture anytime without any care for the receiver.
+ /// The default implementation will treat as an . + ///
+ unsafe IDalamudTextureWrap CreateWrapSharingLowLevelResource() + { + // Dalamud specific: IDalamudTextureWrap always points to an ID3D11ShaderResourceView. + var handle = (IUnknown*)this.ImGuiHandle; + return new UnknownTextureWrap(handle, this.Width, this.Height, true); + } +} diff --git a/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs b/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs new file mode 100644 index 000000000..9fa21a31b --- /dev/null +++ b/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +using CheapLoc; + +using Dalamud.Game.Gui.Toast; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; + +using ImGuiNET; + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Interface.Internal; + +/// +/// Configures the ImGui clipboard behaviour to work nicely with XIV. +/// +/// +/// +/// XIV uses '\r' for line endings and will truncate all text after a '\n' character. +/// This means that copy/pasting multi-line text from ImGui to XIV will only copy the first line. +/// +/// +/// ImGui uses '\n' for line endings and will ignore '\r' entirely. +/// This means that copy/pasting multi-line text from XIV to ImGui will copy all the text +/// without line breaks. +/// +/// +/// To fix this we normalize all clipboard line endings entering/exiting ImGui to '\r\n' which +/// works for both ImGui and XIV. +/// +/// +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class ImGuiClipboardFunctionProvider : IInternalDisposableService +{ + private static readonly ModuleLog Log = new(nameof(ImGuiClipboardFunctionProvider)); + private readonly nint clipboardUserDataOriginal; + private readonly nint setTextOriginal; + private readonly nint getTextOriginal; + + [ServiceManager.ServiceDependency] + private readonly ToastGui toastGui = Service.Get(); + + private ImVectorWrapper clipboardData; + private GCHandle clipboardUserData; + + [ServiceManager.ServiceConstructor] + private ImGuiClipboardFunctionProvider(InterfaceManager.InterfaceManagerWithScene imws) + { + // Effectively waiting for ImGui to become available. + Debug.Assert(ImGuiHelpers.IsImGuiInitialized, "IMWS initialized but IsImGuiInitialized is false?"); + + var io = ImGui.GetIO(); + this.clipboardUserDataOriginal = io.ClipboardUserData; + this.setTextOriginal = io.SetClipboardTextFn; + this.getTextOriginal = io.GetClipboardTextFn; + io.ClipboardUserData = GCHandle.ToIntPtr(this.clipboardUserData = GCHandle.Alloc(this)); + io.SetClipboardTextFn = (nint)(delegate* unmanaged)&StaticSetClipboardTextImpl; + io.GetClipboardTextFn = (nint)(delegate* unmanaged)&StaticGetClipboardTextImpl; + + this.clipboardData = new(0); + return; + + [UnmanagedCallersOnly] + static void StaticSetClipboardTextImpl(nint userData, byte* text) => + ((ImGuiClipboardFunctionProvider)GCHandle.FromIntPtr(userData).Target)!.SetClipboardTextImpl(text); + + [UnmanagedCallersOnly] + static byte* StaticGetClipboardTextImpl(nint userData) => + ((ImGuiClipboardFunctionProvider)GCHandle.FromIntPtr(userData).Target)!.GetClipboardTextImpl(); + } + + /// + void IInternalDisposableService.DisposeService() + { + if (!this.clipboardUserData.IsAllocated) + return; + + var io = ImGui.GetIO(); + io.SetClipboardTextFn = this.setTextOriginal; + io.GetClipboardTextFn = this.getTextOriginal; + io.ClipboardUserData = this.clipboardUserDataOriginal; + + this.clipboardUserData.Free(); + this.clipboardData.Dispose(); + } + + private bool OpenClipboardOrShowError() + { + if (!OpenClipboard(default)) + { + this.toastGui.ShowError( + Loc.Localize( + "ImGuiClipboardFunctionProviderClipboardInUse", + "Some other application is using the clipboard. Try again later.")); + return false; + } + + return true; + } + + private void SetClipboardTextImpl(byte* text) + { + if (!this.OpenClipboardOrShowError()) + return; + + try + { + var len = 0; + while (text[len] != 0) + len++; + var str = Encoding.UTF8.GetString(text, len); + str = str.ReplaceLineEndings("\r\n"); + var hMem = GlobalAlloc(GMEM.GMEM_MOVEABLE, (nuint)((str.Length + 1) * 2)); + if (hMem == 0) + throw new OutOfMemoryException(); + + var ptr = (char*)GlobalLock(hMem); + if (ptr == null) + { + throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()) + ?? throw new InvalidOperationException($"{nameof(GlobalLock)} failed."); + } + + str.AsSpan().CopyTo(new(ptr, str.Length)); + ptr[str.Length] = default; + GlobalUnlock(hMem); + + EmptyClipboard(); + SetClipboardData(CF.CF_UNICODETEXT, hMem); + } + catch (Exception e) + { + Log.Error(e, $"Error in {nameof(this.SetClipboardTextImpl)}"); + this.toastGui.ShowError( + Loc.Localize( + "ImGuiClipboardFunctionProviderErrorCopy", + "Failed to copy. See logs for details.")); + } + finally + { + CloseClipboard(); + } + } + + private byte* GetClipboardTextImpl() + { + this.clipboardData.Clear(); + + var formats = stackalloc uint[] { CF.CF_UNICODETEXT, CF.CF_TEXT }; + if (GetPriorityClipboardFormat(formats, 2) < 1 || !this.OpenClipboardOrShowError()) + { + this.clipboardData.Add(0); + return this.clipboardData.Data; + } + + var hMem = (HGLOBAL)GetClipboardData(CF.CF_UNICODETEXT); + try + { + if (hMem != default) + { + var ptr = (char*)GlobalLock(hMem); + if (ptr == null) + { + throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()) + ?? throw new InvalidOperationException($"{nameof(GlobalLock)} failed."); + } + + var str = new string(ptr); + str = str.ReplaceLineEndings("\r\n"); + this.clipboardData.Resize(Encoding.UTF8.GetByteCount(str) + 1); + Encoding.UTF8.GetBytes(str, this.clipboardData.DataSpan); + this.clipboardData[^1] = 0; + } + else + { + this.clipboardData.Add(0); + } + } + catch (Exception e) + { + Log.Error(e, $"Error in {nameof(this.GetClipboardTextImpl)}"); + this.toastGui.ShowError( + Loc.Localize( + "ImGuiClipboardFunctionProviderErrorPaste", + "Failed to paste. See logs for details.")); + } + finally + { + if (hMem != default) + GlobalUnlock(hMem); + CloseClipboard(); + } + + return this.clipboardData.Data; + } +} diff --git a/Dalamud/Interface/Internal/ImGuiDrawListFixProvider.cs b/Dalamud/Interface/Internal/ImGuiDrawListFixProvider.cs new file mode 100644 index 000000000..139dd96e2 --- /dev/null +++ b/Dalamud/Interface/Internal/ImGuiDrawListFixProvider.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using System.Linq; +using System.Numerics; + +using Dalamud.Hooking; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal; + +/// +/// Fixes ImDrawList not correctly dealing with the current texture for that draw list not in tune with the global +/// state. Currently, ImDrawList::AddPolyLine and ImDrawList::AddRectFilled are affected. +/// +/// * The implementation for AddRectFilled is entirely replaced with the hook below. +/// * The implementation for AddPolyLine is wrapped with Push/PopTextureID. +/// +/// TODO: +/// * imgui_draw.cpp:1433 ImDrawList::AddRectFilled +/// The if block needs a PushTextureID(_Data->TexIdCommon)/PopTextureID() block, +/// if _Data->TexIdCommon != _CmdHeader.TextureId. +/// * imgui_draw.cpp:729 ImDrawList::AddPolyLine +/// The if block always needs to call PushTextureID if the abovementioned condition is not met. +/// Change push_texture_id to only have one condition. +/// +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class ImGuiDrawListFixProvider : IInternalDisposableService +{ + private const int CImGuiImDrawListAddPolyLineOffset = 0x589B0; + private const int CImGuiImDrawListAddRectFilled = 0x59FD0; + private const int CImGuiImDrawListSharedDataTexIdCommonOffset = 0; + + private readonly Hook hookImDrawListAddPolyline; + private readonly Hook hookImDrawListAddRectFilled; + + [ServiceManager.ServiceConstructor] + private ImGuiDrawListFixProvider(InterfaceManager.InterfaceManagerWithScene imws) + { + // Force cimgui.dll to be loaded. + _ = ImGui.GetCurrentContext(); + var cimgui = Process.GetCurrentProcess().Modules.Cast() + .First(x => x.ModuleName == "cimgui.dll") + .BaseAddress; + + this.hookImDrawListAddPolyline = Hook.FromAddress( + cimgui + CImGuiImDrawListAddPolyLineOffset, + this.ImDrawListAddPolylineDetour); + this.hookImDrawListAddRectFilled = Hook.FromAddress( + cimgui + CImGuiImDrawListAddRectFilled, + this.ImDrawListAddRectFilledDetour); + this.hookImDrawListAddPolyline.Enable(); + this.hookImDrawListAddRectFilled.Enable(); + } + + private delegate void ImDrawListAddPolyLine( + ImDrawListPtr drawListPtr, + ref Vector2 points, + int pointsCount, + uint color, + ImDrawFlags flags, + float thickness); + + private delegate void ImDrawListAddRectFilled( + ImDrawListPtr drawListPtr, + ref Vector2 min, + ref Vector2 max, + uint col, + float rounding, + ImDrawFlags flags); + + /// + void IInternalDisposableService.DisposeService() + { + this.hookImDrawListAddPolyline.Dispose(); + this.hookImDrawListAddRectFilled.Dispose(); + } + + private void ImDrawListAddRectFilledDetour( + ImDrawListPtr drawListPtr, + ref Vector2 min, + ref Vector2 max, + uint col, + float rounding, + ImDrawFlags flags) + { + // Skip drawing if we're drawing something with alpha value of 0. + if ((col & 0xFF000000) == 0) + return; + + if (rounding < 0.5f || (flags & ImDrawFlags.RoundCornersMask) == ImDrawFlags.RoundCornersMask) + { + // Take the fast path of drawing two triangles if no rounded corners are required. + + var texIdCommon = *(nint*)(drawListPtr._Data + CImGuiImDrawListSharedDataTexIdCommonOffset); + var pushTextureId = texIdCommon != drawListPtr._CmdHeader.TextureId; + if (pushTextureId) + drawListPtr.PushTextureID(texIdCommon); + + drawListPtr.PrimReserve(6, 4); + drawListPtr.PrimRect(min, max, col); + + if (pushTextureId) + drawListPtr.PopTextureID(); + } + else + { + // Defer drawing rectangle with rounded corners to path drawing operations. + // Note that this may have a slightly different extent behaviors from the above if case. + // This is how it is in imgui_draw.cpp. + drawListPtr.PathRect(min, max, rounding, flags); + drawListPtr.PathFillConvex(col); + } + } + + private void ImDrawListAddPolylineDetour( + ImDrawListPtr drawListPtr, + ref Vector2 points, + int pointsCount, + uint color, + ImDrawFlags flags, + float thickness) + { + var texIdCommon = *(nint*)(drawListPtr._Data + CImGuiImDrawListSharedDataTexIdCommonOffset); + var pushTextureId = texIdCommon != drawListPtr._CmdHeader.TextureId; + if (pushTextureId) + drawListPtr.PushTextureID(texIdCommon); + + this.hookImDrawListAddPolyline.Original(drawListPtr, ref points, pointsCount, color, flags, thickness); + + if (pushTextureId) + drawListPtr.PopTextureID(); + } +} diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index 6bd4a85a5..a44159848 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -1,25 +1,27 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Text; using System.Threading; +using System.Threading.Tasks; using Dalamud.ComInterfaceVTables; using Dalamud.Configuration.Internal; using Dalamud.Game; using Dalamud.Game.ClientState.GamePad; using Dalamud.Game.ClientState.Keys; -using Dalamud.Game.Gui.Internal; using Dalamud.Hooking; using Dalamud.Hooking.Internal; -using Dalamud.Interface.GameFonts; +using Dalamud.Hooking.WndProcHook; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal.ManagedAsserts; -using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Style; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Utility; using Dalamud.Utility.Timing; @@ -33,6 +35,8 @@ using SharpDX.Direct3D; using SharpDX.Direct3D11; using SharpDX.DXGI; +using TerraFX.Interop.DirectX; + // general dev notes, here because it's easiest /* @@ -51,30 +55,31 @@ namespace Dalamud.Interface.Internal; /// This class manages interaction with the ImGui interface. ///
[ServiceManager.BlockingEarlyLoadedService] -internal class InterfaceManager : IDisposable, IServiceType +internal class InterfaceManager : IInternalDisposableService { - private const float DefaultFontSizePt = 12.0f; - private const float DefaultFontSizePx = DefaultFontSizePt * 4.0f / 3.0f; - private const ushort Fallback1Codepoint = 0x3013; // Geta mark; FFXIV uses this to indicate that a glyph is missing. - private const ushort Fallback2Codepoint = '-'; // FFXIV uses dash if Geta mark is unavailable. + /// + /// The default font size, in points. + /// + public const float DefaultFontSizePt = 12.0f; - private readonly HashSet glyphRequests = new(); - private readonly Dictionary loadedFontInfo = new(); + /// + /// The default font size, in pixels. + /// + public const float DefaultFontSizePx = (DefaultFontSizePt * 4.0f) / 3.0f; - private readonly List deferredDisposeTextures = new(); + private readonly ConcurrentBag deferredDisposeTextures = new(); + private readonly ConcurrentBag deferredDisposeImFontLockeds = new(); + + [ServiceManager.ServiceDependency] + private readonly WndProcHookManager wndProcHookManager = Service.Get(); [ServiceManager.ServiceDependency] private readonly Framework framework = Service.Get(); - [ServiceManager.ServiceDependency] - private readonly SigScanner sigScanner = Service.Get(); - - private readonly ManualResetEvent fontBuildSignal; - private readonly Hook dispatchMessageWHook; - private readonly Hook setCursorHook; - private readonly Hook processMessageHook; private RawDX11Scene? scene; + private Hook? setCursorHook; + private ObjectVTableHook? swapChainHook; // For handling the case where ReShade is being used. @@ -87,45 +92,30 @@ internal class InterfaceManager : IDisposable, IServiceType private PresentDelegate? presentOriginal; private ResizeBuffersDelegate? resizeBuffersOriginal; + private IFontAtlas? dalamudAtlas; + private ILockedImFont? defaultFontResourceLock; + // can't access imgui IO before first present call - private bool lastWantCapture = false; - private bool isRebuildingFonts = false; + private bool lastWantCapture; private bool isOverrideGameCursor = true; [ServiceManager.ServiceConstructor] private InterfaceManager() { - this.dispatchMessageWHook = Hook.FromImport( - null, "user32.dll", "DispatchMessageW", 0, this.DispatchMessageWDetour); - this.setCursorHook = Hook.FromImport( - null, "user32.dll", "SetCursor", 0, this.SetCursorDetour); - - var wndProcAddress = this.sigScanner.ScanText("E8 ?? ?? ?? ?? 80 7C 24 ?? ?? 74 ?? B8"); - Log.Information($"WndProc address 0x{wndProcAddress.ToInt64():X}"); - this.processMessageHook = Hook.FromAddress(wndProcAddress, this.ProcessMessageDetour); - - this.fontBuildSignal = new(false); - this.QueueHookResolution(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate void PresentCoreDelegate(IntPtr swapChain); + private delegate void PresentCoreDelegate(nint swapChain); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate IntPtr PresentDelegate(IntPtr swapChain, uint syncInterval, uint presentFlags); + private delegate int PresentDelegate(nint swapChain, uint syncInterval, uint presentFlags); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate IntPtr ResizeBuffersDelegate(IntPtr swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags); + private delegate int ResizeBuffersDelegate(nint swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags); [UnmanagedFunctionPointer(CallingConvention.StdCall)] - private delegate IntPtr SetCursorDelegate(IntPtr hCursor); - - [UnmanagedFunctionPointer(CallingConvention.StdCall)] - private delegate IntPtr DispatchMessageWDelegate(ref User32.MSG msg); - - [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate IntPtr ProcessMessageDelegate(IntPtr hWnd, uint msg, ulong wParam, ulong lParam, IntPtr handeled); + private delegate nint SetCursorDelegate(nint hCursor); /// /// This event gets called each frame to facilitate ImGui drawing. @@ -137,36 +127,57 @@ internal class InterfaceManager : IDisposable, IServiceType /// public event Action? ResizeBuffers; - /// - /// Gets or sets an action that is executed right before fonts are rebuilt. - /// - public event Action? BuildFonts; - /// /// Gets or sets an action that is executed right after fonts are rebuilt. /// public event Action? AfterBuildFonts; /// - /// Gets the default ImGui font. + /// Gets the default ImGui font.
+ /// Accessing this static property outside of the main thread is dangerous and not supported. ///
- public static ImFontPtr DefaultFont { get; private set; } + public static ImFontPtr DefaultFont => + WhenFontsReady().DefaultFontHandle!.LockUntilPostFrame().OrElse(ImGui.GetIO().FontDefault); /// - /// Gets an included FontAwesome icon font. + /// Gets an included FontAwesome icon font.
+ /// Accessing this static property outside of the main thread is dangerous and not supported. ///
- public static ImFontPtr IconFont { get; private set; } + public static ImFontPtr IconFont => + WhenFontsReady().IconFontHandle!.LockUntilPostFrame().OrElse(ImGui.GetIO().FontDefault); /// - /// Gets an included monospaced font. + /// Gets an included monospaced font.
+ /// Accessing this static property outside of the main thread is dangerous and not supported. ///
- public static ImFontPtr MonoFont { get; private set; } + public static ImFontPtr MonoFont => + WhenFontsReady().MonoFontHandle!.LockUntilPostFrame().OrElse(ImGui.GetIO().FontDefault); + + /// + /// Gets the default font handle. + /// + public FontHandle? DefaultFontHandle { get; private set; } + + /// + /// Gets the icon font handle. + /// + public FontHandle? IconFontHandle { get; private set; } + + /// + /// Gets the mono font handle. + /// + public FontHandle? MonoFontHandle { get; private set; } /// /// Gets or sets the pointer to ImGui.IO(), when it was last used. /// public ImGuiIOPtr LastImGuiIoPtr { get; set; } + /// + /// Gets the DX11 scene. + /// + public RawDX11Scene? Scene => this.scene; + /// /// Gets the D3D11 device instance. /// @@ -175,7 +186,7 @@ internal class InterfaceManager : IDisposable, IServiceType /// /// Gets the address handle to the main process window. /// - public IntPtr WindowHandlePtr => this.scene?.WindowHandlePtr ?? IntPtr.Zero; + public nint WindowHandlePtr => this.scene?.WindowHandlePtr ?? nint.Zero; /// /// Gets or sets a value indicating whether or not the game's cursor should be overridden with the ImGui cursor. @@ -191,11 +202,6 @@ internal class InterfaceManager : IDisposable, IServiceType } } - /// - /// Gets or sets a value indicating whether the fonts are built and ready to use. - /// - public bool FontsReady { get; set; } = false; - /// /// Gets a value indicating whether the Dalamud interface ready to use. /// @@ -206,51 +212,63 @@ internal class InterfaceManager : IDisposable, IServiceType /// public bool IsDispatchingEvents { get; set; } = true; - /// - /// Gets or sets a value indicating whether to override configuration for UseAxis. - /// - public bool? UseAxisOverride { get; set; } = null; - - /// - /// Gets a value indicating whether to use AXIS fonts. - /// - public bool UseAxis => this.UseAxisOverride ?? Service.Get().UseAxisFontsFromGame; - - /// - /// Gets or sets the overrided font gamma value, instead of using the value from configuration. - /// - public float? FontGammaOverride { get; set; } = null; - - /// - /// Gets the font gamma value to use. - /// - public float FontGamma => Math.Max(0.1f, this.FontGammaOverride.GetValueOrDefault(Service.Get().FontGammaLevel)); - - /// - /// Gets a value indicating whether we're building fonts but haven't generated atlas yet. - /// - public bool IsBuildingFontsBeforeAtlasBuild => this.isRebuildingFonts && !this.fontBuildSignal.WaitOne(0); - /// /// Gets a value indicating the native handle of the game main window. /// - public IntPtr GameWindowHandle { get; private set; } + public nint GameWindowHandle { get; private set; } + + /// + /// Gets the font build task. + /// + public Task FontBuildTask => WhenFontsReady().dalamudAtlas!.BuildTask; + + /// + /// Gets the number of calls to so far. + /// + public long CumulativePresentCalls { get; private set; } /// /// Dispose of managed and unmanaged resources. /// - public void Dispose() + void IInternalDisposableService.DisposeService() { - this.framework.RunOnFrameworkThread(() => + // Unload hooks from the framework thread if possible. + // We're currently off the framework thread, as this function can only be called from + // ServiceManager.UnloadAllServices, which is called from EntryPoint.RunThread. + // The functions being unhooked are mostly called from the main thread, so unhooking from the main thread when + // possible would avoid any chance of unhooking a function that currently is being called. + // If unloading is initiated from "Unload Dalamud" /xldev menu, then the framework would still be running, as + // Framework.Destroy has never been called and thus Framework.IsFrameworkUnloading cannot be true, and this + // function will actually run the destroy from the framework thread. + // Otherwise, as Framework.IsFrameworkUnloading should have been set, this code should run immediately. + this.framework.RunOnFrameworkThread(ClearHooks).Wait(); + + // Below this point, hooks are guaranteed to be no longer called. + + // A font resource lock outlives the parent handle and the owner atlas. It should be disposed. + Interlocked.Exchange(ref this.defaultFontResourceLock, null)?.Dispose(); + + // Font handles become invalid after disposing the atlas, but just to be safe. + this.DefaultFontHandle?.Dispose(); + this.DefaultFontHandle = null; + + this.MonoFontHandle?.Dispose(); + this.MonoFontHandle = null; + + this.IconFontHandle?.Dispose(); + this.IconFontHandle = null; + + Interlocked.Exchange(ref this.dalamudAtlas, null)?.Dispose(); + Interlocked.Exchange(ref this.scene, null)?.Dispose(); + + return; + + void ClearHooks() { - this.setCursorHook.Dispose(); - this.dispatchMessageWHook.Dispose(); - this.processMessageHook.Dispose(); - }).Wait(); - - this.scene?.Dispose(); - - this.swapChainHook?.Dispose(); + this.wndProcHookManager.PreWndProc -= this.WndProcHookManagerOnPreWndProc; + Interlocked.Exchange(ref this.setCursorHook, null)?.Dispose(); + Interlocked.Exchange(ref this.swapChainHook, null)?.Dispose(); + } } #nullable enable @@ -260,7 +278,7 @@ internal class InterfaceManager : IDisposable, IServiceType ///
/// The filepath to load. /// A texture, ready to use in ImGui. - public TextureWrap? LoadImage(string filePath) + public IDalamudTextureWrap? LoadImage(string filePath) { if (this.scene == null) throw new InvalidOperationException("Scene isn't ready."); @@ -283,7 +301,7 @@ internal class InterfaceManager : IDisposable, IServiceType ///
/// The data to load. /// A texture, ready to use in ImGui. - public TextureWrap? LoadImage(byte[] imageData) + public IDalamudTextureWrap? LoadImage(byte[] imageData) { if (this.scene == null) throw new InvalidOperationException("Scene isn't ready."); @@ -309,7 +327,7 @@ internal class InterfaceManager : IDisposable, IServiceType /// The height in pixels. /// The number of channels. /// A texture, ready to use in ImGui. - public TextureWrap? LoadImageRaw(byte[] imageData, int width, int height, int numChannels) + public IDalamudTextureWrap? LoadImageRaw(byte[] imageData, int width, int height, int numChannels) { if (this.scene == null) throw new InvalidOperationException("Scene isn't ready."); @@ -345,7 +363,7 @@ internal class InterfaceManager : IDisposable, IServiceType /// The height in pixels. /// Format of the texture. /// A texture, ready to use in ImGui. - public IDalamudTextureWrap LoadImageFromDxgiFormat(Span data, int pitch, int width, int height, Format dxgiFormat) + public DalamudTextureWrap LoadImageFromDxgiFormat(Span data, int pitch, int width, int height, Format dxgiFormat) { if (this.scene == null) throw new InvalidOperationException("Scene isn't ready."); @@ -390,104 +408,28 @@ internal class InterfaceManager : IDisposable, IServiceType ///
public void RebuildFonts() { - if (this.scene == null) - { - Log.Verbose("[FONT] RebuildFonts(): scene not ready, doing nothing"); - return; - } - Log.Verbose("[FONT] RebuildFonts() called"); - - // don't invoke this multiple times per frame, in case multiple plugins call it - if (!this.isRebuildingFonts) - { - Log.Verbose("[FONT] RebuildFonts() trigger"); - this.isRebuildingFonts = true; - this.scene.OnNewRenderFrame += this.RebuildFontsInternal; - } - } - - /// - /// Wait for the rebuilding fonts to complete. - /// - public void WaitForFontRebuild() - { - this.fontBuildSignal.WaitOne(); - } - - /// - /// Requests a default font of specified size to exist. - /// - /// Font size in pixels. - /// Ranges of glyphs. - /// Requets handle. - public SpecialGlyphRequest NewFontSizeRef(float size, List> ranges) - { - var allContained = false; - var fonts = ImGui.GetIO().Fonts.Fonts; - ImFontPtr foundFont = null; - unsafe - { - for (int i = 0, iTo = fonts.Size; i < iTo; i++) - { - if (this.glyphRequests.All(x => x.FontInternal.NativePtr != fonts[i].NativePtr)) - continue; - - allContained = true; - foreach (var range in ranges) - { - if (!allContained) - break; - - for (var j = range.Item1; j <= range.Item2 && allContained; j++) - allContained &= fonts[i].FindGlyphNoFallback(j).NativePtr != null; - } - - if (allContained) - foundFont = fonts[i]; - - break; - } - } - - var req = new SpecialGlyphRequest(this, size, ranges); - req.FontInternal = foundFont; - - if (!allContained) - this.RebuildFonts(); - - return req; - } - - /// - /// Requests a default font of specified size to exist. - /// - /// Font size in pixels. - /// Text to calculate glyph ranges from. - /// Requets handle. - public SpecialGlyphRequest NewFontSizeRef(float size, string text) - { - List> ranges = new(); - foreach (var c in new SortedSet(text.ToHashSet())) - { - if (ranges.Any() && ranges[^1].Item2 + 1 == c) - ranges[^1] = Tuple.Create(ranges[^1].Item1, c); - else - ranges.Add(Tuple.Create(c, c)); - } - - return this.NewFontSizeRef(size, ranges); + this.dalamudAtlas?.BuildFontsAsync(); } /// /// Enqueue a texture to be disposed at the end of the frame. /// /// The texture. - public void EnqueueDeferredDispose(DalamudTextureWrap wrap) + public void EnqueueDeferredDispose(IDeferredDisposable wrap) { this.deferredDisposeTextures.Add(wrap); } + /// + /// Enqueue an to be disposed at the end of the frame. + /// + /// The disposable. + public void EnqueueDeferredDispose(in ILockedImFont locked) + { + this.deferredDisposeImFontLockeds.Add(locked); + } + /// /// Get video memory information. /// @@ -500,11 +442,11 @@ internal class InterfaceManager : IDisposable, IServiceType try { var dxgiDev = this.Device.QueryInterfaceOrNull(); - var dxgiAdapter = dxgiDev?.Adapter.QueryInterfaceOrNull(); + var dxgiAdapter = dxgiDev?.Adapter.QueryInterfaceOrNull(); if (dxgiAdapter == null) return null; - var memInfo = dxgiAdapter.QueryVideoMemoryInfo(0, SharpDX.DXGI.MemorySegmentGroup.Local); + var memInfo = dxgiAdapter.QueryVideoMemoryInfo(0, MemorySegmentGroup.Local); return (memInfo.CurrentUsage, memInfo.CurrentReservation); } catch @@ -530,30 +472,52 @@ internal class InterfaceManager : IDisposable, IServiceType /// Value. internal void SetImmersiveMode(bool enabled) { - if (this.GameWindowHandle == nint.Zero) - return; - + if (this.GameWindowHandle == 0) + throw new InvalidOperationException("Game window is not yet ready."); var value = enabled ? 1 : 0; - _ = NativeFunctions.DwmSetWindowAttribute( - this.GameWindowHandle, - NativeFunctions.DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE, - ref value, - sizeof(int)); + ((Result)NativeFunctions.DwmSetWindowAttribute( + this.GameWindowHandle, + NativeFunctions.DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE, + ref value, + sizeof(int))).CheckError(); } - private static void ShowFontError(string path) + private static InterfaceManager WhenFontsReady() { - Util.Fatal($"One or more files required by XIVLauncher were not found.\nPlease restart and report this error if it occurs again.\n\n{path}", "Error"); + var im = Service.GetNullable(); + if (im?.dalamudAtlas is not { } atlas) + throw new InvalidOperationException($"Tried to access fonts before {nameof(ContinueConstruction)} call."); + + if (!atlas.HasBuiltAtlas) + atlas.BuildTask.GetAwaiter().GetResult(); + return im; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void RenderImGui(RawDX11Scene scene) + { + var conf = Service.Get(); + + // Process information needed by ImGuiHelpers each frame. + ImGuiHelpers.NewFrame(); + + // Enable viewports if there are no issues. + if (conf.IsDisableViewport || scene.SwapChain.IsFullScreen || ImGui.GetPlatformIO().Monitors.Size == 1) + ImGui.GetIO().ConfigFlags &= ~ImGuiConfigFlags.ViewportsEnable; + else + ImGui.GetIO().ConfigFlags |= ImGuiConfigFlags.ViewportsEnable; + + scene.Render(); } - private void InitScene(IntPtr swapChain) + private void InitScene(nint swapChain) { RawDX11Scene newScene; using (Timings.Start("IM Scene Init")) { try { - newScene = new(swapChain); + newScene = new RawDX11Scene(swapChain); } catch (DllNotFoundException ex) { @@ -561,7 +525,7 @@ internal class InterfaceManager : IDisposable, IServiceType Log.Error(ex, "Could not load ImGui dependencies."); var res = User32.MessageBox( - IntPtr.Zero, + nint.Zero, "Dalamud plugins require the Microsoft Visual C++ Redistributable to be installed.\nPlease install the runtime from the official Microsoft website or disable Dalamud.\n\nDo you want to download the redistributable now?", "Dalamud Error", User32.MessageBoxOptions.MB_YESNO | User32.MessageBoxOptions.MB_TOPMOST | User32.MessageBoxOptions.MB_ICONERROR); @@ -582,7 +546,7 @@ internal class InterfaceManager : IDisposable, IServiceType return; } - var startInfo = Service.Get(); + var startInfo = Service.Get().StartInfo; var configuration = Service.Get(); var iniFileInfo = new FileInfo(Path.Combine(Path.GetDirectoryName(startInfo.ConfigurationPath)!, "dalamudUI.ini")); @@ -637,8 +601,6 @@ internal class InterfaceManager : IDisposable, IServiceType ImGui.GetIO().FontGlobalScale = configuration.GlobalUiScale; - this.SetupFonts(); - if (!configuration.IsDocking) { ImGui.GetIO().ConfigFlags &= ~ImGuiConfigFlags.DockingEnable; @@ -670,45 +632,15 @@ internal class InterfaceManager : IDisposable, IServiceType this.scene = newScene; Service.Provide(new(this)); + + this.wndProcHookManager.PreWndProc += this.WndProcHookManagerOnPreWndProc; } - private void PresentCore(IntPtr swapChain) + private unsafe void WndProcHookManagerOnPreWndProc(WndProcEventArgs args) { - if (this.scene == null) - { - this.InitScene(swapChain); - } - else if (swapChain != this.scene.SwapChain.NativePointer) - { - return; - } - - this.RenderImGui(); - this.DisposeTextures(); - } - - private void DisposeTextures() - { - if (this.deferredDisposeTextures.Count > 0) - { - Log.Verbose("[IM] Disposing {Count} textures", this.deferredDisposeTextures.Count); - foreach (var texture in this.deferredDisposeTextures) - { - texture.RealDispose(); - } - - this.deferredDisposeTextures.Clear(); - } - } - - /* - * NOTE(goat): When hooking ReShade DXGISwapChain::runtime_present, this is missing the syncInterval arg. - * Seems to work fine regardless, I guess, so whatever. - */ - private IntPtr PresentDetour(IntPtr swapChain, uint syncInterval, uint presentFlags) - { - this.PresentCore(swapChain); - return this.presentOriginal!(swapChain, syncInterval, presentFlags); + var r = this.scene?.ProcessWndProcW(args.Hwnd, (User32.WindowMessage)args.Message, args.WParam, args.LParam); + if (r is not null) + args.SuppressWithValue(r.Value); } private unsafe void QueueHookResolution() @@ -735,7 +667,7 @@ internal class InterfaceManager : IDisposable, IServiceType if (this.GameWindowHandle == 0) { - while ((this.GameWindowHandle = NativeFunctions.FindWindowEx(IntPtr.Zero, this.GameWindowHandle, "FFXIVGAME", IntPtr.Zero)) != IntPtr.Zero) + while ((this.GameWindowHandle = NativeFunctions.FindWindowEx(nint.Zero, this.GameWindowHandle, "FFXIVGAME", nint.Zero)) != nint.Zero) { _ = User32.GetWindowThreadProcessId(this.GameWindowHandle, out var pid); @@ -763,6 +695,9 @@ internal class InterfaceManager : IDisposable, IServiceType Log.Error(ex, "Could not enable immersive mode"); } + this.setCursorHook = Hook.FromImport( + null, "user32.dll", "SetCursor", 0, this.SetCursorDetour); + this.swapChainHook = new(dxgiSwapChain); if (EntryPoint.TryRegisterReshadePresentCallback( @@ -790,413 +725,131 @@ internal class InterfaceManager : IDisposable, IServiceType Log.Information("Present and ResizeBuffers hooked"); this.setCursorHook.Enable(); - this.dispatchMessageWHook.Enable(); - this.processMessageHook.Enable(); Log.Information("Hooks enabled"); }).ContinueWith(_ => this.QueueHookResolution()); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RenderImGui() + private void PresentCore(nint swapChain) { - // Process information needed by ImGuiHelpers each frame. - ImGuiHelpers.NewFrame(); - - // Check if we can still enable viewports without any issues. - this.CheckViewportState(); - - this.scene!.Render(); - } - - private void CheckViewportState() - { - var configuration = Service.Get(); - - if (configuration.IsDisableViewport || this.scene!.SwapChain.IsFullScreen || ImGui.GetPlatformIO().Monitors.Size == 1) + if (this.scene == null) + { + this.InitScene(swapChain); + } + else if (swapChain != this.scene.SwapChain.NativePointer) { - ImGui.GetIO().ConfigFlags &= ~ImGuiConfigFlags.ViewportsEnable; return; } - ImGui.GetIO().ConfigFlags |= ImGuiConfigFlags.ViewportsEnable; + this.CumulativePresentCalls++; + + if (!this.dalamudAtlas!.HasBuiltAtlas) + return; + + Debug.Assert(this.scene is not null, "InitScene did not set the scene field, but did not throw an exception."); + + RenderImGui(this.scene!); + this.CleanupPostImGuiRender(); } - /// - /// Loads font for use in ImGui text functions. - /// - private unsafe void SetupFonts() + /* + * NOTE(goat): When hooking ReShade DXGISwapChain::runtime_present, this is missing the syncInterval arg. + * Seems to work fine regardless, I guess, so whatever. + */ + private int PresentDetour(nint swapChain, uint syncInterval, uint presentFlags) { - using var setupFontsTimings = Timings.Start("IM SetupFonts"); + this.PresentCore(swapChain); + return this.presentOriginal!(swapChain, syncInterval, presentFlags); + } - var gameFontManager = Service.Get(); - var dalamud = Service.Get(); - var io = ImGui.GetIO(); - var ioFonts = io.Fonts; - - var fontGamma = this.FontGamma; - - this.fontBuildSignal.Reset(); - ioFonts.Clear(); - ioFonts.TexDesiredWidth = 4096; - - Log.Verbose("[FONT] SetupFonts - 1"); - - foreach (var v in this.loadedFontInfo) - v.Value.Dispose(); - - this.loadedFontInfo.Clear(); - - Log.Verbose("[FONT] SetupFonts - 2"); - - ImFontConfigPtr fontConfig = null; - List garbageList = new(); - - try + private void CleanupPostImGuiRender() + { + if (!this.deferredDisposeTextures.IsEmpty) { - var dummyRangeHandle = GCHandle.Alloc(new ushort[] { '0', '0', 0 }, GCHandleType.Pinned); - garbageList.Add(dummyRangeHandle); - - fontConfig = ImGuiNative.ImFontConfig_ImFontConfig(); - fontConfig.OversampleH = 1; - fontConfig.OversampleV = 1; - - var fontPathJp = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansCJKjp-Regular.otf"); - if (!File.Exists(fontPathJp)) - fontPathJp = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansCJKjp-Medium.otf"); - if (!File.Exists(fontPathJp)) - ShowFontError(fontPathJp); - Log.Verbose("[FONT] fontPathJp = {0}", fontPathJp); - - var fontPathKr = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansCJKkr-Regular.otf"); - if (!File.Exists(fontPathKr)) - fontPathKr = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansKR-Regular.otf"); - if (!File.Exists(fontPathKr)) - fontPathKr = null; - Log.Verbose("[FONT] fontPathKr = {0}", fontPathKr); - - // Default font - Log.Verbose("[FONT] SetupFonts - Default font"); - var fontInfo = new TargetFontModification( - "Default", - this.UseAxis ? TargetFontModification.AxisMode.Overwrite : TargetFontModification.AxisMode.GameGlyphsOnly, - this.UseAxis ? DefaultFontSizePx : DefaultFontSizePx + 1, - io.FontGlobalScale); - Log.Verbose("[FONT] SetupFonts - Default corresponding AXIS size: {0}pt ({1}px)", fontInfo.SourceAxis?.Style.BaseSizePt, fontInfo.SourceAxis?.Style.BaseSizePx); - fontConfig.SizePixels = fontInfo.TargetSizePx * io.FontGlobalScale; - if (this.UseAxis) + var count = 0; + while (this.deferredDisposeTextures.TryTake(out var d)) { - fontConfig.GlyphRanges = dummyRangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = false; - DefaultFont = ioFonts.AddFontDefault(fontConfig); - this.loadedFontInfo[DefaultFont] = fontInfo; - } - else - { - var japaneseRangeHandle = GCHandle.Alloc(GlyphRangesJapanese.GlyphRanges, GCHandleType.Pinned); - garbageList.Add(japaneseRangeHandle); - - fontConfig.GlyphRanges = japaneseRangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = true; - DefaultFont = ioFonts.AddFontFromFileTTF(fontPathJp, fontConfig.SizePixels, fontConfig); - this.loadedFontInfo[DefaultFont] = fontInfo; + count++; + d.RealDispose(); } - if (fontPathKr != null && Service.Get().EffectiveLanguage == "ko") - { - fontConfig.MergeMode = true; - fontConfig.GlyphRanges = ioFonts.GetGlyphRangesKorean(); - fontConfig.PixelSnapH = true; - ioFonts.AddFontFromFileTTF(fontPathKr, fontConfig.SizePixels, fontConfig); - fontConfig.MergeMode = false; - } + Log.Verbose("[IM] Disposing {Count} textures", count); + } - // FontAwesome icon font - Log.Verbose("[FONT] SetupFonts - FontAwesome icon font"); - { - var fontPathIcon = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "FontAwesomeFreeSolid.otf"); - if (!File.Exists(fontPathIcon)) - ShowFontError(fontPathIcon); + if (!this.deferredDisposeImFontLockeds.IsEmpty) + { + // Not logging; the main purpose of this is to keep resources used for rendering the frame to be kept + // referenced until the resources are actually done being used, and it is expected that this will be + // frequent. + while (this.deferredDisposeImFontLockeds.TryTake(out var d)) + d.Dispose(); + } + } - var iconRangeHandle = GCHandle.Alloc(new ushort[] { 0xE000, 0xF8FF, 0, }, GCHandleType.Pinned); - garbageList.Add(iconRangeHandle); - - fontConfig.GlyphRanges = iconRangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = true; - IconFont = ioFonts.AddFontFromFileTTF(fontPathIcon, DefaultFontSizePx * io.FontGlobalScale, fontConfig); - this.loadedFontInfo[IconFont] = new("Icon", TargetFontModification.AxisMode.GameGlyphsOnly, DefaultFontSizePx, io.FontGlobalScale); - } - - // Monospace font - Log.Verbose("[FONT] SetupFonts - Monospace font"); - { - var fontPathMono = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "Inconsolata-Regular.ttf"); - if (!File.Exists(fontPathMono)) - ShowFontError(fontPathMono); - - fontConfig.GlyphRanges = IntPtr.Zero; - fontConfig.PixelSnapH = true; - MonoFont = ioFonts.AddFontFromFileTTF(fontPathMono, DefaultFontSizePx * io.FontGlobalScale, fontConfig); - this.loadedFontInfo[MonoFont] = new("Mono", TargetFontModification.AxisMode.GameGlyphsOnly, DefaultFontSizePx, io.FontGlobalScale); - } - - // Default font but in requested size for requested glyphs - Log.Verbose("[FONT] SetupFonts - Default font but in requested size for requested glyphs"); - { - Dictionary> extraFontRequests = new(); - foreach (var extraFontRequest in this.glyphRequests) - { - if (!extraFontRequests.ContainsKey(extraFontRequest.Size)) - extraFontRequests[extraFontRequest.Size] = new(); - extraFontRequests[extraFontRequest.Size].Add(extraFontRequest); - } - - foreach (var (fontSize, requests) in extraFontRequests) - { - List> codepointRanges = new() - { - new(Fallback1Codepoint, Fallback1Codepoint), - new(Fallback2Codepoint, Fallback2Codepoint), - - // ImGui default ellipsis characters - new(0x2026, 0x2026), - new(0x0085, 0x0085), - }; - - foreach (var request in requests) - { - foreach (var range in request.CodepointRanges) - codepointRanges.Add(range); - } - - codepointRanges.Sort((x, y) => (x.Item1 == y.Item1 ? (x.Item2 < y.Item2 ? -1 : (x.Item2 == y.Item2 ? 0 : 1)) : (x.Item1 < y.Item1 ? -1 : 1))); - - List flattenedRanges = new(); - foreach (var range in codepointRanges) - { - if (flattenedRanges.Any() && flattenedRanges[^1] >= range.Item1 - 1) + [ServiceManager.CallWhenServicesReady( + "InterfaceManager accepts event registration and stuff even when the game window is not ready.")] + private void ContinueConstruction( + TargetSigScanner sigScanner, + FontAtlasFactory fontAtlasFactory) + { + this.dalamudAtlas = fontAtlasFactory + .CreateFontAtlas(nameof(InterfaceManager), FontAtlasAutoRebuildMode.Disable); + using (this.dalamudAtlas.SuppressAutoRebuild()) + { + this.DefaultFontHandle = (FontHandle)this.dalamudAtlas.NewDelegateFontHandle( + e => e.OnPreBuild(tk => tk.AddDalamudDefaultFont(-1))); + this.IconFontHandle = (FontHandle)this.dalamudAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + tk => tk.AddFontAwesomeIconFont( + new() { - flattenedRanges[^1] = Math.Max(flattenedRanges[^1], range.Item2); - } - else + SizePx = Service.Get().DefaultFontSpec.SizePx, + GlyphMinAdvanceX = DefaultFontSizePx, + GlyphMaxAdvanceX = DefaultFontSizePx, + }))); + this.MonoFontHandle = (FontHandle)this.dalamudAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + tk => tk.AddDalamudAssetFont( + DalamudAsset.InconsolataRegular, + new() { - flattenedRanges.Add(range.Item1); - flattenedRanges.Add(range.Item2); + SizePx = Service.Get().DefaultFontSpec.SizePx, + }))); + this.dalamudAtlas.BuildStepChange += e => e.OnPostBuild( + tk => + { + // Fill missing glyphs in MonoFont from DefaultFont. + tk.CopyGlyphsAcrossFonts( + tk.GetFont(this.DefaultFontHandle), + tk.GetFont(this.MonoFontHandle), + missingOnly: true); + }); + this.DefaultFontHandle.ImFontChanged += (_, font) => + { + var fontLocked = font.NewRef(); + this.framework.RunOnFrameworkThread( + () => + { + // Update the ImGui default font. + unsafe + { + ImGui.GetIO().NativePtr->FontDefault = fontLocked.ImFont; } - } - flattenedRanges.Add(0); + // Update the reference to the resources of the default font. + this.defaultFontResourceLock?.Dispose(); + this.defaultFontResourceLock = fontLocked; - fontInfo = new( - $"Requested({fontSize}px)", - this.UseAxis ? TargetFontModification.AxisMode.Overwrite : TargetFontModification.AxisMode.GameGlyphsOnly, - fontSize, - io.FontGlobalScale); - if (this.UseAxis) - { - fontConfig.GlyphRanges = dummyRangeHandle.AddrOfPinnedObject(); - fontConfig.SizePixels = fontInfo.SourceAxis!.Style.BaseSizePx; - fontConfig.PixelSnapH = false; - - var sizedFont = ioFonts.AddFontDefault(fontConfig); - this.loadedFontInfo[sizedFont] = fontInfo; - foreach (var request in requests) - request.FontInternal = sizedFont; - } - else - { - var rangeHandle = GCHandle.Alloc(flattenedRanges.ToArray(), GCHandleType.Pinned); - garbageList.Add(rangeHandle); - fontConfig.PixelSnapH = true; - - var sizedFont = ioFonts.AddFontFromFileTTF(fontPathJp, fontSize * io.FontGlobalScale, fontConfig, rangeHandle.AddrOfPinnedObject()); - this.loadedFontInfo[sizedFont] = fontInfo; - foreach (var request in requests) - request.FontInternal = sizedFont; - } - } - } - - gameFontManager.BuildFonts(); - - var customFontFirstConfigIndex = ioFonts.ConfigData.Size; - - Log.Verbose("[FONT] Invoke OnBuildFonts"); - this.BuildFonts?.InvokeSafely(); - Log.Verbose("[FONT] OnBuildFonts OK!"); - - for (int i = customFontFirstConfigIndex, iTo = ioFonts.ConfigData.Size; i < iTo; i++) - { - var config = ioFonts.ConfigData[i]; - if (gameFontManager.OwnsFont(config.DstFont)) - continue; - - config.OversampleH = 1; - config.OversampleV = 1; - - var name = Encoding.UTF8.GetString((byte*)config.Name.Data, config.Name.Count).TrimEnd('\0'); - if (name.IsNullOrEmpty()) - name = $"{config.SizePixels}px"; - - // ImFont information is reflected only if corresponding ImFontConfig has MergeMode not set. - if (config.MergeMode) - { - if (!this.loadedFontInfo.ContainsKey(config.DstFont.NativePtr)) - { - Log.Warning("MergeMode specified for {0} but not found in loadedFontInfo. Skipping.", name); - continue; - } - } - else - { - if (this.loadedFontInfo.ContainsKey(config.DstFont.NativePtr)) - { - Log.Warning("MergeMode not specified for {0} but found in loadedFontInfo. Skipping.", name); - continue; - } - - // While the font will be loaded in the scaled size after FontScale is applied, the font will be treated as having the requested size when used from plugins. - this.loadedFontInfo[config.DstFont.NativePtr] = new($"PlReq({name})", config.SizePixels); - } - - config.SizePixels = config.SizePixels * io.FontGlobalScale; - } - - for (int i = 0, iTo = ioFonts.ConfigData.Size; i < iTo; i++) - { - var config = ioFonts.ConfigData[i]; - config.RasterizerGamma *= fontGamma; - } - - Log.Verbose("[FONT] ImGui.IO.Build will be called."); - ioFonts.Build(); - gameFontManager.AfterIoFontsBuild(); - this.ClearStacks(); - Log.Verbose("[FONT] ImGui.IO.Build OK!"); - - gameFontManager.AfterBuildFonts(); - - foreach (var (font, mod) in this.loadedFontInfo) - { - // I have no idea what's causing NPE, so just to be safe - try - { - if (font.NativePtr != null && font.NativePtr->ConfigData != null) - { - var nameBytes = Encoding.UTF8.GetBytes($"{mod.Name}\0"); - Marshal.Copy(nameBytes, 0, (IntPtr)font.ConfigData.Name.Data, Math.Min(nameBytes.Length, font.ConfigData.Name.Count)); - } - } - catch (NullReferenceException) - { - // do nothing - } - - Log.Verbose("[FONT] {0}: Unscale with scale value of {1}", mod.Name, mod.Scale); - GameFontManager.UnscaleFont(font, mod.Scale, false); - - if (mod.Axis == TargetFontModification.AxisMode.Overwrite) - { - Log.Verbose("[FONT] {0}: Overwrite from AXIS of size {1}px (was {2}px)", mod.Name, mod.SourceAxis.ImFont.FontSize, font.FontSize); - GameFontManager.UnscaleFont(font, font.FontSize / mod.SourceAxis.ImFont.FontSize, false); - var ascentDiff = mod.SourceAxis.ImFont.Ascent - font.Ascent; - font.Ascent += ascentDiff; - font.Descent = ascentDiff; - font.FallbackChar = mod.SourceAxis.ImFont.FallbackChar; - font.EllipsisChar = mod.SourceAxis.ImFont.EllipsisChar; - ImGuiHelpers.CopyGlyphsAcrossFonts(mod.SourceAxis.ImFont, font, false, false); - } - else if (mod.Axis == TargetFontModification.AxisMode.GameGlyphsOnly) - { - Log.Verbose("[FONT] {0}: Overwrite game specific glyphs from AXIS of size {1}px (was {2}px)", mod.Name, mod.SourceAxis.ImFont.FontSize, font.FontSize); - if (!this.UseAxis && font.NativePtr == DefaultFont.NativePtr) - mod.SourceAxis.ImFont.FontSize -= 1; - ImGuiHelpers.CopyGlyphsAcrossFonts(mod.SourceAxis.ImFont, font, true, false, 0xE020, 0xE0DB); - if (!this.UseAxis && font.NativePtr == DefaultFont.NativePtr) - mod.SourceAxis.ImFont.FontSize += 1; - } - - Log.Verbose("[FONT] {0}: Resize from {1}px to {2}px", mod.Name, font.FontSize, mod.TargetSizePx); - GameFontManager.UnscaleFont(font, font.FontSize / mod.TargetSizePx, false); - } - - // Fill missing glyphs in MonoFont from DefaultFont - ImGuiHelpers.CopyGlyphsAcrossFonts(DefaultFont, MonoFont, true, false); - - for (int i = 0, iTo = ioFonts.Fonts.Size; i < iTo; i++) - { - var font = ioFonts.Fonts[i]; - if (font.Glyphs.Size == 0) - { - Log.Warning("[FONT] Font has no glyph: {0}", font.GetDebugName()); - continue; - } - - if (font.FindGlyphNoFallback(Fallback1Codepoint).NativePtr != null) - font.FallbackChar = Fallback1Codepoint; - - font.BuildLookupTable(); - } - - Log.Verbose("[FONT] Invoke OnAfterBuildFonts"); - this.AfterBuildFonts?.InvokeSafely(); - Log.Verbose("[FONT] OnAfterBuildFonts OK!"); - - if (ioFonts.Fonts[0].NativePtr != DefaultFont.NativePtr) - Log.Warning("[FONT] First font is not DefaultFont"); - - Log.Verbose("[FONT] Fonts built!"); - - this.fontBuildSignal.Set(); - - this.FontsReady = true; - } - finally - { - if (fontConfig.NativePtr != null) - fontConfig.Destroy(); - - foreach (var garbage in garbageList) - garbage.Free(); - } - } - - // This is intended to only be called as a handler attached to scene.OnNewRenderFrame - private void RebuildFontsInternal() - { - Log.Verbose("[FONT] RebuildFontsInternal() called"); - this.SetupFonts(); - - Log.Verbose("[FONT] RebuildFontsInternal() detaching"); - this.scene!.OnNewRenderFrame -= this.RebuildFontsInternal; - - Log.Verbose("[FONT] Calling InvalidateFonts"); - this.scene.InvalidateFonts(); - - Log.Verbose("[FONT] Font Rebuild OK!"); - - this.isRebuildingFonts = false; - } - - private unsafe IntPtr ProcessMessageDetour(IntPtr hWnd, uint msg, ulong wParam, ulong lParam, IntPtr handeled) - { - var ime = Service.GetNullable(); - _ = ime?.ProcessWndProcW(hWnd, (User32.WindowMessage)msg, (void*)wParam, (void*)lParam); - return this.processMessageHook.Original(hWnd, msg, wParam, lParam, handeled); - } - - private unsafe IntPtr DispatchMessageWDetour(ref User32.MSG msg) - { - if (msg.hwnd == this.GameWindowHandle && this.scene != null) - { - var res = this.scene.ProcessWndProcW(msg.hwnd, msg.message, (void*)msg.wParam, (void*)msg.lParam); - if (res != null) - return res.Value; + // Broadcast to auto-rebuilding instances. + this.AfterBuildFonts?.Invoke(); + }); + }; } - return this.dispatchMessageWHook.IsDisposed ? User32.DispatchMessage(ref msg) : this.dispatchMessageWHook.Original(ref msg); + // This will wait for scene on its own. We just wait for this.dalamudAtlas.BuildTask in this.InitScene. + _ = this.dalamudAtlas.BuildFontsAsync(); } - private IntPtr ResizeBuffersDetour(IntPtr swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags) + private int ResizeBuffersDetour(nint swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags) { #if DEBUG Log.Verbose($"Calling resizebuffers swap@{swapChain.ToInt64():X}{bufferCount} {width} {height} {newFormat} {swapChainFlags}"); @@ -1204,10 +857,15 @@ internal class InterfaceManager : IDisposable, IServiceType this.ResizeBuffers?.InvokeSafely(); + // We have to ensure we're working with the main swapchain, + // as viewports might be resizing as well + if (this.scene == null || swapChain != this.scene.SwapChain.NativePointer) + return this.resizeBuffersOriginal!(swapChain, bufferCount, width, height, newFormat, swapChainFlags); + this.scene?.OnPreResize(); var ret = this.resizeBuffersOriginal!(swapChain, bufferCount, width, height, newFormat, swapChainFlags); - if (ret.ToInt64() == 0x887A0001) + if (ret == DXGI.DXGI_ERROR_INVALID_CALL) { Log.Error("invalid call to resizeBuffers"); } @@ -1217,16 +875,19 @@ internal class InterfaceManager : IDisposable, IServiceType return ret; } - private IntPtr SetCursorDetour(IntPtr hCursor) + private nint SetCursorDetour(nint hCursor) { if (this.lastWantCapture && (!this.scene?.IsImGuiCursor(hCursor) ?? false) && this.OverrideGameCursor) - return IntPtr.Zero; + return nint.Zero; - return this.setCursorHook.IsDisposed ? User32.SetCursor(new User32.SafeCursorHandle(hCursor, false)).DangerousGetHandle() : this.setCursorHook.Original(hCursor); + return this.setCursorHook?.IsDisposed is not false + ? User32.SetCursor(new(hCursor, false)).DangerousGetHandle() + : this.setCursorHook.Original(hCursor); } private void OnNewInputFrame() { + var io = ImGui.GetIO(); var dalamudInterface = Service.GetNullable(); var gamepadState = Service.GetNullable(); var keyState = Service.GetNullable(); @@ -1234,18 +895,21 @@ internal class InterfaceManager : IDisposable, IServiceType if (dalamudInterface == null || gamepadState == null || keyState == null) return; + // Prevent setting the footgun from ImGui Demo; the Space key isn't removing the flag at the moment. + io.ConfigFlags &= ~ImGuiConfigFlags.NoMouse; + // fix for keys in game getting stuck, if you were holding a game key (like run) // and then clicked on an imgui textbox - imgui would swallow the keyup event, // so the game would think the key remained pressed continuously until you left // imgui and pressed and released the key again - if (ImGui.GetIO().WantTextInput) + if (io.WantTextInput) { keyState.ClearAll(); } // TODO: mouse state? - var gamepadEnabled = (ImGui.GetIO().BackendFlags & ImGuiBackendFlags.HasGamepad) > 0; + var gamepadEnabled = (io.BackendFlags & ImGuiBackendFlags.HasGamepad) > 0; // NOTE (Chiv) Activate ImGui navigation via L1+L3 press // (mimicking how mouse navigation is activated via L1+R3 press in game). @@ -1253,12 +917,12 @@ internal class InterfaceManager : IDisposable, IServiceType && gamepadState.Raw(GamepadButtons.L1) > 0 && gamepadState.Pressed(GamepadButtons.L3) > 0) { - ImGui.GetIO().ConfigFlags ^= ImGuiConfigFlags.NavEnableGamepad; + io.ConfigFlags ^= ImGuiConfigFlags.NavEnableGamepad; gamepadState.NavEnableGamepad ^= true; dalamudInterface.ToggleGamepadModeNotifierWindow(); } - if (gamepadEnabled && (ImGui.GetIO().ConfigFlags & ImGuiConfigFlags.NavEnableGamepad) > 0) + if (gamepadEnabled && (io.ConfigFlags & ImGuiConfigFlags.NavEnableGamepad) > 0) { var northButton = gamepadState.Raw(GamepadButtons.North) != 0; var eastButton = gamepadState.Raw(GamepadButtons.East) != 0; @@ -1277,7 +941,6 @@ internal class InterfaceManager : IDisposable, IServiceType var r1Button = gamepadState.Raw(GamepadButtons.R1) != 0; var r2Button = gamepadState.Raw(GamepadButtons.R2) != 0; - var io = ImGui.GetIO(); io.AddKeyEvent(ImGuiKey.GamepadFaceUp, northButton); io.AddKeyEvent(ImGuiKey.GamepadFaceRight, eastButton); io.AddKeyEvent(ImGuiKey.GamepadFaceDown, southButton); @@ -1300,7 +963,8 @@ internal class InterfaceManager : IDisposable, IServiceType if (gamepadState.Pressed(GamepadButtons.R3) > 0) { - dalamudInterface.TogglePluginInstallerWindow(); + var configuration = Service.Get(); + dalamudInterface.TogglePluginInstallerWindowTo(configuration.PluginInstallerOpen); } } } @@ -1324,16 +988,18 @@ internal class InterfaceManager : IDisposable, IServiceType var snap = ImGuiManagedAsserts.GetSnapshot(); if (this.IsDispatchingEvents) + { this.Draw?.Invoke(); + Service.GetNullable()?.Draw(); + } ImGuiManagedAsserts.ReportProblems("Dalamud Core", snap); - - Service.Get().Draw(); } /// /// Represents an instance of InstanceManager with scene ready for use. /// + [ServiceManager.ProvidedService] public class InterfaceManagerWithScene : IServiceType { /// @@ -1350,123 +1016,4 @@ internal class InterfaceManager : IDisposable, IServiceType /// public InterfaceManager Manager { get; init; } } - - /// - /// Represents a glyph request. - /// - public class SpecialGlyphRequest : IDisposable - { - /// - /// Initializes a new instance of the class. - /// - /// InterfaceManager to associate. - /// Font size in pixels. - /// Codepoint ranges. - internal SpecialGlyphRequest(InterfaceManager manager, float size, List> ranges) - { - this.Manager = manager; - this.Size = size; - this.CodepointRanges = ranges; - this.Manager.glyphRequests.Add(this); - } - - /// - /// Gets the font of specified size, or DefaultFont if it's not ready yet. - /// - public ImFontPtr Font - { - get - { - unsafe - { - return this.FontInternal.NativePtr == null ? DefaultFont : this.FontInternal; - } - } - } - - /// - /// Gets or sets the associated ImFont. - /// - internal ImFontPtr FontInternal { get; set; } - - /// - /// Gets associated InterfaceManager. - /// - internal InterfaceManager Manager { get; init; } - - /// - /// Gets font size. - /// - internal float Size { get; init; } - - /// - /// Gets codepoint ranges. - /// - internal List> CodepointRanges { get; init; } - - /// - public void Dispose() - { - this.Manager.glyphRequests.Remove(this); - } - } - - private unsafe class TargetFontModification : IDisposable - { - /// - /// Initializes a new instance of the class. - /// Constructs new target font modification information, assuming that AXIS fonts will not be applied. - /// - /// Name of the font to write to ImGui font information. - /// Target font size in pixels, which will not be considered for further scaling. - internal TargetFontModification(string name, float sizePx) - { - this.Name = name; - this.Axis = AxisMode.Suppress; - this.TargetSizePx = sizePx; - this.Scale = 1; - this.SourceAxis = null; - } - - /// - /// Initializes a new instance of the class. - /// Constructs new target font modification information. - /// - /// Name of the font to write to ImGui font information. - /// Whether and how to use AXIS fonts. - /// Target font size in pixels, which will not be considered for further scaling. - /// Font scale to be referred for loading AXIS font of appropriate size. - internal TargetFontModification(string name, AxisMode axis, float sizePx, float globalFontScale) - { - this.Name = name; - this.Axis = axis; - this.TargetSizePx = sizePx; - this.Scale = globalFontScale; - this.SourceAxis = Service.Get().NewFontRef(new(GameFontFamily.Axis, this.TargetSizePx * this.Scale)); - } - - internal enum AxisMode - { - Suppress, - GameGlyphsOnly, - Overwrite, - } - - internal string Name { get; private init; } - - internal AxisMode Axis { get; private init; } - - internal float TargetSizePx { get; private init; } - - internal float Scale { get; private init; } - - internal GameFontHandle? SourceAxis { get; private init; } - - internal bool SourceAxisAvailable => this.SourceAxis != null && this.SourceAxis.ImFont.NativePtr != null; - - public void Dispose() - { - this.SourceAxis?.Dispose(); - } - } } diff --git a/Dalamud/Interface/Internal/ManagedAsserts/ImGuiContextOffsets.cs b/Dalamud/Interface/Internal/ManagedAsserts/ImGuiContextOffsets.cs index fd203192f..89e23ab78 100644 --- a/Dalamud/Interface/Internal/ManagedAsserts/ImGuiContextOffsets.cs +++ b/Dalamud/Interface/Internal/ManagedAsserts/ImGuiContextOffsets.cs @@ -18,4 +18,6 @@ internal static class ImGuiContextOffsets public const int FontStackOffset = 0x7A4; public const int BeginPopupStackOffset = 0x7B8; + + public const int TextStateOffset = 0x4588; } diff --git a/Dalamud/Interface/Internal/Notifications/NotificationManager.cs b/Dalamud/Interface/Internal/Notifications/NotificationManager.cs deleted file mode 100644 index e941db7a4..000000000 --- a/Dalamud/Interface/Internal/Notifications/NotificationManager.cs +++ /dev/null @@ -1,317 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; - -using Dalamud.Interface.Colors; -using Dalamud.Utility; -using ImGuiNET; - -namespace Dalamud.Interface.Internal.Notifications; - -/// -/// Class handling notifications/toasts in ImGui. -/// Ported from https://github.com/patrickcjk/imgui-notify. -/// -[ServiceManager.EarlyLoadedService] -internal class NotificationManager : IServiceType -{ - /// - /// Value indicating the bottom-left X padding. - /// - internal const float NotifyPaddingX = 20.0f; - - /// - /// Value indicating the bottom-left Y padding. - /// - internal const float NotifyPaddingY = 20.0f; - - /// - /// Value indicating the Y padding between each message. - /// - internal const float NotifyPaddingMessageY = 10.0f; - - /// - /// Value indicating the fade-in and out duration. - /// - internal const int NotifyFadeInOutTime = 500; - - /// - /// Value indicating the default time until the notification is dismissed. - /// - internal const int NotifyDefaultDismiss = 3000; - - /// - /// Value indicating the maximum opacity. - /// - internal const float NotifyOpacity = 0.82f; - - /// - /// Value indicating default window flags for the notifications. - /// - internal const ImGuiWindowFlags NotifyToastFlags = - ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoInputs | - ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoBringToFrontOnFocus | ImGuiWindowFlags.NoFocusOnAppearing; - - private readonly List notifications = new(); - - [ServiceManager.ServiceConstructor] - private NotificationManager() - { - } - - /// - /// Add a notification to the notification queue. - /// - /// The content of the notification. - /// The title of the notification. - /// The type of the notification. - /// The time the notification should be displayed for. - public void AddNotification(string content, string? title = null, NotificationType type = NotificationType.None, uint msDelay = NotifyDefaultDismiss) - { - this.notifications.Add(new Notification - { - Content = content, - Title = title, - NotificationType = type, - DurationMs = msDelay, - }); - } - - /// - /// Draw all currently queued notifications. - /// - public void Draw() - { - var viewportSize = ImGuiHelpers.MainViewport.Size; - var height = 0f; - - for (var i = 0; i < this.notifications.Count; i++) - { - var tn = this.notifications.ElementAt(i); - - if (tn.GetPhase() == Notification.Phase.Expired) - { - this.notifications.RemoveAt(i); - continue; - } - - var opacity = tn.GetFadePercent(); - - var iconColor = tn.Color; - iconColor.W = opacity; - - var windowName = $"##NOTIFY{i}"; - - ImGuiHelpers.ForceNextWindowMainViewport(); - ImGui.SetNextWindowBgAlpha(opacity); - ImGui.SetNextWindowPos(new Vector2(viewportSize.X - NotifyPaddingX, viewportSize.Y - NotifyPaddingY - height), ImGuiCond.Always, Vector2.One); - ImGui.Begin(windowName, NotifyToastFlags); - - ImGui.PushTextWrapPos(viewportSize.X / 3.0f); - - var wasTitleRendered = false; - - if (!tn.Icon.IsNullOrEmpty()) - { - wasTitleRendered = true; - ImGui.PushFont(InterfaceManager.IconFont); - ImGui.TextColored(iconColor, tn.Icon); - ImGui.PopFont(); - } - - var textColor = ImGuiColors.DalamudWhite; - textColor.W = opacity; - - ImGui.PushStyleColor(ImGuiCol.Text, textColor); - - if (!tn.Title.IsNullOrEmpty()) - { - if (!tn.Icon.IsNullOrEmpty()) - { - ImGui.SameLine(); - } - - ImGui.TextUnformatted(tn.Title); - wasTitleRendered = true; - } - else if (!tn.DefaultTitle.IsNullOrEmpty()) - { - if (!tn.Icon.IsNullOrEmpty()) - { - ImGui.SameLine(); - } - - ImGui.TextUnformatted(tn.DefaultTitle); - wasTitleRendered = true; - } - - if (wasTitleRendered && !tn.Content.IsNullOrEmpty()) - { - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 5.0f); - } - - if (!tn.Content.IsNullOrEmpty()) - { - if (wasTitleRendered) - { - ImGui.Separator(); - } - - ImGui.TextUnformatted(tn.Content); - } - - ImGui.PopStyleColor(); - - ImGui.PopTextWrapPos(); - - height += ImGui.GetWindowHeight() + NotifyPaddingMessageY; - - ImGui.End(); - } - } - - /// - /// Container class for notifications. - /// - internal class Notification - { - /// - /// Possible notification phases. - /// - internal enum Phase - { - /// - /// Phase indicating fade-in. - /// - FadeIn, - - /// - /// Phase indicating waiting until fade-out. - /// - Wait, - - /// - /// Phase indicating fade-out. - /// - FadeOut, - - /// - /// Phase indicating that the notification has expired. - /// - Expired, - } - - /// - /// Gets the type of the notification. - /// - internal NotificationType NotificationType { get; init; } - - /// - /// Gets the title of the notification. - /// - internal string? Title { get; init; } - - /// - /// Gets the content of the notification. - /// - internal string Content { get; init; } - - /// - /// Gets the duration of the notification in milliseconds. - /// - internal uint DurationMs { get; init; } - - /// - /// Gets the creation time of the notification. - /// - internal DateTime CreationTime { get; init; } = DateTime.Now; - - /// - /// Gets the default color of the notification. - /// - /// Thrown when is set to an out-of-range value. - internal Vector4 Color => this.NotificationType switch - { - NotificationType.None => ImGuiColors.DalamudWhite, - NotificationType.Success => ImGuiColors.HealerGreen, - NotificationType.Warning => ImGuiColors.DalamudOrange, - NotificationType.Error => ImGuiColors.DalamudRed, - NotificationType.Info => ImGuiColors.TankBlue, - _ => throw new ArgumentOutOfRangeException(), - }; - - /// - /// Gets the icon of the notification. - /// - /// Thrown when is set to an out-of-range value. - internal string? Icon => this.NotificationType switch - { - NotificationType.None => null, - NotificationType.Success => FontAwesomeIcon.CheckCircle.ToIconString(), - NotificationType.Warning => FontAwesomeIcon.ExclamationCircle.ToIconString(), - NotificationType.Error => FontAwesomeIcon.TimesCircle.ToIconString(), - NotificationType.Info => FontAwesomeIcon.InfoCircle.ToIconString(), - _ => throw new ArgumentOutOfRangeException(), - }; - - /// - /// Gets the default title of the notification. - /// - /// Thrown when is set to an out-of-range value. - internal string? DefaultTitle => this.NotificationType switch - { - NotificationType.None => null, - NotificationType.Success => NotificationType.Success.ToString(), - NotificationType.Warning => NotificationType.Warning.ToString(), - NotificationType.Error => NotificationType.Error.ToString(), - NotificationType.Info => NotificationType.Info.ToString(), - _ => throw new ArgumentOutOfRangeException(), - }; - - /// - /// Gets the elapsed time since creating the notification. - /// - internal TimeSpan ElapsedTime => DateTime.Now - this.CreationTime; - - /// - /// Gets the phase of the notification. - /// - /// The phase of the notification. - internal Phase GetPhase() - { - var elapsed = (int)this.ElapsedTime.TotalMilliseconds; - - if (elapsed > NotifyFadeInOutTime + this.DurationMs + NotifyFadeInOutTime) - return Phase.Expired; - else if (elapsed > NotifyFadeInOutTime + this.DurationMs) - return Phase.FadeOut; - else if (elapsed > NotifyFadeInOutTime) - return Phase.Wait; - else - return Phase.FadeIn; - } - - /// - /// Gets the opacity of the notification. - /// - /// The opacity, in a range from 0 to 1. - internal float GetFadePercent() - { - var phase = this.GetPhase(); - var elapsed = this.ElapsedTime.TotalMilliseconds; - - if (phase == Phase.FadeIn) - { - return (float)elapsed / NotifyFadeInOutTime * NotifyOpacity; - } - else if (phase == Phase.FadeOut) - { - return (1.0f - (((float)elapsed - NotifyFadeInOutTime - this.DurationMs) / - NotifyFadeInOutTime)) * NotifyOpacity; - } - - return 1.0f * NotifyOpacity; - } - } -} diff --git a/Dalamud/Interface/Internal/Notifications/NotificationType.cs b/Dalamud/Interface/Internal/Notifications/NotificationType.cs index 1885ec809..5fffbe9af 100644 --- a/Dalamud/Interface/Internal/Notifications/NotificationType.cs +++ b/Dalamud/Interface/Internal/Notifications/NotificationType.cs @@ -1,32 +1,23 @@ -namespace Dalamud.Interface.Internal.Notifications; +using Dalamud.Utility; -/// -/// Possible notification types. -/// +namespace Dalamud.Interface.Internal.Notifications; + +/// Possible notification types. +[Api10ToDo(Api10ToDoAttribute.MoveNamespace, nameof(ImGuiNotification.Internal))] public enum NotificationType { - /// - /// No special type. - /// + /// No special type. None, - /// - /// Type indicating success. - /// + /// Type indicating success. Success, - /// - /// Type indicating a warning. - /// + /// Type indicating a warning. Warning, - /// - /// Type indicating an error. - /// + /// Type indicating an error. Error, - /// - /// Type indicating generic information. - /// + /// Type indicating generic information. Info, } diff --git a/Dalamud/Interface/Internal/PluginCategoryManager.cs b/Dalamud/Interface/Internal/PluginCategoryManager.cs index 9515a55b5..ddfcff6bc 100644 --- a/Dalamud/Interface/Internal/PluginCategoryManager.cs +++ b/Dalamud/Interface/Internal/PluginCategoryManager.cs @@ -28,7 +28,7 @@ internal class PluginCategoryManager new(11, "special.devIconTester", () => Locs.Category_IconTester), new(12, "special.dalamud", () => Locs.Category_Dalamud), new(13, "special.plugins", () => Locs.Category_Plugins), - new(14, "special.profiles", () => Locs.Category_PluginProfiles, CategoryInfo.AppearCondition.ProfilesEnabled), + new(14, "special.profiles", () => Locs.Category_PluginProfiles), new(FirstTagBasedCategoryId + 0, "other", () => Locs.Category_Other), new(FirstTagBasedCategoryId + 1, "jobs", () => Locs.Category_Jobs), new(FirstTagBasedCategoryId + 2, "ui", () => Locs.Category_UI), @@ -129,7 +129,7 @@ internal class PluginCategoryManager /// /// Gets a value indicating whether current group + category selection changed recently. - /// Changes in Available group should be followed with , everythine else can use . + /// Changes in Available group should be followed with , everything else can use . /// public bool IsContentDirty => this.isContentDirty; @@ -353,11 +353,6 @@ internal class PluginCategoryManager /// Check if plugin testing is enabled. ///
DoPluginTest, - - /// - /// Check if plugin profiles are enabled. - /// - ProfilesEnabled, } /// diff --git a/Dalamud/Interface/Internal/TextureManager.cs b/Dalamud/Interface/Internal/TextureManager.cs index 1bc5198e3..74ce91e5e 100644 --- a/Dalamud/Interface/Internal/TextureManager.cs +++ b/Dalamud/Interface/Internal/TextureManager.cs @@ -1,8 +1,6 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using System.Numerics; using Dalamud.Data; @@ -11,11 +9,14 @@ using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Plugin.Services; -using ImGuiScene; using Lumina.Data.Files; +using Lumina.Data.Parsing.Tex.Buffers; +using SharpDX.DXGI; namespace Dalamud.Interface.Internal; +// TODO API10: Remove keepAlive from public APIs + /// /// Service responsible for loading and disposing ImGui texture wraps. /// @@ -23,9 +24,10 @@ namespace Dalamud.Interface.Internal; [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] #pragma warning disable SA1015 +[ResolveVia] [ResolveVia] #pragma warning restore SA1015 -internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionProvider +internal class TextureManager : IInternalDisposableService, ITextureProvider, ITextureSubstitutionProvider { private const string IconFileFormat = "ui/icon/{0:D3}000/{1}{2:D6}.tex"; private const string HighResolutionIconFileFormat = "ui/icon/{0:D3}000/{1}{2:D6}_hr1.tex"; @@ -37,26 +39,28 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP private readonly Framework framework; private readonly DataManager dataManager; private readonly InterfaceManager im; - private readonly DalamudStartInfo startInfo; + + private readonly ClientLanguage language; private readonly Dictionary activeTextures = new(); - private TextureWrap? fallbackTextureWrap; + private IDalamudTextureWrap? fallbackTextureWrap; /// /// Initializes a new instance of the class. /// + /// Dalamud instance. /// Framework instance. /// DataManager instance. /// InterfaceManager instance. - /// DalamudStartInfo instance. [ServiceManager.ServiceConstructor] - public TextureManager(Framework framework, DataManager dataManager, InterfaceManager im, DalamudStartInfo startInfo) + public TextureManager(Dalamud dalamud, Framework framework, DataManager dataManager, InterfaceManager im) { this.framework = framework; this.dataManager = dataManager; this.im = im; - this.startInfo = startInfo; + + this.language = (ClientLanguage)dalamud.StartInfo.Language; this.framework.Update += this.FrameworkOnUpdate; @@ -76,16 +80,16 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP /// If null, default to the game's current language. /// /// - /// Prevent Dalamud from automatically unloading this icon to save memory. Usually does not need to be set. + /// Not used. This parameter is ignored. /// /// /// Null, if the icon does not exist in the specified configuration, or a texture wrap that can be used /// to render the icon. /// - public TextureManagerTextureWrap? GetIcon(uint iconId, ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.HiRes, ClientLanguage? language = null, bool keepAlive = false) + public IDalamudTextureWrap? GetIcon(uint iconId, ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.HiRes, ClientLanguage? language = null, bool keepAlive = false) { var path = this.GetIconPath(iconId, flags, language); - return path == null ? null : this.CreateWrap(path, keepAlive); + return path == null ? null : this.CreateWrap(path); } /// @@ -113,7 +117,7 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP if (this.dataManager.FileExists(path)) return path; - language ??= this.startInfo.Language; + language ??= this.language; var languageFolder = language switch { ClientLanguage.Japanese => "ja/", @@ -169,16 +173,16 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP /// You may only specify paths in the game's VFS. /// /// The path to the texture in the game's VFS. - /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Not used. This parameter is ignored. /// Null, if the icon does not exist, or a texture wrap that can be used to render the texture. - public TextureManagerTextureWrap? GetTextureFromGame(string path, bool keepAlive = false) + public IDalamudTextureWrap? GetTextureFromGame(string path, bool keepAlive = false) { ArgumentException.ThrowIfNullOrEmpty(path); if (Path.IsPathRooted(path)) throw new ArgumentException("Use GetTextureFromFile() to load textures directly from a file.", nameof(path)); - return !this.dataManager.FileExists(path) ? null : this.CreateWrap(path, keepAlive); + return !this.dataManager.FileExists(path) ? null : this.CreateWrap(path); } /// @@ -188,12 +192,12 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP /// This API can load .png and .tex files. /// /// The FileInfo describing the image or texture file. - /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Not used. This parameter is ignored. /// Null, if the file does not exist, or a texture wrap that can be used to render the texture. - public TextureManagerTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive = false) + public IDalamudTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive = false) { ArgumentNullException.ThrowIfNull(file); - return !file.Exists ? null : this.CreateWrap(file.FullName, keepAlive); + return !file.Exists ? null : this.CreateWrap(file.FullName); } /// @@ -201,16 +205,32 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP /// /// The texture to obtain a handle to. /// A texture wrap that can be used to render the texture. - public IDalamudTextureWrap? GetTexture(TexFile file) + /// Thrown when the graphics system is not available yet. Relevant for plugins when LoadRequiredState is set to 0 or 1. + /// Thrown when the given is not supported. Most likely is that the file is corrupt. + public IDalamudTextureWrap GetTexture(TexFile file) { ArgumentNullException.ThrowIfNull(file); if (!this.im.IsReady) throw new InvalidOperationException("Cannot create textures before scene is ready"); - -#pragma warning disable CS0618 - return (IDalamudTextureWrap)this.dataManager.GetImGuiTexture(file); -#pragma warning restore CS0618 + + var buffer = file.TextureBuffer; + var bpp = 1 << (((int)file.Header.Format & (int)TexFile.TextureFormat.BppMask) >> + (int)TexFile.TextureFormat.BppShift); + + var (dxgiFormat, conversion) = TexFile.GetDxgiFormatFromTextureFormat(file.Header.Format, false); + if (conversion != TexFile.DxgiFormatConversion.NoConversion || !this.im.SupportsDxgiFormat((Format)dxgiFormat)) + { + dxgiFormat = (int)Format.B8G8R8A8_UNorm; + buffer = buffer.Filter(0, 0, TexFile.TextureFormat.B8G8R8A8); + bpp = 32; + } + + var pitch = buffer is BlockCompressionTextureBuffer + ? Math.Max(1, (buffer.Width + 3) / 4) * 2 * bpp + : ((buffer.Width * bpp) + 7) / 8; + + return this.im.LoadImageFromDxgiFormat(buffer.RawData, pitch, buffer.Width, buffer.Height, (Format)dxgiFormat); } /// @@ -248,12 +268,15 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP } /// - public void Dispose() + void IInternalDisposableService.DisposeService() { this.fallbackTextureWrap?.Dispose(); this.framework.Update -= this.FrameworkOnUpdate; - Log.Verbose("Disposing {Num} left behind textures."); + if (this.activeTextures.Count == 0) + return; + + Log.Verbose("Disposing {Num} left behind textures.", this.activeTextures.Count); foreach (var activeTexture in this.activeTextures) { @@ -277,23 +300,21 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP TextureInfo? info; lock (this.activeTextures) { + // This either is a new texture, or it had been evicted and now wants to be drawn again. if (!this.activeTextures.TryGetValue(path, out info)) { - Debug.Assert(rethrow, "This should never run when getting outside of creator"); - info = new TextureInfo(); this.activeTextures.Add(path, info); } if (info == null) throw new Exception("null info in activeTextures"); - } - - if (info.KeepAliveCount == 0) + info.LastAccess = DateTime.UtcNow; - if (info is { Wrap: not null }) - return info; + if (info is { Wrap: not null }) + return info; + } if (!this.im.IsReady) throw new InvalidOperationException("Cannot create textures before scene is ready"); @@ -301,7 +322,7 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP // Substitute the path here for loading, instead of when getting the respective TextureInfo path = this.GetSubstitutedPath(path); - TextureWrap? wrap; + IDalamudTextureWrap? wrap; try { // We want to load this from the disk, probably, if the path has a root @@ -366,33 +387,6 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP return info; } - /// - /// Notify the system about an instance of a texture wrap being disposed. - /// If required conditions are met, the texture will be unloaded at the next update. - /// - /// The path to the texture. - /// Whether or not this handle was created in keep-alive mode. - internal void NotifyTextureDisposed(string path, bool keepAlive) - { - lock (this.activeTextures) - { - if (!this.activeTextures.TryGetValue(path, out var info)) - { - Log.Warning("Disposing texture that didn't exist: {Path}", path); - return; - } - - info.RefCount--; - - if (keepAlive) - info.KeepAliveCount--; - - // Clean it up by the next update. If it's re-requested in-between, we don't reload it. - if (info.RefCount <= 0) - info.LastAccess = default; - } - } - private static string FormatIconPath(uint iconId, string? type, bool highResolution) { var format = highResolution ? HighResolutionIconFileFormat : IconFileFormat; @@ -404,27 +398,19 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP return string.Format(format, iconId / 1000, type, iconId); } - private TextureManagerTextureWrap? CreateWrap(string path, bool keepAlive) + private TextureManagerTextureWrap? CreateWrap(string path) { lock (this.activeTextures) { // This will create the texture. // That's fine, it's probably used immediately and this will let the plugin catch load errors. var info = this.GetInfo(path, rethrow: true); - - // We need to increase the refcounts here while locking the collection! - // Otherwise, if this is loaded from a task, cleanup might already try to delete it - // before it can be increased. - info.RefCount++; - - if (keepAlive) - info.KeepAliveCount++; - return new TextureManagerTextureWrap(path, info.Extents, keepAlive, this); + return new TextureManagerTextureWrap(path, info.Extents, this); } } - private void FrameworkOnUpdate(Framework fw) + private void FrameworkOnUpdate(IFramework fw) { lock (this.activeTextures) { @@ -432,19 +418,7 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP foreach (var texInfo in this.activeTextures) { - if (texInfo.Value.RefCount == 0) - { - Log.Verbose("Evicting {Path} since no refs", texInfo.Key); - - Debug.Assert(texInfo.Value.KeepAliveCount == 0, "texInfo.Value.KeepAliveCount == 0"); - - texInfo.Value.Wrap?.Dispose(); - texInfo.Value.Wrap = null; - toRemove.Add(texInfo.Key); - continue; - } - - if (texInfo.Value.KeepAliveCount > 0 || texInfo.Value.Wrap == null) + if (texInfo.Value.Wrap == null) continue; if (DateTime.UtcNow - texInfo.Value.LastAccess > TimeSpan.FromMilliseconds(MillisecondsEvictionTime)) @@ -452,6 +426,7 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP Log.Verbose("Evicting {Path} since too old", texInfo.Key); texInfo.Value.Wrap.Dispose(); texInfo.Value.Wrap = null; + toRemove.Add(texInfo.Key); } } @@ -477,22 +452,12 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP /// /// Gets or sets the actual texture wrap. May be unpopulated. /// - public TextureWrap? Wrap { get; set; } + public IDalamudTextureWrap? Wrap { get; set; } /// /// Gets or sets the time the texture was last accessed. /// public DateTime LastAccess { get; set; } - - /// - /// Gets or sets the number of active holders of this texture. - /// - public uint RefCount { get; set; } - - /// - /// Gets or sets the number of active holders that want this texture to stay alive forever. - /// - public uint KeepAliveCount { get; set; } /// /// Gets or sets the extents of the texture. @@ -501,90 +466,6 @@ internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionP } } -/// -/// Plugin-scoped version of a texture manager. -/// -[PluginInterface] -[InterfaceVersion("1.0")] -[ServiceManager.ScopedService] -#pragma warning disable SA1015 -[ResolveVia] -#pragma warning restore SA1015 -internal class TextureManagerPluginScoped : ITextureProvider, IServiceType, IDisposable -{ - private readonly TextureManager textureManager; - - private readonly List trackedTextures = new(); - - /// - /// Initializes a new instance of the class. - /// - /// TextureManager instance. - public TextureManagerPluginScoped(TextureManager textureManager) - { - this.textureManager = textureManager; - } - - /// - public IDalamudTextureWrap? GetIcon( - uint iconId, - ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.ItemHighQuality, - ClientLanguage? language = null, - bool keepAlive = false) - { - var wrap = this.textureManager.GetIcon(iconId, flags, language, keepAlive); - if (wrap == null) - return null; - - this.trackedTextures.Add(wrap); - return wrap; - } - - /// - public string? GetIconPath(uint iconId, ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.HiRes, ClientLanguage? language = null) - => this.textureManager.GetIconPath(iconId, flags, language); - - /// - public IDalamudTextureWrap? GetTextureFromGame(string path, bool keepAlive = false) - { - ArgumentException.ThrowIfNullOrEmpty(path); - - var wrap = this.textureManager.GetTextureFromGame(path, keepAlive); - if (wrap == null) - return null; - - this.trackedTextures.Add(wrap); - return wrap; - } - - /// - public IDalamudTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive) - { - ArgumentNullException.ThrowIfNull(file); - - var wrap = this.textureManager.GetTextureFromFile(file, keepAlive); - if (wrap == null) - return null; - - this.trackedTextures.Add(wrap); - return wrap; - } - - /// - public IDalamudTextureWrap? GetTexture(TexFile file) - => this.textureManager.GetTexture(file); - - /// - public void Dispose() - { - // Dispose all leaked textures - foreach (var textureWrap in this.trackedTextures.Where(x => !x.IsDisposed)) - { - textureWrap.Dispose(); - } - } -} - /// /// Wrap. /// @@ -592,19 +473,16 @@ internal class TextureManagerTextureWrap : IDalamudTextureWrap { private readonly TextureManager manager; private readonly string path; - private readonly bool keepAlive; /// /// Initializes a new instance of the class. /// /// The path to the texture. /// The extents of the texture. - /// Keep alive or not. /// Manager that we obtained this from. - internal TextureManagerTextureWrap(string path, Vector2 extents, bool keepAlive, TextureManager manager) + internal TextureManagerTextureWrap(string path, Vector2 extents, TextureManager manager) { this.path = path; - this.keepAlive = keepAlive; this.manager = manager; this.Width = (int)extents.X; this.Height = (int)extents.Y; @@ -630,12 +508,7 @@ internal class TextureManagerTextureWrap : IDalamudTextureWrap /// public void Dispose() { - lock (this) - { - if (!this.IsDisposed) - this.manager.NotifyTextureDisposed(this.path, this.keepAlive); - - this.IsDisposed = true; - } + this.IsDisposed = true; + // This is a no-op. The manager cleans up textures that are not being drawn. } } diff --git a/Dalamud/Interface/Internal/UiDebug.cs b/Dalamud/Interface/Internal/UiDebug.cs index d1e7a6b78..d93b90799 100644 --- a/Dalamud/Interface/Internal/UiDebug.cs +++ b/Dalamud/Interface/Internal/UiDebug.cs @@ -1,9 +1,12 @@ using System; using System.Numerics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Dalamud.Game; using Dalamud.Game.Gui; +using Dalamud.Interface.Utility; +using Dalamud.Memory; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Component.GUI; using ImGuiNET; @@ -80,7 +83,7 @@ internal unsafe class UiDebug private void DrawUnitBase(AtkUnitBase* atkUnitBase) { var isVisible = (atkUnitBase->Flags & 0x20) == 0x20; - var addonName = Marshal.PtrToStringAnsi(new IntPtr(atkUnitBase->Name)); + var addonName = MemoryHelper.ReadSeStringAsString(out _, new IntPtr(atkUnitBase->Name)); var agent = Service.Get().FindAgentInterface(atkUnitBase); ImGui.Text($"{addonName}"); @@ -202,7 +205,7 @@ internal unsafe class UiDebug { case NodeType.Text: var textNode = (AtkTextNode*)node; - ImGui.Text($"text: {Marshal.PtrToStringAnsi(new IntPtr(textNode->NodeText.StringPtr))}"); + ImGui.Text($"text: {MemoryHelper.ReadSeStringAsString(out _, (nint)textNode->NodeText.StringPtr)}"); ImGui.InputText($"Replace Text##{(ulong)textNode:X}", new IntPtr(textNode->NodeText.StringPtr), (uint)textNode->NodeText.BufSize); @@ -213,7 +216,7 @@ internal unsafe class UiDebug while (b > byte.MaxValue) b -= byte.MaxValue; while (b < byte.MinValue) b += byte.MaxValue; textNode->AlignmentFontType = (byte)b; - textNode->AtkResNode.Flags_2 |= 0x1; + textNode->AtkResNode.DrawFlags |= 0x1; } ImGui.Text($"Color: #{textNode->TextColor.R:X2}{textNode->TextColor.G:X2}{textNode->TextColor.B:X2}{textNode->TextColor.A:X2}"); @@ -229,7 +232,7 @@ internal unsafe class UiDebug break; case NodeType.Counter: var counterNode = (AtkCounterNode*)node; - ImGui.Text($"text: {Marshal.PtrToStringAnsi(new IntPtr(counterNode->NodeText.StringPtr))}"); + ImGui.Text($"text: {MemoryHelper.ReadSeStringAsString(out _, (nint)counterNode->NodeText.StringPtr)}"); break; case NodeType.Image: var imageNode = (AtkImageNode*)node; @@ -248,8 +251,8 @@ internal unsafe class UiDebug { var texFileNameStdString = &textureInfo->AtkTexture.Resource->TexFileResourceHandle->ResourceHandle.FileName; var texString = texFileNameStdString->Length < 16 - ? Marshal.PtrToStringAnsi((IntPtr)texFileNameStdString->Buffer) - : Marshal.PtrToStringAnsi((IntPtr)texFileNameStdString->BufferPtr); + ? MemoryHelper.ReadSeStringAsString(out _, (nint)texFileNameStdString->Buffer) + : MemoryHelper.ReadSeStringAsString(out _, (nint)texFileNameStdString->BufferPtr); ImGui.Text($"texture path: {texString}"); var kernelTexture = textureInfo->AtkTexture.Resource->KernelTextureObject; @@ -350,13 +353,13 @@ internal unsafe class UiDebug { case ComponentType.TextInput: var textInputComponent = (AtkComponentTextInput*)compNode->Component; - ImGui.Text($"InputBase Text1: {Marshal.PtrToStringAnsi(new IntPtr(textInputComponent->AtkComponentInputBase.UnkText1.StringPtr))}"); - ImGui.Text($"InputBase Text2: {Marshal.PtrToStringAnsi(new IntPtr(textInputComponent->AtkComponentInputBase.UnkText2.StringPtr))}"); - ImGui.Text($"Text1: {Marshal.PtrToStringAnsi(new IntPtr(textInputComponent->UnkText1.StringPtr))}"); - ImGui.Text($"Text2: {Marshal.PtrToStringAnsi(new IntPtr(textInputComponent->UnkText2.StringPtr))}"); - ImGui.Text($"Text3: {Marshal.PtrToStringAnsi(new IntPtr(textInputComponent->UnkText3.StringPtr))}"); - ImGui.Text($"Text4: {Marshal.PtrToStringAnsi(new IntPtr(textInputComponent->UnkText4.StringPtr))}"); - ImGui.Text($"Text5: {Marshal.PtrToStringAnsi(new IntPtr(textInputComponent->UnkText5.StringPtr))}"); + ImGui.Text($"InputBase Text1: {MemoryHelper.ReadSeStringAsString(out _, new IntPtr(textInputComponent->AtkComponentInputBase.UnkText1.StringPtr))}"); + ImGui.Text($"InputBase Text2: {MemoryHelper.ReadSeStringAsString(out _, new IntPtr(textInputComponent->AtkComponentInputBase.UnkText2.StringPtr))}"); + ImGui.Text($"Text1: {MemoryHelper.ReadSeStringAsString(out _, new IntPtr(textInputComponent->UnkText1.StringPtr))}"); + ImGui.Text($"Text2: {MemoryHelper.ReadSeStringAsString(out _, new IntPtr(textInputComponent->UnkText2.StringPtr))}"); + ImGui.Text($"Text3: {MemoryHelper.ReadSeStringAsString(out _, new IntPtr(textInputComponent->UnkText3.StringPtr))}"); + ImGui.Text($"Text4: {MemoryHelper.ReadSeStringAsString(out _, new IntPtr(textInputComponent->UnkText4.StringPtr))}"); + ImGui.Text($"Text5: {MemoryHelper.ReadSeStringAsString(out _, new IntPtr(textInputComponent->UnkText5.StringPtr))}"); break; } @@ -415,7 +418,7 @@ internal unsafe class UiDebug $"MultiplyRGB: {node->MultiplyRed} {node->MultiplyGreen} {node->MultiplyBlue}"); } - private bool DrawUnitListHeader(int index, uint count, ulong ptr, bool highlight) + private bool DrawUnitListHeader(int index, ushort count, ulong ptr, bool highlight) { ImGui.PushStyleColor(ImGuiCol.Text, highlight ? 0xFFAAAA00 : 0xFFFFFFFF); if (!string.IsNullOrEmpty(this.searchInput) && !this.doingSearch) @@ -454,8 +457,6 @@ internal unsafe class UiDebug this.selectedInList[i] = false; var unitManager = &unitManagers[i]; - var unitBaseArray = &unitManager->AtkUnitEntries; - var headerOpen = true; if (!searching) @@ -467,14 +468,14 @@ internal unsafe class UiDebug for (var j = 0; j < unitManager->Count && headerOpen; j++) { - var unitBase = unitBaseArray[j]; + var unitBase = *(AtkUnitBase**)Unsafe.AsPointer(ref unitManager->EntriesSpan[j]); if (this.selectedUnitBase != null && unitBase == this.selectedUnitBase) { this.selectedInList[i] = true; foundSelected = true; } - var name = Marshal.PtrToStringAnsi(new IntPtr(unitBase->Name)); + var name = MemoryHelper.ReadSeStringAsString(out _, new IntPtr(unitBase->Name)); if (searching) { if (name == null || !name.ToLower().Contains(searchStr.ToLower())) continue; @@ -512,7 +513,8 @@ internal unsafe class UiDebug { for (var j = 0; j < unitManager->Count; j++) { - if (this.selectedUnitBase == null || unitBaseArray[j] != this.selectedUnitBase) continue; + var unitBase = *(AtkUnitBase**)Unsafe.AsPointer(ref unitManager->EntriesSpan[j]); + if (this.selectedUnitBase == null || unitBase != this.selectedUnitBase) continue; this.selectedInList[i] = true; foundSelected = true; } diff --git a/Dalamud/Interface/Internal/UnknownTextureWrap.cs b/Dalamud/Interface/Internal/UnknownTextureWrap.cs new file mode 100644 index 000000000..41164f2c3 --- /dev/null +++ b/Dalamud/Interface/Internal/UnknownTextureWrap.cs @@ -0,0 +1,77 @@ +using System.Threading; + +using Dalamud.Utility; + +using TerraFX.Interop.Windows; + +namespace Dalamud.Interface.Internal; + +/// +/// A texture wrap that is created by cloning the underlying . +/// +internal sealed unsafe class UnknownTextureWrap : IDalamudTextureWrap, IDeferredDisposable +{ + private IntPtr imGuiHandle; + + /// + /// Initializes a new instance of the class. + /// + /// The pointer to that is suitable for use with + /// . + /// The width of the texture. + /// The height of the texture. + /// If true, call . + public UnknownTextureWrap(IUnknown* unknown, int width, int height, bool callAddRef) + { + ObjectDisposedException.ThrowIf(unknown is null, typeof(IUnknown)); + this.imGuiHandle = (nint)unknown; + this.Width = width; + this.Height = height; + if (callAddRef) + unknown->AddRef(); + } + + /// + /// Finalizes an instance of the class. + /// + ~UnknownTextureWrap() => this.Dispose(false); + + /// + public nint ImGuiHandle => + this.imGuiHandle == nint.Zero + ? throw new ObjectDisposedException(nameof(UnknownTextureWrap)) + : this.imGuiHandle; + + /// + public int Width { get; } + + /// + public int Height { get; } + + /// + /// Queue the texture to be disposed once the frame ends. + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Actually dispose the wrapped texture. + /// + void IDeferredDisposable.RealDispose() + { + var handle = Interlocked.Exchange(ref this.imGuiHandle, nint.Zero); + if (handle != nint.Zero) + ((IUnknown*)handle)->Release(); + } + + private void Dispose(bool disposing) + { + if (disposing) + Service.GetNullable()?.EnqueueDeferredDispose(this); + else + ((IDeferredDisposable)this).RealDispose(); + } +} diff --git a/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs b/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs index b599fb58f..dcde7d008 100644 --- a/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs +++ b/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Networking.Http; using ImGuiNET; @@ -65,7 +66,7 @@ public class BranchSwitcherWindow : Window return; } - var si = Service.Get(); + var si = Service.Get().StartInfo; var itemsArray = this.branches.Select(x => x.Key).ToArray(); ImGui.ListBox("Branch", ref this.selectedBranchIndex, itemsArray, itemsArray.Length); diff --git a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs index 05854210e..ae59db36a 100644 --- a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs @@ -1,12 +1,20 @@ -using System; -using System.IO; +using System.Linq; using System.Numerics; +using Dalamud.Configuration.Internal; +using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Colors; +using Dalamud.Interface.Components; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Internal; +using Dalamud.Storage.Assets; using Dalamud.Utility; using ImGuiNET; -using ImGuiScene; namespace Dalamud.Interface.Internal.Windows; @@ -15,145 +23,359 @@ namespace Dalamud.Interface.Internal.Windows; /// internal sealed class ChangelogWindow : Window, IDisposable { - /// - /// Whether the latest update warrants a changelog window. - /// - public const string WarrantsChangelogForMajorMinor = "7.4."; - + private const string WarrantsChangelogForMajorMinor = "9.0."; + private const string ChangeLog = - @"• Updated Dalamud for compatibility with Patch 6.3 -• Made things more speedy by updating to .NET 7 + @"• Updated Dalamud for compatibility with Patch 6.5 +• A lot of behind-the-scenes changes to make Dalamud and plugins more stable and reliable +• Added plugin collections, allowing you to create lists of plugins that can be enabled or disabled together +• Plugins can now add tooltips and interaction to the server info bar +• The Dalamud/plugin installer UI has been refreshed +"; -If you note any issues or need help, please check the FAQ, and reach out on our Discord if you need help. -Thanks and have fun!"; + private readonly TitleScreenMenuWindow tsmWindow; - private const string UpdatePluginsInfo = - @"• All of your plugins were disabled automatically, due to this update. This is normal. -• Open the plugin installer, then click 'update plugins'. Updated plugins should update and then re-enable themselves. - => Please keep in mind that not all of your plugins may already be updated for the new version. - => If some plugins are displayed with a red cross in the 'Installed Plugins' tab, they may not yet be available."; + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly IFontAtlas privateAtlas; + private readonly Lazy bannerFont; + private readonly Lazy apiBumpExplainerTexture; + private readonly Lazy logoTexture; + + private readonly InOutCubic windowFade = new(TimeSpan.FromSeconds(2.5f)) + { + Point1 = Vector2.Zero, + Point2 = new Vector2(2f), + }; + + private readonly InOutCubic bodyFade = new(TimeSpan.FromSeconds(1f)) + { + Point1 = Vector2.Zero, + Point2 = Vector2.One, + }; + + private State state = State.WindowFadeIn; - private readonly string assemblyVersion = Util.AssemblyVersion; - - private readonly TextureWrap logoTexture; + private bool needFadeRestart = false; /// /// Initializes a new instance of the class. /// - public ChangelogWindow() - : base("What's new in Dalamud?", ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoResize) + /// TSM window. + /// An instance of . + /// An instance of . + public ChangelogWindow( + TitleScreenMenuWindow tsmWindow, + FontAtlasFactory fontAtlasFactory, + DalamudAssetManager assets) + : base("What's new in Dalamud?##ChangelogWindow", ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse, true) { + this.tsmWindow = tsmWindow; this.Namespace = "DalamudChangelogWindow"; + this.privateAtlas = this.scopedFinalizer.Add( + fontAtlasFactory.CreateFontAtlas(this.Namespace, FontAtlasAutoRebuildMode.Async)); + this.bannerFont = new( + () => this.scopedFinalizer.Add( + this.privateAtlas.NewGameFontHandle(new(GameFontFamilyAndSize.MiedingerMid18)))); - this.Size = new Vector2(885, 463); - this.SizeCondition = ImGuiCond.Appearing; + this.apiBumpExplainerTexture = new(() => assets.GetDalamudTextureWrap(DalamudAsset.ChangelogApiBumpIcon)); + this.logoTexture = new(() => assets.GetDalamudTextureWrap(DalamudAsset.Logo)); - var interfaceManager = Service.Get(); - var dalamud = Service.Get(); + // If we are going to show a changelog, make sure we have the font ready, otherwise it will hitch + if (WarrantsChangelog()) + _ = this.bannerFont.Value; + } - this.logoTexture = - interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "logo.png"))!; + private enum State + { + WindowFadeIn, + ExplainerIntro, + ExplainerApiBump, + Links, + } + + /// + /// Check if a changelog should be shown. + /// + /// True if a changelog should be shown. + public static bool WarrantsChangelog() + { + var configuration = Service.Get(); + var pm = Service.GetNullable(); + var pmWantsChangelog = pm?.InstalledPlugins.Any() ?? true; + return (string.IsNullOrEmpty(configuration.LastChangelogMajorMinor) || + (!WarrantsChangelogForMajorMinor.StartsWith(configuration.LastChangelogMajorMinor) && + Util.AssemblyVersion.StartsWith(WarrantsChangelogForMajorMinor))) && pmWantsChangelog; + } + + /// + public override void OnOpen() + { + Service.Get().SetCreditsDarkeningAnimation(true); + this.tsmWindow.AllowDrawing = false; + + _ = this.bannerFont; + + this.state = State.WindowFadeIn; + this.windowFade.Reset(); + this.bodyFade.Reset(); + this.needFadeRestart = true; + + base.OnOpen(); + } + + /// + public override void OnClose() + { + base.OnClose(); + + this.tsmWindow.AllowDrawing = true; + Service.Get().SetCreditsDarkeningAnimation(false); + } + + /// + public override void PreDraw() + { + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 10f); + + base.PreDraw(); + + if (this.needFadeRestart) + { + this.windowFade.Restart(); + this.needFadeRestart = false; + } + + this.windowFade.Update(); + ImGui.SetNextWindowBgAlpha(Math.Clamp(this.windowFade.EasedPoint.X, 0, 0.9f)); + + this.Size = new Vector2(900, 400); + this.SizeCondition = ImGuiCond.Always; + + // Center the window on the main viewport + var viewportSize = ImGuiHelpers.MainViewport.Size; + var windowSize = this.Size!.Value * ImGuiHelpers.GlobalScale; + ImGui.SetNextWindowPos(new Vector2(viewportSize.X / 2 - windowSize.X / 2, viewportSize.Y / 2 - windowSize.Y / 2)); + } + + /// + public override void PostDraw() + { + ImGui.PopStyleVar(3); + base.PostDraw(); } /// public override void Draw() { - ImGui.Text($"Dalamud has been updated to version D{this.assemblyVersion}."); - - ImGuiHelpers.ScaledDummy(10); - - ImGui.Text("The following changes were introduced:"); - + void Dismiss() + { + var configuration = Service.Get(); + configuration.LastChangelogMajorMinor = WarrantsChangelogForMajorMinor; + configuration.QueueSave(); + } + + var windowSize = ImGui.GetWindowSize(); + + var dummySize = 10 * ImGuiHelpers.GlobalScale; + ImGui.Dummy(new Vector2(dummySize)); ImGui.SameLine(); - ImGuiHelpers.ScaledDummy(0); - var imgCursor = ImGui.GetCursorPos(); - - ImGui.TextWrapped(ChangeLog); - - ImGuiHelpers.ScaledDummy(5); - - ImGui.TextColored(ImGuiColors.DalamudRed, " !!! ATTENTION !!!"); - - ImGui.TextWrapped(UpdatePluginsInfo); - - ImGuiHelpers.ScaledDummy(10); - - // ImGui.Text("Thank you for using our tools!"); - - // ImGuiHelpers.ScaledDummy(10); - - ImGui.PushFont(UiBuilder.IconFont); - - if (ImGui.Button(FontAwesomeIcon.Download.ToIconString())) + + var logoContainerSize = new Vector2(windowSize.X * 0.2f - dummySize, windowSize.Y); + using (var child = ImRaii.Child("###logoContainer", logoContainerSize, false)) { - Service.Get().OpenPluginInstaller(); - } + if (!child) + return; - if (ImGui.IsItemHovered()) - { - ImGui.PopFont(); - ImGui.SetTooltip("Open Plugin Installer"); - ImGui.PushFont(UiBuilder.IconFont); + var logoSize = new Vector2(logoContainerSize.X); + + // Center the logo in the container + ImGui.SetCursorPos(new Vector2(logoContainerSize.X / 2 - logoSize.X / 2, logoContainerSize.Y / 2 - logoSize.Y / 2)); + + using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.windowFade.EasedPoint.X - 0.5f, 0f, 1f))) + ImGui.Image(this.logoTexture.Value.ImGuiHandle, logoSize); } - + ImGui.SameLine(); - - if (ImGui.Button(FontAwesomeIcon.LaughBeam.ToIconString())) - { - Util.OpenLink("https://discord.gg/3NMcUV5"); - } - - if (ImGui.IsItemHovered()) - { - ImGui.PopFont(); - ImGui.SetTooltip("Join our Discord server"); - ImGui.PushFont(UiBuilder.IconFont); - } - + ImGui.Dummy(new Vector2(dummySize)); ImGui.SameLine(); - - if (ImGui.Button(FontAwesomeIcon.Globe.ToIconString())) + + using (var child = ImRaii.Child("###textContainer", new Vector2((windowSize.X * 0.8f) - dummySize * 4, windowSize.Y), false)) { - Util.OpenLink("https://goatcorp.github.io/faq/"); + if (!child) + return; + + ImGuiHelpers.ScaledDummy(20); + + using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.windowFade.EasedPoint.X - 1f, 0f, 1f))) + { + using var font = this.bannerFont.Value.Push(); + + switch (this.state) + { + case State.WindowFadeIn: + case State.ExplainerIntro: + ImGuiHelpers.CenteredText("New And Improved"); + break; + + case State.ExplainerApiBump: + ImGuiHelpers.CenteredText("Plugin Updates"); + break; + + case State.Links: + ImGuiHelpers.CenteredText("Enjoy!"); + break; + } + } + + ImGuiHelpers.ScaledDummy(8); + + if (this.state == State.WindowFadeIn && this.windowFade.EasedPoint.X > 1.5f) + { + this.state = State.ExplainerIntro; + this.bodyFade.Restart(); + } + + this.bodyFade.Update(); + using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.bodyFade.EasedPoint.X, 0, 1f))) + { + void DrawNextButton(State nextState) + { + // Draw big, centered next button at the bottom of the window + var buttonHeight = 30 * ImGuiHelpers.GlobalScale; + var buttonText = "Next"; + var buttonWidth = ImGui.CalcTextSize(buttonText).X + 40 * ImGuiHelpers.GlobalScale; + ImGui.SetCursorPosY(windowSize.Y - buttonHeight - (20 * ImGuiHelpers.GlobalScale)); + ImGuiHelpers.CenterCursorFor((int)buttonWidth); + + if (ImGui.Button(buttonText, new Vector2(buttonWidth, buttonHeight))) + { + this.state = nextState; + this.bodyFade.Restart(); + } + } + + switch (this.state) + { + case State.WindowFadeIn: + case State.ExplainerIntro: + ImGui.TextWrapped($"Welcome to Dalamud v{Util.AssemblyVersion}!"); + ImGuiHelpers.ScaledDummy(5); + ImGui.TextWrapped(ChangeLog); + ImGuiHelpers.ScaledDummy(5); + ImGui.TextWrapped("This changelog is a quick overview of the most important changes in this version."); + ImGui.TextWrapped("Please click next to see a quick guide to updating your plugins."); + + DrawNextButton(State.ExplainerApiBump); + break; + + case State.ExplainerApiBump: + ImGui.TextWrapped("Take care! Due to changes in this patch, all of your plugins need to be updated and were disabled automatically."); + ImGui.TextWrapped("This is normal and required for major game updates."); + ImGuiHelpers.ScaledDummy(5); + ImGui.TextWrapped("To update your plugins, open the plugin installer and click 'update plugins'. Updated plugins should update and then re-enable themselves."); + ImGuiHelpers.ScaledDummy(5); + ImGui.TextWrapped("Please keep in mind that not all of your plugins may already be updated for the new version."); + ImGui.TextWrapped("If some plugins are displayed with a red cross in the 'Installed Plugins' tab, they may not yet be available."); + + ImGuiHelpers.ScaledDummy(15); + + ImGuiHelpers.CenterCursorFor(this.apiBumpExplainerTexture.Value.Width); + ImGui.Image( + this.apiBumpExplainerTexture.Value.ImGuiHandle, + this.apiBumpExplainerTexture.Value.Size); + + DrawNextButton(State.Links); + break; + + case State.Links: + ImGui.TextWrapped("If you note any issues or need help, please check the FAQ, and reach out on our Discord if you need help."); + ImGui.TextWrapped("Enjoy your time with the game and Dalamud!"); + + ImGuiHelpers.ScaledDummy(45); + + bool CenteredIconButton(FontAwesomeIcon icon, string text) + { + var buttonWidth = ImGuiComponents.GetIconButtonWithTextWidth(icon, text); + ImGuiHelpers.CenterCursorFor((int)buttonWidth); + return ImGuiComponents.IconButtonWithText(icon, text); + } + + if (CenteredIconButton(FontAwesomeIcon.Download, "Open Plugin Installer")) + { + Service.Get().OpenPluginInstaller(); + this.IsOpen = false; + Dismiss(); + } + + ImGuiHelpers.ScaledDummy(5); + + ImGuiHelpers.CenterCursorFor( + (int)(ImGuiComponents.GetIconButtonWithTextWidth(FontAwesomeIcon.Globe, "See the FAQ") + + ImGuiComponents.GetIconButtonWithTextWidth(FontAwesomeIcon.LaughBeam, "Join our Discord server") + + (5 * ImGuiHelpers.GlobalScale) + + (ImGui.GetStyle().ItemSpacing.X * 4))); + if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Globe, "See the FAQ")) + { + Util.OpenLink("https://goatcorp.github.io/faq/"); + } + + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(5); + ImGui.SameLine(); + + if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.LaughBeam, "Join our Discord server")) + { + Util.OpenLink("https://discord.gg/3NMcUV5"); + } + + ImGuiHelpers.ScaledDummy(5); + + if (CenteredIconButton(FontAwesomeIcon.Heart, "Support what we care about")) + { + Util.OpenLink("https://goatcorp.github.io/faq/support"); + } + + var buttonHeight = 30 * ImGuiHelpers.GlobalScale; + var buttonText = "Close"; + var buttonWidth = ImGui.CalcTextSize(buttonText).X + 40 * ImGuiHelpers.GlobalScale; + ImGui.SetCursorPosY(windowSize.Y - buttonHeight - (20 * ImGuiHelpers.GlobalScale)); + ImGuiHelpers.CenterCursorFor((int)buttonWidth); + + if (ImGui.Button(buttonText, new Vector2(buttonWidth, buttonHeight))) + { + this.IsOpen = false; + Dismiss(); + } + + break; + } + } + + // Draw close button in the top right corner + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 100f); + var btnAlpha = Math.Clamp(this.windowFade.EasedPoint.X - 0.5f, 0f, 1f); + ImGui.PushStyleColor(ImGuiCol.Button, ImGuiColors.DalamudRed.WithAlpha(btnAlpha).Desaturate(0.3f)); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudWhite.WithAlpha(btnAlpha)); + + var childSize = ImGui.GetWindowSize(); + var closeButtonSize = 15 * ImGuiHelpers.GlobalScale; + ImGui.SetCursorPos(new Vector2(childSize.X - closeButtonSize - 5, 10 * ImGuiHelpers.GlobalScale)); + if (ImGuiComponents.IconButton(FontAwesomeIcon.Times)) + { + Dismiss(); + this.IsOpen = false; + } + + ImGui.PopStyleColor(2); + ImGui.PopStyleVar(); + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("I don't care about this"); + } } - - if (ImGui.IsItemHovered()) - { - ImGui.PopFont(); - ImGui.SetTooltip("See the FAQ"); - ImGui.PushFont(UiBuilder.IconFont); - } - - ImGui.SameLine(); - - if (ImGui.Button(FontAwesomeIcon.Heart.ToIconString())) - { - Util.OpenLink("https://goatcorp.github.io/faq/support"); - } - - if (ImGui.IsItemHovered()) - { - ImGui.PopFont(); - ImGui.SetTooltip("Support what we care about"); - ImGui.PushFont(UiBuilder.IconFont); - } - - ImGui.PopFont(); - - ImGui.SameLine(); - ImGuiHelpers.ScaledDummy(20, 0); - ImGui.SameLine(); - - if (ImGui.Button("Close")) - { - this.IsOpen = false; - } - - imgCursor.X += 750; - imgCursor.Y -= 30; - ImGui.SetCursorPos(imgCursor); - - ImGui.Image(this.logoTexture.ImGuiHandle, new Vector2(100)); } /// @@ -161,6 +383,5 @@ Thanks and have fun!"; /// public void Dispose() { - this.logoTexture.Dispose(); } } diff --git a/Dalamud/Interface/Internal/Windows/ComponentDemoWindow.cs b/Dalamud/Interface/Internal/Windows/ComponentDemoWindow.cs index 638b30e66..8c5458557 100644 --- a/Dalamud/Interface/Internal/Windows/ComponentDemoWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ComponentDemoWindow.cs @@ -6,6 +6,7 @@ using Dalamud.Interface.Animation; using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using ImGuiNET; diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index 872fdcd37..b0ca9c2aa 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -1,19 +1,30 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Numerics; using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; using Dalamud.Configuration.Internal; +using Dalamud.Game; using Dalamud.Game.Command; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.Internal; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Services; +using Dalamud.Utility; + using ImGuiNET; + using Serilog; using Serilog.Events; @@ -24,49 +35,81 @@ namespace Dalamud.Interface.Internal.Windows; /// internal class ConsoleWindow : Window, IDisposable { - private readonly List logText = new(); - private readonly object renderLock = new(); + private const int LogLinesMinimum = 100; + private const int LogLinesMaximum = 1000000; - private readonly string[] logLevelStrings = new[] { "Verbose", "Debug", "Information", "Warning", "Error", "Fatal" }; + // Only this field may be touched from any thread. + private readonly ConcurrentQueue<(string Line, LogEvent LogEvent)> newLogEntries; - private List filteredLogText = new(); - private bool autoScroll; - private bool openAtStartup; + // Fields below should be touched only from the main thread. + private readonly RollingList logText; + private readonly RollingList filteredLogEntries; + + private readonly List history = new(); + private readonly List pluginFilters = new(); + + private int newRolledLines; + private bool pendingRefilter; + private bool pendingClearLog; private bool? lastCmdSuccess; + private ImGuiListClipperPtr clipperPtr; private string commandText = string.Empty; - private string textFilter = string.Empty; - private int levelFilter; - private List sourceFilters = new(); - private bool filterShowUncaughtExceptions = false; - private bool isFiltered = false; + private string textHighlight = string.Empty; + private string selectedSource = "DalamudInternal"; + private string pluginFilter = string.Empty; + + private Regex? compiledLogFilter; + private Regex? compiledLogHighlight; + private Exception? exceptionLogFilter; + private Exception? exceptionLogHighlight; + + private bool filterShowUncaughtExceptions; + private bool settingsPopupWasOpen; + private bool showFilterToolbar; + private bool copyMode; + private bool killGameArmed; + private bool autoScroll; + private int logLinesLimit; + private bool autoOpen; private int historyPos; - private List history = new(); + private int copyStart = -1; - private bool killGameArmed = false; + private IActiveNotification? prevCopyNotification; - /// - /// Initializes a new instance of the class. - /// - public ConsoleWindow() + /// Initializes a new instance of the class. + /// An instance of . + public ConsoleWindow(DalamudConfiguration configuration) : base("Dalamud Console", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) { - var configuration = Service.Get(); - this.autoScroll = configuration.LogAutoScroll; - this.openAtStartup = configuration.LogOpenAtStartup; + this.autoOpen = configuration.LogOpenAtStartup; SerilogEventSink.Instance.LogLine += this.OnLogLine; + Service.GetAsync().ContinueWith(r => r.Result.Update += this.FrameworkOnUpdate); + this.Size = new Vector2(500, 400); this.SizeCondition = ImGuiCond.FirstUseEver; this.RespectCloseHotkey = false; - } - private List LogEntries => this.isFiltered ? this.filteredLogText : this.logText; + this.logLinesLimit = configuration.LogLinesLimit; + + var limit = Math.Max(LogLinesMinimum, this.logLinesLimit); + this.newLogEntries = new(); + this.logText = new(limit); + this.filteredLogEntries = new(limit); + + configuration.DalamudConfigurationSaved += this.OnDalamudConfigurationSaved; + + unsafe + { + this.clipperPtr = new(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + } + } /// public override void OnOpen() @@ -75,277 +118,165 @@ internal class ConsoleWindow : Window, IDisposable base.OnOpen(); } - /// - /// Dispose of managed and unmanaged resources. - /// + /// public void Dispose() { SerilogEventSink.Instance.LogLine -= this.OnLogLine; - } + Service.Get().DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; + if (Service.GetNullable() is { } framework) + framework.Update -= this.FrameworkOnUpdate; - /// - /// Clear the window of all log entries. - /// - public void Clear() - { - lock (this.renderLock) - { - this.logText.Clear(); - this.filteredLogText.Clear(); - } - } - - /// - /// Add a single log line to the display. - /// - /// The line to add. - /// The Serilog event associated with this line. - public void HandleLogLine(string line, LogEvent logEvent) - { - if (line.IndexOfAny(new[] { '\n', '\r' }) != -1) - { - var subLines = line.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries); - - this.AddAndFilter(subLines[0], logEvent, false); - - for (var i = 1; i < subLines.Length; i++) - { - this.AddAndFilter(subLines[i], logEvent, true); - } - } - else - { - this.AddAndFilter(line, logEvent, false); - } + this.clipperPtr.Destroy(); + this.clipperPtr = default; } /// public override void Draw() { - // Options menu - if (ImGui.BeginPopup("Options")) + this.DrawOptionsToolbar(); + + this.DrawFilterToolbar(); + + if (this.exceptionLogFilter is not null) { - var configuration = Service.Get(); - - if (ImGui.Checkbox("Auto-scroll", ref this.autoScroll)) - { - configuration.LogAutoScroll = this.autoScroll; - configuration.QueueSave(); - } - - if (ImGui.Checkbox("Open at startup", ref this.openAtStartup)) - { - configuration.LogOpenAtStartup = this.openAtStartup; - configuration.QueueSave(); - } - - var prevLevel = (int)EntryPoint.LogLevelSwitch.MinimumLevel; - if (ImGui.Combo("Log Level", ref prevLevel, Enum.GetValues(typeof(LogEventLevel)).Cast().Select(x => x.ToString()).ToArray(), 6)) - { - EntryPoint.LogLevelSwitch.MinimumLevel = (LogEventLevel)prevLevel; - configuration.LogLevel = (LogEventLevel)prevLevel; - configuration.QueueSave(); - } - - ImGui.EndPopup(); + ImGui.TextColored( + ImGuiColors.DalamudRed, + $"Regex Filter Error: {this.exceptionLogFilter.GetType().Name}"); + ImGui.TextUnformatted(this.exceptionLogFilter.Message); } - // Filter menu - if (ImGui.BeginPopup("Filters")) + if (this.exceptionLogHighlight is not null) { - if (ImGui.Checkbox("Enabled", ref this.isFiltered)) - { - this.Refilter(); - } - - if (ImGui.InputTextWithHint("##filterText", "Text Filter", ref this.textFilter, 255, ImGuiInputTextFlags.EnterReturnsTrue)) - { - this.Refilter(); - } - - ImGui.TextColored(ImGuiColors.DalamudGrey, "Enter to confirm."); - - if (ImGui.BeginCombo("Levels", this.levelFilter == 0 ? "All Levels..." : "Selected Levels...")) - { - for (var i = 0; i < this.logLevelStrings.Length; i++) - { - if (ImGui.Selectable(this.logLevelStrings[i], ((this.levelFilter >> i) & 1) == 1)) - { - this.levelFilter ^= 1 << i; - this.Refilter(); - } - } - - ImGui.EndCombo(); - } - - // Filter by specific plugin(s) - var pluginInternalNames = Service.Get().InstalledPlugins - .Select(p => p.Manifest.InternalName) - .OrderBy(s => s).ToList(); - var sourcePreviewVal = this.sourceFilters.Count switch - { - 0 => "All plugins...", - 1 => "1 plugin...", - _ => $"{this.sourceFilters.Count} plugins...", - }; - var sourceSelectables = pluginInternalNames.Union(this.sourceFilters).ToList(); - if (ImGui.BeginCombo("Plugins", sourcePreviewVal)) - { - foreach (var selectable in sourceSelectables) - { - if (ImGui.Selectable(selectable, this.sourceFilters.Contains(selectable))) - { - if (!this.sourceFilters.Contains(selectable)) - { - this.sourceFilters.Add(selectable); - } - else - { - this.sourceFilters.Remove(selectable); - } - - this.Refilter(); - } - } - - ImGui.EndCombo(); - } - - if (ImGui.Checkbox("Always Show Uncaught Exceptions", ref this.filterShowUncaughtExceptions)) - { - this.Refilter(); - } - - ImGui.EndPopup(); + ImGui.TextColored( + ImGuiColors.DalamudRed, + $"Regex Highlight Error: {this.exceptionLogHighlight.GetType().Name}"); + ImGui.TextUnformatted(this.exceptionLogHighlight.Message); } - ImGui.SameLine(); - - if (ImGuiComponents.IconButton(FontAwesomeIcon.Cog)) - ImGui.OpenPopup("Options"); - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Options"); - - ImGui.SameLine(); - if (ImGuiComponents.IconButton(FontAwesomeIcon.Search)) - ImGui.OpenPopup("Filters"); - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Filters"); - - ImGui.SameLine(); - var clear = ImGuiComponents.IconButton(FontAwesomeIcon.Trash); - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Clear Log"); - - ImGui.SameLine(); - var copy = ImGuiComponents.IconButton(FontAwesomeIcon.Copy); - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Copy Log"); - - ImGui.SameLine(); - if (this.killGameArmed) - { - if (ImGuiComponents.IconButton(FontAwesomeIcon.Flushed)) - Process.GetCurrentProcess().Kill(); - } - else - { - if (ImGuiComponents.IconButton(FontAwesomeIcon.Skull)) - this.killGameArmed = true; - } - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Kill game"); - - ImGui.BeginChild("scrolling", new Vector2(0, ImGui.GetFrameHeightWithSpacing() - 55), false, ImGuiWindowFlags.AlwaysHorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar); - - if (clear) - { - this.Clear(); - } - - if (copy) - { - ImGui.LogToClipboard(); - } + var sendButtonSize = ImGui.CalcTextSize("Send") + + ((new Vector2(16, 0) + (ImGui.GetStyle().FramePadding * 2)) * ImGuiHelpers.GlobalScale); + var scrollingHeight = ImGui.GetContentRegionAvail().Y - sendButtonSize.Y; + ImGui.BeginChild( + "scrolling", + new Vector2(0, scrollingHeight), + false, + ImGuiWindowFlags.AlwaysHorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar); ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - ImGuiListClipperPtr clipper; - unsafe - { - clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); - } - ImGui.PushFont(InterfaceManager.MonoFont); var childPos = ImGui.GetWindowPos(); var childDrawList = ImGui.GetWindowDrawList(); var childSize = ImGui.GetWindowSize(); - var cursorDiv = ImGuiHelpers.GlobalScale * 92; - var cursorLogLevel = ImGuiHelpers.GlobalScale * 100; - var cursorLogLine = ImGuiHelpers.GlobalScale * 135; + var timestampWidth = ImGui.CalcTextSize("00:00:00.000").X; + var levelWidth = ImGui.CalcTextSize("AAA").X; + var separatorWidth = ImGui.CalcTextSize(" | ").X; + var cursorLogLevel = timestampWidth + separatorWidth; + var cursorLogLine = cursorLogLevel + levelWidth + separatorWidth; - lock (this.renderLock) + var lastLinePosY = 0.0f; + var logLineHeight = 0.0f; + + this.clipperPtr.Begin(this.filteredLogEntries.Count); + while (this.clipperPtr.Step()) { - clipper.Begin(this.LogEntries.Count); - while (clipper.Step()) + for (var i = this.clipperPtr.DisplayStart; i < this.clipperPtr.DisplayEnd; i++) { - for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + var index = Math.Max( + i - this.newRolledLines, + 0); // Prevents flicker effect. Also workaround to avoid negative indexes. + var line = this.filteredLogEntries[index]; + + if (!line.IsMultiline) + ImGui.Separator(); + + if (line.SelectedForCopy) { - var line = this.LogEntries[i]; + ImGui.PushStyleColor(ImGuiCol.Header, ImGuiColors.ParsedGrey); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, ImGuiColors.ParsedGrey); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, ImGuiColors.ParsedGrey); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Header, GetColorForLogEventLevel(line.Level)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, GetColorForLogEventLevel(line.Level)); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, GetColorForLogEventLevel(line.Level)); + } - if (!line.IsMultiline && !copy) - ImGui.Separator(); + ImGui.Selectable( + "###console_null", + true, + ImGuiSelectableFlags.AllowItemOverlap | ImGuiSelectableFlags.SpanAllColumns); - ImGui.PushStyleColor(ImGuiCol.Header, this.GetColorForLogEventLevel(line.Level)); - ImGui.PushStyleColor(ImGuiCol.HeaderActive, this.GetColorForLogEventLevel(line.Level)); - ImGui.PushStyleColor(ImGuiCol.HeaderHovered, this.GetColorForLogEventLevel(line.Level)); + // This must be after ImGui.Selectable, it uses ImGui.IsItem... functions + this.HandleCopyMode(i, line); - ImGui.Selectable("###consolenull", true, ImGuiSelectableFlags.AllowItemOverlap | ImGuiSelectableFlags.SpanAllColumns); + ImGui.SameLine(); + + ImGui.PopStyleColor(3); + + if (!line.IsMultiline) + { + ImGui.TextUnformatted(line.TimestampString); ImGui.SameLine(); - ImGui.PopStyleColor(3); + ImGui.SetCursorPosX(cursorLogLevel); + ImGui.TextUnformatted(GetTextForLogEventLevel(line.Level)); + ImGui.SameLine(); + } - if (!line.IsMultiline) - { - ImGui.TextUnformatted(line.TimeStamp.ToString("HH:mm:ss.fff")); - ImGui.SameLine(); - ImGui.SetCursorPosX(cursorDiv); - ImGui.TextUnformatted("|"); - ImGui.SameLine(); - ImGui.SetCursorPosX(cursorLogLevel); - ImGui.TextUnformatted(this.GetTextForLogEventLevel(line.Level)); - ImGui.SameLine(); - } - - ImGui.SetCursorPosX(cursorLogLine); + ImGui.SetCursorPosX(cursorLogLine); + line.HighlightMatches ??= (this.compiledLogHighlight ?? this.compiledLogFilter)?.Matches(line.Line); + if (line.HighlightMatches is { } matches) + { + this.DrawHighlighted( + line.Line, + matches, + ImGui.GetColorU32(ImGuiCol.Text), + ImGui.GetColorU32(ImGuiColors.HealerGreen)); + } + else + { ImGui.TextUnformatted(line.Line); } - } - clipper.End(); - clipper.Destroy(); + var currentLinePosY = ImGui.GetCursorPosY(); + logLineHeight = currentLinePosY - lastLinePosY; + lastLinePosY = currentLinePosY; + } } + this.clipperPtr.End(); + ImGui.PopFont(); ImGui.PopStyleVar(); + if (!this.autoScroll || ImGui.GetScrollY() < ImGui.GetScrollMaxY()) + { + ImGui.SetScrollY(ImGui.GetScrollY() - (logLineHeight * this.newRolledLines)); + } + if (this.autoScroll && ImGui.GetScrollY() >= ImGui.GetScrollMaxY()) { ImGui.SetScrollHereY(1.0f); } - // Draw dividing line - var offset = ImGuiHelpers.GlobalScale * 127; - childDrawList.AddLine(new Vector2(childPos.X + offset, childPos.Y), new Vector2(childPos.X + offset, childPos.Y + childSize.Y), 0x4FFFFFFF, 1.0f); + // Draw dividing lines + var div1Offset = MathF.Round((timestampWidth + (separatorWidth / 2)) - ImGui.GetScrollX()); + var div2Offset = MathF.Round((cursorLogLevel + levelWidth + (separatorWidth / 2)) - ImGui.GetScrollX()); + childDrawList.AddLine( + new(childPos.X + div1Offset, childPos.Y), + new(childPos.X + div1Offset, childPos.Y + childSize.Y), + 0x4FFFFFFF, + 1.0f); + childDrawList.AddLine( + new(childPos.X + div2Offset, childPos.Y), + new(childPos.X + div2Offset, childPos.Y + childSize.Y), + 0x4FFFFFFF, + 1.0f); ImGui.EndChild(); @@ -363,12 +294,20 @@ internal class ConsoleWindow : Window, IDisposable } } - ImGui.SetNextItemWidth(ImGui.GetWindowSize().X - 80); + ImGui.SetNextItemWidth( + ImGui.GetContentRegionAvail().X - sendButtonSize.X - + (ImGui.GetStyle().ItemSpacing.X * ImGuiHelpers.GlobalScale)); var getFocus = false; unsafe { - if (ImGui.InputText("##commandbox", ref this.commandText, 255, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackCompletion | ImGuiInputTextFlags.CallbackHistory, this.CommandInputCallback)) + if (ImGui.InputText( + "##command_box", + ref this.commandText, + 255, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackCompletion | + ImGuiInputTextFlags.CallbackHistory, + this.CommandInputCallback)) { this.ProcessCommand(); getFocus = true; @@ -378,22 +317,481 @@ internal class ConsoleWindow : Window, IDisposable } ImGui.SetItemDefaultFocus(); - if (getFocus) - ImGui.SetKeyboardFocusHere(-1); // Auto focus previous widget + if (getFocus) ImGui.SetKeyboardFocusHere(-1); // Auto focus previous widget - if (hadColor) - ImGui.PopStyleColor(); + if (hadColor) ImGui.PopStyleColor(); - if (ImGui.Button("Send")) + if (ImGui.Button("Send", sendButtonSize)) { this.ProcessCommand(); } } + private static string GetTextForLogEventLevel(LogEventLevel level) => level switch + { + LogEventLevel.Error => "ERR", + LogEventLevel.Verbose => "VRB", + LogEventLevel.Debug => "DBG", + LogEventLevel.Information => "INF", + LogEventLevel.Warning => "WRN", + LogEventLevel.Fatal => "FTL", + _ => "???", + }; + + private static uint GetColorForLogEventLevel(LogEventLevel level) => level switch + { + LogEventLevel.Error => 0x800000EE, + LogEventLevel.Verbose => 0x00000000, + LogEventLevel.Debug => 0x00000000, + LogEventLevel.Information => 0x00000000, + LogEventLevel.Warning => 0x8A0070EE, + LogEventLevel.Fatal => 0xFF00000A, + _ => 0x30FFFFFF, + }; + + private void FrameworkOnUpdate(IFramework framework) + { + if (this.pendingClearLog) + { + this.pendingClearLog = false; + this.logText.Clear(); + this.filteredLogEntries.Clear(); + this.newLogEntries.Clear(); + } + + if (this.pendingRefilter) + { + this.pendingRefilter = false; + this.filteredLogEntries.Clear(); + foreach (var log in this.logText) + { + if (this.IsFilterApplicable(log)) + this.filteredLogEntries.Add(log); + } + } + + var numPrevFilteredLogEntries = this.filteredLogEntries.Count; + var addedLines = 0; + while (this.newLogEntries.TryDequeue(out var logLine)) + addedLines += this.HandleLogLine(logLine.Line, logLine.LogEvent); + this.newRolledLines = addedLines - (this.filteredLogEntries.Count - numPrevFilteredLogEntries); + } + + private void HandleCopyMode(int i, LogEntry line) + { + var selectionChanged = false; + + // If copyStart is -1, it means a drag has not been started yet, let's start one, and select the starting spot. + if (this.copyMode && this.copyStart == -1 && ImGui.IsItemClicked()) + { + this.copyStart = i; + line.SelectedForCopy = !line.SelectedForCopy; + + selectionChanged = true; + } + + // Update the selected range when dragging over entries + if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() && + ImGui.IsMouseDragging(ImGuiMouseButton.Left)) + { + if (!line.SelectedForCopy) + { + foreach (var index in Enumerable.Range(0, this.filteredLogEntries.Count)) + { + if (this.copyStart < i) + { + this.filteredLogEntries[index].SelectedForCopy = index >= this.copyStart && index <= i; + } + else + { + this.filteredLogEntries[index].SelectedForCopy = index >= i && index <= this.copyStart; + } + } + + selectionChanged = true; + } + } + + // Finish the drag, we should have already marked all dragged entries as selected by now. + if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() && + ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + this.copyStart = -1; + } + + if (selectionChanged) + this.CopyFilteredLogEntries(true); + } + + private void CopyFilteredLogEntries(bool selectedOnly) + { + var sb = new StringBuilder(); + var n = 0; + foreach (var entry in this.filteredLogEntries) + { + if (selectedOnly && !entry.SelectedForCopy) + continue; + + n++; + sb.AppendLine(entry.ToString()); + } + + if (n == 0) + return; + + ImGui.SetClipboardText(sb.ToString()); + this.prevCopyNotification?.DismissNow(); + this.prevCopyNotification = Service.Get().AddNotification( + new() + { + Title = this.WindowName, + Content = $"{n:n0} line(s) copied.", + Type = NotificationType.Success, + }); + } + + private void DrawOptionsToolbar() + { + var configuration = Service.Get(); + + ImGui.PushItemWidth(150.0f * ImGuiHelpers.GlobalScale); + if (ImGui.BeginCombo("##log_level", $"{EntryPoint.LogLevelSwitch.MinimumLevel}+")) + { + foreach (var value in Enum.GetValues()) + { + if (ImGui.Selectable(value.ToString(), value == EntryPoint.LogLevelSwitch.MinimumLevel)) + { + EntryPoint.LogLevelSwitch.MinimumLevel = value; + configuration.LogLevel = value; + configuration.QueueSave(); + this.QueueRefilter(); + } + } + + ImGui.EndCombo(); + } + + ImGui.SameLine(); + + var settingsPopup = ImGui.BeginPopup("##console_settings"); + if (settingsPopup) + { + this.DrawSettingsPopup(configuration); + ImGui.EndPopup(); + } + else if (this.settingsPopupWasOpen) + { + // Prevent side effects in case Apply wasn't clicked + this.logLinesLimit = configuration.LogLinesLimit; + } + + this.settingsPopupWasOpen = settingsPopup; + + if (this.DrawToggleButtonWithTooltip("show_settings", "Show settings", FontAwesomeIcon.List, ref settingsPopup)) + ImGui.OpenPopup("##console_settings"); + + ImGui.SameLine(); + + if (this.DrawToggleButtonWithTooltip( + "show_filters", + "Show filter toolbar", + FontAwesomeIcon.Search, + ref this.showFilterToolbar)) + { + this.showFilterToolbar = !this.showFilterToolbar; + } + + ImGui.SameLine(); + + if (this.DrawToggleButtonWithTooltip( + "show_uncaught_exceptions", + "Show uncaught exception while filtering", + FontAwesomeIcon.Bug, + ref this.filterShowUncaughtExceptions)) + { + this.filterShowUncaughtExceptions = !this.filterShowUncaughtExceptions; + } + + ImGui.SameLine(); + + if (ImGuiComponents.IconButton("clear_log", FontAwesomeIcon.Trash)) + { + this.QueueClear(); + } + + if (ImGui.IsItemHovered()) ImGui.SetTooltip("Clear Log"); + + ImGui.SameLine(); + + if (this.DrawToggleButtonWithTooltip( + "copy_mode", + "Enable Copy Mode\nRight-click to copy entire log", + FontAwesomeIcon.Copy, + ref this.copyMode)) + { + this.copyMode = !this.copyMode; + + if (!this.copyMode) + { + foreach (var entry in this.filteredLogEntries) + { + entry.SelectedForCopy = false; + } + } + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + this.CopyFilteredLogEntries(false); + + ImGui.SameLine(); + if (this.killGameArmed) + { + if (ImGuiComponents.IconButton(FontAwesomeIcon.ExclamationTriangle)) + Process.GetCurrentProcess().Kill(); + } + else + { + if (ImGuiComponents.IconButton(FontAwesomeIcon.Stop)) + this.killGameArmed = true; + } + + if (ImGui.IsItemHovered()) ImGui.SetTooltip("Kill game"); + + ImGui.SameLine(); + + var inputWidth = 200.0f * ImGuiHelpers.GlobalScale; + var nextCursorPosX = ImGui.GetContentRegionMax().X - (2 * inputWidth) - ImGui.GetStyle().ItemSpacing.X; + var breakInputLines = nextCursorPosX < 0; + if (ImGui.GetCursorPosX() > nextCursorPosX) + { + ImGui.NewLine(); + inputWidth = ImGui.GetWindowWidth() - (ImGui.GetStyle().WindowPadding.X * 2); + + if (!breakInputLines) + inputWidth = (inputWidth - ImGui.GetStyle().ItemSpacing.X) / 2; + } + else + { + ImGui.SetCursorPosX(nextCursorPosX); + } + + ImGui.PushItemWidth(inputWidth); + if (ImGui.InputTextWithHint( + "##textHighlight", + "regex highlight", + ref this.textHighlight, + 2048, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll) + || ImGui.IsItemDeactivatedAfterEdit()) + { + this.compiledLogHighlight = null; + this.exceptionLogHighlight = null; + try + { + if (this.textHighlight != string.Empty) + this.compiledLogHighlight = new(this.textHighlight, RegexOptions.IgnoreCase); + } + catch (Exception e) + { + this.exceptionLogHighlight = e; + } + + foreach (var log in this.logText) + log.HighlightMatches = null; + } + + if (!breakInputLines) + ImGui.SameLine(); + + ImGui.PushItemWidth(inputWidth); + if (ImGui.InputTextWithHint( + "##textFilter", + "regex global filter", + ref this.textFilter, + 2048, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll) + || ImGui.IsItemDeactivatedAfterEdit()) + { + this.compiledLogFilter = null; + this.exceptionLogFilter = null; + try + { + this.compiledLogFilter = new(this.textFilter, RegexOptions.IgnoreCase); + + this.QueueRefilter(); + } + catch (Exception e) + { + this.exceptionLogFilter = e; + } + + foreach (var log in this.logText) + log.HighlightMatches = null; + } + } + + private void DrawSettingsPopup(DalamudConfiguration configuration) + { + if (ImGui.Checkbox("Open at startup", ref this.autoOpen)) + { + configuration.LogOpenAtStartup = this.autoOpen; + configuration.QueueSave(); + } + + if (ImGui.Checkbox("Auto-scroll", ref this.autoScroll)) + { + configuration.LogAutoScroll = this.autoScroll; + configuration.QueueSave(); + } + + ImGui.TextUnformatted("Logs buffer"); + ImGui.SliderInt("lines", ref this.logLinesLimit, LogLinesMinimum, LogLinesMaximum); + if (ImGui.Button("Apply")) + { + this.logLinesLimit = Math.Max(LogLinesMinimum, this.logLinesLimit); + + configuration.LogLinesLimit = this.logLinesLimit; + configuration.QueueSave(); + + ImGui.CloseCurrentPopup(); + } + } + + private void DrawFilterToolbar() + { + if (!this.showFilterToolbar) return; + + PluginFilterEntry? removalEntry = null; + using var table = ImRaii.Table( + "plugin_filter_entries", + 4, + ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersInnerV); + if (!table) return; + + ImGui.TableSetupColumn("##remove_button", ImGuiTableColumnFlags.WidthFixed, 25.0f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("##source_name", ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("##log_level", ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("##filter_text", ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableNextColumn(); + if (ImGuiComponents.IconButton("add_entry", FontAwesomeIcon.Plus)) + { + if (this.pluginFilters.All(entry => entry.Source != this.selectedSource)) + { + this.pluginFilters.Add( + new PluginFilterEntry + { + Source = this.selectedSource, + Filter = string.Empty, + Level = LogEventLevel.Debug, + }); + } + + this.QueueRefilter(); + } + + ImGui.TableNextColumn(); + ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); + if (ImGui.BeginCombo("##Sources", this.selectedSource, ImGuiComboFlags.HeightLarge)) + { + var sourceNames = Service.Get().InstalledPlugins + .Select(p => p.Manifest.InternalName) + .OrderBy(s => s) + .Prepend("DalamudInternal") + .Where( + name => this.pluginFilter is "" || new FuzzyMatcher( + this.pluginFilter.ToLowerInvariant(), + MatchMode.Fuzzy).Matches(name.ToLowerInvariant()) != + 0) + .ToList(); + + ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); + ImGui.InputTextWithHint("##PluginSearchFilter", "Filter Plugin List", ref this.pluginFilter, 2048); + ImGui.Separator(); + + if (!sourceNames.Any()) + { + ImGui.TextColored(ImGuiColors.DalamudRed, "No Results"); + } + + foreach (var selectable in sourceNames) + { + if (ImGui.Selectable(selectable, this.selectedSource == selectable)) + { + this.selectedSource = selectable; + } + } + + ImGui.EndCombo(); + } + + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + + foreach (var entry in this.pluginFilters) + { + ImGui.PushID(entry.Source); + + ImGui.TableNextColumn(); + if (ImGuiComponents.IconButton(FontAwesomeIcon.Trash)) + { + removalEntry = entry; + } + + ImGui.TableNextColumn(); + ImGui.Text(entry.Source); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + if (ImGui.BeginCombo("##levels", $"{entry.Level}+")) + { + foreach (var value in Enum.GetValues()) + { + if (ImGui.Selectable(value.ToString(), value == entry.Level)) + { + entry.Level = value; + this.QueueRefilter(); + } + } + + ImGui.EndCombo(); + } + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + var entryFilter = entry.Filter; + if (ImGui.InputTextWithHint( + "##filter", + $"{entry.Source} regex filter", + ref entryFilter, + 2048, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll) + || ImGui.IsItemDeactivatedAfterEdit()) + { + entry.Filter = entryFilter; + if (entry.FilterException is null) + this.QueueRefilter(); + } + + ImGui.PopID(); + } + + if (removalEntry is { } toRemove) + { + this.pluginFilters.Remove(toRemove); + this.QueueRefilter(); + } + } + private void ProcessCommand() { try { + if (this.commandText.StartsWith('/')) + { + this.commandText = this.commandText[1..]; + } + this.historyPos = -1; for (var i = this.history.Count - 1; i >= 0; i--) { @@ -406,9 +804,9 @@ internal class ConsoleWindow : Window, IDisposable this.history.Add(this.commandText); - if (this.commandText == "clear" || this.commandText == "cls") + if (this.commandText is "clear" or "cls") { - this.Clear(); + this.QueueClear(); return; } @@ -443,7 +841,9 @@ internal class ConsoleWindow : Window, IDisposable // TODO: Improve this, add partial completion // https://github.com/ocornut/imgui/blob/master/imgui_demo.cpp#L6443-L6484 - var candidates = Service.Get().Commands.Where(x => x.Key.Contains("/" + words[0])).ToList(); + var candidates = Service.Get().Commands + .Where(x => x.Key.Contains("/" + words[0])) + .ToList(); if (candidates.Count > 0) { ptr.DeleteChars(0, ptr.BufTextLen); @@ -451,6 +851,7 @@ internal class ConsoleWindow : Window, IDisposable } break; + case ImGuiInputTextFlags.CallbackHistory: var prevPos = this.historyPos; @@ -486,38 +887,91 @@ internal class ConsoleWindow : Window, IDisposable return 0; } - private void AddAndFilter(string line, LogEvent logEvent, bool isMultiline) + /// Add a log entry to the display. + /// The line to add. + /// The Serilog event associated with this line. + /// Number of lines added to . + private int HandleLogLine(string line, LogEvent logEvent) { - if (line.StartsWith("TROUBLESHOOTING:") || line.StartsWith("LASTEXCEPTION:")) - return; + ThreadSafety.DebugAssertMainThread(); + // These lines are too huge, and only useful for troubleshooting after the game exist. + if (line.StartsWith("TROUBLESHOOTING:") || line.StartsWith("LASTEXCEPTION:")) + return 0; + + // Create a log entry template. var entry = new LogEntry { - IsMultiline = isMultiline, Level = logEvent.Level, - Line = line, TimeStamp = logEvent.Timestamp, HasException = logEvent.Exception != null, }; - if (logEvent.Properties.TryGetValue("SourceContext", out var sourceProp) && - sourceProp is ScalarValue { Value: string value }) + if (logEvent.Properties.ContainsKey("Dalamud.ModuleName")) { - entry.Source = value; + entry.Source = "DalamudInternal"; } + else if (logEvent.Properties.TryGetValue("Dalamud.PluginName", out var sourceProp) && + sourceProp is ScalarValue { Value: string sourceValue }) + { + entry.Source = sourceValue; + } + + var ssp = line.AsSpan(); + var numLines = 0; + while (true) + { + var next = ssp.IndexOfAny('\r', '\n'); + if (next == -1) + { + // Last occurrence; transfer the ownership of the new entry to the queue. + entry.Line = ssp.ToString(); + numLines += this.AddAndFilter(entry); + break; + } + + // There will be more; create a clone of the entry with the current line. + numLines += this.AddAndFilter(entry with { Line = ssp[..next].ToString() }); + + // Mark further lines as multiline. + entry.IsMultiline = true; + + // Skip the detected line break. + ssp = ssp[next..]; + ssp = ssp.StartsWith("\r\n") ? ssp[2..] : ssp[1..]; + } + + return numLines; + } + + /// Adds a line to the log list and the filtered log list accordingly. + /// The new log entry to add. + /// Number of lines added to . + private int AddAndFilter(LogEntry entry) + { + ThreadSafety.DebugAssertMainThread(); this.logText.Add(entry); - if (!this.isFiltered) - return; + if (!this.IsFilterApplicable(entry)) + return 0; - if (this.IsFilterApplicable(entry)) - this.filteredLogText.Add(entry); + this.filteredLogEntries.Add(entry); + return 1; } + /// Determines if a log entry passes the user-specified filter. + /// The entry to test. + /// true if it passes the filter. private bool IsFilterApplicable(LogEntry entry) { - if (this.levelFilter > 0 && ((this.levelFilter >> (int)entry.Level) & 1) == 0) + ThreadSafety.DebugAssertMainThread(); + + if (this.exceptionLogFilter is not null) + return false; + + // If this entry is below a newly set minimum level, fail it + if (EntryPoint.LogLevelSwitch.MinimumLevel > entry.Level) return false; // Show exceptions that weren't properly tagged with a Source (generally meaning they were uncaught) @@ -525,62 +979,194 @@ internal class ConsoleWindow : Window, IDisposable if (this.filterShowUncaughtExceptions && entry.HasException && entry.Source == null) return true; - if (this.sourceFilters.Count > 0 && !this.sourceFilters.Contains(entry.Source)) - return false; + // (global filter) && (plugin filter) must be satisfied. + var wholeCond = true; - if (!string.IsNullOrEmpty(this.textFilter) && !entry.Line.Contains(this.textFilter)) - return false; - - return true; - } - - private void Refilter() - { - lock (this.renderLock) + // If we have a global filter, check that first + if (this.compiledLogFilter is { } logFilter) { - this.filteredLogText = this.logText.Where(this.IsFilterApplicable).ToList(); + // Someone will definitely try to just text filter a source without using the actual filters, should allow that. + var matchesSource = entry.Source is not null && logFilter.IsMatch(entry.Source); + var matchesContent = logFilter.IsMatch(entry.Line); + + wholeCond &= matchesSource || matchesContent; } + + // If this entry has a filter, check the filter + if (this.pluginFilters.Count > 0) + { + var matchesAny = false; + + foreach (var filterEntry in this.pluginFilters) + { + if (!string.Equals(filterEntry.Source, entry.Source, StringComparison.InvariantCultureIgnoreCase)) + continue; + + var allowedLevel = filterEntry.Level <= entry.Level; + var matchesContent = filterEntry.FilterRegex?.IsMatch(entry.Line) is not false; + + matchesAny |= allowedLevel && matchesContent; + if (matchesAny) + break; + } + + wholeCond &= matchesAny; + } + + return wholeCond; } - private string GetTextForLogEventLevel(LogEventLevel level) => level switch - { - LogEventLevel.Error => "ERR", - LogEventLevel.Verbose => "VRB", - LogEventLevel.Debug => "DBG", - LogEventLevel.Information => "INF", - LogEventLevel.Warning => "WRN", - LogEventLevel.Fatal => "FTL", - _ => throw new ArgumentOutOfRangeException(level.ToString(), "Invalid LogEventLevel"), - }; + /// Queues clearing the window of all log entries, before next call to . + private void QueueClear() => this.pendingClearLog = true; - private uint GetColorForLogEventLevel(LogEventLevel level) => level switch - { - LogEventLevel.Error => 0x800000EE, - LogEventLevel.Verbose => 0x00000000, - LogEventLevel.Debug => 0x00000000, - LogEventLevel.Information => 0x00000000, - LogEventLevel.Warning => 0x8A0070EE, - LogEventLevel.Fatal => 0xFF00000A, - _ => throw new ArgumentOutOfRangeException(level.ToString(), "Invalid LogEventLevel"), - }; + /// Queues filtering the log entries again, before next call to . + private void QueueRefilter() => this.pendingRefilter = true; - private void OnLogLine(object sender, (string Line, LogEvent LogEvent) logEvent) + /// Enqueues the new log line to the log-to-be-processed queue. + /// See for the handler for the queued log entries. + private void OnLogLine(object sender, (string Line, LogEvent LogEvent) logEvent) => + this.newLogEntries.Enqueue(logEvent); + + private bool DrawToggleButtonWithTooltip( + string buttonId, string tooltip, FontAwesomeIcon icon, ref bool enabledState) { - this.HandleLogLine(logEvent.Line, logEvent.LogEvent); + var result = false; + + var buttonEnabled = enabledState; + if (buttonEnabled) ImGui.PushStyleColor(ImGuiCol.Button, ImGuiColors.HealerGreen with { W = 0.25f }); + if (ImGuiComponents.IconButton(buttonId, icon)) + { + result = true; + } + + if (ImGui.IsItemHovered()) ImGui.SetTooltip(tooltip); + + if (buttonEnabled) ImGui.PopStyleColor(); + + return result; } - private class LogEntry + private void OnDalamudConfigurationSaved(DalamudConfiguration dalamudConfiguration) { - public string Line { get; set; } + this.logLinesLimit = dalamudConfiguration.LogLinesLimit; + var limit = Math.Max(LogLinesMinimum, this.logLinesLimit); + this.logText.Size = limit; + this.filteredLogEntries.Size = limit; + } - public LogEventLevel Level { get; set; } + private unsafe void DrawHighlighted( + ReadOnlySpan line, + MatchCollection matches, + uint col, + uint highlightCol) + { + Span charOffsets = stackalloc int[(matches.Count * 2) + 2]; + var charOffsetsIndex = 1; + for (var j = 0; j < matches.Count; j++) + { + var g = matches[j].Groups[0]; + charOffsets[charOffsetsIndex++] = g.Index; + charOffsets[charOffsetsIndex++] = g.Index + g.Length; + } - public DateTimeOffset TimeStamp { get; set; } + charOffsets[charOffsetsIndex++] = line.Length; + + var screenPos = ImGui.GetCursorScreenPos(); + var drawList = ImGui.GetWindowDrawList().NativePtr; + var font = ImGui.GetFont(); + var size = ImGui.GetFontSize(); + var scale = size / font.FontSize; + var hotData = font.IndexedHotDataWrapped(); + var lookup = font.IndexLookupWrapped(); + var kern = (ImGui.GetIO().ConfigFlags & ImGuiConfigFlags.NoKerning) == 0; + var lastc = '\0'; + for (var i = 0; i < charOffsetsIndex - 1; i++) + { + var begin = charOffsets[i]; + var end = charOffsets[i + 1]; + if (begin == end) + continue; + + for (var j = begin; j < end; j++) + { + var currc = line[j]; + if (currc >= lookup.Length || lookup[currc] == ushort.MaxValue) + currc = (char)font.FallbackChar; + + if (kern) + screenPos.X += scale * ImGui.GetFont().GetDistanceAdjustmentForPair(lastc, currc); + font.RenderChar(drawList, size, screenPos, i % 2 == 1 ? highlightCol : col, currc); + + screenPos.X += scale * hotData[currc].AdvanceX; + lastc = currc; + } + } + + ImGui.Dummy(screenPos - ImGui.GetCursorScreenPos()); + } + + private record LogEntry + { + public string Line { get; set; } = string.Empty; + + public LogEventLevel Level { get; init; } + + public DateTimeOffset TimeStamp { get; init; } public bool IsMultiline { get; set; } + /// + /// Gets or sets the system responsible for generating this log entry. Generally will be a plugin's + /// InternalName. + /// public string? Source { get; set; } - public bool HasException { get; set; } + public bool SelectedForCopy { get; set; } + + public bool HasException { get; init; } + + public MatchCollection? HighlightMatches { get; set; } + + public string TimestampString => this.TimeStamp.ToString("HH:mm:ss.fff"); + + public override string ToString() => + this.IsMultiline + ? $"\t{this.Line}" + : $"{this.TimestampString} | {GetTextForLogEventLevel(this.Level)} | {this.Line}"; + } + + private class PluginFilterEntry + { + private string filter = string.Empty; + + public string Source { get; init; } = string.Empty; + + public string Filter + { + get => this.filter; + set + { + this.filter = value; + this.FilterRegex = null; + this.FilterException = null; + if (value == string.Empty) + return; + + try + { + this.FilterRegex = new(value, RegexOptions.IgnoreCase); + } + catch (Exception e) + { + this.FilterException = e; + } + } + } + + public LogEventLevel Level { get; set; } + + public Regex? FilterRegex { get; private set; } + + public Exception? FilterException { get; private set; } } } diff --git a/Dalamud/Interface/Internal/Windows/Data/DataKindEnum.cs b/Dalamud/Interface/Internal/Windows/Data/DataKindEnum.cs deleted file mode 100644 index d7c4eb095..000000000 --- a/Dalamud/Interface/Internal/Windows/Data/DataKindEnum.cs +++ /dev/null @@ -1,163 +0,0 @@ -// ReSharper disable InconsistentNaming // Naming is suppressed so we can replace '_' with ' ' -namespace Dalamud.Interface.Internal.Windows; - -/// -/// Enum representing a DataKind for the Data Window. -/// -internal enum DataKind -{ - /// - /// Server Opcode Display. - /// - Server_OpCode, - - /// - /// Address. - /// - Address, - - /// - /// Object Table. - /// - Object_Table, - - /// - /// Fate Table. - /// - Fate_Table, - - /// - /// SE Font Test. - /// - SE_Font_Test, - - /// - /// FontAwesome Test. - /// - FontAwesome_Test, - - /// - /// Party List. - /// - Party_List, - - /// - /// Buddy List. - /// - Buddy_List, - - /// - /// Plugin IPC Test. - /// - Plugin_IPC, - - /// - /// Player Condition. - /// - Condition, - - /// - /// Gauge. - /// - Gauge, - - /// - /// Command. - /// - Command, - - /// - /// Addon. - /// - Addon, - - /// - /// Addon Inspector. - /// - Addon_Inspector, - - /// - /// AtkArrayData Browser. - /// - AtkArrayData_Browser, - - /// - /// StartInfo. - /// - StartInfo, - - /// - /// Target. - /// - Target, - - /// - /// Toast. - /// - Toast, - - /// - /// Fly Text. - /// - FlyText, - - /// - /// ImGui. - /// - ImGui, - - /// - /// Tex. - /// - Tex, - - /// - /// KeyState. - /// - KeyState, - - /// - /// GamePad. - /// - Gamepad, - - /// - /// Configuration. - /// - Configuration, - - /// - /// Task Scheduler. - /// - TaskSched, - - /// - /// Hook. - /// - Hook, - - /// - /// Aetherytes. - /// - Aetherytes, - - /// - /// DTR Bar. - /// - Dtr_Bar, - - /// - /// UIColor. - /// - UIColor, - - /// - /// Data Share. - /// - Data_Share, - - /// - /// Network Monitor. - /// - Network_Monitor, -} diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs index 9d8dc1e93..951d3d91c 100644 --- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs @@ -1,11 +1,13 @@ -using System; -using System.Collections.Generic; using System.Linq; using System.Numerics; using Dalamud.Game.Gui; using Dalamud.Interface.Components; +using Dalamud.Interface.Internal.Windows.Data.Widgets; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; +using Dalamud.Utility; + using ImGuiNET; using Serilog; @@ -14,67 +16,72 @@ namespace Dalamud.Interface.Internal.Windows.Data; /// /// Class responsible for drawing the data/debug window. /// -internal class DataWindow : Window +internal class DataWindow : Window, IDisposable { private readonly IDataWindowWidget[] modules = { - new ServerOpcodeWidget(), - new AddressesWidget(), - new ObjectTableWidget(), - new FateTableWidget(), - new SeFontTestWidget(), - new FontAwesomeTestWidget(), - new PartyListWidget(), - new BuddyListWidget(), - new PluginIpcWidget(), - new ConditionWidget(), - new GaugeWidget(), - new CommandWidget(), - new AddonWidget(), new AddonInspectorWidget(), + new AddonLifecycleWidget(), + new AddonWidget(), + new AddressesWidget(), + new AetherytesWidget(), new AtkArrayDataBrowserWidget(), + new BuddyListWidget(), + new CommandWidget(), + new ConditionWidget(), + new ConfigurationWidget(), + new DataShareWidget(), + new DtrBarWidget(), + new FateTableWidget(), + new FlyTextWidget(), + new FontAwesomeTestWidget(), + new GameInventoryTestWidget(), + new GamePrebakedFontsTestWidget(), + new GamepadWidget(), + new GaugeWidget(), + new HookWidget(), + new IconBrowserWidget(), + new ImGuiWidget(), + new KeyStateWidget(), + new NetworkMonitorWidget(), + new ObjectTableWidget(), + new PartyListWidget(), + new PluginIpcWidget(), + new SeFontTestWidget(), + new ServicesWidget(), new StartInfoWidget(), new TargetWidget(), - new ToastWidget(), - new FlyTextWidget(), - new ImGuiWidget(), - new TexWidget(), - new KeyStateWidget(), - new GamepadWidget(), - new ConfigurationWidget(), new TaskSchedulerWidget(), - new HookWidget(), - new AetherytesWidget(), - new DtrBarWidget(), + new TexWidget(), + new ToastWidget(), new UIColorWidget(), - new DataShareWidget(), - new NetworkMonitorWidget(), }; - private readonly Dictionary dataKindNames = new(); + private readonly IOrderedEnumerable orderedModules; private bool isExcept; - private DataKind currentKind; - + private bool selectionCollapsed; + private IDataWindowWidget currentWidget; + /// /// Initializes a new instance of the class. /// public DataWindow() - : base("Dalamud Data") + : base("Dalamud Data", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) { - this.Size = new Vector2(500, 500); + this.Size = new Vector2(400, 300); this.SizeCondition = ImGuiCond.FirstUseEver; this.RespectCloseHotkey = false; - - foreach (var dataKind in Enum.GetValues()) - { - this.dataKindNames[dataKind] = dataKind.ToString().Replace("_", " "); - } + this.orderedModules = this.modules.OrderBy(module => module.DisplayName); + this.currentWidget = this.orderedModules.First(); this.Load(); } + /// + public void Dispose() => this.modules.OfType().AggregateToDisposable().Dispose(); + /// public override void OnOpen() { @@ -94,24 +101,9 @@ internal class DataWindow : Window if (string.IsNullOrEmpty(dataKind)) return; - dataKind = dataKind switch + if (this.modules.FirstOrDefault(module => module.IsWidgetCommand(dataKind)) is { } targetModule) { - "ai" => "Addon Inspector", - "at" => "Object Table", // Actor Table - "ot" => "Object Table", - "uic" => "UIColor", - _ => dataKind, - }; - - dataKind = dataKind.Replace(" ", string.Empty).ToLower(); - - var matched = Enum - .GetValues() - .FirstOrDefault(kind => Enum.GetName(kind)?.Replace("_", string.Empty).ToLower() == dataKind); - - if (matched != default) - { - this.currentKind = matched; + this.currentWidget = targetModule; } else { @@ -124,59 +116,113 @@ internal class DataWindow : Window ///
public override void Draw() { - if (ImGuiComponents.IconButton("forceReload", FontAwesomeIcon.Sync)) this.Load(); - if (ImGui.IsItemHovered()) ImGui.SetTooltip("Force Reload"); - ImGui.SameLine(); - var copy = ImGuiComponents.IconButton("copyAll", FontAwesomeIcon.ClipboardList); - if (ImGui.IsItemHovered()) ImGui.SetTooltip("Copy All"); - ImGui.SameLine(); - - ImGui.SetNextItemWidth(275.0f * ImGuiHelpers.GlobalScale); - if (ImGui.BeginCombo("Data Kind", this.dataKindNames[this.currentKind])) + // Only draw the widget contents if the selection pane is collapsed. + if (this.selectionCollapsed) { - foreach (var module in this.modules.OrderBy(module => this.dataKindNames[module.DataKind])) + this.DrawContents(); + return; + } + + if (ImGui.BeginTable("XlData_Table", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.Resizable)) + { + ImGui.TableSetupColumn("##SelectionColumn", ImGuiTableColumnFlags.WidthFixed, 200.0f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("##ContentsColumn", ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableNextColumn(); + this.DrawSelection(); + + ImGui.TableNextColumn(); + this.DrawContents(); + + ImGui.EndTable(); + } + } + + private void DrawSelection() + { + if (ImGui.BeginChild("XlData_SelectionPane", ImGui.GetContentRegionAvail())) + { + if (ImGui.BeginListBox("WidgetSelectionListbox", ImGui.GetContentRegionAvail())) { - if (ImGui.Selectable(this.dataKindNames[module.DataKind], this.currentKind == module.DataKind)) + foreach (var widget in this.orderedModules) { - this.currentKind = module.DataKind; + if (ImGui.Selectable(widget.DisplayName, this.currentWidget == widget)) + { + this.currentWidget = widget; + } + } + + ImGui.EndListBox(); + } + } + + ImGui.EndChild(); + } + + private void DrawContents() + { + if (ImGui.BeginChild("XlData_ContentsPane", ImGui.GetContentRegionAvail())) + { + if (ImGuiComponents.IconButton("collapse-expand", this.selectionCollapsed ? FontAwesomeIcon.ArrowRight : FontAwesomeIcon.ArrowLeft)) + { + this.selectionCollapsed = !this.selectionCollapsed; + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip($"{(this.selectionCollapsed ? "Expand" : "Collapse")} selection pane"); + } + + ImGui.SameLine(); + + if (ImGuiComponents.IconButton("forceReload", FontAwesomeIcon.Sync)) + { + this.Load(); + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Force Reload"); + } + + ImGui.SameLine(); + + var copy = ImGuiComponents.IconButton("copyAll", FontAwesomeIcon.ClipboardList); + + ImGuiHelpers.ScaledDummy(10.0f); + + if (ImGui.BeginChild("XlData_WidgetContents", ImGui.GetContentRegionAvail())) + { + if (copy) + ImGui.LogToClipboard(); + + try + { + if (this.currentWidget is { Ready: true }) + { + this.currentWidget.Draw(); + } + else + { + ImGui.TextUnformatted("Data not ready."); + } + + this.isExcept = false; + } + catch (Exception ex) + { + if (!this.isExcept) + { + Log.Error(ex, "Could not draw data"); + } + + this.isExcept = true; + + ImGui.TextUnformatted(ex.ToString()); } } - - ImGui.EndCombo(); - } - - ImGuiHelpers.ScaledDummy(10.0f); - ImGui.BeginChild("scrolling", Vector2.Zero, false, ImGuiWindowFlags.HorizontalScrollbar); - - if (copy) - ImGui.LogToClipboard(); - - try - { - var selectedWidget = this.modules.FirstOrDefault(dataWindowWidget => dataWindowWidget.DataKind == this.currentKind); - - if (selectedWidget is { Ready: true }) - { - selectedWidget.Draw(); - } - else - { - ImGui.TextUnformatted("Data not ready."); - } - - this.isExcept = false; - } - catch (Exception ex) - { - if (!this.isExcept) - { - Log.Error(ex, "Could not draw data"); - } - - this.isExcept = true; - - ImGui.TextUnformatted(ex.ToString()); + ImGui.EndChild(); } ImGui.EndChild(); diff --git a/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs new file mode 100644 index 000000000..5cede00cf --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; + +using Dalamud.Configuration.Internal; +using Dalamud.Game.Inventory; +using Dalamud.Game.Inventory.InventoryEventArgTypes; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Logging.Internal; + +using ImGuiNET; + +using Serilog.Events; + +namespace Dalamud.Interface.Internal.Windows.Data; + +/// +/// Tester for . +/// +internal class GameInventoryTestWidget : IDataWindowWidget +{ + private static readonly ModuleLog Log = new(nameof(GameInventoryTestWidget)); + + private GameInventoryPluginScoped? scoped; + private bool standardEnabled; + private bool rawEnabled; + + /// + public string[]? CommandShortcuts { get; init; } = { "gameinventorytest" }; + + /// + public string DisplayName { get; init; } = "GameInventory Test"; + + /// + public bool Ready { get; set; } + + /// + public void Load() => this.Ready = true; + + /// + public void Draw() + { + if (Service.Get().LogLevel > LogEventLevel.Information) + { + ImGuiHelpers.SafeTextColoredWrapped( + ImGuiColors.DalamudRed, + "Enable LogLevel=Information display to see the logs."); + } + + using var table = ImRaii.Table(this.DisplayName, 3, ImGuiTableFlags.SizingFixedFit); + if (!table.Success) + return; + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("Standard Logging"); + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.standardEnabled)) + { + if (ImGui.Button("Enable##standard-enable") && !this.standardEnabled) + { + this.scoped ??= new(); + this.scoped.InventoryChanged += ScopedOnInventoryChanged; + this.standardEnabled = true; + } + } + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(!this.standardEnabled)) + { + if (ImGui.Button("Disable##standard-disable") && this.scoped is not null && this.standardEnabled) + { + this.scoped.InventoryChanged -= ScopedOnInventoryChanged; + this.standardEnabled = false; + if (!this.rawEnabled) + { + ((IInternalDisposableService)this.scoped).DisposeService(); + this.scoped = null; + } + } + } + + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("Raw Logging"); + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.rawEnabled)) + { + if (ImGui.Button("Enable##raw-enable") && !this.rawEnabled) + { + this.scoped ??= new(); + this.scoped.InventoryChangedRaw += ScopedOnInventoryChangedRaw; + this.rawEnabled = true; + } + } + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(!this.rawEnabled)) + { + if (ImGui.Button("Disable##raw-disable") && this.scoped is not null && this.rawEnabled) + { + this.scoped.InventoryChangedRaw -= ScopedOnInventoryChangedRaw; + this.rawEnabled = false; + if (!this.standardEnabled) + { + ((IInternalDisposableService)this.scoped).DisposeService(); + this.scoped = null; + } + } + } + + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("All"); + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.standardEnabled && this.rawEnabled)) + { + if (ImGui.Button("Enable##all-enable")) + { + this.scoped ??= new(); + if (!this.standardEnabled) + this.scoped.InventoryChanged += ScopedOnInventoryChanged; + if (!this.rawEnabled) + this.scoped.InventoryChangedRaw += ScopedOnInventoryChangedRaw; + this.standardEnabled = this.rawEnabled = true; + } + } + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.scoped is null)) + { + if (ImGui.Button("Disable##all-disable")) + { + ((IInternalDisposableService)this.scoped)?.DisposeService(); + this.scoped = null; + this.standardEnabled = this.rawEnabled = false; + } + } + } + + private static void ScopedOnInventoryChangedRaw(IReadOnlyCollection events) + { + var i = 0; + foreach (var e in events) + Log.Information($"[{++i}/{events.Count}] Raw: {e}"); + } + + private static void ScopedOnInventoryChanged(IReadOnlyCollection events) + { + var i = 0; + foreach (var e in events) + { + if (e is InventoryComplexEventArgs icea) + Log.Information($"[{++i}/{events.Count}] {icea}\n\t├ {icea.SourceEvent}\n\t└ {icea.TargetEvent}"); + else + Log.Information($"[{++i}/{events.Count}] {e}"); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/IDataWindowWidget.cs b/Dalamud/Interface/Internal/Windows/Data/IDataWindowWidget.cs index ebbdfff83..78df015ed 100644 --- a/Dalamud/Interface/Internal/Windows/Data/IDataWindowWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/IDataWindowWidget.cs @@ -1,4 +1,6 @@ -namespace Dalamud.Interface.Internal.Windows; +using System.Linq; + +namespace Dalamud.Interface.Internal.Windows.Data; /// /// Class representing a date window entry. @@ -6,9 +8,14 @@ internal interface IDataWindowWidget { /// - /// Gets the Data Kind for this data window module. + /// Gets the command strings that can be used to open the data window directly to this module. /// - DataKind DataKind { get; init; } + string[]? CommandShortcuts { get; init; } + + /// + /// Gets the display name for this module. + /// + string DisplayName { get; init; } /// /// Gets or sets a value indicating whether this data window module is ready. @@ -24,4 +31,11 @@ internal interface IDataWindowWidget /// Draws this data window module. /// void Draw(); + + /// + /// Helper method to check if this widget should be activated by the input command. + /// + /// The command being run. + /// true if this module should be activated by the input command. + bool IsWidgetCommand(string command) => this.CommandShortcuts?.Any(shortcut => string.Equals(shortcut, command, StringComparison.InvariantCultureIgnoreCase)) ?? false; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonInspectorWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonInspectorWidget.cs index 977037cc5..d4bea2931 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonInspectorWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonInspectorWidget.cs @@ -1,4 +1,4 @@ -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying addon inspector. @@ -8,7 +8,10 @@ internal class AddonInspectorWidget : IDataWindowWidget private UiDebug? addonInspector; /// - public DataKind DataKind { get; init; } = DataKind.Addon_Inspector; + public string[]? CommandShortcuts { get; init; } = { "ai", "addoninspector" }; + + /// + public string DisplayName { get; init; } = "Addon Inspector"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs new file mode 100644 index 000000000..26af2a8b2 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs @@ -0,0 +1,143 @@ +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Linq; + +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Debug widget for displaying AddonLifecycle data. +/// +public class AddonLifecycleWidget : IDataWindowWidget +{ + /// + public string[]? CommandShortcuts { get; init; } = { "AddonLifecycle" }; + + /// + public string DisplayName { get; init; } = "Addon Lifecycle"; + + /// + [MemberNotNullWhen(true, "AddonLifecycle")] + public bool Ready { get; set; } + + private AddonLifecycle? AddonLifecycle { get; set; } + + /// + public void Load() + { + Service + .GetAsync() + .ContinueWith( + r => + { + this.AddonLifecycle = r.Result; + this.Ready = true; + }); + } + + /// + public void Draw() + { + if (!this.Ready) + { + ImGui.Text("AddonLifecycle Reference is null, reload module."); + return; + } + + if (ImGui.CollapsingHeader("Listeners")) + { + ImGui.Indent(); + this.DrawEventListeners(); + ImGui.Unindent(); + } + + if (ImGui.CollapsingHeader("ReceiveEvent Hooks")) + { + ImGui.Indent(); + this.DrawReceiveEventHooks(); + ImGui.Unindent(); + } + } + + private void DrawEventListeners() + { + if (!this.Ready) return; + + foreach (var eventType in Enum.GetValues()) + { + if (ImGui.CollapsingHeader(eventType.ToString())) + { + ImGui.Indent(); + var listeners = this.AddonLifecycle.EventListeners.Where(listener => listener.EventType == eventType).ToList(); + + if (!listeners.Any()) + { + ImGui.Text("No Listeners Registered for Event"); + } + + if (ImGui.BeginTable("AddonLifecycleListenersTable", 2)) + { + ImGui.TableSetupColumn("##AddonName", ImGuiTableColumnFlags.WidthFixed, 100.0f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("##MethodInvoke", ImGuiTableColumnFlags.WidthStretch); + + foreach (var listener in listeners) + { + ImGui.TableNextColumn(); + ImGui.Text(listener.AddonName is "" ? "GLOBAL" : listener.AddonName); + + ImGui.TableNextColumn(); + ImGui.Text($"{listener.FunctionDelegate.Method.DeclaringType.FullName}::{listener.FunctionDelegate.Method.Name}"); + } + + ImGui.EndTable(); + } + + ImGui.Unindent(); + } + } + } + + private void DrawReceiveEventHooks() + { + if (!this.Ready) return; + + var listeners = this.AddonLifecycle.ReceiveEventListeners; + + if (!listeners.Any()) + { + ImGui.Text("No ReceiveEvent Hooks are Registered"); + } + + foreach (var receiveEventListener in this.AddonLifecycle.ReceiveEventListeners) + { + if (ImGui.CollapsingHeader(string.Join(", ", receiveEventListener.AddonNames))) + { + ImGui.Columns(2); + + ImGui.Text("Hook Address"); + ImGui.NextColumn(); + ImGui.Text(receiveEventListener.HookAddress.ToString("X")); + + ImGui.NextColumn(); + ImGui.Text("Hook Status"); + ImGui.NextColumn(); + if (receiveEventListener.Hook is null) + { + ImGui.Text("Hook is null"); + } + else + { + var color = receiveEventListener.Hook.IsEnabled ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed; + var text = receiveEventListener.Hook.IsEnabled ? "Enabled" : "Disabled"; + ImGui.TextColored(color, text); + } + + ImGui.Columns(1); + } + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonWidget.cs index b26b7e311..1056b434e 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonWidget.cs @@ -3,7 +3,7 @@ using Dalamud.Memory; using Dalamud.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying Addon Data. @@ -15,7 +15,10 @@ internal unsafe class AddonWidget : IDataWindowWidget private nint findAgentInterfacePtr; /// - public DataKind DataKind { get; init; } = DataKind.Addon; + public string DisplayName { get; init; } = "Addon"; + + /// + public string[]? CommandShortcuts { get; init; } /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddressesWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddressesWidget.cs index 606fedadd..dfa6f173d 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddressesWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddressesWidget.cs @@ -3,7 +3,7 @@ using Dalamud.Game; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget to display resolved .text sigs. @@ -14,7 +14,10 @@ internal class AddressesWidget : IDataWindowWidget private nint sigResult = nint.Zero; /// - public DataKind DataKind { get; init; } = DataKind.Address; + public string[]? CommandShortcuts { get; init; } = { "address" }; + + /// + public string DisplayName { get; init; } = "Addresses"; /// public bool Ready { get; set; } @@ -33,7 +36,7 @@ internal class AddressesWidget : IDataWindowWidget { try { - var sigScanner = Service.Get(); + var sigScanner = Service.Get(); this.sigResult = sigScanner.ScanText(this.inputSig); } catch (KeyNotFoundException) diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AetherytesWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AetherytesWidget.cs index cc4771847..fbb945368 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/AetherytesWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AetherytesWidget.cs @@ -1,7 +1,7 @@ using Dalamud.Game.ClientState.Aetherytes; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying aetheryte table. @@ -9,10 +9,13 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class AetherytesWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.Aetherytes; + public bool Ready { get; set; } /// - public bool Ready { get; set; } + public string[]? CommandShortcuts { get; init; } = { "aetherytes" }; + + /// + public string DisplayName { get; init; } = "Aetherytes"; /// public void Load() diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs index df98f99a6..4da2011a6 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AtkArrayDataBrowserWidget.cs @@ -1,10 +1,9 @@ -using System; -using System.Numerics; +using System.Numerics; using Dalamud.Memory; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying AtkArrayData. @@ -12,10 +11,13 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal unsafe class AtkArrayDataBrowserWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.AtkArrayData_Browser; + public bool Ready { get; set; } /// - public bool Ready { get; set; } + public string[]? CommandShortcuts { get; init; } = { "atkarray" }; + + /// + public string DisplayName { get; init; } = "Atk Array Data"; /// public void Load() diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/BuddyListWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/BuddyListWidget.cs index 2aeb9d10d..c35280f92 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/BuddyListWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/BuddyListWidget.cs @@ -2,7 +2,7 @@ using Dalamud.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying data about the Buddy List. @@ -12,10 +12,13 @@ internal class BuddyListWidget : IDataWindowWidget private bool resolveGameData; /// - public DataKind DataKind { get; init; } = DataKind.Buddy_List; + public bool Ready { get; set; } /// - public bool Ready { get; set; } + public string[]? CommandShortcuts { get; init; } = { "buddy", "buddylist" }; + + /// + public string DisplayName { get; init; } = "Buddy List"; /// public void Load() diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs index e415431ba..c4c74274a 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs @@ -1,7 +1,11 @@ -using Dalamud.Game.Command; +using System.Linq; + +using Dalamud.Game.Command; +using Dalamud.Interface.Utility.Raii; + using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying command info. @@ -9,7 +13,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class CommandWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.Command; + public string[]? CommandShortcuts { get; init; } = { "command" }; + + /// + public string DisplayName { get; init; } = "Command"; /// public bool Ready { get; set; } @@ -25,9 +32,52 @@ internal class CommandWidget : IDataWindowWidget { var commandManager = Service.Get(); - foreach (var command in commandManager.Commands) + var tableFlags = ImGuiTableFlags.ScrollY | ImGuiTableFlags.Borders | ImGuiTableFlags.SizingStretchProp | + ImGuiTableFlags.Sortable | ImGuiTableFlags.SortTristate; + using var table = ImRaii.Table("CommandList", 4, tableFlags); + if (table) { - ImGui.Text($"{command.Key}\n -> {command.Value.HelpMessage}\n -> In help: {command.Value.ShowInHelp}\n\n"); + ImGui.TableSetupScrollFreeze(0, 1); + + ImGui.TableSetupColumn("Command"); + ImGui.TableSetupColumn("Plugin"); + ImGui.TableSetupColumn("HelpMessage", ImGuiTableColumnFlags.NoSort); + ImGui.TableSetupColumn("In Help?", ImGuiTableColumnFlags.NoSort); + ImGui.TableHeadersRow(); + + var sortSpecs = ImGui.TableGetSortSpecs(); + var commands = commandManager.Commands.ToArray(); + + if (sortSpecs.SpecsCount != 0) + { + commands = sortSpecs.Specs.ColumnIndex switch + { + 0 => sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending + ? commands.OrderBy(kv => kv.Key).ToArray() + : commands.OrderByDescending(kv => kv.Key).ToArray(), + 1 => sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending + ? commands.OrderBy(kv => kv.Value.LoaderAssemblyName).ToArray() + : commands.OrderByDescending(kv => kv.Value.LoaderAssemblyName).ToArray(), + _ => commands, + }; + } + + foreach (var command in commands) + { + ImGui.TableNextRow(); + + ImGui.TableSetColumnIndex(0); + ImGui.Text(command.Key); + + ImGui.TableNextColumn(); + ImGui.Text(command.Value.LoaderAssemblyName); + + ImGui.TableNextColumn(); + ImGui.TextWrapped(command.Value.HelpMessage); + + ImGui.TableNextColumn(); + ImGui.Text(command.Value.ShowInHelp ? "Yes" : "No"); + } } } } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ConditionWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ConditionWidget.cs index a5224589f..7725df5bf 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ConditionWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ConditionWidget.cs @@ -1,7 +1,7 @@ using Dalamud.Game.ClientState.Conditions; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying current character condition flags. @@ -9,10 +9,13 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class ConditionWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.Condition; + public bool Ready { get; set; } /// - public bool Ready { get; set; } + public string[]? CommandShortcuts { get; init; } = { "condition" }; + + /// + public string DisplayName { get; init; } = "Condition"; /// public void Load() diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ConfigurationWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ConfigurationWidget.cs index 3922f22b7..f66b50fca 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ConfigurationWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ConfigurationWidget.cs @@ -1,7 +1,7 @@ using Dalamud.Configuration.Internal; using Dalamud.Utility; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying configuration info. @@ -9,7 +9,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class ConfigurationWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.Configuration; + public string[]? CommandShortcuts { get; init; } = { "config", "configuration" }; + + /// + public string DisplayName { get; init; } = "Configuration"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs index ec7124042..346255dfe 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs @@ -1,15 +1,45 @@ -using Dalamud.Plugin.Ipc.Internal; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using System.Reflection; +using System.Text; + +using Dalamud.Interface.ImGuiNotification.Internal; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Ipc.Internal; + using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +using Newtonsoft.Json; + +using Formatting = Newtonsoft.Json.Formatting; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying plugin data share modules. /// +[SuppressMessage( + "StyleCop.CSharp.LayoutRules", + "SA1519:Braces should not be omitted from multi-line child statement", + Justification = "Multiple fixed blocks")] internal class DataShareWidget : IDataWindowWidget { + private const ImGuiTabItemFlags NoCloseButton = (ImGuiTabItemFlags)(1 << 20); + + private readonly List<(string Name, byte[]? Data)> dataView = new(); + private int nextTab = -1; + private IReadOnlyDictionary? gates; + private List? gatesSorted; + /// - public DataKind DataKind { get; init; } = DataKind.Data_Share; + public string[]? CommandShortcuts { get; init; } = { "datashare" }; + + /// + public string DisplayName { get; init; } = "Data Share & Call Gate"; /// public bool Ready { get; set; } @@ -21,28 +51,290 @@ internal class DataShareWidget : IDataWindowWidget } /// - public void Draw() + public unsafe void Draw() { - if (!ImGui.BeginTable("###DataShareTable", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg)) + using var tabbar = ImRaii.TabBar("##tabbar"); + if (!tabbar.Success) + return; + + var d = true; + using (var tabitem = ImRaii.TabItem( + "Data Share##tabbar-datashare", + ref d, + NoCloseButton | (this.nextTab == 0 ? ImGuiTabItemFlags.SetSelected : 0))) + { + if (tabitem.Success) + this.DrawDataShare(); + } + + using (var tabitem = ImRaii.TabItem( + "Call Gate##tabbar-callgate", + ref d, + NoCloseButton | (this.nextTab == 1 ? ImGuiTabItemFlags.SetSelected : 0))) + { + if (tabitem.Success) + this.DrawCallGate(); + } + + for (var i = 0; i < this.dataView.Count; i++) + { + using var idpush = ImRaii.PushId($"##tabbar-data-{i}"); + var (name, data) = this.dataView[i]; + d = true; + using var tabitem = ImRaii.TabItem( + name, + ref d, + this.nextTab == 2 + i ? ImGuiTabItemFlags.SetSelected : 0); + if (!d) + this.dataView.RemoveAt(i--); + if (!tabitem.Success) + continue; + + if (ImGui.Button("Refresh")) + data = null; + + if (data is null) + { + try + { + var dataShare = Service.Get(); + var data2 = dataShare.GetData(name); + try + { + data = Encoding.UTF8.GetBytes( + JsonConvert.SerializeObject( + data2, + Formatting.Indented, + new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All })); + } + finally + { + dataShare.RelinquishData(name); + } + } + catch (Exception e) + { + data = Encoding.UTF8.GetBytes(e.ToString()); + } + + this.dataView[i] = (name, data); + } + + ImGui.SameLine(); + if (ImGui.Button("Copy")) + { + fixed (byte* pData = data) + ImGuiNative.igSetClipboardText(pData); + } + + fixed (byte* pLabel = "text"u8) + fixed (byte* pData = data) + { + ImGuiNative.igInputTextMultiline( + pLabel, + pData, + (uint)data.Length, + ImGui.GetContentRegionAvail(), + ImGuiInputTextFlags.ReadOnly, + null, + null); + } + } + + this.nextTab = -1; + } + + private static string ReprMethod(MethodInfo? mi, bool withParams) + { + if (mi is null) + return "-"; + + var sb = new StringBuilder(); + sb.Append(ReprType(mi.DeclaringType)) + .Append("::") + .Append(mi.Name); + if (!withParams) + return sb.ToString(); + sb.Append('('); + var parfirst = true; + foreach (var par in mi.GetParameters()) + { + if (!parfirst) + sb.Append(", "); + else + parfirst = false; + sb.AppendLine() + .Append('\t') + .Append(ReprType(par.ParameterType)) + .Append(' ') + .Append(par.Name); + } + + if (!parfirst) + sb.AppendLine(); + sb.Append(')'); + if (mi.ReturnType != typeof(void)) + sb.Append(" -> ").Append(ReprType(mi.ReturnType)); + return sb.ToString(); + + static string WithoutGeneric(string s) + { + var i = s.IndexOf('`'); + return i != -1 ? s[..i] : s; + } + + static string ReprType(Type? t) => + t switch + { + null => "null", + _ when t == typeof(string) => "string", + _ when t == typeof(object) => "object", + _ when t == typeof(void) => "void", + _ when t == typeof(decimal) => "decimal", + _ when t == typeof(bool) => "bool", + _ when t == typeof(double) => "double", + _ when t == typeof(float) => "float", + _ when t == typeof(char) => "char", + _ when t == typeof(ulong) => "ulong", + _ when t == typeof(long) => "long", + _ when t == typeof(uint) => "uint", + _ when t == typeof(int) => "int", + _ when t == typeof(ushort) => "ushort", + _ when t == typeof(short) => "short", + _ when t == typeof(byte) => "byte", + _ when t == typeof(sbyte) => "sbyte", + _ when t == typeof(nint) => "nint", + _ when t == typeof(nuint) => "nuint", + _ when t.IsArray && t.HasElementType => ReprType(t.GetElementType()) + "[]", + _ when t.IsPointer && t.HasElementType => ReprType(t.GetElementType()) + "*", + _ when t.IsGenericTypeDefinition => + t.Assembly == typeof(object).Assembly + ? t.Name + "<>" + : (t.FullName ?? t.Name) + "<>", + _ when t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>) => + ReprType(t.GetGenericArguments()[0]) + "?", + _ when t.IsGenericType => + WithoutGeneric(ReprType(t.GetGenericTypeDefinition())) + + "<" + string.Join(", ", t.GetGenericArguments().Select(ReprType)) + ">", + _ => t.Assembly == typeof(object).Assembly ? t.Name : t.FullName ?? t.Name, + }; + } + + private void DrawTextCell(string s, Func? tooltip = null, bool framepad = false) + { + ImGui.TableNextColumn(); + var offset = ImGui.GetCursorScreenPos() + new Vector2(0, framepad ? ImGui.GetStyle().FramePadding.Y : 0); + if (framepad) + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(s); + if (ImGui.IsItemHovered()) + { + ImGui.SetNextWindowPos(offset - ImGui.GetStyle().WindowPadding); + var vp = ImGui.GetWindowViewport(); + var wrx = (vp.WorkPos.X + vp.WorkSize.X) - offset.X; + ImGui.SetNextWindowSizeConstraints(Vector2.One, new(wrx, float.MaxValue)); + using (ImRaii.Tooltip()) + { + ImGui.PushTextWrapPos(wrx); + ImGui.TextWrapped((tooltip?.Invoke() ?? s).Replace("%", "%%")); + ImGui.PopTextWrapPos(); + } + } + + if (ImGui.IsItemClicked()) + { + ImGui.SetClipboardText(tooltip?.Invoke() ?? s); + Service.Get().AddNotification( + $"Copied {ImGui.TableGetColumnName()} to clipboard.", + this.DisplayName, + NotificationType.Success); + } + } + + private void DrawCallGate() + { + var callGate = Service.Get(); + if (ImGui.Button("Purge empty call gates")) + callGate.PurgeEmptyGates(); + + using var table = ImRaii.Table("##callgate-table", 5); + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.DefaultSort); + ImGui.TableSetupColumn("Action"); + ImGui.TableSetupColumn("Func"); + ImGui.TableSetupColumn("#", ImGuiTableColumnFlags.WidthFixed, 30 * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Subscriber"); + ImGui.TableHeadersRow(); + + var gates2 = callGate.Gates; + if (!ReferenceEquals(gates2, this.gates) || this.gatesSorted is null) + { + this.gatesSorted = (this.gates = gates2).Values.ToList(); + this.gatesSorted.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + } + + foreach (var item in this.gatesSorted) + { + var subs = item.Subscriptions; + for (var i = 0; i < subs.Count || i == 0; i++) + { + ImGui.TableNextRow(); + this.DrawTextCell(item.Name); + this.DrawTextCell( + ReprMethod(item.Action?.Method, false), + () => ReprMethod(item.Action?.Method, true)); + this.DrawTextCell( + ReprMethod(item.Func?.Method, false), + () => ReprMethod(item.Func?.Method, true)); + if (subs.Count == 0) + { + this.DrawTextCell("0"); + continue; + } + + this.DrawTextCell($"{i + 1}/{subs.Count}"); + this.DrawTextCell($"{subs[i].Method.DeclaringType}::{subs[i].Method.Name}"); + } + } + } + + private void DrawDataShare() + { + if (!ImGui.BeginTable("###DataShareTable", 5, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg)) return; try { ImGui.TableSetupColumn("Shared Tag"); + ImGui.TableSetupColumn("Show"); ImGui.TableSetupColumn("Creator Assembly"); ImGui.TableSetupColumn("#", ImGuiTableColumnFlags.WidthFixed, 30 * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("Consumers"); ImGui.TableHeadersRow(); foreach (var share in Service.Get().GetAllShares()) { + ImGui.TableNextRow(); + this.DrawTextCell(share.Tag, null, true); + ImGui.TableNextColumn(); - ImGui.TextUnformatted(share.Tag); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(share.CreatorAssembly); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(share.Users.Length.ToString()); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(string.Join(", ", share.Users)); + if (ImGui.Button($"Show##datasharetable-show-{share.Tag}")) + { + var index = 0; + for (; index < this.dataView.Count; index++) + { + if (this.dataView[index].Name == share.Tag) + break; + } + + if (index == this.dataView.Count) + this.dataView.Add((share.Tag, null)); + else + this.dataView[index] = (share.Tag, null); + this.nextTab = 2 + index; + } + + this.DrawTextCell(share.CreatorAssembly, null, true); + this.DrawTextCell(share.Users.Length.ToString(), null, true); + this.DrawTextCell(string.Join(", ", share.Users), null, true); } } finally diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/DtrBarWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/DtrBarWidget.cs index 6d3a67e1a..cc4e97779 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/DtrBarWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/DtrBarWidget.cs @@ -2,7 +2,7 @@ using Dalamud.Game.Gui.Dtr; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying dtr test. @@ -14,7 +14,10 @@ internal class DtrBarWidget : IDataWindowWidget private DtrBarEntry? dtrTest3; /// - public DataKind DataKind { get; init; } = DataKind.Dtr_Bar; + public string[]? CommandShortcuts { get; init; } = { "dtr", "dtrbar" }; + + /// + public string DisplayName { get; init; } = "DTR Bar"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs index 779032f1d..de9af9aa2 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/FateTableWidget.cs @@ -1,7 +1,7 @@ using Dalamud.Game.ClientState.Fates; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying the Fate Table. @@ -11,7 +11,10 @@ internal class FateTableWidget : IDataWindowWidget private bool resolveGameData; /// - public DataKind DataKind { get; init; } = DataKind.Fate_Table; + public string[]? CommandShortcuts { get; init; } = { "fate", "fatetable" }; + + /// + public string DisplayName { get; init; } = "Fate Table"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/FlyTextWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/FlyTextWidget.cs index 99c1a3e02..ddbf61342 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/FlyTextWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/FlyTextWidget.cs @@ -1,10 +1,9 @@ -using System; -using System.Numerics; +using System.Numerics; using Dalamud.Game.Gui.FlyText; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying fly text info. @@ -22,7 +21,10 @@ internal class FlyTextWidget : IDataWindowWidget private Vector4 flyColor = new(1, 0, 0, 1); /// - public DataKind DataKind { get; init; } = DataKind.FlyText; + public string[]? CommandShortcuts { get; init; } = { "flytext" }; + + /// + public string DisplayName { get; init; } = "Fly Text"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/FontAwesomeTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/FontAwesomeTestWidget.cs index 1ed5e9e83..22f615e8a 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/FontAwesomeTestWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/FontAwesomeTestWidget.cs @@ -1,11 +1,11 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Numerics; +using Dalamud.Interface.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget to display FontAwesome Symbols. @@ -20,7 +20,10 @@ internal class FontAwesomeTestWidget : IDataWindowWidget private bool iconSearchChanged = true; /// - public DataKind DataKind { get; init; } = DataKind.FontAwesome_Test; + public string[]? CommandShortcuts { get; init; } = { "fa", "fatest", "fontawesome" }; + + /// + public string DisplayName { get; init; } = "Font Awesome Test"; /// public bool Ready { get; set; } @@ -35,12 +38,30 @@ internal class FontAwesomeTestWidget : IDataWindowWidget public void Draw() { ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - - this.iconCategories ??= FontAwesomeHelpers.GetCategories(); + + this.iconCategories ??= new[] { "(Show All)", "(Undefined)" } + .Concat(FontAwesomeHelpers.GetCategories().Skip(1)) + .ToArray(); if (this.iconSearchChanged) { - this.icons = FontAwesomeHelpers.SearchIcons(this.iconSearchInput, this.iconCategories[this.selectedIconCategory]); + if (this.iconSearchInput == string.Empty && this.selectedIconCategory <= 1) + { + var en = InterfaceManager.IconFont.GlyphsWrapped() + .Select(x => (FontAwesomeIcon)x.Codepoint) + .Where(x => (ushort)x is >= 0xE000 and < 0xF000); + en = this.selectedIconCategory == 0 + ? en.Concat(FontAwesomeHelpers.SearchIcons(string.Empty, string.Empty)) + : en.Except(FontAwesomeHelpers.SearchIcons(string.Empty, string.Empty)); + this.icons = en.Distinct().Order().ToList(); + } + else + { + this.icons = FontAwesomeHelpers.SearchIcons( + this.iconSearchInput, + this.selectedIconCategory <= 1 ? string.Empty : this.iconCategories[this.selectedIconCategory]); + } + this.iconNames = this.icons.Select(icon => Enum.GetName(icon)!).ToList(); this.iconSearchChanged = false; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs new file mode 100644 index 000000000..469ef3dc3 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs @@ -0,0 +1,392 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; + +using Dalamud.Game; +using Dalamud.Interface.FontIdentifier; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ImGuiFontChooserDialog; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +using Serilog; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Widget for testing game prebaked fonts. +/// +internal class GamePrebakedFontsTestWidget : IDataWindowWidget, IDisposable +{ + private static readonly string[] FontScaleModes = + { + nameof(FontScaleMode.Default), + nameof(FontScaleMode.SkipHandling), + nameof(FontScaleMode.UndoGlobalScale), + }; + + private ImVectorWrapper testStringBuffer; + private IFontAtlas? privateAtlas; + private SingleFontSpec fontSpec = new() { FontId = DalamudDefaultFontAndFamilyId.Instance }; + private IFontHandle? fontDialogHandle; + private IReadOnlyDictionary Handle)[]>? fontHandles; + private bool atlasScaleMode = true; + private int fontScaleMode = (int)FontScaleMode.UndoGlobalScale; + private bool useWordWrap; + private bool useItalic; + private bool useBold; + private bool useMinimumBuild; + + private SingleFontChooserDialog? chooserDialog; + + /// + public string[]? CommandShortcuts { get; init; } + + /// + public string DisplayName { get; init; } = "Game Prebaked Fonts"; + + /// + public bool Ready { get; set; } + + /// + public void Load() => this.Ready = true; + + /// + public unsafe void Draw() + { + ImGui.AlignTextToFramePadding(); + if (ImGui.Combo("Global Scale per Font", ref this.fontScaleMode, FontScaleModes, FontScaleModes.Length)) + this.ClearAtlas(); + fixed (byte* labelPtr = "Global Scale for Atlas"u8) + { + var v = (byte)(this.atlasScaleMode ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.atlasScaleMode = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Word Wrap"u8) + { + var v = (byte)(this.useWordWrap ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + this.useWordWrap = v != 0; + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Italic"u8) + { + var v = (byte)(this.useItalic ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.useItalic = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Bold"u8) + { + var v = (byte)(this.useBold ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.useBold = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Minimum Range"u8) + { + var v = (byte)(this.useMinimumBuild ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.useMinimumBuild = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + if (ImGui.Button("Reset Text") || this.testStringBuffer.IsDisposed) + { + this.testStringBuffer.Dispose(); + this.testStringBuffer = ImVectorWrapper.CreateFromSpan( + "(Game)-[Font] {Test}. 0123456789!! <氣気气きキ기>。"u8, + minCapacity: 1024); + } + + ImGui.SameLine(); + if (ImGui.Button("Test Lock")) + Task.Run(this.TestLock); + + if (ImGui.Button("Choose Editor Font")) + { + if (this.chooserDialog is null) + { + DoNext(); + } + else + { + this.chooserDialog.Cancel(); + this.chooserDialog.ResultTask.ContinueWith(_ => Service.Get().RunOnFrameworkThread(DoNext)); + this.chooserDialog = null; + } + + void DoNext() + { + var fcd = new SingleFontChooserDialog( + Service.Get(), + $"{nameof(GamePrebakedFontsTestWidget)}:EditorFont"); + this.chooserDialog = fcd; + fcd.SelectedFont = this.fontSpec; + fcd.IgnorePreviewGlobalScale = !this.atlasScaleMode; + fcd.IsModal = false; + Service.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.Get().RunOnFrameworkThread( + () => + { + Service.Get().Draw -= fcd.Draw; + fcd.Dispose(); + + _ = 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 ??= + Service.Get().CreateFontAtlas( + nameof(GamePrebakedFontsTestWidget), + FontAtlasAutoRebuildMode.Async, + this.atlasScaleMode); + this.fontDialogHandle ??= this.fontSpec.CreateFontHandle( + this.privateAtlas, + e => e.OnPreBuild(tk => tk.SetFontScaleMode(tk.Font, (FontScaleMode)this.fontScaleMode))); + + fixed (byte* labelPtr = "Test Input"u8) + { + if (!this.atlasScaleMode) + ImGuiNative.igSetWindowFontScale(1 / ImGuiHelpers.GlobalScale); + using (this.fontDialogHandle.Push()) + { + if (ImGuiNative.igInputTextMultiline( + labelPtr, + this.testStringBuffer.Data, + (uint)this.testStringBuffer.Capacity, + new(ImGui.GetContentRegionAvail().X, ImGui.GetTextLineHeight() * 3), + 0, + null, + null) != 0) + { + var len = this.testStringBuffer.StorageSpan.IndexOf((byte)0); + if (len + 4 >= this.testStringBuffer.Capacity) + this.testStringBuffer.EnsureCapacityExponential(len + 4); + if (len < this.testStringBuffer.Capacity) + { + this.testStringBuffer.LengthUnsafe = len; + this.testStringBuffer.StorageSpan[len] = default; + } + + if (this.useMinimumBuild) + _ = this.privateAtlas?.BuildFontsAsync(); + } + } + + if (!this.atlasScaleMode) + ImGuiNative.igSetWindowFontScale(1); + } + + this.fontHandles ??= + Enum.GetValues() + .Where(x => x.GetAttribute() is not null) + .Select(x => new GameFontStyle(x) { Italic = this.useItalic, Bold = this.useBold }) + .GroupBy(x => x.Family) + .ToImmutableDictionary( + x => x.Key, + x => x.Select( + y => + { + var range = Encoding.UTF8.GetString(this.testStringBuffer.DataSpan).ToGlyphRange(); + + Lazy l; + if (this.useMinimumBuild + || (this.atlasScaleMode && this.fontScaleMode != (int)FontScaleMode.Default)) + { + l = new( + () => this.privateAtlas!.NewDelegateFontHandle( + e => + e.OnPreBuild( + tk => tk.SetFontScaleMode( + tk.AddGameGlyphs(y, range, default), + (FontScaleMode)this.fontScaleMode)))); + } + else + { + l = new(() => this.privateAtlas!.NewGameFontHandle(y)); + } + + return (y, l); + }) + .ToArray()); + + var offsetX = ImGui.CalcTextSize("99.9pt").X + (ImGui.GetStyle().FramePadding.X * 2); + var counter = 0; + foreach (var (family, items) in this.fontHandles) + { + if (!ImGui.CollapsingHeader($"{family} Family")) + continue; + + foreach (var (gfs, handle) in items) + { + ImGui.TextUnformatted($"{gfs.SizePt}pt"); + ImGui.SameLine(offsetX); + ImGuiNative.igPushTextWrapPos(this.useWordWrap ? 0f : -1f); + try + { + if (handle.Value.LoadException is { } exc) + { + ImGui.TextUnformatted(exc.ToString()); + } + else if (!handle.Value.Available) + { + fixed (byte* labelPtr = "Loading..."u8) + ImGuiNative.igTextUnformatted(labelPtr, labelPtr + 8 + ((Environment.TickCount / 200) % 3)); + } + else + { + if (!this.atlasScaleMode) + ImGuiNative.igSetWindowFontScale(1 / ImGuiHelpers.GlobalScale); + if (counter++ % 2 == 0) + { + using var pushPop = handle.Value.Push(); + ImGuiNative.igTextUnformatted( + this.testStringBuffer.Data, + this.testStringBuffer.Data + this.testStringBuffer.Length); + } + else + { + handle.Value.Push(); + ImGuiNative.igTextUnformatted( + this.testStringBuffer.Data, + this.testStringBuffer.Data + this.testStringBuffer.Length); + handle.Value.Pop(); + } + } + } + finally + { + ImGuiNative.igSetWindowFontScale(1); + ImGuiNative.igPopTextWrapPos(); + } + } + } + } + + /// + public void Dispose() + { + this.ClearAtlas(); + this.testStringBuffer.Dispose(); + } + + private void ClearAtlas() + { + this.fontHandles?.Values.SelectMany(x => x.Where(y => y.Handle.IsValueCreated).Select(y => y.Handle.Value)) + .AggregateToDisposable().Dispose(); + this.fontHandles = null; + this.fontDialogHandle?.Dispose(); + this.fontDialogHandle = null; + this.privateAtlas?.Dispose(); + this.privateAtlas = null; + } + + private async void TestLock() + { + if (this.fontHandles is not { } fontHandlesCopy) + return; + + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {nameof(this.TestLock)} waiting for build"); + + await using var garbage = new DisposeSafety.ScopedFinalizer(); + var fonts = new List(); + IFontHandle[] handles; + try + { + handles = fontHandlesCopy.Values.SelectMany(x => x).Select(x => x.Handle.Value).ToArray(); + foreach (var handle in handles) + { + await handle.WaitAsync(); + var locked = handle.Lock(); + garbage.Add(locked); + fonts.Add(locked.ImFont); + } + } + catch (ObjectDisposedException) + { + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {nameof(this.TestLock)} cancelled"); + return; + } + + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {nameof(this.TestLock)} waiting in lock"); + await Task.Delay(5000); + + foreach (var (font, handle) in fonts.Zip(handles)) + TestSingle(font, handle); + + return; + + unsafe void TestSingle(ImFontPtr fontPtr, IFontHandle handle) + { + var dim = default(Vector2); + var test = "Test string"u8; + fixed (byte* pTest = test) + ImGuiNative.ImFont_CalcTextSizeA(&dim, fontPtr, fontPtr.FontSize, float.MaxValue, 0, pTest, null, null); + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {handle} => {dim}"); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/GamepadWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamepadWidget.cs index 1a4408d53..0a8a15580 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/GamepadWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamepadWidget.cs @@ -1,9 +1,7 @@ -using System; - -using Dalamud.Game.ClientState.GamePad; +using Dalamud.Game.ClientState.GamePad; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying gamepad info. @@ -11,7 +9,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class GamepadWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.Gamepad; + public string[]? CommandShortcuts { get; init; } = { "gamepad", "controller" }; + + /// + public string DisplayName { get; init; } = "Gamepad"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/GaugeWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/GaugeWidget.cs index 02862b33d..df350e730 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/GaugeWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/GaugeWidget.cs @@ -4,7 +4,7 @@ using Dalamud.Game.ClientState.JobGauge.Types; using Dalamud.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying job gauge data. @@ -12,7 +12,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class GaugeWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.Gauge; + public string[]? CommandShortcuts { get; init; } = { "gauge", "jobgauge", "job" }; + + /// + public string DisplayName { get; init; } = "Job Gauge"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/HookWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/HookWidget.cs index aa565b1e6..b24587d6c 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/HookWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/HookWidget.cs @@ -1,12 +1,11 @@ -using System; -using System.Runtime.InteropServices; +using System.Runtime.InteropServices; using Dalamud.Hooking; using ImGuiNET; using PInvoke; using Serilog; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying hook information. @@ -23,8 +22,11 @@ internal class HookWidget : IDataWindowWidget NativeFunctions.MessageBoxType type); /// - public DataKind DataKind { get; init; } = DataKind.Hook; + public string DisplayName { get; init; } = "Hook"; + /// + public string[]? CommandShortcuts { get; init; } = { "hook" }; + /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs new file mode 100644 index 000000000..06c691cc9 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs @@ -0,0 +1,217 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Numerics; + +using Dalamud.Data; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Utility; +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Data widget for browsing in-game icons. +/// +public class IconBrowserWidget : IDataWindowWidget +{ + // Remove range 170,000 -> 180,000 by default, this specific range causes exceptions. + private readonly HashSet nullValues = Enumerable.Range(170000, 9999).ToHashSet(); + + private Vector2 iconSize = new(64.0f, 64.0f); + private Vector2 editIconSize = new(64.0f, 64.0f); + + private List valueRange = Enumerable.Range(0, 200000).ToList(); + + private int lastNullValueCount; + private int startRange; + private int stopRange = 200000; + private bool showTooltipImage; + + private Vector2 mouseDragStart; + private bool dragStarted; + private Vector2 lastWindowSize = Vector2.Zero; + + /// + public string[]? CommandShortcuts { get; init; } = { "icon", "icons" }; + + /// + public string DisplayName { get; init; } = "Icon Browser"; + + /// + public bool Ready { get; set; } = true; + + /// + public void Load() + { + } + + /// + public void Draw() + { + this.DrawOptions(); + + if (ImGui.BeginChild("ScrollableSection", ImGui.GetContentRegionAvail(), false, ImGuiWindowFlags.NoMove)) + { + var itemsPerRow = (int)MathF.Floor(ImGui.GetContentRegionMax().X / (this.iconSize.X + ImGui.GetStyle().ItemSpacing.X)); + var itemHeight = this.iconSize.Y + ImGui.GetStyle().ItemSpacing.Y; + + ImGuiClip.ClippedDraw(this.valueRange, this.DrawIcon, itemsPerRow, itemHeight); + } + + ImGui.EndChild(); + + this.ProcessMouseDragging(); + + if (this.lastNullValueCount != this.nullValues.Count) + { + this.RecalculateIndexRange(); + this.lastNullValueCount = this.nullValues.Count; + } + } + + // Limit the popup image to half our screen size. + private static float GetImageScaleFactor(IDalamudTextureWrap texture) + { + var workArea = ImGui.GetMainViewport().Size / 2.0f; + var scale = 1.0f; + + if (texture.Width > workArea.X || texture.Height > workArea.Y) + { + var widthRatio = workArea.X / texture.Width; + var heightRatio = workArea.Y / texture.Height; + + scale = MathF.Min(widthRatio, heightRatio); + } + + return scale; + } + + private void DrawOptions() + { + ImGui.Columns(2); + + ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); + if (ImGui.InputInt("##StartRange", ref this.startRange, 0, 0)) this.RecalculateIndexRange(); + + ImGui.NextColumn(); + ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); + if (ImGui.InputInt("##StopRange", ref this.stopRange, 0, 0)) this.RecalculateIndexRange(); + + ImGui.NextColumn(); + ImGui.Checkbox("Show Image in Tooltip", ref this.showTooltipImage); + + ImGui.NextColumn(); + ImGui.InputFloat2("Icon Size", ref this.editIconSize); + if (ImGui.IsItemDeactivatedAfterEdit()) + { + this.iconSize = this.editIconSize; + } + + ImGui.Columns(1); + } + + private void DrawIcon(int iconId) + { + try + { + var cursor = ImGui.GetCursorScreenPos(); + + if (!this.IsIconValid(iconId)) + { + this.nullValues.Add(iconId); + return; + } + + if (Service.Get().GetIcon((uint)iconId) is { } texture) + { + ImGui.Image(texture.ImGuiHandle, this.iconSize); + + // If we have the option to show a tooltip image, draw the image, but make sure it's not too big. + if (ImGui.IsItemHovered() && this.showTooltipImage) + { + ImGui.BeginTooltip(); + + var scale = GetImageScaleFactor(texture); + + var textSize = ImGui.CalcTextSize(iconId.ToString()); + ImGui.SetCursorPosX(texture.Size.X * scale / 2.0f - textSize.X / 2.0f + ImGui.GetStyle().FramePadding.X * 2.0f); + ImGui.Text(iconId.ToString()); + + ImGui.Image(texture.ImGuiHandle, texture.Size * scale); + ImGui.EndTooltip(); + } + + // else, just draw the iconId. + else if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip(iconId.ToString()); + } + } + else + { + // This texture was null, draw nothing, and prevent from trying to show it again. + this.nullValues.Add(iconId); + } + + ImGui.GetWindowDrawList().AddRect(cursor, cursor + this.iconSize, ImGui.GetColorU32(ImGuiColors.DalamudWhite)); + } + catch (Exception) + { + // If something went wrong, prevent from trying to show this icon again. + this.nullValues.Add(iconId); + } + } + + private void ProcessMouseDragging() + { + if (ImGui.IsItemHovered() || this.dragStarted) + { + if (ImGui.GetWindowSize() == this.lastWindowSize) + { + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) && !this.dragStarted) + { + this.mouseDragStart = ImGui.GetMousePos(); + this.dragStarted = true; + } + } + else + { + this.lastWindowSize = ImGui.GetWindowSize(); + this.dragStarted = false; + } + } + + if (ImGui.IsMouseDragging(ImGuiMouseButton.Left) && this.dragStarted) + { + var delta = this.mouseDragStart - ImGui.GetMousePos(); + ImGui.GetIO().AddMouseWheelEvent(0.0f, -delta.Y / 85.0f); + this.mouseDragStart = ImGui.GetMousePos(); + } + else if (ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + this.dragStarted = false; + } + } + + // Check if the icon has a valid filepath, and exists in the game data. + private bool IsIconValid(int iconId) + { + var filePath = Service.Get().GetIconPath((uint)iconId); + return !filePath.IsNullOrEmpty() && Service.Get().FileExists(filePath); + } + + private void RecalculateIndexRange() + { + if (this.stopRange <= this.startRange || this.stopRange <= 0 || this.startRange < 0) + { + this.valueRange = new List(); + } + else + { + this.valueRange = Enumerable.Range(this.startRange, this.stopRange - this.startRange).ToList(); + this.valueRange.RemoveAll(value => this.nullValues.Contains(value)); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs index 8afce718f..086b0c1ad 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ImGuiWidget.cs @@ -1,18 +1,30 @@ -using System; +using System.Linq; +using System.Threading.Tasks; +using Dalamud.Game.Text; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Windowing; +using Dalamud.Storage.Assets; +using Dalamud.Utility; + using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying ImGui test. /// internal class ImGuiWidget : IDataWindowWidget { + private NotificationTemplate notificationTemplate; + /// - public DataKind DataKind { get; init; } = DataKind.ImGui; + public string[]? CommandShortcuts { get; init; } = { "imgui" }; + + /// + public string DisplayName { get; init; } = "ImGui"; /// public bool Ready { get; set; } @@ -21,6 +33,7 @@ internal class ImGuiWidget : IDataWindowWidget public void Load() { this.Ready = true; + this.notificationTemplate.Reset(); } /// @@ -37,38 +50,374 @@ internal class ImGuiWidget : IDataWindowWidget ImGui.Separator(); - ImGui.TextUnformatted($"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms"); + ImGui.TextUnformatted( + $"WindowSystem.TimeSinceLastAnyFocus: {WindowSystem.TimeSinceLastAnyFocus.TotalMilliseconds:0}ms"); ImGui.Separator(); - if (ImGui.Button("Add random notification")) + ImGui.Checkbox("##manualContent", ref this.notificationTemplate.ManualContent); + ImGui.SameLine(); + ImGui.InputText("Content##content", ref this.notificationTemplate.Content, 255); + + ImGui.Checkbox("##manualTitle", ref this.notificationTemplate.ManualTitle); + ImGui.SameLine(); + ImGui.InputText("Title##title", ref this.notificationTemplate.Title, 255); + + ImGui.Checkbox("##manualMinimizedText", ref this.notificationTemplate.ManualMinimizedText); + ImGui.SameLine(); + ImGui.InputText("MinimizedText##minimizedText", ref this.notificationTemplate.MinimizedText, 255); + + ImGui.Checkbox("##manualType", ref this.notificationTemplate.ManualType); + ImGui.SameLine(); + ImGui.Combo( + "Type##type", + ref this.notificationTemplate.TypeInt, + NotificationTemplate.TypeTitles, + NotificationTemplate.TypeTitles.Length); + + ImGui.Combo( + "Icon##iconCombo", + ref this.notificationTemplate.IconInt, + NotificationTemplate.IconTitles, + NotificationTemplate.IconTitles.Length); + switch (this.notificationTemplate.IconInt) { - var rand = new Random(); + case 1 or 2: + ImGui.InputText( + "Icon Text##iconText", + ref this.notificationTemplate.IconText, + 255); + break; + case 5 or 6: + ImGui.Combo( + "Asset##iconAssetCombo", + ref this.notificationTemplate.IconAssetInt, + NotificationTemplate.AssetSources, + NotificationTemplate.AssetSources.Length); + break; + case 3 or 7: + ImGui.InputText( + "Game Path##iconText", + ref this.notificationTemplate.IconText, + 255); + break; + case 4 or 8: + ImGui.InputText( + "File Path##iconText", + ref this.notificationTemplate.IconText, + 255); + break; + } - var title = rand.Next(0, 5) switch + ImGui.Combo( + "Initial Duration", + ref this.notificationTemplate.InitialDurationInt, + NotificationTemplate.InitialDurationTitles, + NotificationTemplate.InitialDurationTitles.Length); + + ImGui.Combo( + "Extension Duration", + ref this.notificationTemplate.HoverExtendDurationInt, + NotificationTemplate.HoverExtendDurationTitles, + NotificationTemplate.HoverExtendDurationTitles.Length); + + ImGui.Combo( + "Progress", + ref this.notificationTemplate.ProgressMode, + NotificationTemplate.ProgressModeTitles, + NotificationTemplate.ProgressModeTitles.Length); + + ImGui.Checkbox("Respect UI Hidden", ref this.notificationTemplate.RespectUiHidden); + + ImGui.Checkbox("Minimized", ref this.notificationTemplate.Minimized); + + ImGui.Checkbox("Show Indeterminate If No Expiry", ref this.notificationTemplate.ShowIndeterminateIfNoExpiry); + + ImGui.Checkbox("User Dismissable", ref this.notificationTemplate.UserDismissable); + + ImGui.Checkbox( + "Action Bar (always on if not user dismissable for the example)", + ref this.notificationTemplate.ActionBar); + + if (ImGui.Button("Add notification")) + { + var text = + "Bla bla bla bla bla bla bla bla bla bla bla.\nBla bla bla bla bla bla bla bla bla bla bla bla bla bla."; + + NewRandom(out var title, out var type, out var progress); + if (this.notificationTemplate.ManualTitle) + title = this.notificationTemplate.Title; + if (this.notificationTemplate.ManualContent) + text = this.notificationTemplate.Content; + if (this.notificationTemplate.ManualType) + type = (NotificationType)this.notificationTemplate.TypeInt; + + var n = notifications.AddNotification( + new() + { + Content = text, + Title = title, + MinimizedText = this.notificationTemplate.ManualMinimizedText + ? this.notificationTemplate.MinimizedText + : null, + Type = type, + ShowIndeterminateIfNoExpiry = this.notificationTemplate.ShowIndeterminateIfNoExpiry, + RespectUiHidden = this.notificationTemplate.RespectUiHidden, + Minimized = this.notificationTemplate.Minimized, + UserDismissable = this.notificationTemplate.UserDismissable, + InitialDuration = + this.notificationTemplate.InitialDurationInt == 0 + ? TimeSpan.MaxValue + : NotificationTemplate.Durations[this.notificationTemplate.InitialDurationInt], + ExtensionDurationSinceLastInterest = + this.notificationTemplate.HoverExtendDurationInt == 0 + ? TimeSpan.Zero + : NotificationTemplate.Durations[this.notificationTemplate.HoverExtendDurationInt], + Progress = this.notificationTemplate.ProgressMode switch + { + 0 => 1f, + 1 => progress, + 2 => 0f, + 3 => 0f, + 4 => -1f, + _ => 0.5f, + }, + Icon = this.notificationTemplate.IconInt switch + { + 1 => INotificationIcon.From( + (SeIconChar)(this.notificationTemplate.IconText.Length == 0 + ? 0 + : this.notificationTemplate.IconText[0])), + 2 => INotificationIcon.From( + (FontAwesomeIcon)(this.notificationTemplate.IconText.Length == 0 + ? 0 + : this.notificationTemplate.IconText[0])), + 3 => INotificationIcon.FromGame(this.notificationTemplate.IconText), + 4 => INotificationIcon.FromFile(this.notificationTemplate.IconText), + _ => null, + }, + }); + + var dam = Service.Get(); + var tm = Service.Get(); + switch (this.notificationTemplate.IconInt) { - 0 => "This is a toast", - 1 => "Truly, a toast", - 2 => "I am testing this toast", - 3 => "I hope this looks right", - 4 => "Good stuff", - 5 => "Nice", - _ => null, - }; + case 5: + n.SetIconTexture( + dam.GetDalamudTextureWrap( + Enum.Parse( + NotificationTemplate.AssetSources[this.notificationTemplate.IconAssetInt]))); + break; + case 6: + n.SetIconTexture( + dam.GetDalamudTextureWrapAsync( + Enum.Parse( + NotificationTemplate.AssetSources[this.notificationTemplate.IconAssetInt]))); + break; + case 7: + n.SetIconTexture(tm.GetTextureFromGame(this.notificationTemplate.IconText)); + break; + case 8: + n.SetIconTexture(tm.GetTextureFromFile(new(this.notificationTemplate.IconText))); + break; + } - var type = rand.Next(0, 4) switch + switch (this.notificationTemplate.ProgressMode) { - 0 => NotificationType.Error, - 1 => NotificationType.Warning, - 2 => NotificationType.Info, - 3 => NotificationType.Success, - 4 => NotificationType.None, - _ => NotificationType.None, - }; + case 2: + Task.Run( + async () => + { + for (var i = 0; i <= 10 && !n.DismissReason.HasValue; i++) + { + await Task.Delay(500); + n.Progress = i / 10f; + } + }); + break; + case 3: + Task.Run( + async () => + { + for (var i = 0; i <= 10 && !n.DismissReason.HasValue; i++) + { + await Task.Delay(500); + n.Progress = i / 10f; + } - const string text = "Bla bla bla bla bla bla bla bla bla bla bla.\nBla bla bla bla bla bla bla bla bla bla bla bla bla bla."; + n.ExtendBy(NotificationConstants.DefaultDuration); + n.InitialDuration = NotificationConstants.DefaultDuration; + }); + break; + } - notifications.AddNotification(text, title, type); + if (this.notificationTemplate.ActionBar || !this.notificationTemplate.UserDismissable) + { + var nclick = 0; + var testString = "input"; + + n.Click += _ => nclick++; + n.DrawActions += an => + { + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"{nclick}"); + + ImGui.SameLine(); + if (ImGui.Button("Update")) + { + NewRandom(out title, out type, out progress); + an.Notification.Title = title; + an.Notification.Type = type; + an.Notification.Progress = progress; + } + + ImGui.SameLine(); + if (ImGui.Button("Dismiss")) + an.Notification.DismissNow(); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(an.MaxCoord.X - ImGui.GetCursorPosX()); + ImGui.InputText("##input", ref testString, 255); + }; + } + } + } + + private static void NewRandom(out string? title, out NotificationType type, out float progress) + { + var rand = new Random(); + + title = rand.Next(0, 7) switch + { + 0 => "This is a toast", + 1 => "Truly, a toast", + 2 => "I am testing this toast", + 3 => "I hope this looks right", + 4 => "Good stuff", + 5 => "Nice", + _ => null, + }; + + type = rand.Next(0, 5) switch + { + 0 => NotificationType.Error, + 1 => NotificationType.Warning, + 2 => NotificationType.Info, + 3 => NotificationType.Success, + 4 => NotificationType.None, + _ => NotificationType.None, + }; + + if (rand.Next() % 2 == 0) + progress = -1; + else + progress = rand.NextSingle(); + } + + private struct NotificationTemplate + { + public static readonly string[] IconTitles = + { + "None (use Type)", + "SeIconChar", + "FontAwesomeIcon", + "GamePath", + "FilePath", + "TextureWrap from DalamudAssets", + "TextureWrap from DalamudAssets(Async)", + "TextureWrap from GamePath", + "TextureWrap from FilePath", + }; + + public static readonly string[] AssetSources = + Enum.GetValues() + .Where(x => x.GetAttribute()?.Purpose is DalamudAssetPurpose.TextureFromPng) + .Select(Enum.GetName) + .ToArray(); + + public static readonly string[] ProgressModeTitles = + { + "Default", + "Random", + "Increasing", + "Increasing & Auto Dismiss", + "Indeterminate", + }; + + public static readonly string[] TypeTitles = + { + nameof(NotificationType.None), + nameof(NotificationType.Success), + nameof(NotificationType.Warning), + nameof(NotificationType.Error), + nameof(NotificationType.Info), + }; + + public static readonly string[] InitialDurationTitles = + { + "Infinite", + "1 seconds", + "3 seconds (default)", + "10 seconds", + }; + + public static readonly string[] HoverExtendDurationTitles = + { + "Disable", + "1 seconds", + "3 seconds (default)", + "10 seconds", + }; + + public static readonly TimeSpan[] Durations = + { + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + NotificationConstants.DefaultDuration, + TimeSpan.FromSeconds(10), + }; + + public bool ManualContent; + public string Content; + public bool ManualTitle; + public string Title; + public bool ManualMinimizedText; + public string MinimizedText; + public int IconInt; + public string IconText; + public int IconAssetInt; + public bool ManualType; + public int TypeInt; + public int InitialDurationInt; + public int HoverExtendDurationInt; + public bool ShowIndeterminateIfNoExpiry; + public bool RespectUiHidden; + public bool Minimized; + public bool UserDismissable; + public bool ActionBar; + public int ProgressMode; + + public void Reset() + { + this.ManualContent = false; + this.Content = string.Empty; + this.ManualTitle = false; + this.Title = string.Empty; + this.ManualMinimizedText = false; + this.MinimizedText = string.Empty; + this.IconInt = 0; + this.IconText = "ui/icon/000000/000004_hr1.tex"; + this.IconAssetInt = 0; + this.ManualType = false; + this.TypeInt = (int)NotificationType.None; + this.InitialDurationInt = 2; + this.HoverExtendDurationInt = 2; + this.ShowIndeterminateIfNoExpiry = true; + this.Minimized = true; + this.UserDismissable = true; + this.ActionBar = true; + this.ProgressMode = 0; + this.RespectUiHidden = true; } } } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/KeyStateWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/KeyStateWidget.cs index accc48b4b..14fb7a5f2 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/KeyStateWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/KeyStateWidget.cs @@ -2,7 +2,7 @@ using Dalamud.Interface.Colors; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying keyboard state. @@ -10,7 +10,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class KeyStateWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.KeyState; + public string[]? CommandShortcuts { get; init; } = { "keystate" }; + + /// + public string DisplayName { get; init; } = "KeyState"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/NetworkMonitorWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/NetworkMonitorWidget.cs index 01d0b1759..eab1ab781 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/NetworkMonitorWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/NetworkMonitorWidget.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -7,11 +6,12 @@ using System.Text.RegularExpressions; using Dalamud.Data; using Dalamud.Game.Network; -using Dalamud.Interface.Raii; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Memory; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget to display the current packets. @@ -30,7 +30,6 @@ internal class NetworkMonitorWidget : IDataWindowWidget } private readonly ConcurrentQueue packets = new(); - private readonly Dictionary opCodeDict = new(); private bool trackNetwork; private int trackedPackets; @@ -54,7 +53,10 @@ internal class NetworkMonitorWidget : IDataWindowWidget } /// - public DataKind DataKind { get; init; } = DataKind.Network_Monitor; + public string[]? CommandShortcuts { get; init; } = { "network", "netmon", "networkmonitor" }; + + /// + public string DisplayName { get; init; } = "Network Monitor"; /// public bool Ready { get; set; } @@ -68,9 +70,6 @@ internal class NetworkMonitorWidget : IDataWindowWidget this.filterString = string.Empty; this.packets.Clear(); this.Ready = true; - var dataManager = Service.Get(); - foreach (var (name, code) in dataManager.ClientOpCodes.Concat(dataManager.ServerOpCodes)) - this.opCodeDict.TryAdd(code, (name, this.GetSizeFromName(name))); } /// @@ -95,10 +94,15 @@ internal class NetworkMonitorWidget : IDataWindowWidget this.trackedPackets = Math.Clamp(this.trackedPackets, 1, 512); } + if (ImGui.Button("Clear Stored Packets")) + { + this.packets.Clear(); + } + this.DrawFilterInput(); this.DrawNegativeFilterInput(); - ImGuiTable.DrawTable(string.Empty, this.packets, this.DrawNetworkPacket, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg, "Direction", "Known Name", "OpCode", "Hex", "Target", "Source", "Data"); + ImGuiTable.DrawTable(string.Empty, this.packets, this.DrawNetworkPacket, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg, "Direction", "OpCode", "Hex", "Target", "Source", "Data"); } private void DrawNetworkPacket(NetworkPacketData data) @@ -106,16 +110,6 @@ internal class NetworkMonitorWidget : IDataWindowWidget ImGui.TableNextColumn(); ImGui.TextUnformatted(data.Direction.ToString()); - ImGui.TableNextColumn(); - if (this.opCodeDict.TryGetValue(data.OpCode, out var pair)) - { - ImGui.TextUnformatted(pair.Name); - } - else - { - ImGui.Dummy(new Vector2(150 * ImGuiHelpers.GlobalScale, 0)); - } - ImGui.TableNextColumn(); ImGui.TextUnformatted(data.OpCode.ToString()); @@ -209,7 +203,7 @@ internal class NetworkMonitorWidget : IDataWindowWidget } private int GetSizeFromOpCode(ushort opCode) - => this.opCodeDict.TryGetValue(opCode, out var pair) ? pair.Size : 0; + => 0; /// Add known packet-name -> packet struct size associations here to copy the byte data for such packets. > private int GetSizeFromName(string name) @@ -220,5 +214,5 @@ internal class NetworkMonitorWidget : IDataWindowWidget /// The filter should find opCodes by number (decimal and hex) and name, if existing. private string OpCodeToString(ushort opCode) - => this.opCodeDict.TryGetValue(opCode, out var pair) ? $"{opCode}\0{opCode:X}\0{pair.Name}" : $"{opCode}\0{opCode:X}"; + => $"{opCode}\0{opCode:X}"; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ObjectTableWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ObjectTableWidget.cs index dd41315f2..b34eef6c8 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ObjectTableWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ObjectTableWidget.cs @@ -1,5 +1,4 @@ -using System; -using System.Numerics; +using System.Numerics; using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Objects; @@ -7,7 +6,7 @@ using Dalamud.Game.Gui; using Dalamud.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget to display the Object Table. @@ -19,8 +18,11 @@ internal class ObjectTableWidget : IDataWindowWidget private float maxCharaDrawDistance = 20.0f; /// - public DataKind DataKind { get; init; } = DataKind.Object_Table; + public string[]? CommandShortcuts { get; init; } = { "ot", "objecttable" }; + /// + public string DisplayName { get; init; } = "Object Table"; + /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/PartyListWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/PartyListWidget.cs index c5ac1fb8f..01c0b74b3 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/PartyListWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/PartyListWidget.cs @@ -2,7 +2,7 @@ using Dalamud.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying information about the current party. @@ -12,7 +12,10 @@ internal class PartyListWidget : IDataWindowWidget private bool resolveGameData; /// - public DataKind DataKind { get; init; } = DataKind.Party_List; + public string[]? CommandShortcuts { get; init; } = { "partylist", "party" }; + + /// + public string DisplayName { get; init; } = "Party List"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/PluginIpcWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/PluginIpcWidget.cs index 9aae9bba3..8004aa474 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/PluginIpcWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/PluginIpcWidget.cs @@ -1,12 +1,10 @@ -using System; - -using Dalamud.Plugin.Ipc; +using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc.Internal; using Dalamud.Utility; using ImGuiNET; using Serilog; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for testing plugin IPC systems. @@ -19,7 +17,10 @@ internal class PluginIpcWidget : IDataWindowWidget private string callGateResponse = string.Empty; /// - public DataKind DataKind { get; init; } = DataKind.Plugin_IPC; + public string[]? CommandShortcuts { get; init; } = { "ipc" }; + + /// + public string DisplayName { get; init; } = "Plugin IPC"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/SeFontTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/SeFontTestWidget.cs index a642c439d..b59abbff1 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/SeFontTestWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/SeFontTestWidget.cs @@ -1,7 +1,7 @@ using Dalamud.Game.Text; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying test data for SE Font Symbols. @@ -9,7 +9,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class SeFontTestWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.SE_Font_Test; + public string[]? CommandShortcuts { get; init; } = { "sefont", "sefonttest" }; + + /// + public string DisplayName { get; init; } = "SeFont Test"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ServerOpcodeWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ServerOpcodeWidget.cs deleted file mode 100644 index f414e0957..000000000 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ServerOpcodeWidget.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Dalamud.Data; -using ImGuiNET; -using Newtonsoft.Json; - -namespace Dalamud.Interface.Internal.Windows.Data; - -/// -/// Widget to display the currently set server opcodes. -/// -internal class ServerOpcodeWidget : IDataWindowWidget -{ - private string? serverOpString; - - /// - public DataKind DataKind { get; init; } = DataKind.Server_OpCode; - - /// - public bool Ready { get; set; } - - /// - public void Load() - { - var dataManager = Service.Get(); - - if (dataManager.IsDataReady) - { - this.serverOpString = JsonConvert.SerializeObject(dataManager.ServerOpCodes, Formatting.Indented); - this.Ready = true; - } - } - - /// - public void Draw() - { - ImGui.TextUnformatted(this.serverOpString ?? "serverOpString not initialized"); - } -} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ServicesWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ServicesWidget.cs new file mode 100644 index 000000000..22b53cdaa --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ServicesWidget.cs @@ -0,0 +1,335 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.IoC.Internal; +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Widget for displaying start info. +/// +internal class ServicesWidget : IDataWindowWidget +{ + private readonly Dictionary nodeRects = new(); + private readonly HashSet selectedNodes = new(); + private readonly HashSet tempRelatedNodes = new(); + + private bool includeUnloadDependencies; + private List>? dependencyNodes; + + /// + public string[]? CommandShortcuts { get; init; } = { "services" }; + + /// + public string DisplayName { get; init; } = "Service Container"; + + /// + public bool Ready { get; set; } + + /// + public void Load() + { + this.Ready = true; + } + + /// + public void Draw() + { + var container = Service.Get(); + + if (ImGui.CollapsingHeader("Dependencies")) + { + if (ImGui.Button("Clear selection")) + this.selectedNodes.Clear(); + + ImGui.SameLine(); + switch (this.includeUnloadDependencies) + { + case true when ImGui.Button("Show load-time dependencies"): + this.includeUnloadDependencies = false; + this.dependencyNodes = null; + break; + case false when ImGui.Button("Show unload-time dependencies"): + this.includeUnloadDependencies = true; + this.dependencyNodes = null; + break; + } + + this.dependencyNodes ??= ServiceDependencyNode.CreateTreeByLevel(this.includeUnloadDependencies); + var cellPad = ImGui.CalcTextSize("WW"); + var margin = ImGui.CalcTextSize("W\nW\nW"); + var rowHeight = cellPad.Y * 3; + var width = ImGui.GetContentRegionAvail().X; + if (ImGui.BeginChild( + "dependency-graph", + new(width, (this.dependencyNodes.Count * (rowHeight + margin.Y)) + cellPad.Y), + false, + ImGuiWindowFlags.HorizontalScrollbar)) + { + const uint rectBaseBorderColor = 0xFFFFFFFF; + const uint rectHoverFillColor = 0xFF404040; + const uint rectHoverRelatedFillColor = 0xFF802020; + const uint rectSelectedFillColor = 0xFF20A020; + const uint rectSelectedRelatedFillColor = 0xFF204020; + const uint lineBaseColor = 0xFF808080; + const uint lineHoverColor = 0xFFFF8080; + const uint lineHoverNotColor = 0xFF404040; + const uint lineSelectedColor = 0xFF80FF00; + const uint lineInvalidColor = 0xFFFF0000; + + ServiceDependencyNode? hoveredNode = null; + + var pos = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + var mouse = ImGui.GetMousePos(); + var maxRowWidth = 0f; + + // 1. Layout + for (var level = 0; level < this.dependencyNodes.Count; level++) + { + var levelNodes = this.dependencyNodes[level]; + + var rowWidth = 0f; + foreach (var node in levelNodes) + rowWidth += ImGui.CalcTextSize(node.TypeName).X + cellPad.X + margin.X; + + var off = cellPad / 2; + if (rowWidth < width) + off.X += ImGui.GetScrollX() + ((width - rowWidth) / 2); + else if (rowWidth - ImGui.GetScrollX() < width) + off.X += width - (rowWidth - ImGui.GetScrollX()); + off.Y = (rowHeight + margin.Y) * level; + + foreach (var node in levelNodes) + { + var textSize = ImGui.CalcTextSize(node.TypeName); + var cellSize = textSize + cellPad; + + var rc = new Vector4(pos + off, pos.X + off.X + cellSize.X, pos.Y + off.Y + cellSize.Y); + this.nodeRects[node] = rc; + if (rc.X <= mouse.X && mouse.X < rc.Z && rc.Y <= mouse.Y && mouse.Y < rc.W) + { + hoveredNode = node; + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + if (this.selectedNodes.Contains(node.Type)) + this.selectedNodes.Remove(node.Type); + else + this.selectedNodes.Add(node.Type); + } + } + + off.X += cellSize.X + margin.X; + } + + maxRowWidth = Math.Max(maxRowWidth, rowWidth); + } + + // 2. Draw non-hovered lines + foreach (var levelNodes in this.dependencyNodes) + { + foreach (var node in levelNodes) + { + var rect = this.nodeRects[node]; + var point1 = new Vector2((rect.X + rect.Z) / 2, rect.Y); + + foreach (var parent in node.InvalidParents) + { + rect = this.nodeRects[parent]; + var point2 = new Vector2((rect.X + rect.Z) / 2, rect.W); + if (node == hoveredNode || parent == hoveredNode) + continue; + + dl.AddLine(point1, point2, lineInvalidColor, 2f * ImGuiHelpers.GlobalScale); + } + + foreach (var parent in node.Parents) + { + rect = this.nodeRects[parent]; + var point2 = new Vector2((rect.X + rect.Z) / 2, rect.W); + if (node == hoveredNode || parent == hoveredNode) + continue; + + var isSelected = this.selectedNodes.Contains(node.Type) || + this.selectedNodes.Contains(parent.Type); + dl.AddLine( + point1, + point2, + isSelected + ? lineSelectedColor + : hoveredNode is not null + ? lineHoverNotColor + : lineBaseColor); + } + } + } + + // 3. Draw boxes + foreach (var levelNodes in this.dependencyNodes) + { + foreach (var node in levelNodes) + { + var textSize = ImGui.CalcTextSize(node.TypeName); + var cellSize = textSize + cellPad; + + var rc = this.nodeRects[node]; + if (hoveredNode == node) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectHoverFillColor); + else if (this.selectedNodes.Contains(node.Type)) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectSelectedFillColor); + else if (node.Relatives.Any(x => this.selectedNodes.Contains(x.Type))) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectSelectedRelatedFillColor); + else if (hoveredNode?.Relatives.Select(x => x.Type).Contains(node.Type) is true) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectHoverRelatedFillColor); + + dl.AddRect(new(rc.X, rc.Y), new(rc.Z, rc.W), rectBaseBorderColor); + ImGui.SetCursorPos((new Vector2(rc.X, rc.Y) - pos) + ((cellSize - textSize) / 2)); + ImGui.TextUnformatted(node.TypeName); + } + } + + // 4. Draw hovered lines + if (hoveredNode is not null) + { + foreach (var levelNodes in this.dependencyNodes) + { + foreach (var node in levelNodes) + { + var rect = this.nodeRects[node]; + var point1 = new Vector2((rect.X + rect.Z) / 2, rect.Y); + foreach (var parent in node.Parents) + { + if (node == hoveredNode || parent == hoveredNode) + { + rect = this.nodeRects[parent]; + var point2 = new Vector2((rect.X + rect.Z) / 2, rect.W); + dl.AddLine( + point1, + point2, + lineHoverColor, + 2 * ImGuiHelpers.GlobalScale); + } + } + } + } + } + + ImGui.SetCursorPos(default); + ImGui.Dummy(new(maxRowWidth, this.dependencyNodes.Count * rowHeight)); + ImGui.EndChild(); + } + } + + if (ImGui.CollapsingHeader("Plugin-facing Services")) + { + foreach (var instance in container.Instances) + { + var hasInterface = container.InterfaceToTypeMap.Values.Any(x => x == instance.Key); + var isPublic = instance.Key.IsPublic; + + ImGui.BulletText($"{instance.Key.FullName} ({instance.Key.GetServiceKind()})"); + + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed, !hasInterface)) + { + ImGui.Text( + hasInterface + ? $"\t => Provided via interface: {container.InterfaceToTypeMap.First(x => x.Value == instance.Key).Key.FullName}" + : "\t => NO INTERFACE!!!"); + } + + if (isPublic) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + ImGui.Text("\t => PUBLIC!!!"); + } + + ImGuiHelpers.ScaledDummy(2); + } + } + } + + private class ServiceDependencyNode + { + private readonly List parents = new(); + private readonly List children = new(); + private readonly List invalidParents = new(); + + private ServiceDependencyNode(Type t) => this.Type = t; + + public Type Type { get; } + + public string TypeName => this.Type.Name; + + public IReadOnlyList Parents => this.parents; + + public IReadOnlyList Children => this.children; + + public IReadOnlyList InvalidParents => this.invalidParents; + + public IEnumerable Relatives => + this.parents.Concat(this.children).Concat(this.invalidParents); + + public int Level { get; private set; } + + public static List CreateTree(bool includeUnloadDependencies) + { + var nodes = new Dictionary(); + foreach (var t in ServiceManager.GetConcreteServiceTypes()) + nodes.Add(typeof(Service<>).MakeGenericType(t), new(t)); + foreach (var t in ServiceManager.GetConcreteServiceTypes()) + { + var st = typeof(Service<>).MakeGenericType(t); + var node = nodes[st]; + foreach (var depType in ServiceHelpers.GetDependencies(st, includeUnloadDependencies)) + { + var depServiceType = typeof(Service<>).MakeGenericType(depType); + var depNode = nodes[depServiceType]; + if (node.IsAncestorOf(depType)) + { + node.invalidParents.Add(depNode); + } + else + { + depNode.UpdateNodeLevel(1); + node.UpdateNodeLevel(depNode.Level + 1); + node.parents.Add(depNode); + depNode.children.Add(node); + } + } + } + + return nodes.Values.OrderBy(x => x.Level).ThenBy(x => x.Type.Name).ToList(); + } + + public static List> CreateTreeByLevel(bool includeUnloadDependencies) + { + var res = new List>(); + foreach (var n in CreateTree(includeUnloadDependencies)) + { + while (res.Count <= n.Level) + res.Add(new()); + res[n.Level].Add(n); + } + + return res; + } + + private bool IsAncestorOf(Type type) => + this.children.Any(x => x.Type == type) || this.children.Any(x => x.IsAncestorOf(type)); + + private void UpdateNodeLevel(int newLevel) + { + if (this.Level >= newLevel) + return; + + this.Level = newLevel; + foreach (var c in this.children) + c.UpdateNodeLevel(newLevel + 1); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/StartInfoWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/StartInfoWidget.cs index 656efe388..4dee316c5 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/StartInfoWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/StartInfoWidget.cs @@ -1,7 +1,7 @@ using ImGuiNET; using Newtonsoft.Json; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying start info. @@ -9,7 +9,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class StartInfoWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.StartInfo; + public string[]? CommandShortcuts { get; init; } = { "startinfo" }; + + /// + public string DisplayName { get; init; } = "Start Info"; /// public bool Ready { get; set; } @@ -23,7 +26,7 @@ internal class StartInfoWidget : IDataWindowWidget /// public void Draw() { - var startInfo = Service.Get(); + var startInfo = Service.Get().StartInfo; ImGui.Text(JsonConvert.SerializeObject(startInfo, Formatting.Indented)); } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/TargetWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/TargetWidget.cs index 57fd03300..68e00799d 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/TargetWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/TargetWidget.cs @@ -1,9 +1,10 @@ using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Objects; +using Dalamud.Interface.Utility; using Dalamud.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying target info. @@ -13,7 +14,10 @@ internal class TargetWidget : IDataWindowWidget private bool resolveGameData; /// - public DataKind DataKind { get; init; } = DataKind.Target; + public string[]? CommandShortcuts { get; init; } = { "target" }; + + /// + public string DisplayName { get; init; } = "Target"; /// public bool Ready { get; set; } @@ -63,6 +67,12 @@ internal class TargetWidget : IDataWindowWidget if (targetMgr.SoftTarget != null) Util.PrintGameObject(targetMgr.SoftTarget, "SoftTarget", this.resolveGameData); + + if (targetMgr.GPoseTarget != null) + Util.PrintGameObject(targetMgr.GPoseTarget, "GPoseTarget", this.resolveGameData); + + if (targetMgr.MouseOverNameplateTarget != null) + Util.PrintGameObject(targetMgr.MouseOverNameplateTarget, "MouseOverNameplateTarget", this.resolveGameData); if (ImGui.Button("Clear CT")) targetMgr.Target = null; diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs index 7d91cd154..f4086fe5a 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs @@ -1,26 +1,45 @@ // ReSharper disable MethodSupportsCancellation // Using alternative method of cancelling tasks by throwing exceptions. -using System; + +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; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying task scheduler test. /// 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(); /// - public DataKind DataKind { get; init; } = DataKind.TaskSched; + public string[]? CommandShortcuts { get; init; } = { "tasksched", "taskscheduler" }; + + /// + public string DisplayName { get; init; } = "Task Scheduler"; /// public bool Ready { get; set; } @@ -29,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); } /// public void Draw() { + var framework = Service.Get(); + if (ImGui.Button("Clear list")) { TaskTracker.Clear(); @@ -80,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. }); } @@ -90,36 +113,226 @@ internal class TaskSchedulerWidget : IDataWindowWidget if (ImGui.Button("ASAP")) { - Task.Run(async () => await Service.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.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.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("Error in 1s")) + if (ImGui.Button("In 1s+120f")) { - Task.Run(async () => await Service.Get().RunOnTick(() => throw new Exception("Test Exception"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1))); + _ = 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); + } + + if (ImGui.Button("Every 60f")) + { + _ = framework.RunOnTick( + async () => + { + for (var i = 0L; ; i++) + { + Log.Information($"Loop #{i}; MainThread={ThreadSafety.IsMainThread}"); + var it = i; + _ = Task.Factory.StartNew(() => Log.Information($" => Sub #{it}; MainThread={ThreadSafety.IsMainThread}")); + await framework.DelayTicks(60, this.taskSchedulerCancelSource.Token); + } + }, + cancellationToken: this.taskSchedulerCancelSource.Token); + } + + ImGui.SameLine(); + + if (ImGui.Button("Every 1s")) + { + _ = framework.RunOnTick( + async () => + { + for (var i = 0L; ; i++) + { + Log.Information($"Loop #{i}; MainThread={ThreadSafety.IsMainThread}"); + var it = i; + _ = Task.Factory.StartNew(() => Log.Information($" => Sub #{it}; MainThread={ThreadSafety.IsMainThread}")); + await Task.Delay(TimeSpan.FromSeconds(1), this.taskSchedulerCancelSource.Token); + } + }, + cancellationToken: this.taskSchedulerCancelSource.Token); + } + + ImGui.SameLine(); + + if (ImGui.Button("Every 60f (Await)")) + { + _ = framework.Run( + async () => + { + for (var i = 0L; ; i++) + { + Log.Information($"Loop #{i}; MainThread={ThreadSafety.IsMainThread}"); + var it = i; + _ = Task.Factory.StartNew(() => Log.Information($" => Sub #{it}; MainThread={ThreadSafety.IsMainThread}")); + await framework.DelayTicks(60, this.taskSchedulerCancelSource.Token); + } + }, + this.taskSchedulerCancelSource.Token); + } + + ImGui.SameLine(); + + if (ImGui.Button("Every 1s (Await)")) + { + _ = framework.Run( + async () => + { + for (var i = 0L; ; i++) + { + Log.Information($"Loop #{i}; MainThread={ThreadSafety.IsMainThread}"); + var it = i; + _ = Task.Factory.StartNew(() => Log.Information($" => Sub #{it}; MainThread={ThreadSafety.IsMainThread}")); + await Task.Delay(TimeSpan.FromSeconds(1), this.taskSchedulerCancelSource.Token); + } + }, + this.taskSchedulerCancelSource.Token); } ImGui.SameLine(); if (ImGui.Button("As long as it's in Framework Thread")) { - Task.Run(async () => await Service.Get().RunOnFrameworkThread(() => { Log.Information("Task dispatched from non-framework.update thread"); })); - Service.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(); + } + + ImGui.SameLine(); + + if (ImGui.Button("Error in 1s")) + { + _ = framework.RunOnTick(() => throw new Exception("Test Exception"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1)); + } + + ImGui.SameLine(); + + if (ImGui.Button("Freeze 1s")) + { + _ = framework.RunOnFrameworkThread(() => Helper().Wait()); + static async Task Helper() => await Task.Delay(1000); + } + + ImGui.SameLine(); + + if (ImGui.Button("Freeze Completely")) + { + _ = framework.Run(() => Helper().Wait()); + static async Task Helper() => await Task.Delay(1000); + } + + 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.GetTaskFactory(); + 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")) @@ -240,6 +453,8 @@ internal class TaskSchedulerWidget : IDataWindowWidget ImGui.PopStyleColor(1); } + + this.fileDialogManager.Draw(); } private async Task TestTaskInTaskDelay(CancellationToken token) diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs index 5ad5868c3..8d6879ac1 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs @@ -1,21 +1,21 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Numerics; +using Dalamud.Interface.Utility; using Dalamud.Plugin.Services; using ImGuiNET; using ImGuiScene; using Serilog; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying texture test. /// internal class TexWidget : IDataWindowWidget { - private readonly List addedTextures = new(); + private readonly List addedTextures = new(); private string iconId = "18"; private bool hiRes = true; @@ -28,7 +28,10 @@ internal class TexWidget : IDataWindowWidget private Vector2 inputTexScale = Vector2.Zero; /// - public DataKind DataKind { get; init; } = DataKind.Tex; + public string[]? CommandShortcuts { get; init; } = { "tex", "texture" }; + + /// + public string DisplayName { get; init; } = "Tex"; /// public bool Ready { get; set; } @@ -101,7 +104,7 @@ internal class TexWidget : IDataWindowWidget ImGuiHelpers.ScaledDummy(10); - TextureWrap? toRemove = null; + IDalamudTextureWrap? toRemove = null; for (var i = 0; i < this.addedTextures.Count; i++) { if (ImGui.CollapsingHeader($"Tex #{i}")) @@ -116,6 +119,10 @@ internal class TexWidget : IDataWindowWidget if (ImGui.Button($"X##{i}")) toRemove = tex; + + ImGui.SameLine(); + if (ImGui.Button($"Clone##{i}")) + this.addedTextures.Add(tex.CreateWrapSharingLowLevelResource()); } } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ToastWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ToastWidget.cs index c75230e73..4bca6a839 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ToastWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ToastWidget.cs @@ -1,9 +1,10 @@ using System.Numerics; using Dalamud.Game.Gui.Toast; +using Dalamud.Interface.Utility; using ImGuiNET; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying toast test. @@ -19,7 +20,10 @@ internal class ToastWidget : IDataWindowWidget private bool questToastCheckmark; /// - public DataKind DataKind { get; init; } = DataKind.Toast; + public string[]? CommandShortcuts { get; init; } = { "toast" }; + + /// + public string DisplayName { get; init; } = "Toast"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/UIColorWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/UIColorWidget.cs index 1d0ccdce6..3308325bc 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/UIColorWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/UIColorWidget.cs @@ -4,7 +4,7 @@ using Dalamud.Data; using ImGuiNET; using Lumina.Excel.GeneratedSheets; -namespace Dalamud.Interface.Internal.Windows.Data; +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying all UI Colors from Lumina. @@ -12,7 +12,10 @@ namespace Dalamud.Interface.Internal.Windows.Data; internal class UIColorWidget : IDataWindowWidget { /// - public DataKind DataKind { get; init; } = DataKind.UIColor; + public string[]? CommandShortcuts { get; init; } = { "uicolor" }; + + /// + public string DisplayName { get; init; } = "UIColor"; /// public bool Ready { get; set; } diff --git a/Dalamud/Interface/Internal/Windows/GamepadModeNotifierWindow.cs b/Dalamud/Interface/Internal/Windows/GamepadModeNotifierWindow.cs index e95c510d3..ff5af1556 100644 --- a/Dalamud/Interface/Internal/Windows/GamepadModeNotifierWindow.cs +++ b/Dalamud/Interface/Internal/Windows/GamepadModeNotifierWindow.cs @@ -1,6 +1,7 @@ using System.Numerics; using CheapLoc; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using ImGuiNET; diff --git a/Dalamud/Interface/Internal/Windows/IMEWindow.cs b/Dalamud/Interface/Internal/Windows/IMEWindow.cs deleted file mode 100644 index 80e03caf3..000000000 --- a/Dalamud/Interface/Internal/Windows/IMEWindow.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Numerics; - -using Dalamud.Game.ClientState.Keys; -using Dalamud.Game.Gui.Internal; -using Dalamud.Interface.Windowing; -using ImGuiNET; - -namespace Dalamud.Interface.Internal.Windows; - -/// -/// A window for displaying IME details. -/// -internal unsafe class ImeWindow : Window -{ - private const int ImePageSize = 9; - - /// - /// Initializes a new instance of the class. - /// - public ImeWindow() - : base("Dalamud IME", ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoBackground) - { - this.Size = new Vector2(100, 200); - this.SizeCondition = ImGuiCond.FirstUseEver; - - this.RespectCloseHotkey = false; - } - - /// - public override void Draw() - { - if (this.IsOpen && Service.Get()[VirtualKey.SHIFT]) Service.Get().CloseImeWindow(); - var ime = Service.GetNullable(); - - if (ime == null || !ime.IsEnabled) - { - ImGui.Text("IME is unavailable."); - return; - } - - // ImGui.Text($"{ime.GetCursorPos()}"); - // ImGui.Text($"{ImGui.GetWindowViewport().WorkSize}"); - } - - /// - public override void PostDraw() - { - if (this.IsOpen && Service.Get()[VirtualKey.SHIFT]) Service.Get().CloseImeWindow(); - var ime = Service.GetNullable(); - - if (ime == null || !ime.IsEnabled) - return; - - var maxTextWidth = 0f; - var textHeight = ImGui.CalcTextSize(ime.ImmComp).Y; - - var native = ime.ImmCandNative; - var totalIndex = native.Selection + 1; - var totalSize = native.Count; - - var pageStart = native.PageStart; - var pageIndex = (pageStart / ImePageSize) + 1; - var pageCount = (totalSize / ImePageSize) + 1; - var pageInfo = $"{totalIndex}/{totalSize} ({pageIndex}/{pageCount})"; - - // Calc the window size - for (var i = 0; i < ime.ImmCand.Count; i++) - { - var textSize = ImGui.CalcTextSize($"{i + 1}. {ime.ImmCand[i]}"); - maxTextWidth = maxTextWidth > textSize.X ? maxTextWidth : textSize.X; - } - - maxTextWidth = maxTextWidth > ImGui.CalcTextSize(pageInfo).X ? maxTextWidth : ImGui.CalcTextSize(pageInfo).X; - maxTextWidth = maxTextWidth > ImGui.CalcTextSize(ime.ImmComp).X ? maxTextWidth : ImGui.CalcTextSize(ime.ImmComp).X; - - var imeWindowWidth = maxTextWidth + (2 * ImGui.GetStyle().WindowPadding.X); - var imeWindowHeight = (textHeight * (ime.ImmCand.Count + 2)) + (5 * (ime.ImmCand.Count - 1)) + (2 * ImGui.GetStyle().WindowPadding.Y); - - // Calc the window pos - var cursorPos = ime.GetCursorPos(); - var imeWindowMinPos = new Vector2(cursorPos.X, cursorPos.Y); - var imeWindowMaxPos = new Vector2(imeWindowMinPos.X + imeWindowWidth, imeWindowMinPos.Y + imeWindowHeight); - var gameWindowSize = ImGui.GetWindowViewport().WorkSize; - - var offset = new Vector2( - imeWindowMaxPos.X - gameWindowSize.X > 0 ? imeWindowMaxPos.X - gameWindowSize.X : 0, - imeWindowMaxPos.Y - gameWindowSize.Y > 0 ? imeWindowMaxPos.Y - gameWindowSize.Y : 0); - imeWindowMinPos -= offset; - imeWindowMaxPos -= offset; - - var nextDrawPosY = imeWindowMinPos.Y; - var drawAreaPosX = imeWindowMinPos.X + ImGui.GetStyle().WindowPadding.X; - - // Draw the ime window - var drawList = ImGui.GetForegroundDrawList(); - // Draw the background rect - drawList.AddRectFilled(imeWindowMinPos, imeWindowMaxPos, ImGui.GetColorU32(ImGuiCol.WindowBg), ImGui.GetStyle().WindowRounding); - // Add component text - drawList.AddText(new Vector2(drawAreaPosX, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Text), ime.ImmComp); - nextDrawPosY += textHeight + ImGui.GetStyle().ItemSpacing.Y; - // Add separator - drawList.AddLine(new Vector2(drawAreaPosX, nextDrawPosY), new Vector2(drawAreaPosX + maxTextWidth, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Separator)); - // Add candidate words - for (var i = 0; i < ime.ImmCand.Count; i++) - { - var selected = i == (native.Selection % ImePageSize); - var color = ImGui.GetColorU32(ImGuiCol.Text); - if (selected) - color = ImGui.GetColorU32(ImGuiCol.NavHighlight); - - drawList.AddText(new Vector2(drawAreaPosX, nextDrawPosY), color, $"{i + 1}. {ime.ImmCand[i]}"); - nextDrawPosY += textHeight + ImGui.GetStyle().ItemSpacing.Y; - } - - // Add separator - drawList.AddLine(new Vector2(drawAreaPosX, nextDrawPosY), new Vector2(drawAreaPosX + maxTextWidth, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Separator)); - // Add pages infomation - drawList.AddText(new Vector2(drawAreaPosX, nextDrawPosY), ImGui.GetColorU32(ImGuiCol.Text), pageInfo); - } -} diff --git a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs index 766f80b23..97744b1a7 100644 --- a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs +++ b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -12,8 +11,8 @@ using Dalamud.Networking.Http; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types.Manifest; +using Dalamud.Storage.Assets; using Dalamud.Utility; -using ImGuiScene; using Serilog; namespace Dalamud.Interface.Internal.Windows; @@ -22,7 +21,7 @@ namespace Dalamud.Interface.Internal.Windows; /// A cache for plugin icons and images. /// [ServiceManager.EarlyLoadedService] -internal class PluginImageCache : IDisposable, IServiceType +internal class PluginImageCache : IInternalDisposableService { /// /// Maximum plugin image width. @@ -47,9 +46,6 @@ internal class PluginImageCache : IDisposable, IServiceType private const string MainRepoImageUrl = "https://raw.githubusercontent.com/goatcorp/DalamudPlugins/api6/{0}/{1}/images/{2}"; private const string MainRepoDip17ImageUrl = "https://raw.githubusercontent.com/goatcorp/PluginDistD17/main/{0}/{1}/images/{2}"; - [ServiceManager.ServiceDependency] - private readonly InterfaceManager.InterfaceManagerWithScene imWithScene = Service.Get(); - [ServiceManager.ServiceDependency] private readonly HappyHttpClient happyHttpClient = Service.Get(); @@ -59,37 +55,14 @@ internal class PluginImageCache : IDisposable, IServiceType private readonly Task downloadTask; private readonly Task loadTask; - private readonly ConcurrentDictionary pluginIconMap = new(); - private readonly ConcurrentDictionary pluginImagesMap = new(); - - private readonly Task emptyTextureTask; - private readonly Task disabledIconTask; - private readonly Task outdatedInstallableIconTask; - private readonly Task defaultIconTask; - private readonly Task troubleIconTask; - private readonly Task updateIconTask; - private readonly Task installedIconTask; - private readonly Task thirdIconTask; - private readonly Task thirdInstalledIconTask; - private readonly Task corePluginIconTask; + private readonly ConcurrentDictionary pluginIconMap = new(); + private readonly ConcurrentDictionary pluginImagesMap = new(); + private readonly DalamudAssetManager dalamudAssetManager; [ServiceManager.ServiceConstructor] - private PluginImageCache(Dalamud dalamud) + private PluginImageCache(Dalamud dalamud, DalamudAssetManager dalamudAssetManager) { - Task? TaskWrapIfNonNull(TextureWrap? tw) => tw == null ? null : Task.FromResult(tw!); - var imwst = Task.Run(() => this.imWithScene); - - this.emptyTextureTask = imwst.ContinueWith(task => task.Result.Manager.LoadImageRaw(new byte[64], 8, 8, 4)!); - this.defaultIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "defaultIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.disabledIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "disabledIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.outdatedInstallableIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "outdatedInstallableIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.troubleIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "troubleIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.updateIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "updateIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.installedIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "installedIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.thirdIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "thirdIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.thirdInstalledIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "thirdInstalledIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.corePluginIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "tsmLogo.png"))) ?? this.emptyTextureTask).Unwrap(); - + this.dalamudAssetManager = dalamudAssetManager; this.downloadTask = Task.Factory.StartNew( () => this.DownloadTask(8), TaskCreationOptions.LongRunning); this.loadTask = Task.Factory.StartNew( @@ -99,75 +72,71 @@ internal class PluginImageCache : IDisposable, IServiceType /// /// Gets the fallback empty texture. /// - public TextureWrap EmptyTexture => this.emptyTextureTask.IsCompleted - ? this.emptyTextureTask.Result - : this.emptyTextureTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap EmptyTexture => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.Empty4X4); /// /// Gets the disabled plugin icon. /// - public TextureWrap DisabledIcon => this.disabledIconTask.IsCompleted - ? this.disabledIconTask.Result - : this.disabledIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap DisabledIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.DisabledIcon, this.EmptyTexture); /// /// Gets the outdated installable plugin icon. /// - public TextureWrap OutdatedInstallableIcon => this.outdatedInstallableIconTask.IsCompleted - ? this.outdatedInstallableIconTask.Result - : this.outdatedInstallableIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap OutdatedInstallableIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.OutdatedInstallableIcon, this.EmptyTexture); /// /// Gets the default plugin icon. /// - public TextureWrap DefaultIcon => this.defaultIconTask.IsCompleted - ? this.defaultIconTask.Result - : this.defaultIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap DefaultIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.DefaultIcon, this.EmptyTexture); /// /// Gets the plugin trouble icon overlay. /// - public TextureWrap TroubleIcon => this.troubleIconTask.IsCompleted - ? this.troubleIconTask.Result - : this.troubleIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap TroubleIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TroubleIcon, this.EmptyTexture); + + /// + /// Gets the devPlugin icon overlay. + /// + public IDalamudTextureWrap DevPluginIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.DevPluginIcon, this.EmptyTexture); /// /// Gets the plugin update icon overlay. /// - public TextureWrap UpdateIcon => this.updateIconTask.IsCompleted - ? this.updateIconTask.Result - : this.updateIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap UpdateIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.UpdateIcon, this.EmptyTexture); /// /// Gets the plugin installed icon overlay. /// - public TextureWrap InstalledIcon => this.installedIconTask.IsCompleted - ? this.installedIconTask.Result - : this.installedIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap InstalledIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.InstalledIcon, this.EmptyTexture); /// /// Gets the third party plugin icon overlay. /// - public TextureWrap ThirdIcon => this.thirdIconTask.IsCompleted - ? this.thirdIconTask.Result - : this.thirdIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap ThirdIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.ThirdIcon, this.EmptyTexture); /// /// Gets the installed third party plugin icon overlay. /// - public TextureWrap ThirdInstalledIcon => this.thirdInstalledIconTask.IsCompleted - ? this.thirdInstalledIconTask.Result - : this.thirdInstalledIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap ThirdInstalledIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.ThirdInstalledIcon, this.EmptyTexture); /// /// Gets the core plugin icon. /// - public TextureWrap CorePluginIcon => this.corePluginIconTask.IsCompleted - ? this.corePluginIconTask.Result - : this.corePluginIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap CorePluginIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall, this.EmptyTexture); /// - public void Dispose() + void IInternalDisposableService.DisposeService() { this.cancelToken.Cancel(); this.downloadQueue.CompleteAdding(); @@ -182,22 +151,6 @@ internal class PluginImageCache : IDisposable, IServiceType this.downloadQueue.Dispose(); this.loadQueue.Dispose(); - foreach (var task in new[] - { - this.defaultIconTask, - this.troubleIconTask, - this.updateIconTask, - this.installedIconTask, - this.thirdIconTask, - this.thirdInstalledIconTask, - this.corePluginIconTask, - }) - { - task.Wait(); - if (task.IsCompletedSuccessfully) - task.Result.Dispose(); - } - foreach (var icon in this.pluginIconMap.Values) { icon?.Dispose(); @@ -233,7 +186,7 @@ internal class PluginImageCache : IDisposable, IServiceType /// If the plugin was third party sourced. /// Cached image textures, or an empty array. /// True if an entry exists, may be null if currently downloading. - public bool TryGetIcon(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, out TextureWrap? iconTexture) + public bool TryGetIcon(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, out IDalamudTextureWrap? iconTexture) { iconTexture = null; @@ -275,16 +228,16 @@ internal class PluginImageCache : IDisposable, IServiceType /// If the plugin was third party sourced. /// Cached image textures, or an empty array. /// True if the image array exists, may be empty if currently downloading. - public bool TryGetImages(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, out TextureWrap?[] imageTextures) + public bool TryGetImages(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, out IDalamudTextureWrap?[] imageTextures) { if (!this.pluginImagesMap.TryAdd(manifest.InternalName, null)) { var found = this.pluginImagesMap[manifest.InternalName]; - imageTextures = found ?? Array.Empty(); + imageTextures = found ?? Array.Empty(); return true; } - var target = new TextureWrap?[5]; + var target = new IDalamudTextureWrap?[5]; this.pluginImagesMap[manifest.InternalName] = target; imageTextures = target; @@ -304,7 +257,7 @@ internal class PluginImageCache : IDisposable, IServiceType return false; } - private async Task TryLoadImage( + private async Task TryLoadImage( byte[]? bytes, string name, string? loc, @@ -316,10 +269,10 @@ internal class PluginImageCache : IDisposable, IServiceType if (bytes == null) return null; - var interfaceManager = this.imWithScene.Manager; + var interfaceManager = (await Service.GetAsync()).Manager; var framework = await Service.GetAsync(); - TextureWrap? image; + IDalamudTextureWrap? image; // FIXME(goat): This is a hack around this call failing randomly in certain situations. Might be related to not being called on the main thread. try { @@ -492,7 +445,7 @@ internal class PluginImageCache : IDisposable, IServiceType Log.Debug("Plugin image loader has shutdown"); } - private async Task DownloadPluginIconAsync(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, ulong requestedFrame) + private async Task DownloadPluginIconAsync(LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, ulong requestedFrame) { if (plugin is { IsDev: true }) { @@ -559,7 +512,7 @@ internal class PluginImageCache : IDisposable, IServiceType return icon; } - private async Task DownloadPluginImagesAsync(TextureWrap?[] pluginImages, LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, ulong requestedFrame) + private async Task DownloadPluginImagesAsync(IDalamudTextureWrap?[] pluginImages, LocalPlugin? plugin, IPluginManifest manifest, bool isThirdParty, ulong requestedFrame) { if (plugin is { IsDev: true }) { diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs index b4048536e..879034fd4 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs @@ -1,7 +1,6 @@ -using System; - -using CheapLoc; +using CheapLoc; using Dalamud.Plugin.Internal.Types; +using Serilog; namespace Dalamud.Interface.Internal.Windows.PluginInstaller; @@ -36,7 +35,18 @@ internal class PluginChangelogEntry : IChangelogEntry this.Version = plugin.EffectiveVersion.ToString(); this.Text = plugin.Manifest.Changelog ?? Loc.Localize("ChangelogNoText", "No changelog for this version."); this.Author = plugin.Manifest.Author; - this.Date = DateTimeOffset.FromUnixTimeSeconds(this.Plugin.Manifest.LastUpdate).DateTime; + + try + { + this.Date = DateTimeOffset.FromUnixTimeSeconds(this.Plugin.Manifest.LastUpdate).DateTime; + } + catch (ArgumentOutOfRangeException ex) + { + Log.Warning(ex, "Manifest included improper timestamp, e.g. wrong unit: {PluginName}", + plugin.Manifest.Name); + // Create a Date from 0 as with a manifest that does not include a LastUpdate field + this.Date = DateTimeOffset.FromUnixTimeSeconds(0).DateTime; + } } /// diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index b648a8204..210290f17 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -1,8 +1,8 @@ -using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Drawing; using System.IO; using System.Linq; using System.Numerics; @@ -12,11 +12,13 @@ using System.Threading.Tasks; using CheapLoc; using Dalamud.Configuration.Internal; using Dalamud.Game.Command; +using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal.Notifications; -using Dalamud.Interface.Raii; -using Dalamud.Interface.Style; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Logging.Internal; using Dalamud.Plugin; @@ -28,7 +30,6 @@ using Dalamud.Plugin.Internal.Types.Manifest; using Dalamud.Support; using Dalamud.Utility; using ImGuiNET; -using ImGuiScene; namespace Dalamud.Interface.Internal.Windows.PluginInstaller; @@ -53,6 +54,13 @@ internal class PluginInstallerWindow : Window, IDisposable private readonly ProfileManagerWidget profileManagerWidget; + private readonly Stopwatch tooltipFadeInStopwatch = new(); + private readonly InOutCubic tooltipFadeEasing = new(TimeSpan.FromSeconds(0.2f)) + { + Point1 = Vector2.Zero, + Point2 = Vector2.One, + }; + private DalamudChangelogManager? dalamudChangelogManager; private Task? dalamudChangelogRefreshTask; private CancellationTokenSource? dalamudChangelogRefreshTaskCts; @@ -62,8 +70,8 @@ internal class PluginInstallerWindow : Window, IDisposable private string[] testerImagePaths = new string[5]; private string testerIconPath = string.Empty; - private TextureWrap?[] testerImages; - private TextureWrap? testerIcon; + private IDalamudTextureWrap?[]? testerImages; + private IDalamudTextureWrap? testerIcon; private bool testerError = false; private bool testerUpdateAvailable = false; @@ -83,6 +91,11 @@ internal class PluginInstallerWindow : Window, IDisposable private bool testingWarningModalDrawing = true; private bool testingWarningModalOnNextFrame = false; + private bool deletePluginConfigWarningModalDrawing = true; + private bool deletePluginConfigWarningModalOnNextFrame = false; + private string deletePluginConfigWarningModalPluginName = string.Empty; + private TaskCompletionSource? deletePluginConfigWarningModalTaskCompletionSource; + private bool feedbackModalDrawing = true; private bool feedbackModalOnNextFrame = false; private bool feedbackModalOnNextFrameDontClear = false; @@ -95,6 +108,7 @@ internal class PluginInstallerWindow : Window, IDisposable private int updatePluginCount = 0; private List? updatedPlugins; + [SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "Makes sense like this")] private List pluginListAvailable = new(); private List pluginListInstalled = new(); private List pluginListUpdatable = new(); @@ -108,17 +122,22 @@ internal class PluginInstallerWindow : Window, IDisposable private OperationStatus installStatus = OperationStatus.Idle; private OperationStatus updateStatus = OperationStatus.Idle; + private OperationStatus enableDisableStatus = OperationStatus.Idle; + private Guid enableDisableWorkingPluginId = Guid.Empty; private LoadingIndicatorKind loadingIndicatorKind = LoadingIndicatorKind.Unknown; + private string verifiedCheckmarkHoveredPlugin = string.Empty; + /// /// Initializes a new instance of the class. /// /// An instance of class. - public PluginInstallerWindow(PluginImageCache imageCache) + /// An instance of . + public PluginInstallerWindow(PluginImageCache imageCache, DalamudConfiguration configuration) : base( - Locs.WindowTitle + (Service.Get().DoPluginTest ? Locs.WindowTitleMod_Testing : string.Empty) + "###XlPluginInstaller", + Locs.WindowTitle + (configuration.DoPluginTest ? Locs.WindowTitleMod_Testing : string.Empty) + "###XlPluginInstaller", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoScrollbar) { this.IsOpen = true; @@ -130,7 +149,6 @@ internal class PluginInstallerWindow : Window, IDisposable this.SizeConstraints = new WindowSizeConstraints { MinimumSize = this.Size.Value, - MaximumSize = new Vector2(5000, 5000), }; Service.GetAsync().ContinueWith(pluginManagerTask => @@ -157,6 +175,27 @@ internal class PluginInstallerWindow : Window, IDisposable this.profileManagerWidget = new(this); } + /// + /// Enum describing pages the plugin installer can be opened to. + /// + public enum PluginInstallerOpenKind + { + /// + /// Open to the "All Plugins" page. + /// + AllPlugins, + + /// + /// Open to the "Installed Plugins" page. + /// + InstalledPlugins, + + /// + /// Open to the "Changelogs" page. + /// + Changelogs, + } + private enum OperationStatus { Idle, @@ -204,6 +243,28 @@ internal class PluginInstallerWindow : Window, IDisposable } } + /// + /// Open to the installer to the page specified by . + /// + /// The page of the installer to open. + public void OpenTo(PluginInstallerOpenKind kind) + { + this.IsOpen = true; + this.SetOpenPage(kind); + } + + /// + /// Toggle to the installer to the page specified by . + /// + /// The page of the installer to open. + public void ToggleTo(PluginInstallerOpenKind kind) + { + this.Toggle(); + + if (this.IsOpen) + this.SetOpenPage(kind); + } + /// public override void OnOpen() { @@ -248,6 +309,7 @@ internal class PluginInstallerWindow : Window, IDisposable this.DrawErrorModal(); this.DrawUpdateModal(); this.DrawTestingWarningModal(); + this.DrawDeletePluginConfigWarningModal(); this.DrawFeedbackModal(); this.DrawProgressOverlay(); } @@ -261,30 +323,6 @@ internal class PluginInstallerWindow : Window, IDisposable this.imageCache.ClearIconCache(); } - /// - /// Open the window on the plugin changelogs. - /// - public void OpenInstalledPlugins() - { - // Installed group - this.categoryManager.CurrentGroupIdx = 1; - // All category - this.categoryManager.CurrentCategoryIdx = 0; - this.IsOpen = true; - } - - /// - /// Open the window on the plugin changelogs. - /// - public void OpenPluginChangelogs() - { - // Changelog group - this.categoryManager.CurrentGroupIdx = 3; - // Plugins category - this.categoryManager.CurrentCategoryIdx = 2; - this.IsOpen = true; - } - /// /// Sets the current search text and marks it as prefilled. /// @@ -369,6 +407,33 @@ internal class PluginInstallerWindow : Window, IDisposable return true; } + private void SetOpenPage(PluginInstallerOpenKind kind) + { + switch (kind) + { + case PluginInstallerOpenKind.AllPlugins: + // Plugins group + this.categoryManager.CurrentGroupIdx = 2; + // All category + this.categoryManager.CurrentCategoryIdx = 0; + break; + case PluginInstallerOpenKind.InstalledPlugins: + // Installed group + this.categoryManager.CurrentGroupIdx = 1; + // All category + this.categoryManager.CurrentCategoryIdx = 0; + break; + case PluginInstallerOpenKind.Changelogs: + // Changelog group + this.categoryManager.CurrentGroupIdx = 3; + // Plugins category + this.categoryManager.CurrentCategoryIdx = 2; + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind), kind, null); + } + } + private void DrawProgressOverlay() { var pluginManager = Service.Get(); @@ -665,10 +730,10 @@ internal class PluginInstallerWindow : Window, IDisposable } else { - this.updatedPlugins = task.Result.Where(res => res.WasUpdated).ToList(); + this.updatedPlugins = task.Result.Where(res => res.Status == PluginUpdateStatus.StatusKind.Success).ToList(); this.updatePluginCount = this.updatedPlugins.Count; - var errorPlugins = task.Result.Where(res => !res.WasUpdated).ToList(); + var errorPlugins = task.Result.Where(res => res.Status != PluginUpdateStatus.StatusKind.Success).ToList(); var errorPluginCount = errorPlugins.Count; if (errorPluginCount > 0) @@ -676,9 +741,9 @@ internal class PluginInstallerWindow : Window, IDisposable var errorMessage = this.updatePluginCount > 0 ? Locs.ErrorModal_UpdaterFailPartial(this.updatePluginCount, errorPluginCount) : Locs.ErrorModal_UpdaterFail(errorPluginCount); - + var hintInsert = errorPlugins - .Aggregate(string.Empty, (current, pluginUpdateStatus) => $"{current}* {pluginUpdateStatus.InternalName}\n") + .Aggregate(string.Empty, (current, pluginUpdateStatus) => $"{current}* {pluginUpdateStatus.InternalName} ({PluginUpdateStatus.LocalizeUpdateStatusKind(pluginUpdateStatus.Status)})\n") .TrimEnd(); errorMessage += Locs.ErrorModal_HintBlame(hintInsert); @@ -814,6 +879,55 @@ internal class PluginInstallerWindow : Window, IDisposable } } + private Task ShowDeletePluginConfigWarningModal(string pluginName) + { + this.deletePluginConfigWarningModalOnNextFrame = true; + this.deletePluginConfigWarningModalPluginName = pluginName; + this.deletePluginConfigWarningModalTaskCompletionSource = new TaskCompletionSource(); + return this.deletePluginConfigWarningModalTaskCompletionSource.Task; + } + + private void DrawDeletePluginConfigWarningModal() + { + var modalTitle = Locs.DeletePluginConfigWarningModal_Title; + + if (ImGui.BeginPopupModal(modalTitle, ref this.deletePluginConfigWarningModalDrawing, ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoScrollbar)) + { + ImGui.Text(Locs.DeletePluginConfigWarningModal_Body(this.deletePluginConfigWarningModalPluginName)); + ImGui.Spacing(); + + var buttonWidth = 120f; + ImGui.SetCursorPosX((ImGui.GetWindowWidth() - ((buttonWidth * 2) - (ImGui.GetStyle().ItemSpacing.Y * 2))) / 2); + + if (ImGui.Button(Locs.DeletePluginConfirmWarningModal_Yes, new Vector2(buttonWidth, 40))) + { + ImGui.CloseCurrentPopup(); + this.deletePluginConfigWarningModalTaskCompletionSource?.SetResult(true); + } + + ImGui.SameLine(); + + if (ImGui.Button(Locs.DeletePluginConfirmWarningModal_No, new Vector2(buttonWidth, 40))) + { + ImGui.CloseCurrentPopup(); + this.deletePluginConfigWarningModalTaskCompletionSource?.SetResult(false); + } + + ImGui.EndPopup(); + } + + if (this.deletePluginConfigWarningModalOnNextFrame) + { + // NOTE(goat): ImGui cannot open a modal if no window is focused, at the moment. + // If people click out of the installer into the game while a plugin is installing, we won't be able to show a modal if we don't grab focus. + ImGui.SetWindowFocus(this.WindowName); + + ImGui.OpenPopup(modalTitle); + this.deletePluginConfigWarningModalOnNextFrame = false; + this.deletePluginConfigWarningModalDrawing = true; + } + } + private void DrawFeedbackModal() { var modalTitle = Locs.FeedbackModal_Title; @@ -1013,45 +1127,79 @@ internal class PluginInstallerWindow : Window, IDisposable this.DrawChangelog(logEntry); } } - + + private record PluginInstallerAvailablePluginProxy(RemotePluginManifest? RemoteManifest, LocalPlugin? LocalPlugin); + +#pragma warning disable SA1201 private void DrawAvailablePluginList() +#pragma warning restore SA1201 { - var pluginList = this.pluginListAvailable; + var availableManifests = this.pluginListAvailable; + var installedPlugins = this.pluginListInstalled.ToList(); // Copy intended - if (pluginList.Count == 0) + if (availableManifests.Count == 0) { ImGui.TextColored(ImGuiColors.DalamudGrey, Locs.TabBody_SearchNoCompatible); return; } - var filteredManifests = pluginList + var filteredAvailableManifests = availableManifests .Where(rm => !this.IsManifestFiltered(rm)) .ToList(); - if (filteredManifests.Count == 0) + if (filteredAvailableManifests.Count == 0) { ImGui.TextColored(ImGuiColors.DalamudGrey2, Locs.TabBody_SearchNoMatching); return; } - // get list to show and reset category dirty flag - var categoryManifestsList = this.categoryManager.GetCurrentCategoryContent(filteredManifests); + var proxies = new List(); + + // Go through all AVAILABLE manifests, associate them with a NON-DEV local plugin, if one is available, and remove it from the pile + foreach (var availableManifest in this.categoryManager.GetCurrentCategoryContent(filteredAvailableManifests).Cast()) + { + var plugin = this.pluginListInstalled.FirstOrDefault(plugin => plugin.Manifest.InternalName == availableManifest.InternalName && plugin.Manifest.RepoUrl == availableManifest.RepoUrl); + + // We "consumed" this plugin from the pile and remove it. + if (plugin != null && !plugin.IsDev) + { + installedPlugins.Remove(plugin); + proxies.Add(new PluginInstallerAvailablePluginProxy(null, plugin)); + + continue; + } + + proxies.Add(new PluginInstallerAvailablePluginProxy(availableManifest, null)); + } + + // Now, add all applicable local plugins that haven't been "used up", in most cases either dev or orphaned plugins. + foreach (var installedPlugin in installedPlugins) + { + if (this.IsManifestFiltered(installedPlugin.Manifest)) + continue; + + // TODO: We should also check categories here, for good measure + + proxies.Add(new PluginInstallerAvailablePluginProxy(null, installedPlugin)); + } var i = 0; - foreach (var manifest in categoryManifestsList) + foreach (var proxy in proxies) { - if (manifest is not RemotePluginManifest remoteManifest) - continue; - var (isInstalled, plugin) = this.IsManifestInstalled(remoteManifest); + IPluginManifest applicableManifest = proxy.LocalPlugin != null ? proxy.LocalPlugin.Manifest : proxy.RemoteManifest; - ImGui.PushID($"{manifest.InternalName}{manifest.AssemblyVersion}"); - if (isInstalled) + if (applicableManifest == null) + throw new Exception("Could not determine manifest for available plugin"); + + ImGui.PushID($"{applicableManifest.InternalName}{applicableManifest.AssemblyVersion}"); + + if (proxy.LocalPlugin != null) { - this.DrawInstalledPlugin(plugin, i++, true); + this.DrawInstalledPlugin(proxy.LocalPlugin, i++, true); } - else + else if (proxy.RemoteManifest != null) { - this.DrawAvailablePlugin(remoteManifest, i++); + this.DrawAvailablePlugin(proxy.RemoteManifest, i++); } ImGui.PopID(); @@ -1207,10 +1355,6 @@ internal class PluginInstallerWindow : Window, IDisposable if (!Service.Get().DoPluginTest) continue; break; - case PluginCategoryManager.CategoryInfo.AppearCondition.ProfilesEnabled: - if (!Service.Get().ProfilesEnabled) - continue; - break; default: throw new ArgumentOutOfRangeException(); } @@ -1437,7 +1581,7 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.Button($"{buttonText}##{buttonText}testing"); } - this.DrawVisitRepoUrlButton("https://google.com"); + this.DrawVisitRepoUrlButton("https://google.com", true); if (this.testerImages != null) { @@ -1523,7 +1667,7 @@ internal class PluginInstallerWindow : Window, IDisposable ImGuiHelpers.ScaledDummy(20); - static void CheckImageSize(TextureWrap? image, int maxWidth, int maxHeight, bool requireSquare) + static void CheckImageSize(IDalamudTextureWrap? image, int maxWidth, int maxHeight, bool requireSquare) { if (image == null) return; @@ -1568,7 +1712,7 @@ internal class PluginInstallerWindow : Window, IDisposable this.testerIcon = im.LoadImage(this.testerIconPath); } - this.testerImages = new TextureWrap[this.testerImagePaths.Length]; + this.testerImages = new IDalamudTextureWrap[this.testerImagePaths.Length]; for (var i = 0; i < this.testerImagePaths.Length; i++) { @@ -1637,7 +1781,7 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.5f, 0.5f, 0.5f, 0.35f)); ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0); - if (ImGui.Button($"###plugin{index}CollapsibleBtn", new Vector2(ImGui.GetWindowWidth() - (ImGuiHelpers.GlobalScale * 35), sectionSize))) + if (ImGui.Button($"###plugin{index}CollapsibleBtn", new Vector2(ImGui.GetContentRegionAvail().X, sectionSize))) { if (isOpen) { @@ -1699,10 +1843,12 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.Image(this.imageCache.OutdatedInstallableIcon.ImGuiHandle, iconSize); else if (pluginDisabled) ImGui.Image(this.imageCache.DisabledIcon.ImGuiHandle, iconSize); + /* NOTE: Replaced by the checkmarks for now, let's see if that is fine else if (isLoaded && isThirdParty) ImGui.Image(this.imageCache.ThirdInstalledIcon.ImGuiHandle, iconSize); else if (isThirdParty) ImGui.Image(this.imageCache.ThirdIcon.ImGuiHandle, iconSize); + */ else if (isLoaded) ImGui.Image(this.imageCache.InstalledIcon.ImGuiHandle, iconSize); else @@ -1716,6 +1862,36 @@ internal class PluginInstallerWindow : Window, IDisposable // Name ImGui.TextUnformatted(label); + + // Verified Checkmark or dev plugin wrench + { + ImGui.SameLine(); + ImGui.Text(" "); + ImGui.SameLine(); + + var verifiedOutlineColor = KnownColor.White.Vector() with { W = 0.75f }; + var unverifiedOutlineColor = KnownColor.Black.Vector(); + var verifiedIconColor = KnownColor.RoyalBlue.Vector() with { W = 0.75f }; + var unverifiedIconColor = KnownColor.Orange.Vector(); + var devIconOutlineColor = KnownColor.White.Vector(); + var devIconColor = KnownColor.MediumOrchid.Vector(); + + if (plugin is LocalDevPlugin) + { + this.DrawFontawesomeIconOutlined(FontAwesomeIcon.Wrench, devIconOutlineColor, devIconColor); + this.VerifiedCheckmarkFadeTooltip(label, "This is a dev plugin. You added it."); + } + else if (!isThirdParty) + { + this.DrawFontawesomeIconOutlined(FontAwesomeIcon.CheckCircle, verifiedOutlineColor, verifiedIconColor); + this.VerifiedCheckmarkFadeTooltip(label, Locs.VerifiedCheckmark_VerifiedTooltip); + } + else + { + this.DrawFontawesomeIconOutlined(FontAwesomeIcon.ExclamationCircle, unverifiedOutlineColor, unverifiedIconColor); + this.VerifiedCheckmarkFadeTooltip(label, Locs.VerifiedCheckmark_UnverifiedTooltip); + } + } // Download count var downloadCountText = manifest.DownloadCount > 0 @@ -1738,16 +1914,32 @@ internal class PluginInstallerWindow : Window, IDisposable if (plugin is { IsOutdated: true, IsBanned: false } || installableOutdated) { ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); - ImGui.TextWrapped(Locs.PluginBody_Outdated); + + var bodyText = Locs.PluginBody_Outdated + " "; + if (updateAvailable) + bodyText += Locs.PluginBody_Outdated_CanNowUpdate; + else + bodyText += Locs.PluginBody_Outdated_WaitForUpdate; + + ImGui.TextWrapped(bodyText); ImGui.PopStyleColor(); } else if (plugin is { IsBanned: true }) { // Banned warning ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); - ImGuiHelpers.SafeTextWrapped(plugin.BanReason.IsNullOrEmpty() - ? Locs.PluginBody_Banned - : Locs.PluginBody_BannedReason(plugin.BanReason)); + + var bodyText = plugin.BanReason.IsNullOrEmpty() + ? Locs.PluginBody_Banned + : Locs.PluginBody_BannedReason(plugin.BanReason); + bodyText += " "; + + if (updateAvailable) + bodyText += Locs.PluginBody_Outdated_CanNowUpdate; + else + bodyText += Locs.PluginBody_Outdated_WaitForUpdate; + + ImGuiHelpers.SafeTextWrapped(bodyText); ImGui.PopStyleColor(); } @@ -1820,7 +2012,7 @@ internal class PluginInstallerWindow : Window, IDisposable var rectOffset = ImGui.GetWindowContentRegionMin() + ImGui.GetWindowPos(); if (ImGui.IsRectVisible(rectOffset + cursorBeforeImage, rectOffset + cursorBeforeImage + iconSize)) { - TextureWrap icon; + IDalamudTextureWrap icon; if (log is PluginChangelogEntry pluginLog) { icon = this.imageCache.DefaultIcon; @@ -1876,7 +2068,6 @@ internal class PluginInstallerWindow : Window, IDisposable private void DrawAvailablePlugin(RemotePluginManifest manifest, int index) { var configuration = Service.Get(); - var notifications = Service.Get(); var pluginManager = Service.Get(); var useTesting = pluginManager.UseTesting(manifest); @@ -1954,6 +2145,7 @@ internal class PluginInstallerWindow : Window, IDisposable } else { + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGuiColors.DalamudRed.Darken(0.3f).Fade(0.4f)); var buttonText = Locs.PluginButton_InstallVersion(versionString); if (ImGui.Button($"{buttonText}##{buttonText}{index}")) { @@ -1961,11 +2153,19 @@ internal class PluginInstallerWindow : Window, IDisposable } } - this.DrawVisitRepoUrlButton(manifest.RepoUrl); + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(10); + ImGui.SameLine(); + + this.DrawVisitRepoUrlButton(manifest.RepoUrl, true); + + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(3); + ImGui.SameLine(); if (!manifest.SourceRepo.IsThirdParty && manifest.AcceptsFeedback) { - this.DrawSendFeedbackButton(manifest, false); + this.DrawSendFeedbackButton(manifest, false, true); } ImGuiHelpers.ScaledDummy(5); @@ -1983,7 +2183,6 @@ internal class PluginInstallerWindow : Window, IDisposable { var configuration = Service.Get(); var pluginManager = Service.Get(); - var startInfo = Service.Get(); if (ImGui.BeginPopupContextItem("ItemContextMenu")) { @@ -2004,24 +2203,32 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGui.Selectable(Locs.PluginContext_DeletePluginConfig)) { - Log.Debug($"Deleting config for {manifest.InternalName}"); + this.ShowDeletePluginConfigWarningModal(manifest.Name).ContinueWith(t => + { + var shouldDelete = t.Result; - this.installStatus = OperationStatus.InProgress; - - Task.Run(() => + if (shouldDelete) { - pluginManager.PluginConfigs.Delete(manifest.InternalName); + Log.Debug($"Deleting config for {manifest.InternalName}"); - var path = Path.Combine(startInfo.PluginDirectory, manifest.InternalName); - if (Directory.Exists(path)) - Directory.Delete(path, true); - }) - .ContinueWith(task => - { - this.installStatus = OperationStatus.Idle; + this.installStatus = OperationStatus.InProgress; - this.DisplayErrorContinuation(task, Locs.ErrorModal_DeleteConfigFail(manifest.InternalName)); - }); + Task.Run(() => + { + pluginManager.PluginConfigs.Delete(manifest.InternalName); + var dir = pluginManager.PluginConfigs.GetDirectory(manifest.InternalName); + + if (Directory.Exists(dir)) + Directory.Delete(dir, true); + }) + .ContinueWith(task => + { + this.installStatus = OperationStatus.Idle; + + this.DisplayErrorContinuation(task, Locs.ErrorModal_DeleteConfigFail(manifest.InternalName)); + }); + } + }); } ImGui.EndPopup(); @@ -2088,6 +2295,11 @@ internal class PluginInstallerWindow : Window, IDisposable } var availablePluginUpdate = this.pluginListUpdatable.FirstOrDefault(up => up.InstalledPlugin == plugin); + + // Dev plugins can never update + if (plugin.IsDev) + availablePluginUpdate = null; + // Update available if (availablePluginUpdate != default) { @@ -2101,7 +2313,7 @@ internal class PluginInstallerWindow : Window, IDisposable var update = this.updatedPlugins.FirstOrDefault(update => update.InternalName == plugin.Manifest.InternalName); if (update != default) { - if (update.WasUpdated) + if (update.Status == PluginUpdateStatus.StatusKind.Success) { thisWasUpdated = true; label += Locs.PluginTitleMod_Updated; @@ -2149,6 +2361,7 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.PushID($"installed{index}{plugin.Manifest.InternalName}"); var hasChangelog = !plugin.Manifest.Changelog.IsNullOrEmpty(); + var didDrawChangelogInsideCollapsible = false; if (this.DrawPluginCollapsingHeader(label, plugin, plugin.Manifest, plugin.IsThirdParty, trouble, availablePluginUpdate != default, false, false, plugin.IsOrphaned, () => this.DrawInstalledPluginContextMenu(plugin, testingOptIn), index)) { @@ -2214,8 +2427,7 @@ internal class PluginInstallerWindow : Window, IDisposable { var commands = commandManager.Commands .Where(cInfo => - cInfo.Value != null && - cInfo.Value.ShowInHelp && + cInfo.Value is { ShowInHelp: true } && cInfo.Value.LoaderAssemblyName == plugin.Manifest.InternalName) .ToArray(); @@ -2226,18 +2438,20 @@ internal class PluginInstallerWindow : Window, IDisposable { ImGuiHelpers.SafeTextWrapped($"{command.Key} → {command.Value.HelpMessage}"); } + + ImGuiHelpers.ScaledDummy(3); } } // Controls this.DrawPluginControlButton(plugin, availablePluginUpdate); this.DrawDevPluginButtons(plugin); + this.DrawVisitRepoUrlButton(plugin.Manifest.RepoUrl, false); this.DrawDeletePluginButton(plugin); - this.DrawVisitRepoUrlButton(plugin.Manifest.RepoUrl); if (canFeedback) { - this.DrawSendFeedbackButton(plugin.Manifest, plugin.IsTesting); + this.DrawSendFeedbackButton(plugin.Manifest, plugin.IsTesting, false); } if (availablePluginUpdate != default && !plugin.IsDev) @@ -2257,6 +2471,7 @@ internal class PluginInstallerWindow : Window, IDisposable { if (ImGui.TreeNode(Locs.PluginBody_CurrentChangeLog(plugin.EffectiveVersion))) { + didDrawChangelogInsideCollapsible = true; this.DrawInstalledPluginChangelog(plugin.Manifest); ImGui.TreePop(); } @@ -2273,7 +2488,7 @@ internal class PluginInstallerWindow : Window, IDisposable } } - if (thisWasUpdated && hasChangelog) + if (thisWasUpdated && hasChangelog && !didDrawChangelogInsideCollapsible) { this.DrawInstalledPluginChangelog(plugin.Manifest); } @@ -2341,17 +2556,25 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGui.MenuItem(Locs.PluginContext_DeletePluginConfigReload)) { - Log.Debug($"Deleting config for {plugin.Manifest.InternalName}"); + this.ShowDeletePluginConfigWarningModal(plugin.Manifest.Name).ContinueWith(t => + { + var shouldDelete = t.Result; - this.installStatus = OperationStatus.InProgress; - - Task.Run(() => pluginManager.DeleteConfigurationAsync(plugin)) - .ContinueWith(task => + if (shouldDelete) { - this.installStatus = OperationStatus.Idle; + Log.Debug($"Deleting config for {plugin.Manifest.InternalName}"); - this.DisplayErrorContinuation(task, Locs.ErrorModal_DeleteConfigFail(plugin.Name)); - }); + this.installStatus = OperationStatus.InProgress; + + Task.Run(() => pluginManager.DeleteConfigurationAsync(plugin)) + .ContinueWith(task => + { + this.installStatus = OperationStatus.Idle; + + this.DisplayErrorContinuation(task, Locs.ErrorModal_DeleteConfigFail(plugin.Manifest.InternalName)); + }); + } + }); } ImGui.EndPopup(); @@ -2365,12 +2588,12 @@ internal class PluginInstallerWindow : Window, IDisposable var profileManager = Service.Get(); var config = Service.Get(); - var applicableForProfiles = plugin.Manifest.SupportsProfiles && !plugin.IsDev; + var applicableForProfiles = plugin.Manifest.SupportsProfiles /*&& !plugin.IsDev*/; var profilesThatWantThisPlugin = profileManager.Profiles - .Where(x => x.WantsPlugin(plugin.InternalName) != null) + .Where(x => x.WantsPlugin(plugin.Manifest.WorkingPluginId) != null) .ToArray(); var isInSingleProfile = profilesThatWantThisPlugin.Length == 1; - var isDefaultPlugin = profileManager.IsInDefaultProfile(plugin.Manifest.InternalName); + var isDefaultPlugin = profileManager.IsInDefaultProfile(plugin.Manifest.WorkingPluginId); // Disable everything if the updater is running or another plugin is operating var disabled = this.updateStatus == OperationStatus.InProgress || this.installStatus == OperationStatus.InProgress; @@ -2405,17 +2628,17 @@ internal class PluginInstallerWindow : Window, IDisposable foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile)) { - var inProfile = profile.WantsPlugin(plugin.Manifest.InternalName) != null; + var inProfile = profile.WantsPlugin(plugin.Manifest.WorkingPluginId) != null; if (ImGui.Checkbox($"###profilePick{profile.Guid}{plugin.Manifest.InternalName}", ref inProfile)) { if (inProfile) { - Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.InternalName, true)) + Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true)) .ContinueWith(this.DisplayErrorContinuation, Locs.Profiles_CouldNotAdd); } else { - Task.Run(() => profile.RemoveAsync(plugin.Manifest.InternalName)) + Task.Run(() => profile.RemoveAsync(plugin.Manifest.WorkingPluginId)) .ContinueWith(this.DisplayErrorContinuation, Locs.Profiles_CouldNotRemove); } } @@ -2435,11 +2658,11 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGuiComponents.IconButton(FontAwesomeIcon.Times)) { // TODO: Work this out - Task.Run(() => profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.InternalName, plugin.IsLoaded, false)) + Task.Run(() => profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, plugin.IsLoaded, false)) .GetAwaiter().GetResult(); foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile && x.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName))) { - Task.Run(() => profile.RemoveAsync(plugin.Manifest.InternalName, false)) + Task.Run(() => profile.RemoveAsync(plugin.Manifest.WorkingPluginId, false)) .GetAwaiter().GetResult(); } @@ -2464,6 +2687,10 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGui.IsItemHovered()) ImGui.SetTooltip(Locs.PluginButtonToolTip_UnloadFailed); } + else if (this.enableDisableStatus == OperationStatus.InProgress && this.enableDisableWorkingPluginId == plugin.Manifest.WorkingPluginId) + { + ImGuiComponents.DisabledToggleButton(toggleId, this.loadingIndicatorKind == LoadingIndicatorKind.EnablingSingle); + } else if (disabled || inMultipleProfiles || inSingleNonDefaultProfileWhichIsDisabled) { ImGuiComponents.DisabledToggleButton(toggleId, isLoadedAndUnloadable); @@ -2503,12 +2730,13 @@ internal class PluginInstallerWindow : Window, IDisposable { this.enableDisableStatus = OperationStatus.InProgress; this.loadingIndicatorKind = LoadingIndicatorKind.DisablingSingle; + this.enableDisableWorkingPluginId = plugin.Manifest.WorkingPluginId; Task.Run(async () => { await plugin.UnloadAsync(); await applicableProfile.AddOrUpdateAsync( - plugin.Manifest.InternalName, false, false); + plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); notifications.AddNotification(Locs.Notifications_PluginDisabled(plugin.Manifest.Name), Locs.Notifications_PluginDisabledTitle, NotificationType.Success); }).ContinueWith(t => @@ -2523,8 +2751,9 @@ internal class PluginInstallerWindow : Window, IDisposable { this.enableDisableStatus = OperationStatus.InProgress; this.loadingIndicatorKind = LoadingIndicatorKind.EnablingSingle; + this.enableDisableWorkingPluginId = plugin.Manifest.WorkingPluginId; - await applicableProfile.AddOrUpdateAsync(plugin.Manifest.InternalName, true, false); + await applicableProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false); await plugin.LoadAsync(PluginLoadReason.Installer); notifications.AddNotification(Locs.Notifications_PluginEnabled(plugin.Manifest.Name), Locs.Notifications_PluginEnabledTitle, NotificationType.Success); @@ -2545,7 +2774,7 @@ internal class PluginInstallerWindow : Window, IDisposable if (shouldUpdate) { // We need to update the profile right here, because PM will not enable the plugin otherwise - await applicableProfile.AddOrUpdateAsync(plugin.InternalName, true, false); + await applicableProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false); await this.UpdateSinglePlugin(availableUpdate); } else @@ -2571,6 +2800,9 @@ internal class PluginInstallerWindow : Window, IDisposable { // Only if the plugin isn't broken. this.DrawOpenPluginSettingsButton(plugin); + + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(5, 0); } if (applicableForProfiles && config.ProfilesEnabled) @@ -2610,8 +2842,15 @@ internal class PluginInstallerWindow : Window, IDisposable // There is no need to set as Complete for an individual plugin installation this.installStatus = OperationStatus.Idle; - var errorMessage = Locs.ErrorModal_SingleUpdateFail(update.UpdateManifest.Name); - return this.DisplayErrorContinuation(task, errorMessage); + if (task.IsCompletedSuccessfully && + task.Result.Status != PluginUpdateStatus.StatusKind.Success) + { + this.ShowErrorModal( + Locs.ErrorModal_SingleUpdateFail(update.UpdateManifest.Name, PluginUpdateStatus.LocalizeUpdateStatusKind(task.Result.Status))); + return false; + } + + return this.DisplayErrorContinuation(task, Locs.ErrorModal_SingleUpdateFail(update.UpdateManifest.Name, "Exception")); }); } @@ -2635,10 +2874,39 @@ internal class PluginInstallerWindow : Window, IDisposable private void DrawOpenPluginSettingsButton(LocalPlugin plugin) { - if (plugin.DalamudInterface?.UiBuilder?.HasConfigUi ?? false) + var hasMainUi = plugin.DalamudInterface?.UiBuilder.HasMainUi ?? false; + var hasConfig = plugin.DalamudInterface?.UiBuilder.HasConfigUi ?? false; + if (hasMainUi) { ImGui.SameLine(); - if (ImGuiComponents.IconButton(FontAwesomeIcon.Cog)) + if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.ArrowUpRightFromSquare, Locs.PluginButton_OpenUi)) + { + try + { + plugin.DalamudInterface.UiBuilder.OpenMain(); + } + catch (Exception ex) + { + Log.Error(ex, $"Error during OpenMain(): {plugin.Name}"); + } + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip(Locs.PluginButtonToolTip_OpenUi); + } + } + + if (hasConfig) + { + if (hasMainUi) + { + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(5, 0); + } + + ImGui.SameLine(); + if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Cog, Locs.PluginButton_OpenSettings)) { try { @@ -2646,7 +2914,7 @@ internal class PluginInstallerWindow : Window, IDisposable } catch (Exception ex) { - Log.Error(ex, $"Error during OpenConfigUi: {plugin.Name}"); + Log.Error(ex, $"Error during OpenConfig: {plugin.Name}"); } } @@ -2657,10 +2925,15 @@ internal class PluginInstallerWindow : Window, IDisposable } } - private void DrawSendFeedbackButton(IPluginManifest manifest, bool isTesting) + private void DrawSendFeedbackButton(IPluginManifest manifest, bool isTesting, bool big) { ImGui.SameLine(); - if (ImGuiComponents.IconButton(FontAwesomeIcon.Comment)) + + var clicked = big ? + ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Comment, Locs.FeedbackModal_Title) : + ImGuiComponents.IconButton(FontAwesomeIcon.Comment); + + if (clicked) { this.feedbackPlugin = manifest; this.feedbackModalOnNextFrame = true; @@ -2682,7 +2955,7 @@ internal class PluginInstallerWindow : Window, IDisposable if (localPlugin is LocalDevPlugin plugin) { var isInDefaultProfile = - Service.Get().IsInDefaultProfile(localPlugin.Manifest.InternalName); + Service.Get().IsInDefaultProfile(localPlugin.Manifest.WorkingPluginId); // https://colorswall.com/palette/2868/ var greenColor = new Vector4(0x5C, 0xB8, 0x5C, 0xFF) / 0xFF; @@ -2806,12 +3079,16 @@ internal class PluginInstallerWindow : Window, IDisposable } } - private void DrawVisitRepoUrlButton(string? repoUrl) + private void DrawVisitRepoUrlButton(string? repoUrl, bool big) { if (!string.IsNullOrEmpty(repoUrl) && repoUrl.StartsWith("https://")) { ImGui.SameLine(); - if (ImGuiComponents.IconButton(FontAwesomeIcon.Globe)) + + var clicked = big ? + ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Globe, "Open website") : + ImGuiComponents.IconButton(FontAwesomeIcon.Globe); + if (clicked) { try { @@ -3022,7 +3299,7 @@ internal class PluginInstallerWindow : Window, IDisposable this.pluginListAvailable.Sort((p1, p2) => p1.Name.CompareTo(p2.Name)); var profman = Service.Get(); - this.pluginListInstalled.Sort((p1, p2) => profman.IsInDefaultProfile(p1.InternalName).CompareTo(profman.IsInDefaultProfile(p2.InternalName))); + this.pluginListInstalled.Sort((p1, p2) => profman.IsInDefaultProfile(p1.Manifest.WorkingPluginId).CompareTo(profman.IsInDefaultProfile(p2.Manifest.WorkingPluginId))); break; default: throw new InvalidEnumArgumentException("Unknown plugin sort type."); @@ -3068,6 +3345,73 @@ internal class PluginInstallerWindow : Window, IDisposable this.UpdateCategoriesOnSearchChange(); } + private void DrawFontawesomeIconOutlined(FontAwesomeIcon icon, Vector4 outline, Vector4 iconColor) + { + var positionOffset = ImGuiHelpers.ScaledVector2(0.0f, 1.0f); + var cursorStart = ImGui.GetCursorPos() + positionOffset; + ImGui.PushFont(UiBuilder.IconFont); + + ImGui.PushStyleColor(ImGuiCol.Text, outline); + foreach (var x in Enumerable.Range(-1, 3)) + { + foreach (var y in Enumerable.Range(-1, 3)) + { + if (x is 0 && y is 0) continue; + + ImGui.SetCursorPos(cursorStart + new Vector2(x, y)); + ImGui.Text(icon.ToIconString()); + } + } + + ImGui.PopStyleColor(); + + ImGui.PushStyleColor(ImGuiCol.Text, iconColor); + ImGui.SetCursorPos(cursorStart); + ImGui.Text(icon.ToIconString()); + ImGui.PopStyleColor(); + + ImGui.PopFont(); + + ImGui.SetCursorPos(ImGui.GetCursorPos() - positionOffset); + } + + // Animates a tooltip when hovering over the ImGui Item before this call. + private void VerifiedCheckmarkFadeTooltip(string source, string tooltip) + { + const float fadeInStartDelay = 250.0f; + + var isHoveringSameItem = this.verifiedCheckmarkHoveredPlugin == source; + + // If we just started a hover, start the timer + if (ImGui.IsItemHovered() && !this.tooltipFadeInStopwatch.IsRunning) + { + this.verifiedCheckmarkHoveredPlugin = source; + this.tooltipFadeInStopwatch.Restart(); + } + + // If we were last hovering this plugins item and are no longer hovered over that item, reset the timer + if (!ImGui.IsItemHovered() && isHoveringSameItem) + { + this.verifiedCheckmarkHoveredPlugin = string.Empty; + this.tooltipFadeInStopwatch.Stop(); + this.tooltipFadeEasing.Reset(); + } + + // If we have been hovering this item for > fadeInStartDelay milliseconds, fade in tooltip over fadeInTime milliseconds + if (ImGui.IsItemHovered() && isHoveringSameItem && this.tooltipFadeInStopwatch.ElapsedMilliseconds >= fadeInStartDelay) + { + if (!this.tooltipFadeEasing.IsRunning) + this.tooltipFadeEasing.Start(); + + this.tooltipFadeEasing.Update(); + var fadePercent = this.tooltipFadeEasing.EasedPoint.X; + ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.Text] with { W = fadePercent }); + ImGui.PushStyleColor(ImGuiCol.FrameBg, ImGui.GetStyle().Colors[(int)ImGuiCol.FrameBg] with { W = fadePercent }); + ImGui.SetTooltip(tooltip); + ImGui.PopStyleColor(2); + } + } + [SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "Disregard here")] [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Locs")] internal static class Locs @@ -3202,7 +3546,11 @@ internal class PluginInstallerWindow : Window, IDisposable public static string PluginBody_Plugin3rdPartyRepo(string url) => Loc.Localize("InstallerPlugin3rdPartyRepo", "From custom plugin repository {0}").Format(url); - public static string PluginBody_Outdated => Loc.Localize("InstallerOutdatedPluginBody ", "This plugin is outdated and incompatible at the moment. Please wait for it to be updated by its author."); + public static string PluginBody_Outdated => Loc.Localize("InstallerOutdatedPluginBody ", "This plugin is outdated and incompatible."); + + public static string PluginBody_Outdated_WaitForUpdate => Loc.Localize("InstallerOutdatedWaitForUpdate", "Please wait for it to be updated by its author."); + + public static string PluginBody_Outdated_CanNowUpdate => Loc.Localize("InstallerOutdatedCanNowUpdate", "An update is available for installation."); public static string PluginBody_Orphaned => Loc.Localize("InstallerOrphanedPluginBody ", "This plugin's source repository is no longer available. You may need to reinstall it from its repository, or re-add the repository."); @@ -3212,7 +3560,7 @@ internal class PluginInstallerWindow : Window, IDisposable public static string PluginBody_LoadFailed => Loc.Localize("InstallerLoadFailedPluginBody ", "This plugin failed to load. Please contact the author for more information."); - public static string PluginBody_Banned => Loc.Localize("InstallerBannedPluginBody ", "This plugin was automatically disabled due to incompatibilities and is not available at the moment. Please wait for it to be updated by its author."); + public static string PluginBody_Banned => Loc.Localize("InstallerBannedPluginBody ", "This plugin was automatically disabled due to incompatibilities and is not available."); public static string PluginBody_Policy => Loc.Localize("InstallerPolicyPluginBody ", "Plugin loads for this type of plugin were manually disabled."); @@ -3234,12 +3582,18 @@ internal class PluginInstallerWindow : Window, IDisposable public static string PluginButton_Unload => Loc.Localize("InstallerUnload", "Unload"); public static string PluginButton_SafeMode => Loc.Localize("InstallerSafeModeButton", "Can't change in safe mode"); + + public static string PluginButton_OpenUi => Loc.Localize("InstallerOpenPluginUi", "Open"); + + public static string PluginButton_OpenSettings => Loc.Localize("InstallerOpenPluginSettings", "Settings"); #endregion #region Plugin button tooltips + + public static string PluginButtonToolTip_OpenUi => Loc.Localize("InstallerTooltipOpenUi", "Open this plugin's interface"); - public static string PluginButtonToolTip_OpenConfiguration => Loc.Localize("InstallerOpenConfig", "Open Configuration"); + public static string PluginButtonToolTip_OpenConfiguration => Loc.Localize("InstallerTooltipOpenConfig", "Open this plugin's settings"); public static string PluginButtonToolTip_PickProfiles => Loc.Localize("InstallerPickProfiles", "Pick collections for this plugin"); @@ -3343,7 +3697,7 @@ internal class PluginInstallerWindow : Window, IDisposable public static string ErrorModal_InstallFail(string name) => Loc.Localize("InstallerInstallFail", "Failed to install plugin {0}.\n{1}").Format(name, ErrorModal_InstallContactAuthor); - public static string ErrorModal_SingleUpdateFail(string name) => Loc.Localize("InstallerSingleUpdateFail", "Failed to update plugin {0}.\n{1}").Format(name, ErrorModal_InstallContactAuthor); + public static string ErrorModal_SingleUpdateFail(string name, string why) => Loc.Localize("InstallerSingleUpdateFail", "Failed to update plugin {0} ({1}).\n{2}").Format(name, why, ErrorModal_InstallContactAuthor); public static string ErrorModal_DeleteConfigFail(string name) => Loc.Localize("InstallerDeleteConfigFail", "Failed to reset the plugin {0}.\n\nThe plugin may not support this action. You can try deleting the configuration manually while the game is shut down - please see the FAQ.").Format(name); @@ -3415,6 +3769,18 @@ internal class PluginInstallerWindow : Window, IDisposable #endregion + #region Delete Plugin Config Warning Modal + + public static string DeletePluginConfigWarningModal_Title => Loc.Localize("InstallerDeletePluginConfigWarning", "Warning###InstallerDeletePluginConfigWarning"); + + public static string DeletePluginConfigWarningModal_Body(string pluginName) => Loc.Localize("InstallerDeletePluginConfigWarningBody", "Are you sure you want to delete all data and configuration for {0}?").Format(pluginName); + + public static string DeletePluginConfirmWarningModal_Yes => Loc.Localize("InstallerDeletePluginConfigWarningYes", "Yes"); + + public static string DeletePluginConfirmWarningModal_No => Loc.Localize("InstallerDeletePluginConfigWarningNo", "No"); + + #endregion + #region Plugin Update chatbox public static string PluginUpdateHeader_Chatbox => Loc.Localize("DalamudPluginUpdates", "Updates:"); @@ -3447,5 +3813,19 @@ internal class PluginInstallerWindow : Window, IDisposable Loc.Localize("InstallerProfilesRemoveFromAll", "Remove from all collections"); #endregion + + #region VerifiedCheckmark + + public static string VerifiedCheckmark_VerifiedTooltip => + Loc.Localize("VerifiedCheckmarkVerifiedTooltip", "This plugin has been reviewed by the Dalamud team.\n" + + "It follows our technical and safety criteria, and adheres to our guidelines."); + + public static string VerifiedCheckmark_UnverifiedTooltip => + Loc.Localize("VerifiedCheckmarkUnverifiedTooltip", "This plugin has not been reviewed by the Dalamud team.\n" + + "We cannot take any responsibility for custom plugins and repositories.\n" + + "Please make absolutely sure that you only install plugins from developers you trust.\n\n" + + "You will not receive support for plugins installed from custom repositories on the XIVLauncher & Dalamud server."); + + #endregion } } diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs index 301e43473..857002771 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs @@ -7,10 +7,13 @@ using CheapLoc; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal.Notifications; -using Dalamud.Interface.Raii; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal.Profiles; +using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; using ImGuiNET; using Serilog; @@ -49,6 +52,12 @@ internal class ProfileManagerWidget /// public void Draw() { + if (!Service.Get().ProfilesEnabled) + { + this.DrawChoice(); + return; + } + var tutorialTitle = Locs.TutorialTitle + "###collectionsTutorWindow"; var tutorialId = ImGui.GetID(tutorialTitle); this.DrawTutorial(tutorialTitle); @@ -75,10 +84,27 @@ internal class ProfileManagerWidget this.pickerSearch = string.Empty; } + private void DrawChoice() + { + ImGuiHelpers.ScaledDummy(60); + ImGuiHelpers.CenteredText(Locs.Choice1); + ImGuiHelpers.CenteredText(Locs.Choice2); + ImGuiHelpers.ScaledDummy(20); + + var buttonWidth = ImGui.GetWindowWidth() / 3; + ImGuiHelpers.CenterCursorFor((int)buttonWidth); + if (ImGui.Button(Locs.ChoiceConfirmation, new Vector2(buttonWidth, 40 * ImGuiHelpers.GlobalScale))) + { + var config = Service.Get(); + config.ProfilesEnabled = true; + config.QueueSave(); + } + } + private void DrawTutorial(string modalTitle) { var open = true; - ImGui.SetNextWindowSize(new Vector2(450, 350), ImGuiCond.Appearing); + ImGui.SetNextWindowSize(new Vector2(650, 550), ImGuiCond.Appearing); using (var popup = ImRaii.PopupModal(modalTitle, ref open)) { if (popup) @@ -228,7 +254,7 @@ internal class ProfileManagerWidget if (ImGuiComponents.IconButton($"###exportButton{profile.Guid}", FontAwesomeIcon.FileExport)) { - ImGui.SetClipboardText(profile.Model.Serialize()); + ImGui.SetClipboardText(profile.Model.SerializeForShare()); Service.Get().AddNotification(Locs.CopyToClipboardNotification, type: NotificationType.Success); } @@ -291,15 +317,15 @@ internal class ProfileManagerWidget if (ImGui.BeginListBox("###pluginPicker", new Vector2(width, width - 80))) { // TODO: Plugin searching should be abstracted... installer and this should use the same search - foreach (var plugin in pm.InstalledPlugins.Where(x => x.Manifest.SupportsProfiles && !x.IsDev && + foreach (var plugin in pm.InstalledPlugins.Where(x => x.Manifest.SupportsProfiles && (this.pickerSearch.IsNullOrWhitespace() || x.Manifest.Name.ToLowerInvariant().Contains(this.pickerSearch.ToLowerInvariant())))) { using var disabled2 = ImRaii.Disabled(profile.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName)); - if (ImGui.Selectable($"{plugin.Manifest.Name}###selector{plugin.Manifest.InternalName}")) + if (ImGui.Selectable($"{plugin.Manifest.Name}{(plugin is LocalDevPlugin ? "(dev plugin)" : string.Empty)}###selector{plugin.Manifest.InternalName}")) { - Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.InternalName, true, false)) + Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false)) .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState); } } @@ -326,7 +352,7 @@ internal class ProfileManagerWidget if (ImGuiComponents.IconButton(FontAwesomeIcon.FileExport)) { - ImGui.SetClipboardText(profile.Model.Serialize()); + ImGui.SetClipboardText(profile.Model.SerializeForShare()); Service.Get().AddNotification(Locs.CopyToClipboardNotification, type: NotificationType.Success); } @@ -399,24 +425,34 @@ internal class ProfileManagerWidget if (pluginListChild) { var pluginLineHeight = 32 * ImGuiHelpers.GlobalScale; - string? wantRemovePluginInternalName = null; + Guid? wantRemovePluginGuid = null; using var syncScope = profile.GetSyncScope(); - foreach (var plugin in profile.Plugins.ToArray()) + foreach (var profileEntry in profile.Plugins.ToArray()) { didAny = true; - var pmPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.InternalName == plugin.InternalName); + var pmPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.WorkingPluginId == profileEntry.WorkingPluginId); var btnOffset = 2; if (pmPlugin != null) { + var cursorBeforeIcon = ImGui.GetCursorPos(); pic.TryGetIcon(pmPlugin, pmPlugin.Manifest, pmPlugin.IsThirdParty, out var icon); icon ??= pic.DefaultIcon; ImGui.Image(icon.ImGuiHandle, new Vector2(pluginLineHeight)); + + if (pmPlugin.IsDev) + { + ImGui.SetCursorPos(cursorBeforeIcon); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.7f); + ImGui.Image(pic.DevPluginIcon.ImGuiHandle, new Vector2(pluginLineHeight)); + ImGui.PopStyleVar(); + } + ImGui.SameLine(); - var text = $"{pmPlugin.Name}"; + var text = $"{pmPlugin.Name}{(pmPlugin.IsDev ? " (dev plugin" : string.Empty)}"; var textHeight = ImGui.CalcTextSize(text); var before = ImGui.GetCursorPos(); @@ -430,32 +466,53 @@ internal class ProfileManagerWidget ImGui.Image(pic.DefaultIcon.ImGuiHandle, new Vector2(pluginLineHeight)); ImGui.SameLine(); - var text = Locs.NotInstalled(plugin.InternalName); + var text = Locs.NotInstalled(profileEntry.InternalName); var textHeight = ImGui.CalcTextSize(text); var before = ImGui.GetCursorPos(); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (textHeight.Y / 2)); ImGui.TextUnformatted(text); - - var available = + + var firstAvailableInstalled = pm.InstalledPlugins.FirstOrDefault(x => x.InternalName == profileEntry.InternalName); + var installable = pm.AvailablePlugins.FirstOrDefault( - x => x.InternalName == plugin.InternalName && !x.SourceRepo.IsThirdParty); - if (available != null) + x => x.InternalName == profileEntry.InternalName && !x.SourceRepo.IsThirdParty); + + if (firstAvailableInstalled != null) + { + ImGui.Text($"Match to plugin '{firstAvailableInstalled.Name}'?"); + ImGui.SameLine(); + if (ImGuiComponents.IconButtonWithText( + FontAwesomeIcon.Check, + "Yes, use this one")) + { + profileEntry.WorkingPluginId = firstAvailableInstalled.Manifest.WorkingPluginId; + Task.Run(async () => + { + await profman.ApplyAllWantStatesAsync(); + }) + .ContinueWith(t => + { + this.installer.DisplayErrorContinuation(t, Locs.ErrorCouldNotChangeState); + }); + } + } + else if (installable != null) { ImGui.SameLine(); ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * 2) - 2); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); btnOffset = 3; - if (ImGuiComponents.IconButton($"###installMissingPlugin{available.InternalName}", FontAwesomeIcon.Download)) + if (ImGuiComponents.IconButton($"###installMissingPlugin{installable.InternalName}", FontAwesomeIcon.Download)) { - this.installer.StartInstall(available, false); + this.installer.StartInstall(installable, false); } if (ImGui.IsItemHovered()) ImGui.SetTooltip(Locs.InstallPlugin); } - + ImGui.SetCursorPos(before); } @@ -463,10 +520,10 @@ internal class ProfileManagerWidget ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30)); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); - var enabled = plugin.IsEnabled; - if (ImGui.Checkbox($"###{this.editingProfileGuid}-{plugin.InternalName}", ref enabled)) + var enabled = profileEntry.IsEnabled; + if (ImGui.Checkbox($"###{this.editingProfileGuid}-{profileEntry.InternalName}", ref enabled)) { - Task.Run(() => profile.AddOrUpdateAsync(plugin.InternalName, enabled)) + Task.Run(() => profile.AddOrUpdateAsync(profileEntry.WorkingPluginId, profileEntry.InternalName, enabled)) .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState); } @@ -474,19 +531,19 @@ internal class ProfileManagerWidget ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * btnOffset) - 5); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); - if (ImGuiComponents.IconButton($"###removePlugin{plugin.InternalName}", FontAwesomeIcon.Trash)) + if (ImGuiComponents.IconButton($"###removePlugin{profileEntry.InternalName}", FontAwesomeIcon.Trash)) { - wantRemovePluginInternalName = plugin.InternalName; + wantRemovePluginGuid = profileEntry.WorkingPluginId; } if (ImGui.IsItemHovered()) ImGui.SetTooltip(Locs.RemovePlugin); } - if (wantRemovePluginInternalName != null) + if (wantRemovePluginGuid != null) { // TODO: handle error - Task.Run(() => profile.RemoveAsync(wantRemovePluginInternalName, false)) + Task.Run(() => profile.RemoveAsync(wantRemovePluginGuid.Value, false)) .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotRemove); } @@ -605,6 +662,15 @@ internal class ProfileManagerWidget public static string TutorialCommandsEnd => Loc.Localize("ProfileManagerTutorialCommandsEnd", "If you run multiple of these commands, they will be executed in order."); + public static string Choice1 => + Loc.Localize("ProfileManagerChoice1", "Plugin collections are a new feature that allow you to group plugins into collections which can be toggled and shared."); + + public static string Choice2 => + Loc.Localize("ProfileManagerChoice2", "They are experimental and may still contain bugs. Do you want to enable them now?"); + + public static string ChoiceConfirmation => + Loc.Localize("ProfileManagerChoiceConfirmation", "Yes, enable Plugin Collections"); + public static string NotInstalled(string name) => Loc.Localize("ProfileManagerNotInstalled", "{0} (Not Installed)").Format(name); } diff --git a/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs b/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs index 44e43fbd3..bfa30cafd 100644 --- a/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginStatWindow.cs @@ -7,6 +7,7 @@ using System.Reflection; using Dalamud.Game; using Dalamud.Hooking.Internal; using Dalamud.Interface.Components; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Internal; @@ -44,7 +45,8 @@ internal class PluginStatWindow : Window { var pluginManager = Service.Get(); - ImGui.BeginTabBar("Stat Tabs"); + if (!ImGui.BeginTabBar("Stat Tabs")) + return; if (ImGui.BeginTabItem("Draw times")) { diff --git a/Dalamud/Interface/Internal/Windows/ProfilerWindow.cs b/Dalamud/Interface/Internal/Windows/ProfilerWindow.cs index 2d0f54912..cd653143b 100644 --- a/Dalamud/Interface/Internal/Windows/ProfilerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ProfilerWindow.cs @@ -1,10 +1,10 @@ -using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Utility.Numerics; using Dalamud.Utility.Timing; @@ -45,7 +45,7 @@ public class ProfilerWindow : Window ImGui.Text("Timings"); - var childHeight = Math.Max(300, 20 * (2 + this.occupied.Count)); + var childHeight = Math.Max(300, 20 * (2.5f + this.occupied.Count)); if (ImGui.BeginChild("Timings", new Vector2(0, childHeight), true)) { @@ -114,7 +114,7 @@ public class ProfilerWindow : Window parentDepthDict[timingHandle.Id] = depth; startX = Math.Max(startX, 0); - endX = Math.Max(endX, 0); + endX = Math.Max(endX, startX + (ImGuiHelpers.GlobalScale * 16)); Vector4 rectColor; if (this.occupied[depth].Count % 2 == 0) @@ -128,11 +128,6 @@ public class ProfilerWindow : Window if (maxRectDept < depth) maxRectDept = (uint)depth; - if (startX == endX) - { - continue; - } - var minPos = pos + new Vector2((uint)startX, 20 * depth); var maxPos = pos + new Vector2((uint)endX, 20 * (depth + 1)); @@ -230,22 +225,22 @@ public class ProfilerWindow : Window ImGui.EndChild(); var sliderMin = (float)this.min / 1000f; - if (ImGui.SliderFloat("Start", ref sliderMin, (float)actualMin / 1000f, (float)this.max / 1000f, "%.1fs")) + if (ImGui.SliderFloat("Start", ref sliderMin, (float)actualMin / 1000f, (float)this.max / 1000f, "%.2fs")) { this.min = sliderMin * 1000f; } var sliderMax = (float)this.max / 1000f; - if (ImGui.SliderFloat("End", ref sliderMax, (float)this.min / 1000f, (float)actualMax / 1000f, "%.1fs")) + if (ImGui.SliderFloat("End", ref sliderMax, (float)this.min / 1000f, (float)actualMax / 1000f, "%.2fs")) { this.max = sliderMax * 1000f; } - var sizeShown = (float)(this.max - this.min); - var sizeActual = (float)(actualMax - actualMin); - if (ImGui.SliderFloat("Size", ref sizeShown, sizeActual / 10f, sizeActual, "%.1fs")) + var sizeShown = (float)(this.max - this.min) / 1000f; + var sizeActual = (float)(actualMax - actualMin) / 1000f; + if (ImGui.SliderFloat("Size", ref sizeShown, sizeActual / 10f, sizeActual, "%.2fs")) { - this.max = this.min + sizeShown; + this.max = this.min + (sizeShown * 1000f); } ImGui.Text("Min: " + actualMin.ToString("0.000")); @@ -256,6 +251,7 @@ public class ProfilerWindow : Window [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Internals")] private class RectInfo { + // ReSharper disable once NotNullOrRequiredMemberIsNotInitialized <- well you're wrong internal TimingHandle Timing; internal Vector2 MinPos; internal Vector2 MaxPos; diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/AddonLifecycleAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/AddonLifecycleAgingStep.cs new file mode 100644 index 000000000..b2229e4e4 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/AddonLifecycleAgingStep.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; + +using Dalamud.Game.Addon; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; + +/// +/// Test setup AddonLifecycle Service. +/// +internal class AddonLifecycleAgingStep : IAgingStep +{ + private readonly List listeners; + + private AddonLifecycle? service; + private TestStep currentStep = TestStep.CharacterRefresh; + private bool listenersRegistered; + + /// + /// Initializes a new instance of the class. + /// + public AddonLifecycleAgingStep() + { + this.listeners = new List + { + new(AddonEvent.PostSetup, "Character", this.PostSetup), + new(AddonEvent.PostUpdate, "Character", this.PostUpdate), + new(AddonEvent.PostDraw, "Character", this.PostDraw), + new(AddonEvent.PostRefresh, "Character", this.PostRefresh), + new(AddonEvent.PostRequestedUpdate, "Character", this.PostRequestedUpdate), + new(AddonEvent.PreFinalize, "Character", this.PreFinalize), + }; + } + + private enum TestStep + { + CharacterRefresh, + CharacterSetup, + CharacterRequestedUpdate, + CharacterUpdate, + CharacterDraw, + CharacterFinalize, + Complete, + } + + /// + public string Name => "Test AddonLifecycle"; + + /// + public SelfTestStepResult RunStep() + { + this.service ??= Service.Get(); + if (this.service is null) return SelfTestStepResult.Fail; + + if (!this.listenersRegistered) + { + foreach (var listener in this.listeners) + { + this.service.RegisterListener(listener); + } + + this.listenersRegistered = true; + } + + switch (this.currentStep) + { + case TestStep.CharacterRefresh: + ImGui.Text("Open Character Window."); + break; + + case TestStep.CharacterSetup: + ImGui.Text("Open Character Window."); + break; + + case TestStep.CharacterRequestedUpdate: + ImGui.Text("Change tabs, or un-equip/equip gear."); + break; + + case TestStep.CharacterFinalize: + ImGui.Text("Close Character Window."); + break; + + case TestStep.CharacterUpdate: + case TestStep.CharacterDraw: + case TestStep.Complete: + default: + // Nothing to report to tester. + break; + } + + return this.currentStep is TestStep.Complete ? SelfTestStepResult.Pass : SelfTestStepResult.Waiting; + } + + /// + public void CleanUp() + { + foreach (var listener in this.listeners) + { + this.service?.UnregisterListener(listener); + } + } + + private void PostSetup(AddonEvent eventType, AddonArgs addonInfo) + { + if (this.currentStep is TestStep.CharacterSetup) this.currentStep++; + } + + private void PostUpdate(AddonEvent eventType, AddonArgs addonInfo) + { + if (this.currentStep is TestStep.CharacterUpdate) this.currentStep++; + } + + private void PostDraw(AddonEvent eventType, AddonArgs addonInfo) + { + if (this.currentStep is TestStep.CharacterDraw) this.currentStep++; + } + + private void PostRefresh(AddonEvent eventType, AddonArgs addonInfo) + { + if (this.currentStep is TestStep.CharacterRefresh) this.currentStep++; + } + + private void PostRequestedUpdate(AddonEvent eventType, AddonArgs addonInfo) + { + if (this.currentStep is TestStep.CharacterRequestedUpdate) this.currentStep++; + } + + private void PreFinalize(AddonEvent eventType, AddonArgs addonInfo) + { + if (this.currentStep is TestStep.CharacterFinalize) this.currentStep++; + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs index 570e362ef..579f8357b 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs @@ -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; /// 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 itemSheet; + private ExcelSheet materiaSheet; + private ExcelSheet stainSheet; private enum SubStep { Start, - TestItem, - TestGameObject, - TestSubMenu, - TestMultiple, + TestInventoryAndSubmenu, + TestDefault, Finish, } - */ /// public string Name => "Test Context Menu"; @@ -45,23 +43,24 @@ internal class ContextMenuAgingStep : IAgingStep /// public SelfTestStepResult RunStep() { - /* var contextMenu = Service.Get(); var dataMgr = Service.Get(); + this.itemSheet = dataMgr.GetExcelSheet()!; + this.materiaSheet = dataMgr.GetExcelSheet()!; + this.stainSheet = dataMgr.GetExcelSheet()!; 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()!.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; } - + /// public void CleanUp() { - /* var contextMenu = Service.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(); + 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"})"); } } - */ } diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/EnterTerritoryAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/EnterTerritoryAgingStep.cs index d301cb1ff..4f5c758d6 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/EnterTerritoryAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/EnterTerritoryAgingStep.cs @@ -59,9 +59,9 @@ internal class EnterTerritoryAgingStep : IAgingStep this.subscribed = false; } - private void ClientStateOnTerritoryChanged(object sender, ushort e) + private void ClientStateOnTerritoryChanged(ushort territoryId) { - if (e == this.territory) + if (territoryId == this.territory) { this.hasPassed = true; } diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LoginEventAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LoginEventAgingStep.cs index c1dba389f..23b0b903a 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LoginEventAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LoginEventAgingStep.cs @@ -51,7 +51,7 @@ internal class LoginEventAgingStep : IAgingStep } } - private void ClientStateOnOnLogin(object sender, EventArgs e) + private void ClientStateOnOnLogin() { this.hasPassed = true; } diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LogoutEventAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LogoutEventAgingStep.cs index 060c0bcc8..c4c6ebfce 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LogoutEventAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/LogoutEventAgingStep.cs @@ -51,7 +51,7 @@ internal class LogoutEventAgingStep : IAgingStep } } - private void ClientStateOnOnLogout(object sender, EventArgs e) + private void ClientStateOnOnLogout() { this.hasPassed = true; } diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs index 3e25b6f5a..8e43d30a6 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs @@ -6,6 +6,7 @@ using System.Numerics; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Logging.Internal; using ImGuiNET; @@ -39,6 +40,7 @@ internal class SelfTestWindow : Window new ChatAgingStep(), new HoverAgingStep(), new LuminaAgingStep(), + new AddonLifecycleAgingStep(), new PartyFinderAgingStep(), new HandledExceptionAgingStep(), new DutyStateAgingStep(), diff --git a/Dalamud/Interface/Internal/Windows/Settings/SettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/SettingsEntry.cs index 5e1dc7884..1e57d716e 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/SettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/SettingsEntry.cs @@ -42,6 +42,14 @@ public abstract class SettingsEntry /// public abstract void Draw(); + /// + /// Function to be called when the tab is opened. + /// + public virtual void OnOpen() + { + // ignored + } + /// /// Function to be called when the tab is closed. /// diff --git a/Dalamud/Interface/Internal/Windows/Settings/SettingsTab.cs b/Dalamud/Interface/Internal/Windows/Settings/SettingsTab.cs index 16b7749cb..d06fe0fb6 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/SettingsTab.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/SettingsTab.cs @@ -1,5 +1,6 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; + +using Dalamud.Interface.Utility; namespace Dalamud.Interface.Internal.Windows.Settings; @@ -16,7 +17,10 @@ public abstract class SettingsTab : IDisposable public virtual void OnOpen() { - // ignored + foreach (var settingsEntry in this.Entries) + { + settingsEntry.OnOpen(); + } } public virtual void OnClose() diff --git a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs index 97d9eac5c..47ba2c65f 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs @@ -5,9 +5,10 @@ using CheapLoc; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Internal.Windows.Settings.Tabs; -using Dalamud.Interface.Raii; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; -using Dalamud.Plugin.Internal; using Dalamud.Utility; using ImGuiNET; @@ -18,14 +19,7 @@ namespace Dalamud.Interface.Internal.Windows.Settings; /// internal class SettingsWindow : Window { - private readonly SettingsTab[] tabs = - { - new SettingsTabGeneral(), - new SettingsTabLook(), - new SettingsTabDtr(), - new SettingsTabExperimental(), - new SettingsTabAbout(), - }; + private SettingsTab[]? tabs; private string searchInput = string.Empty; @@ -48,6 +42,15 @@ internal class SettingsWindow : Window /// public override void OnOpen() { + this.tabs ??= new SettingsTab[] + { + new SettingsTabGeneral(), + new SettingsTabLook(), + new SettingsTabDtr(), + new SettingsTabExperimental(), + new SettingsTabAbout(), + }; + foreach (var settingsTab in this.tabs) { settingsTab.Load(); @@ -63,15 +66,13 @@ internal class SettingsWindow : Window { var configuration = Service.Get(); var interfaceManager = Service.Get(); + var fontAtlasFactory = Service.Get(); - var rebuildFont = - ImGui.GetIO().FontGlobalScale != configuration.GlobalUiScale || - interfaceManager.FontGamma != configuration.FontGammaLevel || - interfaceManager.UseAxis != configuration.UseAxisFontsFromGame; + var rebuildFont = !Equals(fontAtlasFactory.DefaultFontSpec, configuration.DefaultFontSpec); + rebuildFont |= !Equals(ImGui.GetIO().FontGlobalScale, configuration.GlobalUiScale); ImGui.GetIO().FontGlobalScale = configuration.GlobalUiScale; - interfaceManager.FontGammaOverride = null; - interfaceManager.UseAxisOverride = null; + fontAtlasFactory.DefaultFontSpecOverride = null; if (rebuildFont) interfaceManager.RebuildFonts(); @@ -154,6 +155,8 @@ internal class SettingsWindow : Window ImGui.EndTabItem(); } } + + ImGui.EndTabBar(); } ImGui.SetCursorPos(windowSize - ImGuiHelpers.ScaledVector2(70)); diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs index 325d0b8b7..8714fd666 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs @@ -1,19 +1,20 @@ -using System; -using System.Diagnostics; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using System.Numerics; using CheapLoc; using Dalamud.Game.Gui; using Dalamud.Interface.GameFonts; -using Dalamud.Interface.Raii; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; +using Dalamud.Storage.Assets; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game.UI; using ImGuiNET; -using ImGuiScene; namespace Dalamud.Interface.Internal.Windows.Settings.Tabs; @@ -170,21 +171,22 @@ Dalamud is licensed under AGPL v3 or later. Contribute at: https://github.com/goatcorp/Dalamud "; - private readonly TextureWrap logoTexture; private readonly Stopwatch creditsThrottler; + private readonly IFontAtlas privateAtlas; private string creditsText; private bool resetNow = false; - private GameFontHandle? thankYouFont; + private IDalamudTextureWrap? logoTexture; + private IFontHandle? thankYouFont; public SettingsTabAbout() { - var dalamud = Service.Get(); - var interfaceManager = Service.Get(); - - this.logoTexture = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "logo.png"))!; this.creditsThrottler = new(); + + this.privateAtlas = Service + .Get() + .CreateFontAtlas(nameof(SettingsTabAbout), FontAtlasAutoRebuildMode.Async); } public override SettingsEntry[] Entries { get; } = { }; @@ -209,11 +211,7 @@ Contribute at: https://github.com/goatcorp/Dalamud this.creditsThrottler.Restart(); - if (this.thankYouFont == null) - { - var gfm = Service.Get(); - this.thankYouFont = gfm.NewFontRef(new GameFontStyle(GameFontFamilyAndSize.TrumpGothic34)); - } + this.thankYouFont ??= this.privateAtlas.NewGameFontHandle(new(GameFontFamilyAndSize.TrumpGothic34)); this.resetNow = true; @@ -251,6 +249,7 @@ Contribute at: https://github.com/goatcorp/Dalamud const float imageSize = 190f; ImGui.SameLine((ImGui.GetWindowWidth() / 2) - (imageSize / 2)); + this.logoTexture ??= Service.Get().GetDalamudTextureWrap(DalamudAsset.Logo); ImGui.Image(this.logoTexture.ImGuiHandle, ImGuiHelpers.ScaledVector2(imageSize)); ImGuiHelpers.ScaledDummy(0, 20f); @@ -270,14 +269,12 @@ Contribute at: https://github.com/goatcorp/Dalamud if (this.thankYouFont != null) { - ImGui.PushFont(this.thankYouFont.ImFont); + using var fontPush = this.thankYouFont.Push(); var thankYouLenX = ImGui.CalcTextSize(ThankYouText).X; ImGui.Dummy(new Vector2((windowX / 2) - (thankYouLenX / 2), 0f)); ImGui.SameLine(); ImGui.TextUnformatted(ThankYouText); - - ImGui.PopFont(); } ImGuiHelpers.ScaledDummy(0, windowSize.Y + 50f); @@ -306,9 +303,5 @@ Contribute at: https://github.com/goatcorp/Dalamud /// /// Disposes of managed and unmanaged resources. /// - public override void Dispose() - { - this.logoTexture?.Dispose(); - this.thankYouFont?.Dispose(); - } + public override void Dispose() => this.privateAtlas.Dispose(); } diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabDtr.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabDtr.cs index 85cb8219f..7dd0fa5d1 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabDtr.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabDtr.cs @@ -8,6 +8,7 @@ using Dalamud.Configuration.Internal; using Dalamud.Game.Gui.Dtr; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; +using Dalamud.Interface.Utility; using ImGuiNET; namespace Dalamud.Interface.Internal.Windows.Settings.Tabs; diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs index 62981f4a2..c706a42c1 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabExperimental.cs @@ -6,6 +6,7 @@ using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.Settings.Widgets; +using Dalamud.Interface.Utility; using Dalamud.Plugin.Internal; using Dalamud.Utility; @@ -25,8 +26,16 @@ public class SettingsTabExperimental : SettingsTab c => c.DoPluginTest, (v, c) => c.DoPluginTest = v), new HintSettingsEntry( - Loc.Localize("DalamudSettingsPluginTestWarning", "Testing plugins may not have been vetted before being published. Please only enable this if you are aware of the risks."), + Loc.Localize("DalamudSettingsPluginTestWarning", "Testing plugins may contain bugs or crash your game. Please only enable this if you are aware of the risks."), ImGuiColors.DalamudRed), + + new GapSettingsEntry(5), + + new SettingsEntry( + Loc.Localize("DalamudSettingEnablePluginUIAdditionalOptions", "Add a button to the title bar of plugin windows to open additional options"), + Loc.Localize("DalamudSettingEnablePluginUIAdditionalOptionsHint", "This will allow you to pin certain plugin windows, make them clickthrough or adjust their opacity.\nThis may not be supported by all of your plugins. Contact the plugin author if you want them to support this feature."), + c => c.EnablePluginUiAdditionalOptions, + (v, c) => c.EnablePluginUiAdditionalOptions = v), new GapSettingsEntry(5), @@ -47,6 +56,7 @@ public class SettingsTabExperimental : SettingsTab new ThirdRepoSettingsEntry(), + /* Disabling profiles after they've been enabled doesn't make much sense, at least not if the user has already created profiles. new GapSettingsEntry(5, true), new SettingsEntry( @@ -54,6 +64,7 @@ public class SettingsTabExperimental : SettingsTab Loc.Localize("DalamudSettingsEnableProfilesHint", "Enables plugin collections, which lets you create toggleable lists of plugins."), c => c.ProfilesEnabled, (v, c) => c.ProfilesEnabled = v), + */ }; public override string Title => Loc.Localize("DalamudSettingsExperimental", "Experimental"); diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs index 3e801a8c3..5ccace850 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs @@ -1,10 +1,19 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using System.Text; using CheapLoc; using Dalamud.Configuration.Internal; +using Dalamud.Game; using Dalamud.Interface.Colors; +using Dalamud.Interface.FontIdentifier; +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.Internals; +using Dalamud.Interface.Utility; using Dalamud.Utility; using ImGuiNET; using Serilog; @@ -14,25 +23,21 @@ namespace Dalamud.Interface.Internal.Windows.Settings.Tabs; [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Internals")] public class SettingsTabLook : SettingsTab { + private static readonly (string, float)[] GlobalUiScalePresets = + { + ("80%##DalamudSettingsGlobalUiScaleReset96", 0.8f), + ("100%##DalamudSettingsGlobalUiScaleReset12", 1f), + ("117%##DalamudSettingsGlobalUiScaleReset14", 14 / 12f), + ("150%##DalamudSettingsGlobalUiScaleReset18", 1.5f), + ("200%##DalamudSettingsGlobalUiScaleReset24", 2f), + ("300%##DalamudSettingsGlobalUiScaleReset36", 3f), + }; + private float globalUiScale; - private float fontGamma; + private IFontSpec defaultFontSpec = null!; public override SettingsEntry[] Entries { get; } = { - new GapSettingsEntry(5), - - new SettingsEntry( - Loc.Localize("DalamudSettingToggleAxisFonts", "Use AXIS fonts as default Dalamud font"), - Loc.Localize("DalamudSettingToggleUiAxisFontsHint", "Use AXIS fonts (the game's main UI fonts) as default Dalamud font."), - c => c.UseAxisFontsFromGame, - (v, c) => c.UseAxisFontsFromGame = v, - v => - { - var im = Service.Get(); - im.UseAxisOverride = v; - im.RebuildFonts(); - }), - new GapSettingsEntry(5, true), new ButtonSettingsEntry( @@ -119,6 +124,12 @@ public class SettingsTabLook : SettingsTab Loc.Localize("DalamudSettingToggleTsmHint", "This will allow you to access certain Dalamud and Plugin functionality from the title screen."), c => c.ShowTsm, (v, c) => c.ShowTsm = v), + + new SettingsEntry( + Loc.Localize("DalamudSettingInstallerOpenDefault", "Open the Plugin Installer to the \"Installed Plugins\" tab by default"), + Loc.Localize("DalamudSettingInstallerOpenDefaultHint", "This will allow you to open the Plugin Installer to the \"Installed Plugins\" tab by default, instead of the \"Available Plugins\" tab."), + c => c.PluginInstallerOpen == PluginInstallerWindow.PluginInstallerOpenKind.InstalledPlugins, + (v, c) => c.PluginInstallerOpen = v ? PluginInstallerWindow.PluginInstallerOpenKind.InstalledPlugins : PluginInstallerWindow.PluginInstallerOpenKind.AllPlugins), }; public override string Title => Loc.Localize("DalamudSettingsVisual", "Look & Feel"); @@ -126,96 +137,109 @@ public class SettingsTabLook : SettingsTab public override void Draw() { var interfaceManager = Service.Get(); + var fontBuildTask = interfaceManager.FontBuildTask; - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3); + ImGui.AlignTextToFramePadding(); ImGui.Text(Loc.Localize("DalamudSettingsGlobalUiScale", "Global Font Scale")); - ImGui.SameLine(); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - 3); - if (ImGui.Button("9.6pt##DalamudSettingsGlobalUiScaleReset96")) + + var buttonSize = + GlobalUiScalePresets + .Select(x => ImGui.CalcTextSize(x.Item1, 0, x.Item1.IndexOf('#'))) + .Aggregate(Vector2.Zero, Vector2.Max) + + (ImGui.GetStyle().FramePadding * 2); + foreach (var (buttonLabel, scale) in GlobalUiScalePresets) { - this.globalUiScale = 9.6f / 12.0f; - ImGui.GetIO().FontGlobalScale = this.globalUiScale; - interfaceManager.RebuildFonts(); + ImGui.SameLine(); + if (ImGui.Button(buttonLabel, buttonSize) && Math.Abs(this.globalUiScale - scale) > float.Epsilon) + { + ImGui.GetIO().FontGlobalScale = this.globalUiScale = scale; + interfaceManager.RebuildFonts(); + } } - ImGui.SameLine(); - if (ImGui.Button("12pt##DalamudSettingsGlobalUiScaleReset12")) + if (!fontBuildTask.IsCompleted) { - this.globalUiScale = 1.0f; - ImGui.GetIO().FontGlobalScale = this.globalUiScale; - interfaceManager.RebuildFonts(); + ImGui.SameLine(); + var buildingFonts = Loc.Localize("DalamudSettingsFontBuildInProgressWithEndingThreeDots", "Building fonts..."); + unsafe + { + var len = Encoding.UTF8.GetByteCount(buildingFonts); + var p = stackalloc byte[len]; + Encoding.UTF8.GetBytes(buildingFonts, new(p, len)); + ImGuiNative.igTextUnformatted(p, (p + len + ((Environment.TickCount / 200) % 3)) - 2); + } } - ImGui.SameLine(); - if (ImGui.Button("14pt##DalamudSettingsGlobalUiScaleReset14")) + var globalUiScaleInPct = 100f * this.globalUiScale; + if (ImGui.DragFloat("##DalamudSettingsGlobalUiScaleDrag", ref globalUiScaleInPct, 1f, 80f, 300f, "%.0f%%", ImGuiSliderFlags.AlwaysClamp)) { - this.globalUiScale = 14.0f / 12.0f; - ImGui.GetIO().FontGlobalScale = this.globalUiScale; - interfaceManager.RebuildFonts(); - } - - ImGui.SameLine(); - if (ImGui.Button("18pt##DalamudSettingsGlobalUiScaleReset18")) - { - this.globalUiScale = 18.0f / 12.0f; - ImGui.GetIO().FontGlobalScale = this.globalUiScale; - interfaceManager.RebuildFonts(); - } - - ImGui.SameLine(); - if (ImGui.Button("24pt##DalamudSettingsGlobalUiScaleReset24")) - { - this.globalUiScale = 24.0f / 12.0f; - ImGui.GetIO().FontGlobalScale = this.globalUiScale; - interfaceManager.RebuildFonts(); - } - - ImGui.SameLine(); - if (ImGui.Button("36pt##DalamudSettingsGlobalUiScaleReset36")) - { - this.globalUiScale = 36.0f / 12.0f; - ImGui.GetIO().FontGlobalScale = this.globalUiScale; - interfaceManager.RebuildFonts(); - } - - var globalUiScaleInPt = 12f * this.globalUiScale; - if (ImGui.DragFloat("##DalamudSettingsGlobalUiScaleDrag", ref globalUiScaleInPt, 0.1f, 9.6f, 36f, "%.1fpt", ImGuiSliderFlags.AlwaysClamp)) - { - this.globalUiScale = globalUiScaleInPt / 12f; + this.globalUiScale = globalUiScaleInPct / 100f; ImGui.GetIO().FontGlobalScale = this.globalUiScale; interfaceManager.RebuildFonts(); } ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsGlobalUiScaleHint", "Scale text in all XIVLauncher UI elements - this is useful for 4K displays.")); + if (fontBuildTask.IsFaulted || fontBuildTask.IsCanceled) + { + ImGui.TextColored( + ImGuiColors.DalamudRed, + Loc.Localize("DalamudSettingsFontBuildFaulted", "Failed to load fonts as requested.")); + if (fontBuildTask.Exception is not null + && ImGui.CollapsingHeader("##DalamudSetingsFontBuildFaultReason")) + { + foreach (var e in fontBuildTask.Exception.InnerExceptions) + ImGui.TextUnformatted(e.ToString()); + } + } + ImGuiHelpers.ScaledDummy(5); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3); - ImGui.Text(Loc.Localize("DalamudSettingsFontGamma", "Font Gamma")); + if (ImGui.Button(Loc.Localize("DalamudSettingChooseDefaultFont", "Choose Default Font"))) + { + var faf = Service.Get(); + 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.Get().RunOnFrameworkThread( + () => + { + interfaceManager.Draw -= fcd.Draw; + fcd.Dispose(); + + _ = r.Exception; + if (!r.IsCompletedSuccessfully) + return; + + faf.DefaultFontSpecOverride = this.defaultFontSpec = r.Result; + interfaceManager.RebuildFonts(); + })); + } + ImGui.SameLine(); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - 3); - if (ImGui.Button(Loc.Localize("DalamudSettingsIndividualConfigResetToDefaultValue", "Reset") + "##DalamudSettingsFontGammaReset")) - { - this.fontGamma = 1.4f; - interfaceManager.FontGammaOverride = this.fontGamma; - interfaceManager.RebuildFonts(); - } - if (ImGui.DragFloat("##DalamudSettingsFontGammaDrag", ref this.fontGamma, 0.005f, 0.3f, 3f, "%.2f", ImGuiSliderFlags.AlwaysClamp)) + using (interfaceManager.MonoFontHandle?.Push()) { - interfaceManager.FontGammaOverride = this.fontGamma; - interfaceManager.RebuildFonts(); + if (ImGui.Button(Loc.Localize("DalamudSettingResetDefaultFont", "Reset Default Font"))) + { + var faf = Service.Get(); + faf.DefaultFontSpecOverride = + this.defaultFontSpec = + new SingleFontSpec { FontId = new GameFontAndFamilyId(GameFontFamily.Axis) }; + interfaceManager.RebuildFonts(); + } } - ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsFontGammaHint", "Changes the thickness of text.")); - base.Draw(); } public override void Load() { this.globalUiScale = Service.Get().GlobalUiScale; - this.fontGamma = Service.Get().FontGammaLevel; + this.defaultFontSpec = Service.Get().DefaultFontSpec; base.Load(); } @@ -223,6 +247,7 @@ public class SettingsTabLook : SettingsTab public override void Save() { Service.Get().GlobalUiScale = this.globalUiScale; + Service.Get().DefaultFontSpec = this.defaultFontSpec; base.Save(); } diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/ButtonSettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/ButtonSettingsEntry.cs index 9c635fb99..6adddbc82 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/ButtonSettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/ButtonSettingsEntry.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; using ImGuiNET; namespace Dalamud.Interface.Internal.Windows.Settings.Widgets; diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/DevPluginsSettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/DevPluginsSettingsEntry.cs index 3e73454f3..55deb61bc 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/DevPluginsSettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/DevPluginsSettingsEntry.cs @@ -11,7 +11,8 @@ using Dalamud.Configuration; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; -using Dalamud.Interface.Raii; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; using ImGuiNET; diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/GapSettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/GapSettingsEntry.cs index bc5c2fd0a..1db3c4756 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/GapSettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/GapSettingsEntry.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using Dalamud.Interface.Utility; using ImGuiNET; namespace Dalamud.Interface.Internal.Windows.Settings.Widgets; diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/HintSettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/HintSettingsEntry.cs index d1eb43c1f..3edd3ae1d 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/HintSettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/HintSettingsEntry.cs @@ -2,6 +2,7 @@ using System.Numerics; using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; namespace Dalamud.Interface.Internal.Windows.Settings.Widgets; diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs index 0bb373576..c8cc1f42c 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs @@ -7,6 +7,7 @@ using System.Linq; using CheapLoc; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; using ImGuiNET; namespace Dalamud.Interface.Internal.Windows.Settings.Widgets; @@ -30,17 +31,20 @@ public sealed class LanguageChooserSettingsEntry : SettingsEntry try { var locLanguagesList = new List(); - string locLanguage; foreach (var language in this.languages) { - if (language != "ko") + switch (language) { - locLanguage = CultureInfo.GetCultureInfo(language).NativeName; - locLanguagesList.Add(char.ToUpper(locLanguage[0]) + locLanguage[1..]); - } - else - { - locLanguagesList.Add("Korean"); + case "ko": + locLanguagesList.Add("Korean"); + break; + case "tw": + locLanguagesList.Add("中華民國國語"); + break; + default: + string locLanguage = CultureInfo.GetCultureInfo(language).NativeName; + locLanguagesList.Add(char.ToUpper(locLanguage[0]) + locLanguage[1..]); + break; } } diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/SettingsEntry{T}.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/SettingsEntry{T}.cs index 83be6a052..dcbb42089 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/SettingsEntry{T}.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/SettingsEntry{T}.cs @@ -7,7 +7,8 @@ using System.Linq; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; -using Dalamud.Interface.Raii; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; using ImGuiNET; diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/ThirdRepoSettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/ThirdRepoSettingsEntry.cs index be2e34a57..1d6aab1bd 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/ThirdRepoSettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/ThirdRepoSettingsEntry.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -10,8 +9,10 @@ using Dalamud.Configuration; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; -using Dalamud.Interface.Raii; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; +using Dalamud.Utility; using ImGuiNET; namespace Dalamud.Interface.Internal.Windows.Settings.Widgets; @@ -23,7 +24,13 @@ public class ThirdRepoSettingsEntry : SettingsEntry private bool thirdRepoListChanged; private string thirdRepoTempUrl = string.Empty; private string thirdRepoAddError = string.Empty; + private DateTime timeSinceOpened; + public override void OnOpen() + { + this.timeSinceOpened = DateTime.Now; + } + public override void OnClose() { this.thirdRepoList = @@ -51,6 +58,8 @@ public class ThirdRepoSettingsEntry : SettingsEntry public override void Draw() { + var config = Service.Get(); + using var id = ImRaii.PushId("thirdRepo"); ImGui.TextUnformatted(Loc.Localize("DalamudSettingsCustomRepo", "Custom Plugin Repositories")); if (this.thirdRepoListChanged) @@ -61,12 +70,61 @@ public class ThirdRepoSettingsEntry : SettingsEntry ImGui.TextUnformatted(Loc.Localize("DalamudSettingsChanged", "(Changed)")); } } - + ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingCustomRepoHint", "Add custom plugin repositories.")); - ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudRed, Loc.Localize("DalamudSettingCustomRepoWarning", "We cannot take any responsibility for third-party plugins and repositories.")); - ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudRed, Loc.Localize("DalamudSettingCustomRepoWarning2", "Plugins have full control over your PC, like any other program, and may cause harm or crashes.")); - ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudRed, Loc.Localize("DalamudSettingCustomRepoWarning4", "They can delete your character, upload your family photos and burn down your house.")); - ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudRed, Loc.Localize("DalamudSettingCustomRepoWarning3", "Please make absolutely sure that you only install third-party plugins from developers you trust.")); + + ImGuiHelpers.ScaledDummy(2); + + config.ThirdRepoSpeedbumpDismissed ??= config.ThirdRepoList.Any(x => x.IsEnabled); + var disclaimerDismissed = config.ThirdRepoSpeedbumpDismissed.Value; + + ImGui.PushFont(InterfaceManager.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudOrange); + ImGuiHelpers.SafeTextWrapped(FontAwesomeIcon.ExclamationTriangle.ToIconString()); + ImGui.PopFont(); + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(2); + ImGui.SameLine(); + ImGuiHelpers.SafeTextWrapped(Loc.Localize("DalamudSettingCustomRepoWarningReadThis", "READ THIS FIRST!")); + ImGui.SameLine(); + ImGuiHelpers.ScaledDummy(2); + ImGui.SameLine(); + ImGui.PushFont(InterfaceManager.IconFont); + ImGuiHelpers.SafeTextWrapped(FontAwesomeIcon.ExclamationTriangle.ToIconString()); + ImGui.PopFont(); + + ImGuiHelpers.SafeTextWrapped(Loc.Localize("DalamudSettingCustomRepoWarning", "We cannot take any responsibility for custom plugins and repositories.")); + ImGuiHelpers.SafeTextWrapped(Loc.Localize("DalamudSettingCustomRepoWarning5", "If someone told you to copy/paste something here, it's very possible that you are being scammed or taken advantage of.")); + ImGuiHelpers.SafeTextWrapped(Loc.Localize("DalamudSettingCustomRepoWarning2", "Plugins have full control over your PC, like any other program, and may cause harm or crashes.")); + ImGuiHelpers.SafeTextWrapped(Loc.Localize("DalamudSettingCustomRepoWarning4", "They can delete your character, steal your FC or Discord account, and burn down your house.")); + ImGuiHelpers.SafeTextWrapped(Loc.Localize("DalamudSettingCustomRepoWarning3", "Please make absolutely sure that you only install plugins from developers you trust.")); + + ImGui.PopStyleColor(); + + if (!disclaimerDismissed) + { + const int speedbumpTime = 15; + var elapsed = DateTime.Now - this.timeSinceOpened; + if (elapsed < TimeSpan.FromSeconds(speedbumpTime)) + { + ImGui.BeginDisabled(); + ImGui.Button( + Loc.Localize("DalamudSettingCustomRepoWarningPleaseWait", "Please wait {0} seconds...").Format(speedbumpTime - elapsed.Seconds)); + ImGui.EndDisabled(); + } + else + { + if (ImGui.Button(Loc.Localize("DalamudSettingCustomRepoWarningIReadIt", "Ok, I have read and understood this warning"))) + { + config.ThirdRepoSpeedbumpDismissed = true; + config.QueueSave(); + } + } + } + + ImGuiHelpers.ScaledDummy(2); + + using var disabled = ImRaii.Disabled(!disclaimerDismissed); ImGuiHelpers.ScaledDummy(5); diff --git a/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs b/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs index 419361b3b..9ee4123cd 100644 --- a/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs +++ b/Dalamud/Interface/Internal/Windows/StyleEditor/StyleEditorWindow.cs @@ -10,6 +10,7 @@ using Dalamud.Data; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.Style; +using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Utility; using ImGuiNET; @@ -42,7 +43,6 @@ public class StyleEditorWindow : Window this.SizeConstraints = new WindowSizeConstraints { MinimumSize = new Vector2(890, 560), - MaximumSize = new Vector2(10000, 10000), }; } @@ -210,121 +210,122 @@ public class StyleEditorWindow : Window if (ImGui.BeginTabItem(Loc.Localize("StyleEditorVariables", "Variables"))) { - ImGui.BeginChild($"ScrollingVars", ImGuiHelpers.ScaledVector2(0, -32), true, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.NoBackground); + if (ImGui.BeginChild($"ScrollingVars", ImGuiHelpers.ScaledVector2(0, -32), true, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.NoBackground)) + { + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - 5); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - 5); + ImGui.SliderFloat2("WindowPadding", ref style.WindowPadding, 0.0f, 20.0f, "%.0f"); + ImGui.SliderFloat2("FramePadding", ref style.FramePadding, 0.0f, 20.0f, "%.0f"); + ImGui.SliderFloat2("CellPadding", ref style.CellPadding, 0.0f, 20.0f, "%.0f"); + ImGui.SliderFloat2("ItemSpacing", ref style.ItemSpacing, 0.0f, 20.0f, "%.0f"); + ImGui.SliderFloat2("ItemInnerSpacing", ref style.ItemInnerSpacing, 0.0f, 20.0f, "%.0f"); + ImGui.SliderFloat2("TouchExtraPadding", ref style.TouchExtraPadding, 0.0f, 10.0f, "%.0f"); + ImGui.SliderFloat("IndentSpacing", ref style.IndentSpacing, 0.0f, 30.0f, "%.0f"); + ImGui.SliderFloat("ScrollbarSize", ref style.ScrollbarSize, 1.0f, 20.0f, "%.0f"); + ImGui.SliderFloat("GrabMinSize", ref style.GrabMinSize, 1.0f, 20.0f, "%.0f"); + ImGui.Text("Borders"); + ImGui.SliderFloat("WindowBorderSize", ref style.WindowBorderSize, 0.0f, 1.0f, "%.0f"); + ImGui.SliderFloat("ChildBorderSize", ref style.ChildBorderSize, 0.0f, 1.0f, "%.0f"); + ImGui.SliderFloat("PopupBorderSize", ref style.PopupBorderSize, 0.0f, 1.0f, "%.0f"); + ImGui.SliderFloat("FrameBorderSize", ref style.FrameBorderSize, 0.0f, 1.0f, "%.0f"); + ImGui.SliderFloat("TabBorderSize", ref style.TabBorderSize, 0.0f, 1.0f, "%.0f"); + ImGui.Text("Rounding"); + ImGui.SliderFloat("WindowRounding", ref style.WindowRounding, 0.0f, 12.0f, "%.0f"); + ImGui.SliderFloat("ChildRounding", ref style.ChildRounding, 0.0f, 12.0f, "%.0f"); + ImGui.SliderFloat("FrameRounding", ref style.FrameRounding, 0.0f, 12.0f, "%.0f"); + ImGui.SliderFloat("PopupRounding", ref style.PopupRounding, 0.0f, 12.0f, "%.0f"); + ImGui.SliderFloat("ScrollbarRounding", ref style.ScrollbarRounding, 0.0f, 12.0f, "%.0f"); + ImGui.SliderFloat("GrabRounding", ref style.GrabRounding, 0.0f, 12.0f, "%.0f"); + ImGui.SliderFloat("LogSliderDeadzone", ref style.LogSliderDeadzone, 0.0f, 12.0f, "%.0f"); + ImGui.SliderFloat("TabRounding", ref style.TabRounding, 0.0f, 12.0f, "%.0f"); + ImGui.Text("Alignment"); + ImGui.SliderFloat2("WindowTitleAlign", ref style.WindowTitleAlign, 0.0f, 1.0f, "%.2f"); + var windowMenuButtonPosition = (int)style.WindowMenuButtonPosition + 1; + if (ImGui.Combo("WindowMenuButtonPosition", ref windowMenuButtonPosition, "None\0Left\0Right\0")) + style.WindowMenuButtonPosition = (ImGuiDir)(windowMenuButtonPosition - 1); + ImGui.SliderFloat2("ButtonTextAlign", ref style.ButtonTextAlign, 0.0f, 1.0f, "%.2f"); + ImGui.SameLine(); + ImGuiComponents.HelpMarker("Alignment applies when a button is larger than its text content."); + ImGui.SliderFloat2("SelectableTextAlign", ref style.SelectableTextAlign, 0.0f, 1.0f, "%.2f"); + ImGui.SameLine(); + ImGuiComponents.HelpMarker("Alignment applies when a selectable is larger than its text content."); + ImGui.SliderFloat2("DisplaySafeAreaPadding", ref style.DisplaySafeAreaPadding, 0.0f, 30.0f, "%.0f"); + ImGui.SameLine(); + ImGuiComponents.HelpMarker( + "Adjust if you cannot see the edges of your screen (e.g. on a TV where scaling has not been configured)."); - ImGui.SliderFloat2("WindowPadding", ref style.WindowPadding, 0.0f, 20.0f, "%.0f"); - ImGui.SliderFloat2("FramePadding", ref style.FramePadding, 0.0f, 20.0f, "%.0f"); - ImGui.SliderFloat2("CellPadding", ref style.CellPadding, 0.0f, 20.0f, "%.0f"); - ImGui.SliderFloat2("ItemSpacing", ref style.ItemSpacing, 0.0f, 20.0f, "%.0f"); - ImGui.SliderFloat2("ItemInnerSpacing", ref style.ItemInnerSpacing, 0.0f, 20.0f, "%.0f"); - ImGui.SliderFloat2("TouchExtraPadding", ref style.TouchExtraPadding, 0.0f, 10.0f, "%.0f"); - ImGui.SliderFloat("IndentSpacing", ref style.IndentSpacing, 0.0f, 30.0f, "%.0f"); - ImGui.SliderFloat("ScrollbarSize", ref style.ScrollbarSize, 1.0f, 20.0f, "%.0f"); - ImGui.SliderFloat("GrabMinSize", ref style.GrabMinSize, 1.0f, 20.0f, "%.0f"); - ImGui.Text("Borders"); - ImGui.SliderFloat("WindowBorderSize", ref style.WindowBorderSize, 0.0f, 1.0f, "%.0f"); - ImGui.SliderFloat("ChildBorderSize", ref style.ChildBorderSize, 0.0f, 1.0f, "%.0f"); - ImGui.SliderFloat("PopupBorderSize", ref style.PopupBorderSize, 0.0f, 1.0f, "%.0f"); - ImGui.SliderFloat("FrameBorderSize", ref style.FrameBorderSize, 0.0f, 1.0f, "%.0f"); - ImGui.SliderFloat("TabBorderSize", ref style.TabBorderSize, 0.0f, 1.0f, "%.0f"); - ImGui.Text("Rounding"); - ImGui.SliderFloat("WindowRounding", ref style.WindowRounding, 0.0f, 12.0f, "%.0f"); - ImGui.SliderFloat("ChildRounding", ref style.ChildRounding, 0.0f, 12.0f, "%.0f"); - ImGui.SliderFloat("FrameRounding", ref style.FrameRounding, 0.0f, 12.0f, "%.0f"); - ImGui.SliderFloat("PopupRounding", ref style.PopupRounding, 0.0f, 12.0f, "%.0f"); - ImGui.SliderFloat("ScrollbarRounding", ref style.ScrollbarRounding, 0.0f, 12.0f, "%.0f"); - ImGui.SliderFloat("GrabRounding", ref style.GrabRounding, 0.0f, 12.0f, "%.0f"); - ImGui.SliderFloat("LogSliderDeadzone", ref style.LogSliderDeadzone, 0.0f, 12.0f, "%.0f"); - ImGui.SliderFloat("TabRounding", ref style.TabRounding, 0.0f, 12.0f, "%.0f"); - ImGui.Text("Alignment"); - ImGui.SliderFloat2("WindowTitleAlign", ref style.WindowTitleAlign, 0.0f, 1.0f, "%.2f"); - var windowMenuButtonPosition = (int)style.WindowMenuButtonPosition + 1; - if (ImGui.Combo("WindowMenuButtonPosition", ref windowMenuButtonPosition, "None\0Left\0Right\0")) - style.WindowMenuButtonPosition = (ImGuiDir)(windowMenuButtonPosition - 1); - ImGui.SliderFloat2("ButtonTextAlign", ref style.ButtonTextAlign, 0.0f, 1.0f, "%.2f"); - ImGui.SameLine(); - ImGuiComponents.HelpMarker("Alignment applies when a button is larger than its text content."); - ImGui.SliderFloat2("SelectableTextAlign", ref style.SelectableTextAlign, 0.0f, 1.0f, "%.2f"); - ImGui.SameLine(); - ImGuiComponents.HelpMarker("Alignment applies when a selectable is larger than its text content."); - ImGui.SliderFloat2("DisplaySafeAreaPadding", ref style.DisplaySafeAreaPadding, 0.0f, 30.0f, "%.0f"); - ImGui.SameLine(); - ImGuiComponents.HelpMarker( - "Adjust if you cannot see the edges of your screen (e.g. on a TV where scaling has not been configured)."); - ImGui.EndTabItem(); - - ImGui.EndChild(); + ImGui.EndChild(); + } ImGui.EndTabItem(); } if (ImGui.BeginTabItem(Loc.Localize("StyleEditorColors", "Colors"))) { - ImGui.BeginChild("ScrollingColors", ImGuiHelpers.ScaledVector2(0, -30), true, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.NoBackground); - - ImGui.SetCursorPosY(ImGui.GetCursorPosY() - 5); - - if (ImGui.RadioButton("Opaque", this.alphaFlags == ImGuiColorEditFlags.None)) - this.alphaFlags = ImGuiColorEditFlags.None; - ImGui.SameLine(); - if (ImGui.RadioButton("Alpha", this.alphaFlags == ImGuiColorEditFlags.AlphaPreview)) - this.alphaFlags = ImGuiColorEditFlags.AlphaPreview; - ImGui.SameLine(); - if (ImGui.RadioButton("Both", this.alphaFlags == ImGuiColorEditFlags.AlphaPreviewHalf)) - this.alphaFlags = ImGuiColorEditFlags.AlphaPreviewHalf; - ImGui.SameLine(); - - ImGuiComponents.HelpMarker( - "In the color list:\n" + - "Left-click on color square to open color picker,\n" + - "Right-click to open edit options menu."); - - foreach (var imGuiCol in Enum.GetValues()) + if (ImGui.BeginChild("ScrollingColors", ImGuiHelpers.ScaledVector2(0, -30), true, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.NoBackground)) { - if (imGuiCol == ImGuiCol.COUNT) - continue; + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - 5); - ImGui.PushID(imGuiCol.ToString()); + if (ImGui.RadioButton("Opaque", this.alphaFlags == ImGuiColorEditFlags.None)) + this.alphaFlags = ImGuiColorEditFlags.None; + ImGui.SameLine(); + if (ImGui.RadioButton("Alpha", this.alphaFlags == ImGuiColorEditFlags.AlphaPreview)) + this.alphaFlags = ImGuiColorEditFlags.AlphaPreview; + ImGui.SameLine(); + if (ImGui.RadioButton("Both", this.alphaFlags == ImGuiColorEditFlags.AlphaPreviewHalf)) + this.alphaFlags = ImGuiColorEditFlags.AlphaPreviewHalf; + ImGui.SameLine(); - ImGui.ColorEdit4("##color", ref style.Colors[(int)imGuiCol], ImGuiColorEditFlags.AlphaBar | this.alphaFlags); + ImGuiComponents.HelpMarker( + "In the color list:\n" + + "Left-click on color square to open color picker,\n" + + "Right-click to open edit options menu."); - ImGui.SameLine(0.0f, style.ItemInnerSpacing.X); - ImGui.TextUnformatted(imGuiCol.ToString()); - - ImGui.PopID(); - } - - ImGui.Separator(); - - foreach (var property in typeof(DalamudColors).GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - ImGui.PushID(property.Name); - - var colorVal = property.GetValue(workStyle.BuiltInColors); - if (colorVal == null) + foreach (var imGuiCol in Enum.GetValues()) { - colorVal = property.GetValue(StyleModelV1.DalamudStandard.BuiltInColors); - property.SetValue(workStyle.BuiltInColors, colorVal); + if (imGuiCol == ImGuiCol.COUNT) + continue; + + ImGui.PushID(imGuiCol.ToString()); + + ImGui.ColorEdit4("##color", ref style.Colors[(int)imGuiCol], ImGuiColorEditFlags.AlphaBar | this.alphaFlags); + + ImGui.SameLine(0.0f, style.ItemInnerSpacing.X); + ImGui.TextUnformatted(imGuiCol.ToString()); + + ImGui.PopID(); } - var color = (Vector4)colorVal; + ImGui.Separator(); - if (ImGui.ColorEdit4("##color", ref color, ImGuiColorEditFlags.AlphaBar | this.alphaFlags)) + foreach (var property in typeof(DalamudColors).GetProperties(BindingFlags.Public | BindingFlags.Instance)) { - property.SetValue(workStyle.BuiltInColors, color); - workStyle.BuiltInColors?.Apply(); + ImGui.PushID(property.Name); + + var colorVal = property.GetValue(workStyle.BuiltInColors); + if (colorVal == null) + { + colorVal = property.GetValue(StyleModelV1.DalamudStandard.BuiltInColors); + property.SetValue(workStyle.BuiltInColors, colorVal); + } + + var color = (Vector4)colorVal; + + if (ImGui.ColorEdit4("##color", ref color, ImGuiColorEditFlags.AlphaBar | this.alphaFlags)) + { + property.SetValue(workStyle.BuiltInColors, color); + workStyle.BuiltInColors?.Apply(); + } + + ImGui.SameLine(0.0f, style.ItemInnerSpacing.X); + ImGui.TextUnformatted(property.Name); + + ImGui.PopID(); } - ImGui.SameLine(0.0f, style.ItemInnerSpacing.X); - ImGui.TextUnformatted(property.Name); - - ImGui.PopID(); + ImGui.EndChild(); } - ImGui.EndChild(); - ImGui.EndTabItem(); } diff --git a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs index 10180f0c3..9c385a99c 100644 --- a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs +++ b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Numerics; @@ -9,10 +7,16 @@ using Dalamud.Game; using Dalamud.Game.ClientState; using Dalamud.Game.Gui; using Dalamud.Interface.Animation.EasingFunctions; -using Dalamud.Interface.Raii; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using Dalamud.Storage.Assets; +using Dalamud.Utility; + using ImGuiNET; -using ImGuiScene; namespace Dalamud.Interface.Internal.Windows; @@ -24,12 +28,19 @@ internal class TitleScreenMenuWindow : Window, IDisposable private const float TargetFontSizePt = 18f; private const float TargetFontSizePx = TargetFontSizePt * 4 / 3; - private readonly TextureWrap shadeTexture; + private readonly ClientState clientState; + private readonly DalamudConfiguration configuration; + private readonly GameGui gameGui; + private readonly TitleScreenMenu titleScreenMenu; + + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly IFontAtlas privateAtlas; + private readonly Lazy myFontHandle; + private readonly Lazy shadeTexture; private readonly Dictionary shadeEasings = new(); private readonly Dictionary moveEasings = new(); private readonly Dictionary logoEasings = new(); - private readonly Dictionary specialGlyphRequests = new(); private InOutCubic? fadeOutEasing; @@ -38,12 +49,31 @@ internal class TitleScreenMenuWindow : Window, IDisposable /// /// Initializes a new instance of the class. /// - public TitleScreenMenuWindow() + /// An instance of . + /// An instance of . + /// An instance of . + /// An instance of . + /// An instance of . + /// An instance of . + /// An instance of . + public TitleScreenMenuWindow( + ClientState clientState, + DalamudConfiguration configuration, + DalamudAssetManager dalamudAssetManager, + FontAtlasFactory fontAtlasFactory, + Framework framework, + GameGui gameGui, + TitleScreenMenu titleScreenMenu) : base( "TitleScreenMenuOverlay", ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNavFocus) { + this.clientState = clientState; + this.configuration = configuration; + this.gameGui = gameGui; + this.titleScreenMenu = titleScreenMenu; + this.IsOpen = true; this.DisableWindowSounds = true; this.ForceMainWindow = true; @@ -52,15 +82,25 @@ internal class TitleScreenMenuWindow : Window, IDisposable this.PositionCondition = ImGuiCond.Always; this.RespectCloseHotkey = false; - var dalamud = Service.Get(); - var interfaceManager = Service.Get(); + this.shadeTexture = new(() => dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TitleScreenMenuShade)); + this.privateAtlas = fontAtlasFactory.CreateFontAtlas(this.WindowName, FontAtlasAutoRebuildMode.Async); + this.scopedFinalizer.Add(this.privateAtlas); - var shadeTex = - interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "tsmShade.png")); - this.shadeTexture = shadeTex ?? throw new Exception("Could not load TSM background texture."); + this.myFontHandle = new( + () => this.scopedFinalizer.Add( + this.privateAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + toolkit => toolkit.AddDalamudDefaultFont( + TargetFontSizePx, + titleScreenMenu.Entries.SelectMany(x => x.Name).ToGlyphRange()))))); + + titleScreenMenu.EntryListChange += this.TitleScreenMenuEntryListChange; + this.scopedFinalizer.Add(() => titleScreenMenu.EntryListChange -= this.TitleScreenMenuEntryListChange); + + this.shadeTexture = new(() => dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TitleScreenMenuShade)); - var framework = Service.Get(); framework.Update += this.FrameworkOnUpdate; + this.scopedFinalizer.Add(() => framework.Update -= this.FrameworkOnUpdate); } private enum State @@ -69,6 +109,14 @@ internal class TitleScreenMenuWindow : Window, IDisposable Show, FadeOut, } + + /// + /// Gets or sets a value indicating whether drawing is allowed. + /// + public bool AllowDrawing { get; set; } = true; + + /// + public void Dispose() => this.scopedFinalizer.Dispose(); /// public override void PreDraw() @@ -85,28 +133,24 @@ internal class TitleScreenMenuWindow : Window, IDisposable base.PostDraw(); } - /// - public void Dispose() - { - this.shadeTexture.Dispose(); - var framework = Service.Get(); - framework.Update -= this.FrameworkOnUpdate; - } - /// public override void Draw() { + if (!this.AllowDrawing) + return; + var scale = ImGui.GetIO().FontGlobalScale; - - var tsm = Service.Get(); + var entries = this.titleScreenMenu.Entries; switch (this.state) { case State.Show: { - for (var i = 0; i < tsm.Entries.Count; i++) + var i = 0; + foreach (var entry in entries) { - var entry = tsm.Entries[i]; + if (!entry.IsShowConditionSatisfied()) + continue; if (!this.moveEasings.TryGetValue(entry.Id, out var moveEasing)) { @@ -126,7 +170,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable moveEasing.Update(); - var finalPos = (i + 1) * this.shadeTexture.Height * scale; + var finalPos = (i + 1) * this.shadeTexture.Value.Height * scale; var pos = moveEasing.Value * finalPos; // FIXME(goat): Sometimes, easings can overshoot and bring things out of alignment. @@ -140,6 +184,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable var cursor = ImGui.GetCursorPos(); cursor.Y = (float)pos; ImGui.SetCursorPos(cursor); + i++; } if (!ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows | @@ -172,17 +217,20 @@ internal class TitleScreenMenuWindow : Window, IDisposable using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, (float)this.fadeOutEasing.Value)) { - for (var i = 0; i < tsm.Entries.Count; i++) + var i = 0; + foreach (var entry in entries) { - var entry = tsm.Entries[i]; + if (!entry.IsShowConditionSatisfied()) + continue; - var finalPos = (i + 1) * this.shadeTexture.Height * scale; + var finalPos = (i + 1) * this.shadeTexture.Value.Height * scale; this.DrawEntry(entry, i != 0, true, i == 0, false, false); var cursor = ImGui.GetCursorPos(); cursor.Y = finalPos; ImGui.SetCursorPos(cursor); + i++; } } @@ -205,7 +253,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable case State.Hide: { - if (this.DrawEntry(tsm.Entries[0], true, false, true, true, false)) + if (this.DrawEntry(entries[0], true, false, true, true, false)) { this.state = State.Show; } @@ -216,33 +264,12 @@ internal class TitleScreenMenuWindow : Window, IDisposable break; } } - - var srcText = tsm.Entries.Select(e => e.Name).ToHashSet(); - var keys = this.specialGlyphRequests.Keys.ToHashSet(); - keys.RemoveWhere(x => srcText.Contains(x)); - foreach (var key in keys) - { - this.specialGlyphRequests[key].Dispose(); - this.specialGlyphRequests.Remove(key); - } } private bool DrawEntry( - TitleScreenMenu.TitleScreenMenuEntry entry, bool inhibitFadeout, bool showText, bool isFirst, bool overrideAlpha, bool interactable) + TitleScreenMenuEntry entry, bool inhibitFadeout, bool showText, bool isFirst, bool overrideAlpha, bool interactable) { - InterfaceManager.SpecialGlyphRequest fontHandle; - if (this.specialGlyphRequests.TryGetValue(entry.Name, out fontHandle) && fontHandle.Size != TargetFontSizePx) - { - fontHandle.Dispose(); - this.specialGlyphRequests.Remove(entry.Name); - fontHandle = null; - } - - if (fontHandle == null) - this.specialGlyphRequests[entry.Name] = fontHandle = Service.Get().NewFontSizeRef(TargetFontSizePx, entry.Name); - - ImGui.PushFont(fontHandle.Font); - ImGui.SetWindowFontScale(TargetFontSizePx / fontHandle.Size); + using var fontScopeDispose = this.myFontHandle.Value.Push(); var scale = ImGui.GetIO().FontGlobalScale; @@ -256,7 +283,8 @@ internal class TitleScreenMenuWindow : Window, IDisposable using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, (float)shadeEasing.Value)) { - ImGui.Image(this.shadeTexture.ImGuiHandle, new Vector2(this.shadeTexture.Width * scale, this.shadeTexture.Height * scale)); + var texture = this.shadeTexture.Value; + ImGui.Image(texture.ImGuiHandle, new Vector2(texture.Width, texture.Height) * scale); } var isHover = ImGui.IsItemHovered(); @@ -334,7 +362,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable // Drop shadow using (ImRaii.PushColor(ImGuiCol.Text, 0xFF000000)) { - for (int i = 0, i_ = (int)Math.Ceiling(1 * scale); i < i_; i++) + for (int i = 0, to = (int)Math.Ceiling(1 * scale); i < to; i++) { ImGui.SetCursorPos(new Vector2(cursor.X, cursor.Y + i)); ImGui.Text(entry.Name); @@ -352,25 +380,22 @@ internal class TitleScreenMenuWindow : Window, IDisposable initialCursor.Y += entry.Texture.Height * scale; ImGui.SetCursorPos(initialCursor); - ImGui.PopFont(); - return isHover; } - private void FrameworkOnUpdate(Framework framework) + private void FrameworkOnUpdate(IFramework unused) { - var clientState = Service.Get(); - this.IsOpen = !clientState.IsLoggedIn; + this.IsOpen = !this.clientState.IsLoggedIn; - var configuration = Service.Get(); - if (!configuration.ShowTsm) + if (!this.configuration.ShowTsm) this.IsOpen = false; - var gameGui = Service.Get(); - var charaSelect = gameGui.GetAddonByName("CharaSelect", 1); - var charaMake = gameGui.GetAddonByName("CharaMake", 1); - var titleDcWorldMap = gameGui.GetAddonByName("TitleDCWorldMap", 1); + var charaSelect = this.gameGui.GetAddonByName("CharaSelect", 1); + var charaMake = this.gameGui.GetAddonByName("CharaMake", 1); + var titleDcWorldMap = this.gameGui.GetAddonByName("TitleDCWorldMap", 1); if (charaMake != IntPtr.Zero || charaSelect != IntPtr.Zero || titleDcWorldMap != IntPtr.Zero) this.IsOpen = false; } + + private void TitleScreenMenuEntryListChange() => this.privateAtlas.BuildFontsAsync(); } diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasAutoRebuildMode.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasAutoRebuildMode.cs new file mode 100644 index 000000000..50e591390 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasAutoRebuildMode.cs @@ -0,0 +1,22 @@ +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// How to rebuild . +/// +public enum FontAtlasAutoRebuildMode +{ + /// + /// Do not rebuild. + /// + Disable, + + /// + /// Rebuild on new frame. + /// + OnNewFrame, + + /// + /// Rebuild asynchronously. + /// + Async, +} diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStep.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStep.cs new file mode 100644 index 000000000..dcfcc32e3 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStep.cs @@ -0,0 +1,30 @@ +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Build step for . +/// +public enum FontAtlasBuildStep +{ + // Note: leave 0 alone; make default(FontAtlasBuildStep) not have a valid value + + /// + /// Called before calling .
+ /// Expect to be passed.
+ /// When called from , this will be called before the delegates + /// passed to . + ///
+ PreBuild = 1, + + /// + /// Called after calling .
+ /// Expect to be passed.
+ /// When called from , this will be called after the delegates + /// passed to ; you can do cross-font operations here.
+ ///
+ /// This callback is not guaranteed to happen after , + /// but it will never happen on its own. + ///
+ PostBuild = 2, +} diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStepDelegate.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStepDelegate.cs new file mode 100644 index 000000000..2ed88102f --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStepDelegate.cs @@ -0,0 +1,14 @@ +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Delegate to be called when a font needs to be built. +/// +/// A toolkit that may help you for font building steps. +/// +/// An implementation of may implement all of +/// and .
+/// Either use to identify the build step, or use +/// and +/// for routing. +///
+public delegate void FontAtlasBuildStepDelegate(IFontAtlasBuildToolkit toolkit); diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildToolkitUtilities.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildToolkitUtilities.cs new file mode 100644 index 000000000..4c3e9023a --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildToolkitUtilities.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Convenience function for building fonts through . +/// +public static class FontAtlasBuildToolkitUtilities +{ + /// + /// Compiles given s into an array of containing ImGui glyph ranges. + /// + /// The chars. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// The compiled range. + public static ushort[] ToGlyphRange( + this IEnumerable enumerable, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) + { + using var builderScoped = ImGuiHelpers.NewFontGlyphRangeBuilderPtrScoped(out var builder); + foreach (var c in enumerable) + builder.AddChar(c); + return builder.BuildRangesToArray(addFallbackCodepoints, addEllipsisCodepoints); + } + + /// + /// Compiles given s into an array of containing ImGui glyph ranges. + /// + /// The chars. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// The compiled range. + public static ushort[] ToGlyphRange( + this ReadOnlySpan span, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) + { + using var builderScoped = ImGuiHelpers.NewFontGlyphRangeBuilderPtrScoped(out var builder); + foreach (var c in span) + builder.AddChar(c); + return builder.BuildRangesToArray(addFallbackCodepoints, addEllipsisCodepoints); + } + + /// + /// Compiles given string into an array of containing ImGui glyph ranges. + /// + /// The string. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// The compiled range. + public static ushort[] ToGlyphRange( + this string @string, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) => + @string.AsSpan().ToGlyphRange(addFallbackCodepoints, addEllipsisCodepoints); + + /// + /// Finds the corresponding in + /// . that corresponds to the + /// specified font . + /// + /// The toolkit. + /// The font. + /// The relevant config pointer, or empty config pointer if not found. + public static unsafe ImFontConfigPtr FindConfigPtr(this IFontAtlasBuildToolkit toolkit, ImFontPtr fontPtr) + { + foreach (ref var c in toolkit.NewImAtlas.ConfigDataWrapped().DataSpan) + { + if (c.DstFont == fontPtr.NativePtr) + return new((nint)Unsafe.AsPointer(ref c)); + } + + return default; + } + + /// + /// Invokes + /// if of + /// is . + /// + /// The toolkit. + /// The action. + /// This, for method chaining. + public static IFontAtlasBuildToolkit OnPreBuild( + this IFontAtlasBuildToolkit toolkit, + Action action) + { + if (toolkit.BuildStep is FontAtlasBuildStep.PreBuild) + action.Invoke((IFontAtlasBuildToolkitPreBuild)toolkit); + return toolkit; + } + + /// + /// Invokes + /// if of + /// is . + /// + /// The toolkit. + /// The action. + /// toolkit, for method chaining. + public static IFontAtlasBuildToolkit OnPostBuild( + this IFontAtlasBuildToolkit toolkit, + Action action) + { + if (toolkit.BuildStep is FontAtlasBuildStep.PostBuild) + action.Invoke((IFontAtlasBuildToolkitPostBuild)toolkit); + return toolkit; + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/FontScaleMode.cs b/Dalamud/Interface/ManagedFontAtlas/FontScaleMode.cs new file mode 100644 index 000000000..b30d5c26c --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontScaleMode.cs @@ -0,0 +1,33 @@ +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Specifies how should global font scale affect a font. +/// +public enum FontScaleMode +{ + /// + /// Do the default handling. Dalamud will load the sufficienty large font that will accomodate the global scale, + /// and stretch the loaded glyphs so that they look pixel-perfect after applying global scale on drawing. + /// Note that bitmap fonts and game fonts will always look blurry if they're not in their original sizes. + /// + Default, + + /// + /// Do nothing with the font. Dalamud will load the font with the size that is exactly as specified. + /// On drawing, the font will look blurry due to stretching. + /// Intended for use with custom scale handling. + /// + SkipHandling, + + /// + /// Stretch the glyphs of the loaded font by the inverse of the global scale. + /// On drawing, the font will always render exactly as the requested size without blurring, as long as + /// and do not affect the scale any + /// further. Note that bitmap fonts and game fonts will always look blurry if they're not in their original sizes. + /// + UndoGlobalScale, +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs new file mode 100644 index 000000000..2feac8849 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs @@ -0,0 +1,163 @@ +using System.Threading.Tasks; + +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Wrapper for .
+/// Not intended for plugins to implement. +///
+public interface IFontAtlas : IDisposable +{ + /// + /// Event to be called on build step changes.
+ /// is meaningless for this event. + ///
+ event FontAtlasBuildStepDelegate? BuildStepChange; + + /// + /// Event fired when a font rebuild operation is recommended.
+ /// This event will be invoked from the main thread.
+ ///
+ /// Reasons for the event include changes in and + /// initialization of new associated font handles. + ///
+ /// + /// You should call or + /// if is not set to true.
+ /// Avoid calling here; it will block the main thread. + ///
+ event Action? RebuildRecommend; + + /// + /// Gets the name of the atlas. For logging and debugging purposes. + /// + string Name { get; } + + /// + /// Gets a value how the atlas should be rebuilt when the relevant Dalamud Configuration changes. + /// + FontAtlasAutoRebuildMode AutoRebuildMode { get; } + + /// + /// Gets the font atlas. Might be empty. + /// + ImFontAtlasPtr ImAtlas { get; } + + /// + /// Gets the task that represents the current font rebuild state. + /// + Task BuildTask { get; } + + /// + /// Gets a value indicating whether there exists any built atlas, regardless of . + /// + bool HasBuiltAtlas { get; } + + /// + /// Gets a value indicating whether this font atlas is under the effect of global scale. + /// + bool IsGlobalScaled { get; } + + /// + /// Suppresses automatically rebuilding fonts for the scope. + /// + /// An instance of that will release the suppression. + /// + /// Use when you will be creating multiple new handles, and want rebuild to trigger only when you're done doing so. + /// This function will effectively do nothing, if is set to + /// . + /// + /// + /// + /// using (atlas.SuppressBuild()) { + /// this.font1 = atlas.NewGameFontHandle(...); + /// this.font2 = atlas.NewDelegateFontHandle(...); + /// } + /// + /// + public IDisposable SuppressAutoRebuild(); + + /// Creates a new from game's built-in fonts. + /// Font to use. + /// Handle to a font that may or may not be ready yet. + /// When called during , + /// , , and alike. Move the font handle + /// creating code outside those handlers, and only initialize them once. Call + /// on a previous font handle if you're replacing one. + /// This function does not throw. will be populated instead, if + /// the build procedure has failed. can be used regardless of the state of the font + /// handle. + public IFontHandle NewGameFontHandle(GameFontStyle style); + + /// Creates a new IFontHandle using your own callbacks. + /// Callback for . + /// Handle to a font that may or may not be ready yet. + /// When called during , + /// , , and alike. Move the font handle + /// creating code outside those handlers, and only initialize them once. Call + /// on a previous font handle if you're replacing one. + /// Consider calling to + /// support glyphs that are not supplied by the game by default; this mostly affects Chinese and Korean language + /// users. + /// + /// Consider calling to + /// support glyphs that are not supplied by the game by default; this mostly affects Chinese and Korean language + /// users. + /// This function does not throw, even if would throw exceptions. + /// Instead, if it fails, the returned handle will contain an property + /// containing the exception happened during the build process. can be used even if + /// the build process has not been completed yet or failed. + /// + /// + /// On initialization: + /// + /// this.fontHandle = atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { + /// var config = new SafeFontConfig { SizePx = UiBuilder.DefaultFontSizePx }; + /// config.MergeFont = tk.AddFontFromFile(@"C:\Windows\Fonts\comic.ttf", config); + /// tk.AddGameSymbol(config); + /// tk.AddExtraGlyphsForDalamudLanguage(config); + /// // optionally do the following if you have to add more than one font here, + /// // to specify which font added during this delegate is the final font to use. + /// tk.Font = config.MergeFont; + /// })); + /// // or + /// this.fontHandle = atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => tk.AddDalamudDefaultFont(36))); + /// + ///
+ /// On use: + /// + /// using (this.fontHandle.Push()) + /// ImGui.TextUnformatted("Example"); + /// + ///
+ public IFontHandle NewDelegateFontHandle(FontAtlasBuildStepDelegate buildStepDelegate); + + /// + /// Queues rebuilding fonts, on the main thread.
+ /// Note that would not necessarily get changed from calling this function. + ///
+ /// If is . + /// + /// Using this method will block the main thread on rebuilding fonts, effectively calling + /// from the main thread. Consider migrating to . + /// + void BuildFontsOnNextFrame(); + + /// + /// Rebuilds fonts immediately, on the current thread. + /// + /// If is . + void BuildFontsImmediately(); + + /// + /// Rebuilds fonts asynchronously, on any thread. + /// + /// The task. + /// If is . + Task BuildFontsAsync(); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkit.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkit.cs new file mode 100644 index 000000000..158366b12 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkit.cs @@ -0,0 +1,92 @@ +using System.Runtime.InteropServices; + +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Common stuff for and .
+/// Not intended for plugins to implement. +///
+public interface IFontAtlasBuildToolkit +{ + /// + /// Functionalities for compatibility behavior.
+ ///
+ [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + internal interface IApi9Compat : IFontAtlasBuildToolkit + { + /// + /// Invokes , temporarily applying s.
+ ///
+ /// The action to invoke. + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public void FromUiBuilderObsoleteEventHandlers(Action action); + } + + /// + /// Gets or sets the font relevant to the call. + /// + ImFontPtr Font { get; set; } + + /// + /// Gets the current scale this font atlas is being built with. + /// + float Scale { get; } + + /// + /// Gets a value indicating whether the current build operation is asynchronous. + /// + bool IsAsyncBuildOperation { get; } + + /// + /// Gets the current build step. + /// + FontAtlasBuildStep BuildStep { get; } + + /// + /// Gets the font atlas being built. + /// + ImFontAtlasPtr NewImAtlas { get; } + + /// + /// Gets the wrapper for of .
+ /// This does not need to be disposed. Calling does nothing.- + ///
+ /// Modification of this vector may result in undefined behaviors. + ///
+ ImVectorWrapper Fonts { get; } + + /// + /// Queues an item to be disposed after the native atlas gets disposed, successful or not. + /// + /// Disposable type. + /// The disposable. + /// The same . + T DisposeWithAtlas(T disposable) where T : IDisposable; + + /// + /// Queues an item to be disposed after the native atlas gets disposed, successful or not. + /// + /// The gc handle. + /// The same . + GCHandle DisposeWithAtlas(GCHandle gcHandle); + + /// + /// Queues an item to be disposed after the native atlas gets disposed, successful or not. + /// + /// The action to run on dispose. + void DisposeWithAtlas(Action action); + + /// + /// Gets the instance of corresponding to + /// from . + /// + /// The font handle. + /// The corresonding , or default if not found. + ImFontPtr GetFont(IFontHandle fontHandle); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPostBuild.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPostBuild.cs new file mode 100644 index 000000000..827187063 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPostBuild.cs @@ -0,0 +1,53 @@ +using Dalamud.Interface.Internal; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Toolkit for use when the build state is .
+/// Not intended for plugins to implement. +///
+public interface IFontAtlasBuildToolkitPostBuild : IFontAtlasBuildToolkit +{ + /// + [Obsolete($"Use {nameof(this.GetFontScaleMode)}")] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + bool IsGlobalScaleIgnored(ImFontPtr fontPtr) => this.GetFontScaleMode(fontPtr) == FontScaleMode.UndoGlobalScale; + + /// + FontScaleMode GetFontScaleMode(ImFontPtr fontPtr); + + /// + /// Stores a texture to be managed with the atlas. + /// + /// The texture wrap. + /// Dispose the wrap on error. + /// The texture index. + int StoreTexture(IDalamudTextureWrap textureWrap, bool disposeOnError); + + /// + /// Copies glyphs across fonts, in a safer way.
+ /// If the font does not belong to the current atlas, this function is a no-op. + ///
+ /// Source font. + /// Target font. + /// Whether to copy missing glyphs only. + /// Whether to call target.BuildLookupTable(). + /// Low codepoint range to copy. + /// High codepoing range to copy. + void CopyGlyphsAcrossFonts( + ImFontPtr source, + ImFontPtr target, + bool missingOnly, + bool rebuildLookupTable = true, + char rangeLow = ' ', + char rangeHigh = '\uFFFE'); + + /// + /// Calls , with some fixups. + /// + /// The font. + void BuildLookupTable(ImFontPtr font); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPreBuild.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPreBuild.cs new file mode 100644 index 000000000..9b80d27ff --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPreBuild.cs @@ -0,0 +1,225 @@ +using System.IO; +using System.Runtime.InteropServices; + +using Dalamud.Interface.FontIdentifier; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Toolkit for use when the build state is .
+/// Not intended for plugins to implement.
+///
+/// After returns, +/// either must be set, +/// or at least one font must have been added to the atlas using one of AddFont... functions. +///
+public interface IFontAtlasBuildToolkitPreBuild : IFontAtlasBuildToolkit +{ + /// + /// Queues an item to be disposed after the whole build process gets complete, successful or not. + /// + /// Disposable type. + /// The disposable. + /// The same . + T DisposeAfterBuild(T disposable) where T : IDisposable; + + /// + /// Queues an item to be disposed after the whole build process gets complete, successful or not. + /// + /// The gc handle. + /// The same . + GCHandle DisposeAfterBuild(GCHandle gcHandle); + + /// + /// Queues an item to be disposed after the whole build process gets complete, successful or not. + /// + /// The action to run on dispose. + void DisposeAfterBuild(Action action); + + /// + /// Excludes given font from global scaling. + /// + /// The font. + /// Same with . + [Obsolete( + $"Use {nameof(this.SetFontScaleMode)} with {nameof(FontScaleMode)}.{nameof(FontScaleMode.UndoGlobalScale)}")] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + ImFontPtr IgnoreGlobalScale(ImFontPtr fontPtr) => this.SetFontScaleMode(fontPtr, FontScaleMode.UndoGlobalScale); + + /// + /// Gets whether global scaling is ignored for the given font. + /// + /// The font. + /// True if ignored. + [Obsolete($"Use {nameof(this.GetFontScaleMode)}")] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + bool IsGlobalScaleIgnored(ImFontPtr fontPtr) => this.GetFontScaleMode(fontPtr) == FontScaleMode.UndoGlobalScale; + + /// + /// Sets the scaling mode for the given font. + /// + /// The font, returned from and alike. + /// Note that property is not guaranteed to be automatically updated upon + /// calling font adding functions. Pass the return value from font adding functions, not + /// property. + /// The scaling mode. + /// . + ImFontPtr SetFontScaleMode(ImFontPtr fontPtr, FontScaleMode mode); + + /// + /// Gets the scaling mode for the given font. + /// + /// The font. + /// The scaling mode. + FontScaleMode GetFontScaleMode(ImFontPtr fontPtr); + + /// + /// Registers a function to be run after build. + /// + /// The action to run. + void RegisterPostBuild(Action action); + + /// + /// Adds a font from memory region allocated using .
+ /// It WILL crash if you try to use a memory pointer allocated in some other way.
+ /// + /// Do NOT call on the once this function has + /// been called, unless is set and the function has thrown an error. + /// + ///
+ /// Memory address for the data allocated using . + /// The size of the font file.. + /// The font config. + /// Free if an exception happens. + /// A debug tag. + /// The newly added font. + unsafe ImFontPtr AddFontFromImGuiHeapAllocatedMemory( + nint dataPointer, + int dataSize, + in SafeFontConfig fontConfig, + bool freeOnException, + string debugTag) + => this.AddFontFromImGuiHeapAllocatedMemory( + (void*)dataPointer, + dataSize, + fontConfig, + freeOnException, + debugTag); + + /// + /// Adds a font from memory region allocated using .
+ /// It WILL crash if you try to use a memory pointer allocated in some other way.
+ /// + /// Do NOT call on the once this function has + /// been called, unless is set and the function has thrown an error. + /// + ///
+ /// Memory address for the data allocated using . + /// The size of the font file.. + /// The font config. + /// Free if an exception happens. + /// A debug tag. + /// The newly added font. + unsafe ImFontPtr AddFontFromImGuiHeapAllocatedMemory( + void* dataPointer, + int dataSize, + in SafeFontConfig fontConfig, + bool freeOnException, + string debugTag); + + /// + /// Adds a font from a file. + /// + /// The file path to create a new font from. + /// The font config. + /// The newly added font. + ImFontPtr AddFontFromFile(string path, in SafeFontConfig fontConfig); + + /// + /// Adds a font from a stream. + /// + /// The stream to create a new font from. + /// The font config. + /// Dispose when this function returns or throws. + /// A debug tag. + /// The newly added font. + ImFontPtr AddFontFromStream(Stream stream, in SafeFontConfig fontConfig, bool leaveOpen, string debugTag); + + /// + /// Adds a font from memory. + /// + /// The span to create from. + /// The font config. + /// A debug tag. + /// The newly added font. + ImFontPtr AddFontFromMemory(ReadOnlySpan span, in SafeFontConfig fontConfig, string debugTag); + + /// + /// Adds the default font known to the current font atlas.
+ ///
+ /// Includes and .
+ /// As this involves adding multiple fonts, calling this function will set + /// as the return value of this function, if it was empty before. + ///
+ /// + /// Font size in pixels. + /// If a negative value is supplied, + /// (. * ) will be + /// used as the font size. Specify -1 to use the default font size. + /// + /// The glyph ranges. Use .ToGlyphRange to build. + /// A font returned from . + ImFontPtr AddDalamudDefaultFont(float sizePx, ushort[]? glyphRanges = null); + + /// + /// Adds a font that is shipped with Dalamud.
+ ///
+ /// Note: if game symbols font file is requested but is unavailable, + /// then it will take the glyphs from game's built-in fonts, and everything in + /// will be ignored but , , + /// and . + ///
+ /// The font type. + /// The font config. + /// The added font. + ImFontPtr AddDalamudAssetFont(DalamudAsset asset, in SafeFontConfig fontConfig); + + /// + /// Same with (, ...), + /// but using only FontAwesome icon ranges.
+ /// will be ignored. + ///
+ /// The font config. + /// The added font. + ImFontPtr AddFontAwesomeIconFont(in SafeFontConfig fontConfig); + + /// + /// Adds the game's symbols into the provided font.
+ /// will be ignored.
+ /// If the game symbol font file is unavailable, only will be honored. + ///
+ /// The font config. + /// The added font. + ImFontPtr AddGameSymbol(in SafeFontConfig fontConfig); + + /// + /// Adds the game glyphs to the font. + /// + /// The font style. + /// The glyph ranges. + /// The font to merge to. If empty, then a new font will be created. + /// The added font. + ImFontPtr AddGameGlyphs(GameFontStyle gameFontStyle, ushort[]? glyphRanges, ImFontPtr mergeFont); + + /// + /// Adds glyphs of extra languages into the provided font, depending on Dalamud Configuration.
+ /// will be ignored. + ///
+ /// The font config. + void AttachExtraGlyphsForDalamudLanguage(in SafeFontConfig fontConfig); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs new file mode 100644 index 000000000..0a9e9072e --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs @@ -0,0 +1,94 @@ +using System.Threading.Tasks; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Represents a reference counting handle for fonts.
+/// Not intended for plugins to implement. +///
+public interface IFontHandle : IDisposable +{ + /// + /// Delegate for . + /// + /// The relevant font handle. + /// The locked font for this font handle, locked during the call of this delegate. + public delegate void ImFontChangedDelegate(IFontHandle fontHandle, ILockedImFont lockedFont); + + /// + /// Called when the built instance of has been changed.
+ /// This event can be invoked outside the main thread. + ///
+ event ImFontChangedDelegate ImFontChanged; + + /// + /// Gets the load exception, if it failed to load. Otherwise, it is null. + /// + Exception? LoadException { get; } + + /// + /// Gets a value indicating whether this font is ready for use. + /// + /// + /// Use directly if you want to keep the current ImGui font if the font is not ready.
+ /// Alternatively, use to wait for this property to become true. + ///
+ bool Available { get; } + + /// + /// Locks the fully constructed instance of corresponding to the this + /// , for use in any thread.
+ /// Modification of the font will exhibit undefined behavior if some other thread also uses the font. + ///
+ /// An instance of that must be disposed after use. + /// + /// Calling . will not unlock the + /// locked by this function. + /// + /// If is false. + ILockedImFont Lock(); + + /// + /// Pushes the current font into ImGui font stack, if available.
+ /// Use to access the current font.
+ /// You may not access the font once you dispose this object. + ///
+ /// A disposable object that will pop the font on dispose. + /// If called outside of the main thread. + /// + /// This function uses , and may do extra things. + /// Use or to undo this operation. + /// Do not use . + /// + /// + /// Push a font with `using` clause. + /// + /// using (fontHandle.Push()) + /// ImGui.TextUnformatted("Test"); + /// + /// Push a font with a matching call to . + /// + /// fontHandle.Push(); + /// ImGui.TextUnformatted("Test 2"); + /// + /// Push a font between two choices. + /// + /// using ((someCondition ? myFontHandle : dalamudPluginInterface.UiBuilder.MonoFontHandle).Push()) + /// ImGui.TextUnformatted("Test 3"); + /// + /// + IDisposable Push(); + + /// + /// Pops the font pushed to ImGui using , cleaning up any extra information as needed. + /// + void Pop(); + + /// + /// Waits for to become true. + /// + /// A task containing this . + Task WaitAsync(); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/ILockedImFont.cs b/Dalamud/Interface/ManagedFontAtlas/ILockedImFont.cs new file mode 100644 index 000000000..a4cc3afa7 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/ILockedImFont.cs @@ -0,0 +1,22 @@ +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// The wrapper for , guaranteeing that the associated data will be available as long as +/// this struct is not disposed.
+/// Not intended for plugins to implement. +///
+public interface ILockedImFont : IDisposable +{ + /// + /// Gets the associated . + /// + ImFontPtr ImFont { get; } + + /// + /// Creates a new instance of with an additional reference to the owner. + /// + /// The new locked instance. + ILockedImFont NewRef(); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/DelegateFontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/DelegateFontHandle.cs new file mode 100644 index 000000000..b13c60a53 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/DelegateFontHandle.cs @@ -0,0 +1,303 @@ +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// A font handle representing a user-callback generated font. +/// +internal sealed class DelegateFontHandle : FontHandle +{ + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// Callback for . + public DelegateFontHandle(IFontHandleManager manager, FontAtlasBuildStepDelegate callOnBuildStepChange) + : base(manager) + { + this.CallOnBuildStepChange = callOnBuildStepChange; + } + + /// + /// Gets the function to be called on build step changes. + /// + public FontAtlasBuildStepDelegate CallOnBuildStepChange { get; } + + /// + /// Manager for s. + /// + internal sealed class HandleManager : IFontHandleManager + { + private readonly HashSet handles = new(); + private readonly object syncRoot = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The name of the owner atlas. + public HandleManager(string atlasName) => this.Name = $"{atlasName}:{nameof(DelegateFontHandle)}:Manager"; + + /// + public event Action? RebuildRecommend; + + /// + public string Name { get; } + + /// + public IFontHandleSubstance? Substance { get; set; } + + /// + public void Dispose() + { + lock (this.syncRoot) + this.handles.Clear(); + } + + /// + public IFontHandle NewFontHandle(FontAtlasBuildStepDelegate buildStepDelegate) + { + var key = new DelegateFontHandle(this, buildStepDelegate); + lock (this.syncRoot) + this.handles.Add(key); + this.RebuildRecommend?.Invoke(); + return key; + } + + /// + public void FreeFontHandle(IFontHandle handle) + { + if (handle is not DelegateFontHandle cgfh) + return; + + lock (this.syncRoot) + this.handles.Remove(cgfh); + } + + /// + public IFontHandleSubstance NewSubstance(IRefCountable dataRoot) + { + lock (this.syncRoot) + return new HandleSubstance(this, dataRoot, this.handles.ToArray()); + } + } + + /// + /// Substance from . + /// + internal sealed class HandleSubstance : IFontHandleSubstance + { + private static readonly ModuleLog Log = new($"{nameof(DelegateFontHandle)}.{nameof(HandleSubstance)}"); + + // Owned by this class, but ImFontPtr values still do not belong to this. + private readonly Dictionary fonts = new(); + private readonly Dictionary buildExceptions = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The manager. + /// The data root. + /// The relevant handles. + public HandleSubstance( + IFontHandleManager manager, + IRefCountable dataRoot, + DelegateFontHandle[] relevantHandles) + { + // We do not call dataRoot.AddRef; this object is dependant on lifetime of dataRoot. + + this.Manager = manager; + this.DataRoot = dataRoot; + this.RelevantHandles = relevantHandles; + } + + /// + /// Gets the relevant handles. + /// + // Not owned by this class. Do not dispose. + public DelegateFontHandle[] RelevantHandles { get; } + + /// + ICollection IFontHandleSubstance.RelevantHandles => this.RelevantHandles; + + /// + public IRefCountable DataRoot { get; } + + /// + public IFontHandleManager Manager { get; } + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public IFontAtlasBuildToolkitPreBuild? PreBuildToolkitForApi9Compat { get; set; } + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public bool CreateFontOnAccess { get; set; } + + /// + public void Dispose() + { + this.fonts.Clear(); + this.buildExceptions.Clear(); + } + + /// + public ImFontPtr GetFontPtr(IFontHandle handle) => + handle is DelegateFontHandle cgfh ? this.fonts.GetValueOrDefault(cgfh) : default; + + /// + public Exception? GetBuildException(IFontHandle handle) => + handle is DelegateFontHandle cgfh ? this.buildExceptions.GetValueOrDefault(cgfh) : default; + + /// + public void OnPreBuild(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + var fontsVector = toolkitPreBuild.Fonts; + foreach (var k in this.RelevantHandles) + { + var fontCountPrevious = fontsVector.Length; + + try + { + toolkitPreBuild.Font = default; + k.CallOnBuildStepChange(toolkitPreBuild); + if (toolkitPreBuild.Font.IsNull()) + { + if (fontCountPrevious == fontsVector.Length) + { + throw new InvalidOperationException( + $"{nameof(FontAtlasBuildStepDelegate)} must either set the " + + $"{nameof(IFontAtlasBuildToolkitPreBuild.Font)} property, or add at least one font."); + } + + toolkitPreBuild.Font = fontsVector[^1]; + } + else + { + var found = false; + unsafe + { + for (var i = fontCountPrevious; !found && i < fontsVector.Length; i++) + { + if (fontsVector[i].NativePtr == toolkitPreBuild.Font.NativePtr) + found = true; + } + } + + if (!found) + { + throw new InvalidOperationException( + "The font does not exist in the atlas' font array. If you need an empty font, try" + + "adding Noto Sans from Dalamud Assets, but using new ushort[]{ ' ', ' ', 0 } as the" + + "glyph range."); + } + } + + if (fontsVector.Length - fontCountPrevious != 1) + { + Log.Warning( + "[{name}:Substance] {n} fonts added from {delegate} PreBuild call; " + + "Using the most recently added font. " + + "Did you mean to use {sfd}.{sfdprop} or {ifcp}.{ifcpprop}?", + this.Manager.Name, + fontsVector.Length - fontCountPrevious, + nameof(FontAtlasBuildStepDelegate), + nameof(SafeFontConfig), + nameof(SafeFontConfig.MergeFont), + nameof(ImFontConfigPtr), + nameof(ImFontConfigPtr.MergeMode)); + } + + for (var i = fontCountPrevious; i < fontsVector.Length; i++) + { + if (fontsVector[i].ValidateUnsafe() is { } ex) + { + throw new InvalidOperationException( + "One of the newly added fonts seem to be pointing to an invalid memory address.", + ex); + } + } + + // Check for duplicate entries; duplicates will result in free-after-free + for (var i = 0; i < fontCountPrevious; i++) + { + for (var j = fontCountPrevious; j < fontsVector.Length; j++) + { + unsafe + { + if (fontsVector[i].NativePtr == fontsVector[j].NativePtr) + throw new InvalidOperationException("An already added font has been added again."); + } + } + } + + this.fonts[k] = toolkitPreBuild.Font; + } + catch (Exception e) + { + this.fonts[k] = default; + this.buildExceptions[k] = e; + + Log.Error( + e, + "[{name}:Substance] An error has occurred while during {delegate} PreBuild call.", + this.Manager.Name, + nameof(FontAtlasBuildStepDelegate)); + + // Sanitization, in a futile attempt to prevent crashes on invalid parameters + unsafe + { + var distinct = + fontsVector + .DistinctBy(x => (nint)x.NativePtr) // Remove duplicates + .Where(x => x.ValidateUnsafe() is null) // Remove invalid entries without freeing them + .ToArray(); + + // We're adding the contents back; do not destroy the contents + fontsVector.Clear(true); + fontsVector.AddRange(distinct.AsSpan()); + } + } + } + } + + /// + public void OnPreBuildCleanup(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + // irrelevant + } + + /// + public void OnPostBuild(IFontAtlasBuildToolkitPostBuild toolkitPostBuild) + { + foreach (var k in this.RelevantHandles) + { + if (!this.fonts[k].IsNotNullAndLoaded()) + continue; + + try + { + toolkitPostBuild.Font = this.fonts[k]; + k.CallOnBuildStepChange.Invoke(toolkitPostBuild); + } + catch (Exception e) + { + this.fonts[k] = default; + this.buildExceptions[k] = e; + + Log.Error( + e, + "[{name}] An error has occurred while during {delegate} PostBuild call.", + this.Manager.Name, + nameof(FontAtlasBuildStepDelegate)); + } + } + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.BuildToolkit.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.BuildToolkit.cs new file mode 100644 index 000000000..55af20329 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.BuildToolkit.cs @@ -0,0 +1,723 @@ +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Unicode; + +using Dalamud.Configuration.Internal; +using Dalamud.Interface.FontIdentifier; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Storage.Assets; +using Dalamud.Utility; + +using ImGuiNET; + +using SharpDX.DXGI; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Standalone font atlas. +/// +internal sealed partial class FontAtlasFactory +{ + private static readonly Dictionary> PairAdjustmentsCache = + new(); + + /// + /// Implementations for and + /// . + /// + private class BuildToolkit : IFontAtlasBuildToolkit.IApi9Compat, IFontAtlasBuildToolkitPreBuild, IFontAtlasBuildToolkitPostBuild, IDisposable + { + private static readonly ushort FontAwesomeIconMin = + (ushort)Enum.GetValues().Where(x => x > 0).Min(); + + private static readonly ushort FontAwesomeIconMax = + (ushort)Enum.GetValues().Where(x => x > 0).Max(); + + private readonly DisposeSafety.ScopedFinalizer disposeAfterBuild = new(); + private readonly GamePrebakedFontHandle.HandleSubstance gameFontHandleSubstance; + private readonly FontAtlasFactory factory; + private readonly FontAtlasBuiltData data; + private readonly List registeredPostBuildActions = new(); + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// New atlas. + /// An instance of . + /// Specify whether the current build operation is an asynchronous one. + public BuildToolkit( + FontAtlasFactory factory, + FontAtlasBuiltData data, + GamePrebakedFontHandle.HandleSubstance gameFontHandleSubstance, + bool isAsync) + { + this.data = data; + this.gameFontHandleSubstance = gameFontHandleSubstance; + this.IsAsyncBuildOperation = isAsync; + this.factory = factory; + } + + /// + public ImFontPtr Font { get; set; } + + /// + public float Scale => this.data.Scale; + + /// + public bool IsAsyncBuildOperation { get; } + + /// + public FontAtlasBuildStep BuildStep { get; set; } + + /// + public ImFontAtlasPtr NewImAtlas => this.data.Atlas; + + /// + public ImVectorWrapper Fonts => this.data.Fonts; + + /// + /// Gets the font scale modes. + /// + private Dictionary FontScaleModes { get; } = new(); + + /// + public void Dispose() => this.disposeAfterBuild.Dispose(); + + /// + public T2 DisposeAfterBuild(T2 disposable) where T2 : IDisposable => + this.disposeAfterBuild.Add(disposable); + + /// + public GCHandle DisposeAfterBuild(GCHandle gcHandle) => this.disposeAfterBuild.Add(gcHandle); + + /// + public void DisposeAfterBuild(Action action) => this.disposeAfterBuild.Add(action); + + /// + public T DisposeWithAtlas(T disposable) where T : IDisposable => this.data.Garbage.Add(disposable); + + /// + public GCHandle DisposeWithAtlas(GCHandle gcHandle) => this.data.Garbage.Add(gcHandle); + + /// + public void DisposeWithAtlas(Action action) => this.data.Garbage.Add(action); + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public void FromUiBuilderObsoleteEventHandlers(Action action) + { + var previousSubstances = new IFontHandleSubstance[this.data.Substances.Count]; + for (var i = 0; i < previousSubstances.Length; i++) + { + previousSubstances[i] = this.data.Substances[i].Manager.Substance; + this.data.Substances[i].Manager.Substance = this.data.Substances[i]; + this.data.Substances[i].CreateFontOnAccess = true; + this.data.Substances[i].PreBuildToolkitForApi9Compat = this; + } + + try + { + action(); + } + finally + { + for (var i = 0; i < previousSubstances.Length; i++) + { + this.data.Substances[i].Manager.Substance = previousSubstances[i]; + this.data.Substances[i].CreateFontOnAccess = false; + this.data.Substances[i].PreBuildToolkitForApi9Compat = null; + } + } + } + + /// + public ImFontPtr GetFont(IFontHandle fontHandle) + { + foreach (var s in this.data.Substances) + { + var f = s.GetFontPtr(fontHandle); + if (!f.IsNull()) + return f; + } + + return default; + } + + /// + public ImFontPtr SetFontScaleMode(ImFontPtr fontPtr, FontScaleMode scaleMode) + { + this.FontScaleModes[fontPtr] = scaleMode; + return fontPtr; + } + + /// + public FontScaleMode GetFontScaleMode(ImFontPtr fontPtr) => + this.FontScaleModes.GetValueOrDefault(fontPtr, FontScaleMode.Default); + + /// + public int StoreTexture(IDalamudTextureWrap textureWrap, bool disposeOnError) => + this.data.AddNewTexture(textureWrap, disposeOnError); + + /// + public void RegisterPostBuild(Action action) => this.registeredPostBuildActions.Add(action); + + /// + public unsafe ImFontPtr AddFontFromImGuiHeapAllocatedMemory( + void* dataPointer, + int dataSize, + in SafeFontConfig fontConfig, + bool freeOnException, + string debugTag) + { + Log.Verbose( + "[{name}] 0x{atlas:X}: {funcname}(0x{dataPointer:X}, 0x{dataSize:X}, ...) from {tag}", + this.data.Owner?.Name ?? "(error)", + (nint)this.NewImAtlas.NativePtr, + nameof(this.AddFontFromImGuiHeapAllocatedMemory), + (nint)dataPointer, + dataSize, + debugTag); + + var font = default(ImFontPtr); + try + { + fontConfig.ThrowOnInvalidValues(); + + var raw = fontConfig.Raw with + { + FontData = dataPointer, + FontDataOwnedByAtlas = 1, + FontDataSize = dataSize, + }; + + if (fontConfig.GlyphRanges is not { Length: > 0 } ranges) + ranges = new ushort[] { 1, 0xFFFE, 0 }; + + raw.GlyphRanges = (ushort*)this.DisposeAfterBuild( + GCHandle.Alloc(ranges, GCHandleType.Pinned)).AddrOfPinnedObject(); + + TrueTypeUtils.CheckImGuiCompatibleOrThrow(raw); + + font = this.NewImAtlas.AddFont(&raw); + + var dataHash = default(HashCode); + dataHash.AddBytes(new(dataPointer, dataSize)); + var hashIdent = (uint)dataHash.ToHashCode() | ((ulong)dataSize << 32); + + List<(char Left, char Right, float Distance)> pairAdjustments; + lock (PairAdjustmentsCache) + { + if (!PairAdjustmentsCache.TryGetValue(hashIdent, out pairAdjustments)) + { + PairAdjustmentsCache.Add(hashIdent, pairAdjustments = new()); + try + { + pairAdjustments.AddRange(TrueTypeUtils.ExtractHorizontalPairAdjustments(raw).ToArray()); + } + catch + { + // don't care + } + } + } + + foreach (var pair in pairAdjustments) + { + if (!ImGuiHelpers.IsCodepointInSuppliedGlyphRangesUnsafe(pair.Left, raw.GlyphRanges)) + continue; + if (!ImGuiHelpers.IsCodepointInSuppliedGlyphRangesUnsafe(pair.Right, raw.GlyphRanges)) + continue; + + font.AddKerningPair(pair.Left, pair.Right, pair.Distance * raw.SizePixels); + } + + return font; + } + catch + { + if (!font.IsNull()) + { + // Note that for both RemoveAt calls, corresponding destructors will be called. + + var configIndex = this.data.ConfigData.FindIndex(x => x.DstFont == font.NativePtr); + if (configIndex >= 0) + this.data.ConfigData.RemoveAt(configIndex); + + var index = this.Fonts.IndexOf(font); + if (index >= 0) + this.Fonts.RemoveAt(index); + } + + // ImFontConfig has no destructor, and does not free the data. + if (freeOnException) + ImGuiNative.igMemFree(dataPointer); + + throw; + } + } + + /// + public ImFontPtr AddFontFromFile(string path, in SafeFontConfig fontConfig) + { + return this.AddFontFromStream( + File.OpenRead(path), + fontConfig, + false, + $"{nameof(this.AddFontFromFile)}({path})"); + } + + /// + public unsafe ImFontPtr AddFontFromStream( + Stream stream, + in SafeFontConfig fontConfig, + bool leaveOpen, + string debugTag) + { + using var streamCloser = leaveOpen ? null : stream; + if (!stream.CanSeek) + { + // There is no need to dispose a MemoryStream. + var ms = new MemoryStream(); + stream.CopyTo(ms); + stream = ms; + } + + var length = checked((int)(uint)stream.Length); + var memory = ImGuiHelpers.AllocateMemory(length); + try + { + stream.ReadExactly(new(memory, length)); + return this.AddFontFromImGuiHeapAllocatedMemory( + memory, + length, + fontConfig, + false, + $"{nameof(this.AddFontFromStream)}({debugTag})"); + } + catch + { + ImGuiNative.igMemFree(memory); + throw; + } + } + + /// + public unsafe ImFontPtr AddFontFromMemory( + ReadOnlySpan span, + in SafeFontConfig fontConfig, + string debugTag) + { + var length = span.Length; + var memory = ImGuiHelpers.AllocateMemory(length); + try + { + span.CopyTo(new(memory, length)); + return this.AddFontFromImGuiHeapAllocatedMemory( + memory, + length, + fontConfig, + false, + $"{nameof(this.AddFontFromMemory)}({debugTag})"); + } + catch + { + ImGuiNative.igMemFree(memory); + throw; + } + } + + /// + public ImFontPtr AddDalamudDefaultFont(float sizePx, ushort[]? glyphRanges) + { + ImFontPtr font = default; + glyphRanges ??= this.factory.DefaultGlyphRanges; + + var dfid = this.factory.DefaultFontSpec; + if (sizePx < 0f) + sizePx *= -dfid.SizePx; + + if (dfid is SingleFontSpec sfs) + { + if (sfs.FontId is DalamudDefaultFontAndFamilyId) + { + // invalid; calling sfs.AddToBuildToolkit calls this function, causing infinite recursion + } + else + { + sfs = sfs with { SizePx = sizePx }; + font = sfs.AddToBuildToolkit(this); + if (sfs.FontId is not GameFontAndFamilyId { GameFontFamily: GameFontFamily.Axis }) + this.AddGameSymbol(new() { SizePx = sizePx, MergeFont = font }); + } + } + + if (font.IsNull()) + { + // fall back to AXIS fonts + font = this.AddGameGlyphs(new(GameFontFamily.Axis, sizePx), glyphRanges, default); + } + + this.AttachExtraGlyphsForDalamudLanguage(new() { SizePx = sizePx, MergeFont = font }); + if (this.Font.IsNull()) + this.Font = font; + return font; + } + + /// + public ImFontPtr AddDalamudAssetFont(DalamudAsset asset, in SafeFontConfig fontConfig) + { + if (asset.GetPurpose() != DalamudAssetPurpose.Font) + throw new ArgumentOutOfRangeException(nameof(asset), asset, "Must have the purpose of Font."); + + switch (asset) + { + case DalamudAsset.LodestoneGameSymbol when this.factory.HasGameSymbolsFontFile: + return this.factory.AddFont( + this, + asset, + fontConfig with + { + FontNo = 0, + SizePx = (fontConfig.SizePx * 3) / 2, + }); + + case DalamudAsset.LodestoneGameSymbol when !this.factory.HasGameSymbolsFontFile: + { + return this.AddGameGlyphs( + new(GameFontFamily.Axis, fontConfig.SizePx), + fontConfig.GlyphRanges, + fontConfig.MergeFont); + } + + default: + return this.factory.AddFont( + this, + asset, + fontConfig with + { + FontNo = 0, + }); + } + } + + /// + public ImFontPtr AddFontAwesomeIconFont(in SafeFontConfig fontConfig) => this.AddDalamudAssetFont( + DalamudAsset.FontAwesomeFreeSolid, + fontConfig with + { + GlyphRanges = new ushort[] { FontAwesomeIconMin, FontAwesomeIconMax, 0 }, + }); + + /// + public ImFontPtr AddGameSymbol(in SafeFontConfig fontConfig) => + this.AddDalamudAssetFont( + DalamudAsset.LodestoneGameSymbol, + fontConfig with + { + GlyphRanges = new ushort[] + { + GamePrebakedFontHandle.SeIconCharMin, + GamePrebakedFontHandle.SeIconCharMax, + 0, + }, + }); + + /// + public ImFontPtr AddGameGlyphs(GameFontStyle gameFontStyle, ushort[]? glyphRanges, ImFontPtr mergeFont) => + this.gameFontHandleSubstance.AttachGameGlyphs(this, mergeFont, gameFontStyle, glyphRanges); + + /// + public void AttachExtraGlyphsForDalamudLanguage(in SafeFontConfig fontConfig) + { + var dalamudConfiguration = Service.Get(); + if (dalamudConfiguration.EffectiveLanguage == "ko" + || Service.GetNullable()?.EncounteredHangul is true) + { + this.AddDalamudAssetFont( + DalamudAsset.NotoSansKrRegular, + fontConfig with + { + GlyphRanges = ImGuiHelpers.CreateImGuiRangesFrom( + UnicodeRanges.HangulJamo, + UnicodeRanges.HangulCompatibilityJamo, + UnicodeRanges.HangulSyllables, + UnicodeRanges.HangulJamoExtendedA, + UnicodeRanges.HangulJamoExtendedB), + }); + } + + var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var fontPathChs = Path.Combine(windowsDir, "Fonts", "msyh.ttc"); + if (!File.Exists(fontPathChs)) + fontPathChs = null; + + var fontPathCht = Path.Combine(windowsDir, "Fonts", "msjh.ttc"); + if (!File.Exists(fontPathCht)) + fontPathCht = null; + + if (fontPathCht != null && Service.Get().EffectiveLanguage == "tw") + { + this.AddFontFromFile(fontPathCht, fontConfig with + { + GlyphRanges = ImGuiHelpers.CreateImGuiRangesFrom( + UnicodeRanges.CjkUnifiedIdeographs, + UnicodeRanges.CjkUnifiedIdeographsExtensionA), + }); + } + else if (fontPathChs != null && (Service.Get().EffectiveLanguage == "zh" + || Service.GetNullable()?.EncounteredHan is true)) + { + this.AddFontFromFile(fontPathChs, fontConfig with + { + GlyphRanges = ImGuiHelpers.CreateImGuiRangesFrom( + UnicodeRanges.CjkUnifiedIdeographs, + UnicodeRanges.CjkUnifiedIdeographsExtensionA), + }); + } + } + + public void PreBuildSubstances() + { + foreach (var substance in this.data.Substances) + substance.OnPreBuild(this); + foreach (var substance in this.data.Substances) + substance.OnPreBuildCleanup(this); + } + + public unsafe void PreBuild() + { + var configData = this.data.ConfigData; + foreach (ref var config in configData.DataSpan) + { + if (this.GetFontScaleMode(config.DstFont) != FontScaleMode.Default) + continue; + + config.SizePixels *= this.Scale; + + config.GlyphMaxAdvanceX *= this.Scale; + if (float.IsInfinity(config.GlyphMaxAdvanceX) || float.IsNaN(config.GlyphMaxAdvanceX)) + config.GlyphMaxAdvanceX = config.GlyphMaxAdvanceX > 0 ? float.MaxValue : -float.MaxValue; + + config.GlyphMinAdvanceX *= this.Scale; + if (float.IsInfinity(config.GlyphMinAdvanceX) || float.IsNaN(config.GlyphMinAdvanceX)) + config.GlyphMinAdvanceX = config.GlyphMinAdvanceX > 0 ? float.MaxValue : -float.MaxValue; + + config.GlyphOffset *= this.Scale; + } + } + + public void DoBuild() + { + // ImGui will call AddFontDefault() on Build() call. + // AddFontDefault() will reliably crash, when invoked multithreaded. + // We add a dummy font to prevent that. + if (this.data.ConfigData.Length == 0) + { + this.AddDalamudAssetFont( + DalamudAsset.NotoSansJpMedium, + new() { GlyphRanges = new ushort[] { ' ', ' ', '\0' }, SizePx = 1 }); + } + + if (!this.NewImAtlas.Build()) + throw new InvalidOperationException("ImFontAtlas.Build failed"); + + this.BuildStep = FontAtlasBuildStep.PostBuild; + } + + public unsafe void PostBuild() + { + var scale = this.Scale; + foreach (ref var font in this.Fonts.DataSpan) + { + if (this.GetFontScaleMode(font) != FontScaleMode.SkipHandling) + font.AdjustGlyphMetrics(1 / scale, 1 / scale); + + foreach (var c in FallbackCodepoints) + { + var g = font.FindGlyphNoFallback(c); + if (g.NativePtr == null) + continue; + + font.UpdateFallbackChar(c); + break; + } + + foreach (var c in EllipsisCodepoints) + { + var g = font.FindGlyphNoFallback(c); + if (g.NativePtr == null) + continue; + + font.EllipsisChar = c; + break; + } + } + } + + public void PostBuildSubstances() + { + foreach (var substance in this.data.Substances) + substance.OnPostBuild(this); + } + + public void PostBuildCallbacks() + { + foreach (var ac in this.registeredPostBuildActions) + ac.InvokeSafely(); + this.registeredPostBuildActions.Clear(); + } + + public unsafe void UploadTextures() + { + var buf = Array.Empty(); + try + { + var use4 = this.factory.InterfaceManager.SupportsDxgiFormat(Format.B4G4R4A4_UNorm); + var bpp = use4 ? 2 : 4; + var width = this.NewImAtlas.TexWidth; + var height = this.NewImAtlas.TexHeight; + foreach (ref var texture in this.data.ImTextures.DataSpan) + { + if (texture.TexID != 0) + { + // Nothing to do + } + else if (texture.TexPixelsRGBA32 is not null) + { + var wrap = this.factory.InterfaceManager.LoadImageFromDxgiFormat( + new(texture.TexPixelsRGBA32, width * height * 4), + width * 4, + width, + height, + use4 ? Format.B4G4R4A4_UNorm : Format.R8G8B8A8_UNorm); + this.data.AddExistingTexture(wrap); + texture.TexID = wrap.ImGuiHandle; + } + else if (texture.TexPixelsAlpha8 is not null) + { + var numPixels = width * height; + if (buf.Length < numPixels * bpp) + { + ArrayPool.Shared.Return(buf); + buf = ArrayPool.Shared.Rent(numPixels * bpp); + } + + fixed (void* pBuf = buf) + { + var sourcePtr = texture.TexPixelsAlpha8; + if (use4) + { + var target = (ushort*)pBuf; + while (numPixels-- > 0) + { + *target = (ushort)((*sourcePtr << 8) | 0x0FFF); + target++; + sourcePtr++; + } + } + else + { + var target = (uint*)pBuf; + while (numPixels-- > 0) + { + *target = (uint)((*sourcePtr << 24) | 0x00FFFFFF); + target++; + sourcePtr++; + } + } + } + + var wrap = this.factory.InterfaceManager.LoadImageFromDxgiFormat( + buf, + width * bpp, + width, + height, + use4 ? Format.B4G4R4A4_UNorm : Format.B8G8R8A8_UNorm); + this.data.AddExistingTexture(wrap); + texture.TexID = wrap.ImGuiHandle; + continue; + } + else + { + Log.Warning( + "[{name}]: TexID, TexPixelsRGBA32, and TexPixelsAlpha8 are all null", + this.data.Owner?.Name ?? "(error)"); + } + + if (texture.TexPixelsRGBA32 is not null) + ImGuiNative.igMemFree(texture.TexPixelsRGBA32); + if (texture.TexPixelsAlpha8 is not null) + ImGuiNative.igMemFree(texture.TexPixelsAlpha8); + texture.TexPixelsRGBA32 = null; + texture.TexPixelsAlpha8 = null; + } + } + finally + { + ArrayPool.Shared.Return(buf); + } + } + + /// + public unsafe void CopyGlyphsAcrossFonts( + ImFontPtr source, + ImFontPtr target, + bool missingOnly, + bool rebuildLookupTable = true, + char rangeLow = ' ', + char rangeHigh = '\uFFFE') + { + var sourceFound = false; + var targetFound = false; + foreach (var f in this.Fonts) + { + sourceFound |= f.NativePtr == source.NativePtr; + targetFound |= f.NativePtr == target.NativePtr; + } + + if (sourceFound && targetFound) + { + ImGuiHelpers.CopyGlyphsAcrossFonts( + source, + target, + missingOnly, + false, + rangeLow, + rangeHigh); + if (rebuildLookupTable) + this.BuildLookupTable(target); + } + } + + /// + public unsafe void BuildLookupTable(ImFontPtr font) + { + // Need to clear previous Fallback pointers before BuildLookupTable, or it may crash + font.NativePtr->FallbackGlyph = null; + font.NativePtr->FallbackHotData = null; + font.BuildLookupTable(); + + // Need to fix our custom ImGui, so that imgui_widgets.cpp:3656 stops thinking + // Codepoint < FallbackHotData.size always means that it's not fallback char. + // Otherwise, having a fallback character in ImGui.InputText gets strange. + var indexedHotData = font.IndexedHotDataWrapped(); + var indexLookup = font.IndexLookupWrapped(); + ref var fallbackHotData = ref *(ImGuiHelpers.ImFontGlyphHotDataReal*)font.NativePtr->FallbackHotData; + for (var codepoint = 0; codepoint < indexedHotData.Length; codepoint++) + { + if (indexLookup[codepoint] == ushort.MaxValue) + { + indexedHotData[codepoint].AdvanceX = fallbackHotData.AdvanceX; + indexedHotData[codepoint].OccupiedWidth = fallbackHotData.OccupiedWidth; + } + } + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.Implementation.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.Implementation.cs new file mode 100644 index 000000000..3c175ae3c --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.Implementation.cs @@ -0,0 +1,840 @@ +// #define VeryVerboseLog + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Disposables; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; +using Dalamud.Utility; + +using ImGuiNET; + +using JetBrains.Annotations; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Standalone font atlas. +/// +internal sealed partial class FontAtlasFactory +{ + /// + /// Fallback codepoints for ImFont. + /// + public const string FallbackCodepoints = "\u3013\uFFFD?-"; + + /// + /// Ellipsis codepoints for ImFont. + /// + public const string EllipsisCodepoints = "\u2026\u0085"; + + /// Marker for tasks on whether it's being called inside a font build cycle. + public static readonly AsyncLocal IsBuildInProgressForTask = new(); + + /// + /// If set, disables concurrent font build operation. + /// + private static readonly object? NoConcurrentBuildOperationLock = null; // new(); + + private static readonly ModuleLog Log = new(nameof(FontAtlasFactory)); + + private static readonly Task EmptyTask = Task.FromResult(default(FontAtlasBuiltData)); + + private class FontAtlasBuiltData : IRefCountable + { + // Field for debugging. + private static int numActiveInstances; + + private readonly List wraps; + private readonly List substances; + + private int refCount; + + public unsafe FontAtlasBuiltData(DalamudFontAtlas owner, float scale) + { + this.Owner = owner; + this.Scale = scale; + this.Garbage = new(); + this.refCount = 1; + + try + { + var substancesList = this.substances = new(); + this.Garbage.Add(() => substancesList.Clear()); + + var wrapsCopy = this.wraps = new(); + this.Garbage.Add(() => wrapsCopy.Clear()); + + var atlasPtr = ImGuiNative.ImFontAtlas_ImFontAtlas(); + this.Atlas = atlasPtr; + if (this.Atlas.NativePtr is null) + throw new OutOfMemoryException($"Failed to allocate a new {nameof(ImFontAtlas)}."); + + this.Garbage.Add(() => ImGuiNative.ImFontAtlas_destroy(atlasPtr)); + this.IsBuildInProgress = true; + + Interlocked.Increment(ref numActiveInstances); + this.Garbage.Add(() => Interlocked.Decrement(ref numActiveInstances)); + } + catch + { + this.Garbage.Dispose(); + throw; + } + } + + public DalamudFontAtlas? Owner { get; } + + public ImFontAtlasPtr Atlas { get; } + + public float Scale { get; } + + public bool IsBuildInProgress { get; set; } + + public DisposeSafety.ScopedFinalizer Garbage { get; } + + public ImVectorWrapper Fonts => this.Atlas.FontsWrapped(); + + public ImVectorWrapper ConfigData => this.Atlas.ConfigDataWrapped(); + + public ImVectorWrapper ImTextures => this.Atlas.TexturesWrapped(); + + public IReadOnlyList Wraps => this.wraps; + + public IReadOnlyList Substances => this.substances; + + public void InitialAddSubstance(IFontHandleSubstance substance) => + this.substances.Add(this.Garbage.Add(substance)); + + public void AddExistingTexture(IDalamudTextureWrap wrap) + { + if (this.wraps is null) + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)); + + this.wraps.Add(this.Garbage.Add(wrap)); + } + + public int AddNewTexture(IDalamudTextureWrap wrap, bool disposeOnError) + { + if (this.wraps is null) + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)); + + var handle = wrap.ImGuiHandle; + var index = this.ImTextures.IndexOf(x => x.TexID == handle); + if (index == -1) + { + try + { + this.wraps.EnsureCapacity(this.wraps.Count + 1); + this.ImTextures.EnsureCapacityExponential(this.ImTextures.Length + 1); + + index = this.ImTextures.Length; + this.wraps.Add(this.Garbage.Add(wrap)); + this.ImTextures.Add(new() { TexID = handle }); + } + catch (Exception e) + { + if (disposeOnError) + wrap.Dispose(); + + if (this.wraps.Count != this.ImTextures.Length) + { + Log.Error( + e, + "{name} failed, and {wraps} and {imtextures} have different number of items", + nameof(this.AddNewTexture), + nameof(this.Wraps), + nameof(this.ImTextures)); + + if (this.wraps.Count > 0 && this.wraps[^1] == wrap) + this.wraps.RemoveAt(this.wraps.Count - 1); + if (this.ImTextures.Length > 0 && this.ImTextures[^1].TexID == handle) + this.ImTextures.RemoveAt(this.ImTextures.Length - 1); + + if (this.wraps.Count != this.ImTextures.Length) + Log.Fatal("^ Failed to undo due to an internal inconsistency; embrace for a crash"); + } + + throw; + } + } + + return index; + } + + public int AddRef() => IRefCountable.AlterRefCount(1, ref this.refCount, out var newRefCount) switch + { + IRefCountable.RefCountResult.StillAlive => newRefCount, + IRefCountable.RefCountResult.AlreadyDisposed => + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)), + IRefCountable.RefCountResult.FinalRelease => throw new InvalidOperationException(), + _ => throw new InvalidOperationException(), + }; + + public int Release() + { + switch (IRefCountable.AlterRefCount(-1, ref this.refCount, out var newRefCount)) + { + case IRefCountable.RefCountResult.StillAlive: + return newRefCount; + + case IRefCountable.RefCountResult.FinalRelease: +#if VeryVerboseLog + Log.Verbose("[{name}] 0x{ptr:X}: Disposing", this.Owner?.Name ?? "", (nint)this.Atlas.NativePtr); +#endif + + if (this.IsBuildInProgress) + { + unsafe + { + Log.Error( + "[{name}] 0x{ptr:X}: Trying to dispose while build is in progress; disposing later.\n" + + "Stack:\n{trace}", + this.Owner?.Name ?? "", + (nint)this.Atlas.NativePtr, + new StackTrace()); + } + + Task.Run( + async () => + { + while (this.IsBuildInProgress) + await Task.Delay(100); + this.Clear(); + }); + } + else + { + this.Clear(); + } + + return newRefCount; + + case IRefCountable.RefCountResult.AlreadyDisposed: + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)); + + default: + throw new InvalidOperationException(); + } + } + + public BuildToolkit CreateToolkit(FontAtlasFactory factory, bool isAsync) + { + var axisSubstance = this.Substances.OfType().Single(); + return new(factory, this, axisSubstance, isAsync) { BuildStep = FontAtlasBuildStep.PreBuild }; + } + + public void Clear() + { + try + { + this.Garbage.Dispose(); + } + catch (Exception e) + { + Log.Error( + e, + $"Disposing {nameof(FontAtlasBuiltData)} of {this.Owner?.Name ?? "???"}."); + } + } + } + + private class DalamudFontAtlas : IFontAtlas, DisposeSafety.IDisposeCallback + { + private readonly DisposeSafety.ScopedFinalizer disposables = new(); + private readonly FontAtlasFactory factory; + private readonly DelegateFontHandle.HandleManager delegateFontHandleManager; + private readonly GamePrebakedFontHandle.HandleManager gameFontHandleManager; + private readonly IFontHandleManager[] fontHandleManagers; + + private readonly object syncRoot = new(); + + private Task buildTask = EmptyTask; + private FontAtlasBuiltData? builtData; + + private int buildSuppressionCounter; + private bool buildSuppressionSuppressed; + + private int buildIndex; + private bool buildQueued; + private bool disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The factory. + /// Name of atlas, for debugging and logging purposes. + /// Specify how to auto rebuild. + /// Whether the fonts in the atlas are under the effect of global scale. + public DalamudFontAtlas( + FontAtlasFactory factory, + string atlasName, + FontAtlasAutoRebuildMode autoRebuildMode, + bool isGlobalScaled) + { + this.IsGlobalScaled = isGlobalScaled; + try + { + this.factory = factory; + this.AutoRebuildMode = autoRebuildMode; + this.Name = atlasName; + + this.factory.InterfaceManager.AfterBuildFonts += this.OnRebuildRecommend; + this.disposables.Add(() => this.factory.InterfaceManager.AfterBuildFonts -= this.OnRebuildRecommend); + + this.fontHandleManagers = new IFontHandleManager[] + { + this.delegateFontHandleManager = this.disposables.Add( + new DelegateFontHandle.HandleManager(atlasName)), + this.gameFontHandleManager = this.disposables.Add( + new GamePrebakedFontHandle.HandleManager(atlasName, factory)), + }; + foreach (var fhm in this.fontHandleManagers) + fhm.RebuildRecommend += this.OnRebuildRecommend; + } + catch + { + this.disposables.Dispose(); + throw; + } + + this.factory.SceneTask.ContinueWith( + r => + { + lock (this.syncRoot) + { + if (this.disposed) + return; + + r.Result.OnNewRenderFrame += this.ImGuiSceneOnNewRenderFrame; + this.disposables.Add(() => r.Result.OnNewRenderFrame -= this.ImGuiSceneOnNewRenderFrame); + } + + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.OnNewFrame) + this.BuildFontsOnNextFrame(); + }); + } + + /// + /// Finalizes an instance of the class. + /// + ~DalamudFontAtlas() + { + lock (this.syncRoot) + { + this.buildTask.ToDisposableIgnoreExceptions().Dispose(); + this.builtData?.Release(); + this.builtData = null; + } + } + + /// + public event FontAtlasBuildStepDelegate? BuildStepChange; + + /// + public event Action? RebuildRecommend; + + /// + public event Action? BeforeDispose; + + /// + public event Action? AfterDispose; + + /// + public string Name { get; } + + /// + public FontAtlasAutoRebuildMode AutoRebuildMode { get; } + + /// + public ImFontAtlasPtr ImAtlas + { + get + { + lock (this.syncRoot) + return this.builtData?.Atlas ?? default; + } + } + + /// + public Task BuildTask => this.buildTask; + + /// + public bool HasBuiltAtlas => !(this.builtData?.Atlas.IsNull() ?? true); + + /// + public bool IsGlobalScaled { get; } + + /// + public void Dispose() + { + if (this.disposed) + return; + + this.BeforeDispose?.InvokeSafely(this); + + try + { + lock (this.syncRoot) + { + this.disposed = true; + this.buildTask.ToDisposableIgnoreExceptions().Dispose(); + this.buildTask = EmptyTask; + this.disposables.Add(this.builtData); + this.builtData = default; + this.disposables.Dispose(); + } + + try + { + this.AfterDispose?.Invoke(this, null); + } + catch + { + // ignore + } + } + catch (Exception e) + { + try + { + this.AfterDispose?.Invoke(this, e); + } + catch + { + // ignore + } + } + + GC.SuppressFinalize(this); + } + + /// + public IDisposable SuppressAutoRebuild() + { + this.buildSuppressionCounter++; + return Disposable.Create( + () => + { + this.buildSuppressionCounter--; + if (this.buildSuppressionSuppressed) + this.OnRebuildRecommend(); + }); + } + + /// + public IFontHandle NewGameFontHandle(GameFontStyle style) + { + if (IsBuildInProgressForTask.Value) + { + throw new InvalidOperationException( + $"{nameof(this.NewGameFontHandle)} may not be called during {nameof(this.BuildStepChange)}, the callback of {nameof(this.NewDelegateFontHandle)}, {nameof(UiBuilder.BuildFonts)} or {nameof(UiBuilder.AfterBuildFonts)}."); + } + + return this.gameFontHandleManager.NewFontHandle(style); + } + + /// + public IFontHandle NewDelegateFontHandle(FontAtlasBuildStepDelegate buildStepDelegate) + { + if (IsBuildInProgressForTask.Value) + { + throw new InvalidOperationException( + $"{nameof(this.NewDelegateFontHandle)} may not be called during {nameof(this.BuildStepChange)} or the callback of {nameof(this.NewDelegateFontHandle)}, {nameof(UiBuilder.BuildFonts)} or {nameof(UiBuilder.AfterBuildFonts)}."); + } + + return this.delegateFontHandleManager.NewFontHandle(buildStepDelegate); + } + + /// + public void BuildFontsOnNextFrame() + { + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.Async) + { + throw new InvalidOperationException( + $"{nameof(this.BuildFontsOnNextFrame)} cannot be used when " + + $"{nameof(this.AutoRebuildMode)} is set to " + + $"{nameof(FontAtlasAutoRebuildMode.Async)}."); + } + + if (!this.buildTask.IsCompleted || this.buildQueued) + return; + +#if VeryVerboseLog + Log.Verbose("[{name}] Queueing from {source}.", this.Name, nameof(this.BuildFontsOnNextFrame)); +#endif + + this.buildQueued = true; + } + + /// + public void BuildFontsImmediately() + { +#if VeryVerboseLog + Log.Verbose("[{name}] Called: {source}.", this.Name, nameof(this.BuildFontsImmediately)); +#endif + + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.Async) + { + throw new InvalidOperationException( + $"{nameof(this.BuildFontsImmediately)} cannot be used when " + + $"{nameof(this.AutoRebuildMode)} is set to " + + $"{nameof(FontAtlasAutoRebuildMode.Async)}."); + } + + var tcs = new TaskCompletionSource(); + try + { + var rebuildIndex = Interlocked.Increment(ref this.buildIndex); + lock (this.syncRoot) + { + if (!this.buildTask.IsCompleted) + throw new InvalidOperationException("Font rebuild is already in progress."); + + this.buildTask = tcs.Task; + } + +#if VeryVerboseLog + Log.Verbose("[{name}] Building from {source}.", this.Name, nameof(this.BuildFontsImmediately)); +#endif + + var scale = this.IsGlobalScaled ? ImGuiHelpers.GlobalScaleSafe : 1f; + var r = this.RebuildFontsPrivate(false, scale); + r.Wait(); + if (r.IsCompletedSuccessfully) + { + this.PromoteBuiltData(rebuildIndex, r.Result, nameof(this.BuildFontsImmediately)); + tcs.SetResult(r.Result); + } + else if ((r.Exception?.InnerException ?? r.Exception) is { } taskException) + { + ExceptionDispatchInfo.Capture(taskException).Throw(); + } + else + { + throw new OperationCanceledException(); + } + } + catch (Exception e) + { + tcs.SetException(e); + Log.Error(e, "[{name}] Failed to build fonts.", this.Name); + throw; + } + } + + /// + public Task BuildFontsAsync() + { +#if VeryVerboseLog + Log.Verbose("[{name}] Called: {source}.", this.Name, nameof(this.BuildFontsAsync)); +#endif + + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.OnNewFrame) + { + throw new InvalidOperationException( + $"{nameof(this.BuildFontsAsync)} cannot be used when " + + $"{nameof(this.AutoRebuildMode)} is set to " + + $"{nameof(FontAtlasAutoRebuildMode.OnNewFrame)}."); + } + + lock (this.syncRoot) + { + var scale = this.IsGlobalScaled ? ImGuiHelpers.GlobalScaleSafe : 1f; + var rebuildIndex = Interlocked.Increment(ref this.buildIndex); + return this.buildTask = this.buildTask.ContinueWith(BuildInner).Unwrap(); + + async Task BuildInner(Task unused) + { + Log.Verbose("[{name}] Building from {source}.", this.Name, nameof(this.BuildFontsAsync)); + lock (this.syncRoot) + { + if (this.buildIndex != rebuildIndex) + return null; + } + + var res = await this.RebuildFontsPrivate(true, scale); + if (res.Atlas.IsNull()) + return res; + + this.PromoteBuiltData(rebuildIndex, res, nameof(this.BuildFontsAsync)); + + return res; + } + } + } + + private void PromoteBuiltData(int rebuildIndex, FontAtlasBuiltData data, [UsedImplicitly] string source) + { + // Capture the locks inside the lock block, so that the fonts are guaranteed to be the ones just built. + var fontsAndLocks = new List<(FontHandle FontHandle, ILockedImFont Lock)>(); + using var garbage = new DisposeSafety.ScopedFinalizer(); + + lock (this.syncRoot) + { + if (this.buildIndex != rebuildIndex) + { + data.Release(); + return; + } + + var prevBuiltData = this.builtData; + this.builtData = data; + prevBuiltData?.Release(); + + this.buildTask = EmptyTask; + fontsAndLocks.EnsureCapacity(data.Substances.Sum(x => x.RelevantHandles.Count)); + foreach (var substance in data.Substances) + { + substance.Manager.Substance = substance; + foreach (var fontHandle in substance.RelevantHandles) + { + substance.DataRoot.AddRef(); + var locked = new LockedImFont( + substance.GetFontPtr(fontHandle), + substance.DataRoot); + fontsAndLocks.Add((fontHandle, garbage.Add(locked))); + } + } + } + + foreach (var (fontHandle, lockedFont) in fontsAndLocks) + fontHandle.InvokeImFontChanged(lockedFont); + +#if VeryVerboseLog + Log.Verbose("[{name}] Built from {source}.", this.Name, source); +#endif + } + + private void ImGuiSceneOnNewRenderFrame() + { + if (!this.buildQueued) + return; + + try + { + if (this.AutoRebuildMode != FontAtlasAutoRebuildMode.Async) + this.BuildFontsImmediately(); + } + finally + { + this.buildQueued = false; + } + } + + private Task RebuildFontsPrivate(bool isAsync, float scale) + { + if (NoConcurrentBuildOperationLock is null) + return this.RebuildFontsPrivateReal(isAsync, scale); + lock (NoConcurrentBuildOperationLock) + return this.RebuildFontsPrivateReal(isAsync, scale); + } + + private async Task RebuildFontsPrivateReal(bool isAsync, float scale) + { + lock (this.syncRoot) + { + // this lock ensures that this.buildTask is properly set. + } + + var sw = new Stopwatch(); + sw.Start(); + + FontAtlasBuiltData? res = null; + nint atlasPtr = 0; + BuildToolkit? toolkit = null; + + IsBuildInProgressForTask.Value = true; + try + { + res = new(this, scale); + foreach (var fhm in this.fontHandleManagers) + res.InitialAddSubstance(fhm.NewSubstance(res)); + unsafe + { + atlasPtr = (nint)res.Atlas.NativePtr; + } + + Log.Verbose( + "[{name}:{functionname}] 0x{ptr:X}: PreBuild (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + + toolkit = res.CreateToolkit(this.factory, isAsync); + this.BuildStepChange?.Invoke(toolkit); + toolkit.PreBuildSubstances(); + toolkit.PreBuild(); + + // Prevent NewImAtlas.ConfigData[].DstFont pointing to a font not owned by the new atlas, + // by making it add a font with default configuration first instead. + if (!ValidateMergeFontReferences(default)) + { + Log.Warning( + "[{name}:{functionname}] 0x{ptr:X}: refering to fonts outside the new atlas; " + + "adding a default font, and using that as the merge target.", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr); + + res.IsBuildInProgress = false; + toolkit.Dispose(); + res.Release(); + + res = new(this, scale); + foreach (var fhm in this.fontHandleManagers) + res.InitialAddSubstance(fhm.NewSubstance(res)); + unsafe + { + atlasPtr = (nint)res.Atlas.NativePtr; + } + + toolkit = res.CreateToolkit(this.factory, isAsync); + + // PreBuildSubstances deals with toolkit.Add... function family. Do this first. + var defaultFont = toolkit.AddDalamudDefaultFont(-1, null); + + this.BuildStepChange?.Invoke(toolkit); + toolkit.PreBuildSubstances(); + toolkit.PreBuild(); + + _ = ValidateMergeFontReferences(defaultFont); + } + +#if VeryVerboseLog + Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: Build (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds); +#endif + + toolkit.DoBuild(); + +#if VeryVerboseLog + Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: PostBuild (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds); +#endif + + toolkit.PostBuild(); + toolkit.PostBuildSubstances(); + toolkit.PostBuildCallbacks(); + this.BuildStepChange?.Invoke(toolkit); + + foreach (var font in toolkit.Fonts) + toolkit.BuildLookupTable(font); + + if (this.factory.SceneTask is { IsCompleted: false } sceneTask) + { + Log.Verbose( + "[{name}:{functionname}] 0x{ptr:X}: await SceneTask (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + await sceneTask.ConfigureAwait(!isAsync); + } + +#if VeryVerboseLog + Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: UploadTextures (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds); +#endif + toolkit.UploadTextures(); + + Log.Verbose( + "[{name}:{functionname}] 0x{ptr:X}: Complete (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + + res.IsBuildInProgress = false; + return res; + } + catch (Exception e) + { + Log.Error( + e, + "[{name}:{functionname}] 0x{ptr:X}: Failed (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + if (res is not null) + { + res.IsBuildInProgress = false; + res.Release(); + } + + throw; + } + finally + { + // RS is being dumb + // ReSharper disable once ConstantConditionalAccessQualifier + toolkit?.Dispose(); + this.buildQueued = false; + IsBuildInProgressForTask.Value = false; + } + + unsafe bool ValidateMergeFontReferences(ImFontPtr replacementDstFont) + { + var correct = true; + foreach (ref var configData in toolkit.NewImAtlas.ConfigDataWrapped().DataSpan) + { + var found = false; + foreach (ref var font in toolkit.Fonts.DataSpan) + { + if (configData.DstFont == font) + { + found = true; + break; + } + } + + if (!found) + { + correct = false; + configData.DstFont = replacementDstFont; + } + } + + return correct; + } + } + + private void OnRebuildRecommend() + { + if (this.disposed) + return; + + if (this.buildSuppressionCounter > 0) + { + this.buildSuppressionSuppressed = true; + return; + } + + this.buildSuppressionSuppressed = false; + this.factory.Framework.RunOnFrameworkThread( + () => + { + this.RebuildRecommend?.InvokeSafely(); + + switch (this.AutoRebuildMode) + { + case FontAtlasAutoRebuildMode.Async: + _ = this.BuildFontsAsync(); + break; + case FontAtlasAutoRebuildMode.OnNewFrame: + this.BuildFontsOnNextFrame(); + break; + case FontAtlasAutoRebuildMode.Disable: + default: + break; + } + }); + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.cs new file mode 100644 index 000000000..7fa41487a --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.cs @@ -0,0 +1,384 @@ +using System.Buffers; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Configuration.Internal; +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Interface.FontIdentifier; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Storage.Assets; +using Dalamud.Utility; + +using ImGuiNET; + +using ImGuiScene; + +using Lumina.Data.Files; + +using SharpDX; +using SharpDX.Direct3D11; +using SharpDX.DXGI; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Factory for the implementation of . +/// +[ServiceManager.BlockingEarlyLoadedService] +internal sealed partial class FontAtlasFactory + : IInternalDisposableService, GamePrebakedFontHandle.IGameFontTextureProvider +{ + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly CancellationTokenSource cancellationTokenSource = new(); + private readonly IReadOnlyDictionary> fdtFiles; + private readonly IReadOnlyDictionary[]>> texFiles; + private readonly IReadOnlyDictionary> prebakedTextureWraps; + private readonly Task defaultGlyphRanges; + private readonly DalamudAssetManager dalamudAssetManager; + + [ServiceManager.ServiceConstructor] + private FontAtlasFactory( + DataManager dataManager, + Framework framework, + InterfaceManager interfaceManager, + DalamudAssetManager dalamudAssetManager) + { + this.Framework = framework; + this.InterfaceManager = interfaceManager; + this.dalamudAssetManager = dalamudAssetManager; + this.SceneTask = Service + .GetAsync() + .ContinueWith(r => r.Result.Manager.Scene); + + var gffasInfo = Enum.GetValues() + .Select( + x => + ( + Font: x, + Attr: x.GetAttribute())) + .Where(x => x.Attr is not null) + .ToArray(); + var texPaths = gffasInfo.Select(x => x.Attr.TexPathFormat).Distinct().ToArray(); + + this.fdtFiles = gffasInfo.ToImmutableDictionary( + x => x.Font, + x => Task.Run(() => dataManager.GetFile(x.Attr.Path)!.Data)); + var channelCountsTask = texPaths.ToImmutableDictionary( + x => x, + x => Task.WhenAll( + gffasInfo.Where(y => y.Attr.TexPathFormat == x) + .Select(y => this.fdtFiles[y.Font])) + .ContinueWith( + files => 1 + files.Result.Max( + file => + { + unsafe + { + using var pin = file.AsMemory().Pin(); + var fdt = new FdtFileView(pin.Pointer, file.Length); + return fdt.MaxTextureIndex; + } + }))); + this.prebakedTextureWraps = channelCountsTask.ToImmutableDictionary( + x => x.Key, + x => x.Value.ContinueWith(y => new IDalamudTextureWrap?[y.Result])); + this.texFiles = channelCountsTask.ToImmutableDictionary( + x => x.Key, + x => x.Value.ContinueWith( + y => Enumerable + .Range(1, 1 + ((y.Result - 1) / 4)) + .Select(z => Task.Run(() => dataManager.GetFile(string.Format(x.Key, z))!)) + .ToArray())); + this.defaultGlyphRanges = + this.fdtFiles[GameFontFamilyAndSize.Axis12] + .ContinueWith( + file => + { + unsafe + { + using var pin = file.Result.AsMemory().Pin(); + var fdt = new FdtFileView(pin.Pointer, file.Result.Length); + return fdt.ToGlyphRanges(); + } + }); + } + + /// + /// Gets or sets a value indicating whether to override configuration for . + /// + public IFontSpec? DefaultFontSpecOverride { get; set; } = null; + + /// + /// Gets the default font ID. + /// + public IFontSpec DefaultFontSpec => + this.DefaultFontSpecOverride + ?? Service.Get().DefaultFontSpec +#pragma warning disable CS0618 // Type or member is obsolete + ?? (Service.Get().UseAxisFontsFromGame +#pragma warning restore CS0618 // Type or member is obsolete + ? new() + { + FontId = new GameFontAndFamilyId(GameFontFamily.Axis), + SizePx = InterfaceManager.DefaultFontSizePx, + } + : new SingleFontSpec + { + FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansJpMedium), + SizePx = InterfaceManager.DefaultFontSizePx + 1, + }); + + /// + /// Gets the service instance of . + /// + public Framework Framework { get; } + + /// + /// Gets the service instance of .
+ /// may not yet be available. + ///
+ public InterfaceManager InterfaceManager { get; } + + /// + /// Gets the async task for inside . + /// + public Task SceneTask { get; } + + /// + /// Gets the default glyph ranges (glyph ranges of ). + /// + public ushort[] DefaultGlyphRanges => ExtractResult(this.defaultGlyphRanges); + + /// + /// Gets a value indicating whether game symbol font file is available. + /// + public bool HasGameSymbolsFontFile => + this.dalamudAssetManager.IsStreamImmediatelyAvailable(DalamudAsset.LodestoneGameSymbol); + + /// + void IInternalDisposableService.DisposeService() + { + this.cancellationTokenSource.Cancel(); + this.scopedFinalizer.Dispose(); + this.cancellationTokenSource.Dispose(); + } + + /// + /// Creates a new instance of a class that implements the interface. + /// + /// Name of atlas, for debugging and logging purposes. + /// Specify how to auto rebuild. + /// Whether the fonts in the atlas is global scaled. + /// The new font atlas. + public IFontAtlas CreateFontAtlas( + string atlasName, + FontAtlasAutoRebuildMode autoRebuildMode, + bool isGlobalScaled = true) => + new DalamudFontAtlas(this, atlasName, autoRebuildMode, isGlobalScaled); + + /// + /// Adds the font from Dalamud Assets. + /// + /// The toolkitPostBuild. + /// The font. + /// The font config. + /// The address and size. + public ImFontPtr AddFont( + IFontAtlasBuildToolkitPreBuild toolkitPreBuild, + DalamudAsset asset, + in SafeFontConfig fontConfig) => + toolkitPreBuild.AddFontFromStream( + this.dalamudAssetManager.CreateStream(asset), + fontConfig, + false, + $"Asset({asset})"); + + /// + /// Gets the for the . + /// + /// The font family and size. + /// The . + public FdtReader GetFdtReader(GameFontFamilyAndSize gffas) => new(ExtractResult(this.fdtFiles[gffas])); + + /// + public unsafe MemoryHandle CreateFdtFileView(GameFontFamilyAndSize gffas, out FdtFileView fdtFileView) + { + var arr = ExtractResult(this.fdtFiles[gffas]); + var handle = arr.AsMemory().Pin(); + try + { + fdtFileView = new(handle.Pointer, arr.Length); + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + /// + public int GetFontTextureCount(string texPathFormat) => + ExtractResult(this.prebakedTextureWraps[texPathFormat]).Length; + + /// + public TexFile GetTexFile(string texPathFormat, int index) => + ExtractResult(ExtractResult(this.texFiles[texPathFormat])[index]); + + /// + public IDalamudTextureWrap NewFontTextureRef(string texPathFormat, int textureIndex) + { + lock (this.prebakedTextureWraps[texPathFormat]) + { + var wraps = ExtractResult(this.prebakedTextureWraps[texPathFormat]); + var fileIndex = textureIndex / 4; + var channelIndex = FdtReader.FontTableEntry.TextureChannelOrder[textureIndex % 4]; + wraps[textureIndex] ??= this.GetChannelTexture(texPathFormat, fileIndex, channelIndex); + return CloneTextureWrap(wraps[textureIndex]); + } + } + + private static T ExtractResult(Task t) => t.IsCompleted ? t.Result : t.GetAwaiter().GetResult(); + + /// + /// Clones a texture wrap, by getting a new reference to the underlying and the + /// texture behind. + /// + /// The to clone from. + /// The cloned . + private static IDalamudTextureWrap CloneTextureWrap(IDalamudTextureWrap wrap) + { + var srv = CppObject.FromPointer(wrap.ImGuiHandle); + using var res = srv.Resource; + using var tex2D = res.QueryInterface(); + var description = tex2D.Description; + return new DalamudTextureWrap( + new D3DTextureWrap( + srv.QueryInterface(), + description.Width, + description.Height)); + } + + private static unsafe void ExtractChannelFromB8G8R8A8( + Span target, + ReadOnlySpan source, + int channelIndex, + bool targetIsB4G4R4A4) + { + var numPixels = Math.Min(source.Length / 4, target.Length / (targetIsB4G4R4A4 ? 2 : 4)); + + fixed (byte* sourcePtrImmutable = source) + { + var rptr = sourcePtrImmutable + channelIndex; + fixed (void* targetPtr = target) + { + if (targetIsB4G4R4A4) + { + var wptr = (ushort*)targetPtr; + while (numPixels-- > 0) + { + *wptr = (ushort)((*rptr << 8) | 0x0FFF); + wptr++; + rptr += 4; + } + } + else + { + var wptr = (uint*)targetPtr; + while (numPixels-- > 0) + { + *wptr = (uint)((*rptr << 24) | 0x00FFFFFF); + wptr++; + rptr += 4; + } + } + } + } + } + + private static unsafe void ExtractChannelFromB4G4R4A4( + Span target, + ReadOnlySpan source, + int channelIndex, + bool targetIsB4G4R4A4) + { + var numPixels = Math.Min(source.Length / 2, target.Length / (targetIsB4G4R4A4 ? 2 : 4)); + fixed (byte* sourcePtrImmutable = source) + { + var rptr = sourcePtrImmutable + (channelIndex / 2); + var rshift = (channelIndex & 1) == 0 ? 0 : 4; + fixed (void* targetPtr = target) + { + if (targetIsB4G4R4A4) + { + var wptr = (ushort*)targetPtr; + while (numPixels-- > 0) + { + *wptr = (ushort)(((*rptr >> rshift) << 12) | 0x0FFF); + wptr++; + rptr += 2; + } + } + else + { + var wptr = (uint*)targetPtr; + while (numPixels-- > 0) + { + var v = (*rptr >> rshift) & 0xF; + v |= v << 4; + *wptr = (uint)((v << 24) | 0x00FFFFFF); + wptr++; + rptr += 2; + } + } + } + } + } + + private IDalamudTextureWrap GetChannelTexture(string texPathFormat, int fileIndex, int channelIndex) + { + var texFile = ExtractResult(ExtractResult(this.texFiles[texPathFormat])[fileIndex]); + var numPixels = texFile.Header.Width * texFile.Header.Height; + + _ = Service.Get(); + var targetIsB4G4R4A4 = this.InterfaceManager.SupportsDxgiFormat(Format.B4G4R4A4_UNorm); + var bpp = targetIsB4G4R4A4 ? 2 : 4; + var buffer = ArrayPool.Shared.Rent(numPixels * bpp); + try + { + var sliceSpan = texFile.SliceSpan(0, 0, out _, out _, out _); + switch (texFile.Header.Format) + { + case TexFile.TextureFormat.B4G4R4A4: + // Game ships with this format. + ExtractChannelFromB4G4R4A4(buffer, sliceSpan, channelIndex, targetIsB4G4R4A4); + break; + case TexFile.TextureFormat.B8G8R8A8: + // In case of modded font textures. + ExtractChannelFromB8G8R8A8(buffer, sliceSpan, channelIndex, targetIsB4G4R4A4); + break; + default: + // Unlikely. + ExtractChannelFromB8G8R8A8(buffer, texFile.ImageData, channelIndex, targetIsB4G4R4A4); + break; + } + + return this.scopedFinalizer.Add( + this.InterfaceManager.LoadImageFromDxgiFormat( + buffer, + texFile.Header.Width * bpp, + texFile.Header.Width, + texFile.Header.Height, + targetIsB4G4R4A4 ? Format.B4G4R4A4_UNorm : Format.B8G8R8A8_UNorm)); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs new file mode 100644 index 000000000..0e26145f0 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs @@ -0,0 +1,307 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Utility; + +using ImGuiNET; + +using Serilog; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Default implementation for . +/// +internal abstract class FontHandle : IFontHandle +{ + private const int NonMainThreadFontAccessWarningCheckInterval = 10000; + private static readonly ConditionalWeakTable NonMainThreadFontAccessWarning = new(); + private static long nextNonMainThreadFontAccessWarningCheck; + + private readonly List pushedFonts = new(8); + + private IFontHandleManager? manager; + private long lastCumulativePresentCalls; + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + protected FontHandle(IFontHandleManager manager) + { + this.manager = manager; + } + + /// + public event IFontHandle.ImFontChangedDelegate? ImFontChanged; + + /// + /// Event to be called on the first call. + /// + protected event Action? Disposed; + + /// + public Exception? LoadException => this.Manager.Substance?.GetBuildException(this); + + /// + public bool Available => (this.Manager.Substance?.GetFontPtr(this) ?? default).IsNotNullAndLoaded(); + + /// + /// Gets the associated . + /// + /// When the object has already been disposed. + protected IFontHandleManager Manager => + this.manager + ?? throw new ObjectDisposedException( + this.GetType().Name, + "Did you write `using (fontHandle)` instead of `using (fontHandle.Push())`?"); + + /// + public void Dispose() + { + if (this.manager is null) + return; + + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Invokes . + /// + /// The font, locked during the call of . + public void InvokeImFontChanged(ILockedImFont font) + { + try + { + this.ImFontChanged?.Invoke(this, font); + } + catch (Exception e) + { + Log.Error(e, $"{nameof(this.InvokeImFontChanged)}: error"); + } + } + + /// + /// Obtains an instance of corresponding to this font handle, + /// to be released after rendering the current frame. + /// + /// The font pointer, or default if unavailble. + /// + /// Behavior is undefined on access outside the main thread. + /// + public ImFontPtr LockUntilPostFrame() + { + if (this.TryLock(out _) is not { } locked) + return default; + + if (!ThreadSafety.IsMainThread && nextNonMainThreadFontAccessWarningCheck < Environment.TickCount64) + { + nextNonMainThreadFontAccessWarningCheck = + Environment.TickCount64 + NonMainThreadFontAccessWarningCheckInterval; + var stack = new StackTrace(); + if (Service.GetNullable()?.FindCallingPlugin(stack) is { } plugin) + { + if (!NonMainThreadFontAccessWarning.TryGetValue(plugin, out _)) + { + NonMainThreadFontAccessWarning.Add(plugin, new()); + Log.Warning( + "[IM] {pluginName}: Accessing fonts outside the main thread is deprecated.\n{stack}", + plugin.Name, + stack); + } + } + else + { + // Dalamud internal should be made safe right now + throw new InvalidOperationException("Attempted to access fonts outside the main thread."); + } + } + + Service.Get().EnqueueDeferredDispose(locked); + return locked.ImFont; + } + + /// + /// Attempts to lock the fully constructed instance of corresponding to the this + /// , for use in any thread.
+ /// Modification of the font will exhibit undefined behavior if some other thread also uses the font. + ///
+ /// The error message, if any. + /// + /// An instance of that must be disposed after use on success; + /// null with populated on failure. + /// + public ILockedImFont? TryLock(out string? errorMessage) + { + IFontHandleSubstance? prevSubstance = default; + while (true) + { + 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) + { + errorMessage = "The font atlas has not been built yet."; + return null; + } + + // Did we loop (because it did not have the requested font), + // and are the fetched substance same between loops? + if (substance == prevSubstance) + { + errorMessage = "The font atlas did not built the requested handle yet."; + return null; + } + + prevSubstance = substance; + + // Try to lock the substance. + try + { + substance.DataRoot.AddRef(); + } + catch (ObjectDisposedException) + { + // If it got invalidated, it's probably because a new substance is incoming. Try again. + continue; + } + + var fontPtr = substance.GetFontPtr(this); + if (fontPtr.IsNull()) + { + // The font for the requested handle is unavailable. Release the reference and try again. + substance.DataRoot.Release(); + continue; + } + + // Transfer the ownership of reference. + errorMessage = null; + return new LockedImFont(fontPtr, substance.DataRoot); + } + } + + /// + public ILockedImFont Lock() => + this.TryLock(out var errorMessage) ?? throw new InvalidOperationException(errorMessage); + + /// + public IDisposable Push() + { + ThreadSafety.AssertMainThread(); + + // Warn if the client is not properly managing the pushed font stack. + var cumulativePresentCalls = Service.Get().CumulativePresentCalls; + if (this.lastCumulativePresentCalls != cumulativePresentCalls) + { + this.lastCumulativePresentCalls = cumulativePresentCalls; + if (this.pushedFonts.Count > 0) + { + Log.Warning( + $"{nameof(this.Push)} has been called, but the handle-private stack was not empty. " + + $"You might be missing a call to {nameof(this.Pop)}."); + this.pushedFonts.Clear(); + } + } + + var font = default(ImFontPtr); + if (this.TryLock(out _) is { } locked) + { + font = locked.ImFont; + Service.Get().EnqueueDeferredDispose(locked); + } + + var rented = SimplePushedFont.Rent(this.pushedFonts, font); + this.pushedFonts.Add(rented); + return rented; + } + + /// + public void Pop() + { + ThreadSafety.AssertMainThread(); + this.pushedFonts[^1].Dispose(); + } + + /// + public Task WaitAsync() + { + if (this.Available) + return Task.FromResult(this); + + var tcs = new TaskCompletionSource(); + this.ImFontChanged += OnImFontChanged; + this.Disposed += OnDisposed; + if (this.Available) + OnImFontChanged(this, null); + return tcs.Task; + + void OnImFontChanged(IFontHandle unused, ILockedImFont? unused2) + { + if (tcs.Task.IsCompletedSuccessfully) + return; + + this.ImFontChanged -= OnImFontChanged; + this.Disposed -= OnDisposed; + try + { + tcs.SetResult(this); + } + catch + { + // ignore + } + } + + void OnDisposed() + { + if (tcs.Task.IsCompletedSuccessfully) + return; + + this.ImFontChanged -= OnImFontChanged; + this.Disposed -= OnDisposed; + try + { + tcs.SetException(new ObjectDisposedException(nameof(GamePrebakedFontHandle))); + } + catch + { + // ignore + } + } + } + + /// + /// Implementation for . + /// + /// If true, then the function is being called from . + protected void Dispose(bool disposing) + { + if (disposing) + { + if (Interlocked.Exchange(ref this.manager, null) is not { } managerToDisassociate) + return; + + if (this.pushedFonts.Count > 0) + Log.Warning($"{nameof(IFontHandle)}.{nameof(IDisposable.Dispose)}: fonts were still in a stack."); + + managerToDisassociate.FreeFontHandle(this); + this.Disposed?.InvokeSafely(); + this.Disposed = null; + this.ImFontChanged = null; + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/GamePrebakedFontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/GamePrebakedFontHandle.cs new file mode 100644 index 000000000..1101e7119 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/GamePrebakedFontHandle.cs @@ -0,0 +1,895 @@ +using System.Buffers; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reactive.Disposables; + +using Dalamud.Game.Text; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +using Lumina.Data.Files; + +using Vector4 = System.Numerics.Vector4; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// A font handle that uses the game's built-in fonts, optionally with some styling. +/// +internal class GamePrebakedFontHandle : FontHandle +{ + /// + /// The smallest value of . + /// + public static readonly char SeIconCharMin = (char)Enum.GetValues().Min(); + + /// + /// The largest value of . + /// + public static readonly char SeIconCharMax = (char)Enum.GetValues().Max(); + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// Font to use. + public GamePrebakedFontHandle(IFontHandleManager manager, GameFontStyle style) + : base(manager) + { + if (!Enum.IsDefined(style.FamilyAndSize) || style.FamilyAndSize == GameFontFamilyAndSize.Undefined) + throw new ArgumentOutOfRangeException(nameof(style), style, null); + + if (style.SizePt <= 0) + throw new ArgumentException($"{nameof(style.SizePt)} must be a positive number.", nameof(style)); + + this.FontStyle = style; + } + + /// + /// Provider for for `common/font/fontNN.tex`. + /// + public interface IGameFontTextureProvider + { + /// + /// Creates the for the .
+ /// Dispose after use. + ///
+ /// The font family and size. + /// The view. + /// Dispose this after use.. + public MemoryHandle CreateFdtFileView(GameFontFamilyAndSize gffas, out FdtFileView fdtFileView); + + /// + /// Gets the number of font textures. + /// + /// Format of .tex path. + /// The number of textures. + public int GetFontTextureCount(string texPathFormat); + + /// + /// Gets the for the given index of a font. + /// + /// Format of .tex path. + /// The index of .tex file. + /// The . + public TexFile GetTexFile(string texPathFormat, int index); + + /// + /// Gets a new reference of the font texture. + /// + /// Format of .tex path. + /// Texture index. + /// The texture. + public IDalamudTextureWrap NewFontTextureRef(string texPathFormat, int textureIndex); + } + + /// + /// Gets the font style. + /// + public GameFontStyle FontStyle { get; } + + /// + public override string ToString() => $"{nameof(GamePrebakedFontHandle)}({this.FontStyle})"; + + /// + /// Manager for s. + /// + internal sealed class HandleManager : IFontHandleManager + { + private readonly Dictionary gameFontsRc = new(); + private readonly HashSet handles = new(); + private readonly object syncRoot = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The name of the owner atlas. + /// An instance of . + public HandleManager(string atlasName, IGameFontTextureProvider gameFontTextureProvider) + { + this.GameFontTextureProvider = gameFontTextureProvider; + this.Name = $"{atlasName}:{nameof(GamePrebakedFontHandle)}:Manager"; + } + + /// + public event Action? RebuildRecommend; + + /// + public string Name { get; } + + /// + public IFontHandleSubstance? Substance { get; set; } + + /// + /// Gets an instance of . + /// + public IGameFontTextureProvider GameFontTextureProvider { get; } + + /// + public void Dispose() + { + // empty + } + + /// + public IFontHandle NewFontHandle(GameFontStyle style) + { + var handle = new GamePrebakedFontHandle(this, style); + bool suggestRebuild; + lock (this.syncRoot) + { + this.handles.Add(handle); + this.gameFontsRc[style] = this.gameFontsRc.GetValueOrDefault(style, 0) + 1; + suggestRebuild = this.Substance?.GetFontPtr(handle).IsNotNullAndLoaded() is not true; + } + + if (suggestRebuild) + this.RebuildRecommend?.Invoke(); + + return handle; + } + + /// + public void FreeFontHandle(IFontHandle handle) + { + if (handle is not GamePrebakedFontHandle ggfh) + return; + + lock (this.syncRoot) + { + this.handles.Remove(ggfh); + if (!this.gameFontsRc.ContainsKey(ggfh.FontStyle)) + return; + + if ((this.gameFontsRc[ggfh.FontStyle] -= 1) == 0) + this.gameFontsRc.Remove(ggfh.FontStyle); + } + } + + /// + public IFontHandleSubstance NewSubstance(IRefCountable dataRoot) + { + lock (this.syncRoot) + return new HandleSubstance(this, dataRoot, this.handles.ToArray(), this.gameFontsRc.Keys); + } + } + + /// + /// Substance from . + /// + internal sealed class HandleSubstance : IFontHandleSubstance + { + private readonly HandleManager handleManager; + private readonly HashSet gameFontStyles; + + // Owned by this class, but ImFontPtr values still do not belong to this. + private readonly Dictionary fonts = new(); + private readonly Dictionary buildExceptions = new(); + private readonly List<(ImFontPtr Font, GameFontStyle Style, ushort[]? Ranges)> attachments = new(); + + private readonly HashSet templatedFonts = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The manager. + /// The data root. + /// The relevant handles. + /// The game font styles. + public HandleSubstance( + HandleManager manager, + IRefCountable dataRoot, + GamePrebakedFontHandle[] relevantHandles, + IEnumerable gameFontStyles) + { + // We do not call dataRoot.AddRef; this object is dependant on lifetime of dataRoot. + + this.handleManager = manager; + this.DataRoot = dataRoot; + this.RelevantHandles = relevantHandles; + this.gameFontStyles = new(gameFontStyles); + } + + /// + /// Gets the relevant handles. + /// + // Not owned by this class. Do not dispose. + public GamePrebakedFontHandle[] RelevantHandles { get; } + + /// + ICollection IFontHandleSubstance.RelevantHandles => this.RelevantHandles; + + /// + public IRefCountable DataRoot { get; } + + /// + public IFontHandleManager Manager => this.handleManager; + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public IFontAtlasBuildToolkitPreBuild? PreBuildToolkitForApi9Compat { get; set; } + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public bool CreateFontOnAccess { get; set; } + + /// + public void Dispose() + { + // empty + } + + /// + /// Attaches game symbols to the given font. If font is null, it will be created. + /// + /// The toolkitPostBuild. + /// The font to attach to. + /// The game font style. + /// The intended glyph ranges. + /// if it is not empty; otherwise a new font. + public ImFontPtr AttachGameGlyphs( + IFontAtlasBuildToolkitPreBuild toolkitPreBuild, + ImFontPtr font, + GameFontStyle style, + ushort[]? glyphRanges = null) + { + if (font.IsNull()) + font = this.CreateTemplateFont(toolkitPreBuild, style.SizePx); + this.attachments.Add((font, style, glyphRanges)); + return font; + } + + /// + /// Creates or gets a relevant for the given . + /// + /// The game font style. + /// The toolkitPostBuild. + /// The font. + public ImFontPtr GetOrCreateFont(GameFontStyle style, IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + try + { + if (!this.fonts.TryGetValue(style, out var plan)) + { + plan = new( + style, + toolkitPreBuild.Scale, + this.handleManager.GameFontTextureProvider, + this.CreateTemplateFont(toolkitPreBuild, style.SizePx)); + this.fonts[style] = plan; + } + + plan.AttachFont(plan.FullRangeFont); + return plan.FullRangeFont; + } + catch (Exception e) + { + this.buildExceptions[style] = e; + throw; + } + } + + // Use this on API 10. + // /// + // public ImFontPtr GetFontPtr(IFontHandle handle) => + // handle is GamePrebakedFontHandle ggfh + // ? this.fonts.GetValueOrDefault(ggfh.FontStyle)?.FullRangeFont ?? default + // : default; + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public ImFontPtr GetFontPtr(IFontHandle handle) + { + if (handle is not GamePrebakedFontHandle ggfh) + return default; + if (this.fonts.GetValueOrDefault(ggfh.FontStyle)?.FullRangeFont is { } font) + return font; + if (!this.CreateFontOnAccess) + return default; + if (this.PreBuildToolkitForApi9Compat is not { } tk) + return default; + return this.GetOrCreateFont(ggfh.FontStyle, tk); + } + + /// + public Exception? GetBuildException(IFontHandle handle) => + handle is GamePrebakedFontHandle ggfh ? this.buildExceptions.GetValueOrDefault(ggfh.FontStyle) : default; + + /// + public void OnPreBuild(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + foreach (var style in this.gameFontStyles) + { + if (this.fonts.ContainsKey(style)) + continue; + + try + { + _ = this.GetOrCreateFont(style, toolkitPreBuild); + } + catch + { + // ignore; it should have been recorded from the call + } + } + } + + /// + public void OnPreBuildCleanup(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + foreach (var (font, style, ranges) in this.attachments) + { + if (!this.fonts.TryGetValue(style, out var plan)) + { + switch (toolkitPreBuild.GetFontScaleMode(font)) + { + case FontScaleMode.Default: + default: + plan = new( + style, + toolkitPreBuild.Scale, + this.handleManager.GameFontTextureProvider, + this.CreateTemplateFont(toolkitPreBuild, style.SizePx)); + break; + + case FontScaleMode.SkipHandling: + plan = new( + style, + 1f, + this.handleManager.GameFontTextureProvider, + this.CreateTemplateFont(toolkitPreBuild, style.SizePx)); + break; + + case FontScaleMode.UndoGlobalScale: + plan = new( + style.Scale(1 / toolkitPreBuild.Scale), + toolkitPreBuild.Scale, + this.handleManager.GameFontTextureProvider, + this.CreateTemplateFont(toolkitPreBuild, style.SizePx)); + break; + } + + this.fonts[style] = plan; + } + + plan.AttachFont(font, ranges); + } + + foreach (var plan in this.fonts.Values) + { + plan.EnsureGlyphs(toolkitPreBuild.NewImAtlas); + } + } + + /// + public unsafe void OnPostBuild(IFontAtlasBuildToolkitPostBuild toolkitPostBuild) + { + var allTextureIndices = new Dictionary(); + var allTexFiles = new Dictionary(); + using var rentReturn = Disposable.Create( + () => + { + foreach (var x in allTextureIndices.Values) + ArrayPool.Shared.Return(x); + foreach (var x in allTexFiles.Values) + ArrayPool.Shared.Return(x); + }); + + var pixels8Array = new byte*[toolkitPostBuild.NewImAtlas.Textures.Size]; + var widths = new int[toolkitPostBuild.NewImAtlas.Textures.Size]; + for (var i = 0; i < pixels8Array.Length; i++) + toolkitPostBuild.NewImAtlas.GetTexDataAsAlpha8(i, out pixels8Array[i], out widths[i], out _); + + foreach (var (style, plan) in this.fonts) + { + try + { + foreach (var font in plan.Ranges.Keys) + this.PatchFontMetricsIfNecessary(style, font, toolkitPostBuild.Scale); + + plan.SetFullRangeFontGlyphs(toolkitPostBuild, allTexFiles, allTextureIndices, pixels8Array, widths); + plan.CopyGlyphsToRanges(toolkitPostBuild); + plan.PostProcessFullRangeFont(toolkitPostBuild.Scale); + } + catch (Exception e) + { + this.buildExceptions[style] = e; + this.fonts[style] = default; + } + } + } + + /// + /// Creates a new template font. + /// + /// The toolkitPostBuild. + /// The size of the font. + /// The font. + private ImFontPtr CreateTemplateFont(IFontAtlasBuildToolkitPreBuild toolkitPreBuild, float sizePx) + { + var font = toolkitPreBuild.AddDalamudAssetFont( + DalamudAsset.NotoSansJpMedium, + new() + { + GlyphRanges = new ushort[] { ' ', ' ', '\0' }, + SizePx = sizePx, + }); + this.templatedFonts.Add(font); + return font; + } + + private unsafe void PatchFontMetricsIfNecessary(GameFontStyle style, ImFontPtr font, float atlasScale) + { + if (!this.templatedFonts.Contains(font)) + return; + + var fas = style.Scale(atlasScale).FamilyAndSize; + using var handle = this.handleManager.GameFontTextureProvider.CreateFdtFileView(fas, out var fdt); + ref var fdtFontHeader = ref fdt.FontHeader; + var fontPtr = font.NativePtr; + + var scale = style.SizePt / fdtFontHeader.Size; + fontPtr->Ascent = fdtFontHeader.Ascent * scale; + fontPtr->Descent = fdtFontHeader.Descent * scale; + fontPtr->EllipsisChar = '…'; + } + } + + [SuppressMessage( + "StyleCop.CSharp.MaintainabilityRules", + "SA1401:Fields should be private", + Justification = "Internal")] + private sealed class FontDrawPlan : IDisposable + { + public readonly GameFontStyle Style; + public readonly GameFontStyle BaseStyle; + public readonly GameFontFamilyAndSizeAttribute BaseAttr; + public readonly int TexCount; + public readonly Dictionary Ranges = new(); + public readonly List<(int RectId, int FdtGlyphIndex)> Rects = new(); + public readonly ushort[] RectLookup = new ushort[0x10000]; + public readonly FdtFileView Fdt; + public readonly ImFontPtr FullRangeFont; + + private readonly IDisposable fdtHandle; + private readonly IGameFontTextureProvider gftp; + + public FontDrawPlan( + GameFontStyle style, + float scale, + IGameFontTextureProvider gameFontTextureProvider, + ImFontPtr fullRangeFont) + { + this.Style = style; + this.BaseStyle = style.Scale(scale); + this.BaseAttr = this.BaseStyle.FamilyAndSize.GetAttribute()!; + this.gftp = gameFontTextureProvider; + this.TexCount = this.gftp.GetFontTextureCount(this.BaseAttr.TexPathFormat); + this.fdtHandle = this.gftp.CreateFdtFileView(this.BaseStyle.FamilyAndSize, out this.Fdt); + this.RectLookup.AsSpan().Fill(ushort.MaxValue); + this.FullRangeFont = fullRangeFont; + this.Ranges[fullRangeFont] = new(0x10000); + } + + public void Dispose() + { + this.fdtHandle.Dispose(); + } + + public void AttachFont(ImFontPtr font, ushort[]? glyphRanges = null) + { + if (!this.Ranges.TryGetValue(font, out var rangeBitArray)) + rangeBitArray = this.Ranges[font] = new(0x10000); + + if (glyphRanges is null) + { + foreach (ref var g in this.Fdt.Glyphs) + { + var c = g.CharInt; + if (c is >= 0x20 and <= 0xFFFE) + rangeBitArray[c] = true; + } + + return; + } + + for (var i = 0; i < glyphRanges.Length - 1; i += 2) + { + if (glyphRanges[i] == 0) + break; + var from = (int)glyphRanges[i]; + var to = (int)glyphRanges[i + 1]; + for (var j = from; j <= to; j++) + rangeBitArray[j] = true; + } + } + + public unsafe void EnsureGlyphs(ImFontAtlasPtr atlas) + { + var glyphs = this.Fdt.Glyphs; + var ranges = this.Ranges[this.FullRangeFont]; + foreach (var (font, extraRange) in this.Ranges) + { + if (font.NativePtr != this.FullRangeFont.NativePtr) + ranges.Or(extraRange); + } + + if (this.Style is not { Weight: 0, SkewStrength: 0 }) + { + for (var fdtGlyphIndex = 0; fdtGlyphIndex < glyphs.Length; fdtGlyphIndex++) + { + ref var glyph = ref glyphs[fdtGlyphIndex]; + var cint = glyph.CharInt; + if (cint > char.MaxValue) + continue; + if (!ranges[cint] || this.RectLookup[cint] != ushort.MaxValue) + continue; + + var widthAdjustment = this.BaseStyle.CalculateBaseWidthAdjustment(this.Fdt.FontHeader, glyph); + this.RectLookup[cint] = (ushort)this.Rects.Count; + this.Rects.Add( + ( + atlas.AddCustomRectFontGlyph( + this.FullRangeFont, + (char)cint, + glyph.BoundingWidth + widthAdjustment, + glyph.BoundingHeight, + glyph.AdvanceWidth, + new(this.BaseAttr.HorizontalOffset, glyph.CurrentOffsetY)), + fdtGlyphIndex)); + } + } + else + { + for (var fdtGlyphIndex = 0; fdtGlyphIndex < glyphs.Length; fdtGlyphIndex++) + { + ref var glyph = ref glyphs[fdtGlyphIndex]; + var cint = glyph.CharInt; + if (cint > char.MaxValue) + continue; + if (!ranges[cint] || this.RectLookup[cint] != ushort.MaxValue) + continue; + + this.RectLookup[cint] = (ushort)this.Rects.Count; + this.Rects.Add((-1, fdtGlyphIndex)); + } + } + } + + public unsafe void PostProcessFullRangeFont(float atlasScale) + { + var round = 1 / atlasScale; + var pfrf = this.FullRangeFont.NativePtr; + ref var frf = ref *pfrf; + + frf.FontSize = MathF.Round(frf.FontSize / round) * round; + frf.Ascent = MathF.Round(frf.Ascent / round) * round; + frf.Descent = MathF.Round(frf.Descent / round) * round; + + var scale = this.Style.SizePt / this.Fdt.FontHeader.Size; + foreach (ref var g in this.FullRangeFont.GlyphsWrapped().DataSpan) + { + var w = (g.X1 - g.X0) * scale; + var h = (g.Y1 - g.Y0) * scale; + g.X0 = MathF.Round((g.X0 * scale) / round) * round; + g.Y0 = MathF.Round((g.Y0 * scale) / round) * round; + g.X1 = g.X0 + w; + g.Y1 = g.Y0 + h; + g.AdvanceX = MathF.Round((g.AdvanceX * scale) / round) * round; + } + + var fullRange = this.Ranges[this.FullRangeFont]; + foreach (ref var k in this.Fdt.PairAdjustments) + { + var (leftInt, rightInt) = (k.LeftInt, k.RightInt); + if (leftInt > char.MaxValue || rightInt > char.MaxValue) + continue; + if (!fullRange[leftInt] || !fullRange[rightInt]) + continue; + ImGuiNative.ImFont_AddKerningPair( + pfrf, + (ushort)leftInt, + (ushort)rightInt, + MathF.Round((k.RightOffset * scale) / round) * round); + } + + pfrf->FallbackGlyph = null; + ImGuiNative.ImFont_BuildLookupTable(pfrf); + + foreach (var fallbackCharCandidate in FontAtlasFactory.FallbackCodepoints) + { + var glyph = ImGuiNative.ImFont_FindGlyphNoFallback(pfrf, fallbackCharCandidate); + if ((nint)glyph == IntPtr.Zero) + continue; + frf.FallbackChar = fallbackCharCandidate; + frf.FallbackGlyph = glyph; + frf.FallbackHotData = + (ImFontGlyphHotData*)frf.IndexedHotData.Address( + fallbackCharCandidate); + break; + } + } + + public unsafe void CopyGlyphsToRanges(IFontAtlasBuildToolkitPostBuild toolkitPostBuild) + { + var scale = this.Style.SizePt / this.Fdt.FontHeader.Size; + + foreach (var (font, rangeBits) in this.Ranges) + { + if (font.NativePtr == this.FullRangeFont.NativePtr) + continue; + + var fontScaleMode = toolkitPostBuild.GetFontScaleMode(font); + var round = fontScaleMode == FontScaleMode.SkipHandling ? 1 : 1 / toolkitPostBuild.Scale; + + var lookup = font.IndexLookupWrapped(); + var glyphs = font.GlyphsWrapped(); + foreach (ref var sourceGlyph in this.FullRangeFont.GlyphsWrapped().DataSpan) + { + if (!rangeBits[sourceGlyph.Codepoint]) + continue; + + var glyphIndex = ushort.MaxValue; + if (sourceGlyph.Codepoint < lookup.Length) + glyphIndex = lookup[sourceGlyph.Codepoint]; + + if (glyphIndex == ushort.MaxValue) + { + glyphIndex = (ushort)glyphs.Length; + glyphs.Add(default); + } + + ref var g = ref glyphs[glyphIndex]; + g = sourceGlyph; + if (fontScaleMode == FontScaleMode.SkipHandling) + { + g.XY *= scale; + g.AdvanceX *= scale; + } + else + { + var w = (g.X1 - g.X0) * scale; + var h = (g.Y1 - g.Y0) * scale; + g.X0 = MathF.Round((g.X0 * scale) / round) * round; + g.Y0 = MathF.Round((g.Y0 * scale) / round) * round; + g.X1 = g.X0 + w; + g.Y1 = g.Y0 + h; + g.AdvanceX = MathF.Round((g.AdvanceX * scale) / round) * round; + } + } + + foreach (ref var k in this.Fdt.PairAdjustments) + { + var (leftInt, rightInt) = (k.LeftInt, k.RightInt); + if (leftInt > char.MaxValue || rightInt > char.MaxValue) + continue; + if (!rangeBits[leftInt] || !rangeBits[rightInt]) + continue; + if (fontScaleMode == FontScaleMode.SkipHandling) + { + font.AddKerningPair((ushort)leftInt, (ushort)rightInt, k.RightOffset * scale); + } + else + { + font.AddKerningPair( + (ushort)leftInt, + (ushort)rightInt, + MathF.Round((k.RightOffset * scale) / round) * round); + } + } + + font.NativePtr->FallbackGlyph = null; + font.BuildLookupTable(); + + foreach (var fallbackCharCandidate in FontAtlasFactory.FallbackCodepoints) + { + var glyph = font.FindGlyphNoFallback(fallbackCharCandidate).NativePtr; + if ((nint)glyph == IntPtr.Zero) + continue; + + ref var frf = ref *font.NativePtr; + frf.FallbackChar = fallbackCharCandidate; + frf.FallbackGlyph = glyph; + frf.FallbackHotData = + (ImFontGlyphHotData*)frf.IndexedHotData.Address( + fallbackCharCandidate); + break; + } + } + } + + public unsafe void SetFullRangeFontGlyphs( + IFontAtlasBuildToolkitPostBuild toolkitPostBuild, + Dictionary allTexFiles, + Dictionary allTextureIndices, + byte*[] pixels8Array, + int[] widths) + { + var glyphs = this.FullRangeFont.GlyphsWrapped(); + var lookups = this.FullRangeFont.IndexLookupWrapped(); + + ref var fdtFontHeader = ref this.Fdt.FontHeader; + var fdtGlyphs = this.Fdt.Glyphs; + var fdtTexSize = new Vector4( + this.Fdt.FontHeader.TextureWidth, + this.Fdt.FontHeader.TextureHeight, + this.Fdt.FontHeader.TextureWidth, + this.Fdt.FontHeader.TextureHeight); + + if (!allTexFiles.TryGetValue(this.BaseAttr.TexPathFormat, out var texFiles)) + { + allTexFiles.Add( + this.BaseAttr.TexPathFormat, + texFiles = ArrayPool.Shared.Rent(this.TexCount)); + } + + if (!allTextureIndices.TryGetValue(this.BaseAttr.TexPathFormat, out var textureIndices)) + { + allTextureIndices.Add( + this.BaseAttr.TexPathFormat, + textureIndices = ArrayPool.Shared.Rent(this.TexCount)); + textureIndices.AsSpan(0, this.TexCount).Fill(-1); + } + + var pixelWidth = Math.Max(1, (int)MathF.Ceiling(this.BaseStyle.Weight + 1)); + var pixelStrength = stackalloc byte[pixelWidth]; + for (var i = 0; i < pixelWidth; i++) + pixelStrength[i] = (byte)(255 * Math.Min(1f, (this.BaseStyle.Weight + 1) - i)); + + var minGlyphY = 0; + var maxGlyphY = 0; + foreach (ref var g in fdtGlyphs) + { + minGlyphY = Math.Min(g.CurrentOffsetY, minGlyphY); + maxGlyphY = Math.Max(g.BoundingHeight + g.CurrentOffsetY, maxGlyphY); + } + + var horzShift = stackalloc int[maxGlyphY - minGlyphY]; + var horzBlend = stackalloc byte[maxGlyphY - minGlyphY]; + horzShift -= minGlyphY; + horzBlend -= minGlyphY; + if (this.BaseStyle.BaseSkewStrength != 0) + { + for (var i = minGlyphY; i < maxGlyphY; i++) + { + float blend = this.BaseStyle.BaseSkewStrength switch + { + > 0 => fdtFontHeader.LineHeight - i, + < 0 => -i, + _ => throw new InvalidOperationException(), + }; + blend *= this.BaseStyle.BaseSkewStrength / fdtFontHeader.LineHeight; + horzShift[i] = (int)MathF.Floor(blend); + horzBlend[i] = (byte)(255 * (blend - horzShift[i])); + } + } + + foreach (var (rectId, fdtGlyphIndex) in this.Rects) + { + ref var fdtGlyph = ref fdtGlyphs[fdtGlyphIndex]; + if (rectId == -1) + { + ref var textureIndex = ref textureIndices[fdtGlyph.TextureIndex]; + if (textureIndex == -1) + { + textureIndex = toolkitPostBuild.StoreTexture( + this.gftp.NewFontTextureRef(this.BaseAttr.TexPathFormat, fdtGlyph.TextureIndex), + true); + } + + var glyph = new ImGuiHelpers.ImFontGlyphReal + { + AdvanceX = fdtGlyph.AdvanceWidth, + Codepoint = fdtGlyph.Char, + Colored = false, + TextureIndex = textureIndex, + Visible = true, + X0 = this.BaseAttr.HorizontalOffset, + Y0 = fdtGlyph.CurrentOffsetY, + U0 = fdtGlyph.TextureOffsetX, + V0 = fdtGlyph.TextureOffsetY, + U1 = fdtGlyph.BoundingWidth, + V1 = fdtGlyph.BoundingHeight, + }; + + glyph.XY1 = glyph.XY0 + glyph.UV1; + glyph.UV1 += glyph.UV0; + glyph.UV /= fdtTexSize; + + glyphs.Add(glyph); + } + else + { + ref var rc = ref *(ImGuiHelpers.ImFontAtlasCustomRectReal*)toolkitPostBuild.NewImAtlas + .GetCustomRectByIndex(rectId) + .NativePtr; + var widthAdjustment = this.BaseStyle.CalculateBaseWidthAdjustment(fdtFontHeader, fdtGlyph); + + // Glyph is scaled at this point; undo that. + ref var glyph = ref glyphs[lookups[rc.GlyphId]]; + glyph.X0 = this.BaseAttr.HorizontalOffset; + glyph.Y0 = fdtGlyph.CurrentOffsetY; + glyph.X1 = glyph.X0 + fdtGlyph.BoundingWidth + widthAdjustment; + glyph.Y1 = glyph.Y0 + fdtGlyph.BoundingHeight; + glyph.AdvanceX = fdtGlyph.AdvanceWidth; + + var pixels8 = pixels8Array[rc.TextureIndex]; + var width = widths[rc.TextureIndex]; + texFiles[fdtGlyph.TextureFileIndex] ??= + this.gftp.GetTexFile(this.BaseAttr.TexPathFormat, fdtGlyph.TextureFileIndex); + var sourceBuffer = texFiles[fdtGlyph.TextureFileIndex].ImageData; + var sourceBufferDelta = fdtGlyph.TextureChannelByteIndex; + + for (var y = 0; y < fdtGlyph.BoundingHeight; y++) + { + var sourcePixelIndex = + ((fdtGlyph.TextureOffsetY + y) * fdtFontHeader.TextureWidth) + fdtGlyph.TextureOffsetX; + sourcePixelIndex *= 4; + sourcePixelIndex += sourceBufferDelta; + var blend1 = horzBlend[fdtGlyph.CurrentOffsetY + y]; + + var targetOffset = ((rc.Y + y) * width) + rc.X; + for (var x = 0; x < rc.Width; x++) + pixels8[targetOffset + x] = 0; + + targetOffset += horzShift[fdtGlyph.CurrentOffsetY + y]; + if (blend1 == 0) + { + for (var x = 0; x < fdtGlyph.BoundingWidth; x++, sourcePixelIndex += 4, targetOffset++) + { + var n = sourceBuffer[sourcePixelIndex + 4]; + for (var boldOffset = 0; boldOffset < pixelWidth; boldOffset++) + { + ref var p = ref pixels8[targetOffset + boldOffset]; + p = Math.Max(p, (byte)((pixelStrength[boldOffset] * n) / 255)); + } + } + } + else + { + var blend2 = 255 - blend1; + for (var x = 0; x < fdtGlyph.BoundingWidth; x++, sourcePixelIndex += 4, targetOffset++) + { + var a1 = sourceBuffer[sourcePixelIndex]; + var a2 = x == fdtGlyph.BoundingWidth - 1 ? 0 : sourceBuffer[sourcePixelIndex + 4]; + var n = (a1 * blend1) + (a2 * blend2); + + for (var boldOffset = 0; boldOffset < pixelWidth; boldOffset++) + { + ref var p = ref pixels8[targetOffset + boldOffset]; + p = Math.Max(p, (byte)((pixelStrength[boldOffset] * n) / 255 / 255)); + } + } + } + } + } + } + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleManager.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleManager.cs new file mode 100644 index 000000000..94976598a --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleManager.cs @@ -0,0 +1,35 @@ +using Dalamud.Utility; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Manager for . +/// +internal interface IFontHandleManager : IDisposable +{ + /// + event Action? RebuildRecommend; + + /// + /// Gets the name of the font handle manager. For logging and debugging purposes. + /// + string Name { get; } + + /// + /// Gets or sets the active font handle substance. + /// + IFontHandleSubstance? Substance { get; set; } + + /// + /// Decrease font reference counter. + /// + /// Handle being released. + void FreeFontHandle(IFontHandle handle); + + /// + /// Creates a new substance of the font atlas. + /// + /// The data root. + /// The new substance. + IFontHandleSubstance NewSubstance(IRefCountable dataRoot); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleSubstance.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleSubstance.cs new file mode 100644 index 000000000..62c893a48 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleSubstance.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; + +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Substance of a font. +/// +internal interface IFontHandleSubstance : IDisposable +{ + /// + /// Gets the data root relevant to this instance of . + /// + IRefCountable DataRoot { get; } + + /// + /// Gets the manager relevant to this instance of . + /// + IFontHandleManager Manager { get; } + + /// + /// Gets or sets the relevant for this. + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + IFontAtlasBuildToolkitPreBuild? PreBuildToolkitForApi9Compat { get; set; } + + /// + /// Gets or sets a value indicating whether to create a new instance of on first + /// access, for compatibility with API 9. + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + bool CreateFontOnAccess { get; set; } + + /// + /// Gets the relevant handles. + /// + public ICollection RelevantHandles { get; } + + /// + /// Gets the font. + /// + /// The handle to get from. + /// Corresponding font or null. + ImFontPtr GetFontPtr(IFontHandle handle); + + /// + /// Gets the exception happened while loading for the font. + /// + /// The handle to get from. + /// Corresponding font or null. + Exception? GetBuildException(IFontHandle handle); + + /// + /// Called before call. + /// + /// The toolkit. + void OnPreBuild(IFontAtlasBuildToolkitPreBuild toolkitPreBuild); + + /// + /// Called between and calls.
+ /// Any further modification to will result in undefined behavior. + ///
+ /// The toolkit. + void OnPreBuildCleanup(IFontAtlasBuildToolkitPreBuild toolkitPreBuild); + + /// + /// Called after call. + /// + /// The toolkit. + void OnPostBuild(IFontAtlasBuildToolkitPostBuild toolkitPostBuild); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/LockedImFont.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/LockedImFont.cs new file mode 100644 index 000000000..bd50502c8 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/LockedImFont.cs @@ -0,0 +1,62 @@ +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// The implementation for . +/// +internal class LockedImFont : ILockedImFont +{ + private IRefCountable? owner; + + /// + /// Initializes a new instance of the class. + /// Ownership of reference of is transferred. + /// + /// The contained font. + /// The owner. + /// The rented instance of . + internal LockedImFont(ImFontPtr font, IRefCountable owner) + { + this.ImFont = font; + this.owner = owner; + } + + /// + /// Finalizes an instance of the class. + /// + ~LockedImFont() => this.FreeOwner(); + + /// + public ImFontPtr ImFont { get; private set; } + + /// + public ILockedImFont NewRef() + { + if (this.owner is null) + throw new ObjectDisposedException(nameof(LockedImFont)); + + var newRef = new LockedImFont(this.ImFont, this.owner); + this.owner.AddRef(); + return newRef; + } + + /// + public void Dispose() + { + this.FreeOwner(); + GC.SuppressFinalize(this); + } + + private void FreeOwner() + { + if (this.owner is null) + return; + + this.owner.Release(); + this.owner = null; + this.ImFont = default; + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/SimplePushedFont.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/SimplePushedFont.cs new file mode 100644 index 000000000..0c96025ac --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/SimplePushedFont.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Diagnostics; + +using Dalamud.Interface.Utility; + +using ImGuiNET; + +using Microsoft.Extensions.ObjectPool; + +using Serilog; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Reusable font push/popper. +/// +internal sealed class SimplePushedFont : IDisposable +{ + // Using constructor instead of DefaultObjectPoolProvider, since we do not want the pool to call Dispose. + private static readonly ObjectPool Pool = + new DefaultObjectPool(new DefaultPooledObjectPolicy()); + + private List? stack; + private ImFontPtr font; + + /// + /// Pushes the font, and return an instance of . + /// + /// The -private stack. + /// The font pointer being pushed. + /// The rented instance of . + public static SimplePushedFont Rent(List stack, ImFontPtr fontPtr) + { + var rented = Pool.Get(); + Debug.Assert(rented.font.IsNull(), "Rented object must not have its font set"); + rented.stack = stack; + + if (fontPtr.IsNotNullAndLoaded()) + { + rented.font = fontPtr; + ImGui.PushFont(fontPtr); + } + + return rented; + } + + /// + public unsafe void Dispose() + { + if (this.stack is null || !ReferenceEquals(this.stack[^1], this)) + { + throw new InvalidOperationException("Tried to pop a non-pushed font."); + } + + this.stack.RemoveAt(this.stack.Count - 1); + + if (!this.font.IsNull()) + { + if (ImGui.GetFont().NativePtr == this.font.NativePtr) + { + ImGui.PopFont(); + } + else + { + Log.Warning( + $"{nameof(IFontHandle.Pop)}: The font currently being popped does not match the pushed font. " + + $"Doing nothing."); + } + } + + this.font = default; + this.stack = null; + Pool.Return(this); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Common.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Common.cs new file mode 100644 index 000000000..8e7149853 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Common.cs @@ -0,0 +1,203 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + private struct Fixed : IComparable + { + public ushort Major; + public ushort Minor; + + public Fixed(ushort major, ushort minor) + { + this.Major = major; + this.Minor = minor; + } + + public Fixed(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.Major); + span.ReadBig(ref offset, out this.Minor); + } + + public int CompareTo(Fixed other) + { + var majorComparison = this.Major.CompareTo(other.Major); + return majorComparison != 0 ? majorComparison : this.Minor.CompareTo(other.Minor); + } + } + + private struct KerningPair : IEquatable + { + public ushort Left; + public ushort Right; + public short Value; + + public KerningPair(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.Left); + span.ReadBig(ref offset, out this.Right); + span.ReadBig(ref offset, out this.Value); + } + + public KerningPair(ushort left, ushort right, short value) + { + this.Left = left; + this.Right = right; + this.Value = value; + } + + public static bool operator ==(KerningPair left, KerningPair right) => left.Equals(right); + + public static bool operator !=(KerningPair left, KerningPair right) => !left.Equals(right); + + public static KerningPair ReverseEndianness(KerningPair pair) => new() + { + Left = BinaryPrimitives.ReverseEndianness(pair.Left), + Right = BinaryPrimitives.ReverseEndianness(pair.Right), + Value = BinaryPrimitives.ReverseEndianness(pair.Value), + }; + + public bool Equals(KerningPair other) => + this.Left == other.Left && this.Right == other.Right && this.Value == other.Value; + + public override bool Equals(object? obj) => obj is KerningPair other && this.Equals(other); + + public override int GetHashCode() => HashCode.Combine(this.Left, this.Right, this.Value); + + public override string ToString() => $"KerningPair[{this.Left}, {this.Right}] = {this.Value}"; + } + + [StructLayout(LayoutKind.Explicit, Size = 4)] + private struct PlatformAndEncoding + { + [FieldOffset(0)] + public PlatformId Platform; + + [FieldOffset(2)] + public UnicodeEncodingId UnicodeEncoding; + + [FieldOffset(2)] + public MacintoshEncodingId MacintoshEncoding; + + [FieldOffset(2)] + public IsoEncodingId IsoEncoding; + + [FieldOffset(2)] + public WindowsEncodingId WindowsEncoding; + + public PlatformAndEncoding(PointerSpan source) + { + var offset = 0; + source.ReadBig(ref offset, out this.Platform); + source.ReadBig(ref offset, out this.UnicodeEncoding); + } + + public static PlatformAndEncoding ReverseEndianness(PlatformAndEncoding value) => new() + { + Platform = (PlatformId)BinaryPrimitives.ReverseEndianness((ushort)value.Platform), + UnicodeEncoding = (UnicodeEncodingId)BinaryPrimitives.ReverseEndianness((ushort)value.UnicodeEncoding), + }; + + public readonly string Decode(Span data) + { + switch (this.Platform) + { + case PlatformId.Unicode: + switch (this.UnicodeEncoding) + { + case UnicodeEncodingId.Unicode_2_0_Bmp: + case UnicodeEncodingId.Unicode_2_0_Full: + return Encoding.BigEndianUnicode.GetString(data); + } + + break; + + case PlatformId.Macintosh: + switch (this.MacintoshEncoding) + { + case MacintoshEncodingId.Roman: + return Encoding.ASCII.GetString(data); + } + + break; + + case PlatformId.Windows: + switch (this.WindowsEncoding) + { + case WindowsEncodingId.Symbol: + case WindowsEncodingId.UnicodeBmp: + case WindowsEncodingId.UnicodeFullRepertoire: + return Encoding.BigEndianUnicode.GetString(data); + } + + break; + } + + throw new NotSupportedException(); + } + } + + [StructLayout(LayoutKind.Explicit)] + private struct TagStruct : IEquatable, IComparable + { + [FieldOffset(0)] + public unsafe fixed byte Tag[4]; + + [FieldOffset(0)] + public uint NativeValue; + + public unsafe TagStruct(char c1, char c2, char c3, char c4) + { + this.Tag[0] = checked((byte)c1); + this.Tag[1] = checked((byte)c2); + this.Tag[2] = checked((byte)c3); + this.Tag[3] = checked((byte)c4); + } + + public unsafe TagStruct(PointerSpan span) + { + this.Tag[0] = span[0]; + this.Tag[1] = span[1]; + this.Tag[2] = span[2]; + this.Tag[3] = span[3]; + } + + public unsafe TagStruct(ReadOnlySpan span) + { + this.Tag[0] = span[0]; + this.Tag[1] = span[1]; + this.Tag[2] = span[2]; + this.Tag[3] = span[3]; + } + + public unsafe byte this[int index] + { + get => this.Tag[index]; + set => this.Tag[index] = value; + } + + public static bool operator ==(TagStruct left, TagStruct right) => left.Equals(right); + + public static bool operator !=(TagStruct left, TagStruct right) => !left.Equals(right); + + public bool Equals(TagStruct other) => this.NativeValue == other.NativeValue; + + public override bool Equals(object? obj) => obj is TagStruct other && this.Equals(other); + + public override int GetHashCode() => (int)this.NativeValue; + + public int CompareTo(TagStruct other) => this.NativeValue.CompareTo(other.NativeValue); + + public override unsafe string ToString() => + $"0x{this.NativeValue:08X} \"{(char)this.Tag[0]}{(char)this.Tag[1]}{(char)this.Tag[2]}{(char)this.Tag[3]}\""; + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Enums.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Enums.cs new file mode 100644 index 000000000..f6a653a51 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Enums.cs @@ -0,0 +1,84 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Version name in enum value names")] + private enum IsoEncodingId : ushort + { + Ascii = 0, + Iso_10646 = 1, + Iso_8859_1 = 2, + } + + private enum MacintoshEncodingId : ushort + { + Roman = 0, + } + + private enum NameId : ushort + { + CopyrightNotice = 0, + FamilyName = 1, + SubfamilyName = 2, + UniqueId = 3, + FullFontName = 4, + VersionString = 5, + PostScriptName = 6, + Trademark = 7, + Manufacturer = 8, + Designer = 9, + Description = 10, + UrlVendor = 11, + UrlDesigner = 12, + LicenseDescription = 13, + LicenseInfoUrl = 14, + TypographicFamilyName = 16, + TypographicSubfamilyName = 17, + CompatibleFullMac = 18, + SampleText = 19, + PoscSriptCidFindFontName = 20, + WwsFamilyName = 21, + WwsSubfamilyName = 22, + LightBackgroundPalette = 23, + DarkBackgroundPalette = 24, + VariationPostScriptNamePrefix = 25, + } + + private enum PlatformId : ushort + { + Unicode = 0, + Macintosh = 1, // discouraged + Iso = 2, // deprecated + Windows = 3, + Custom = 4, // OTF Windows NT compatibility mapping + } + + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Version name in enum value names")] + private enum UnicodeEncodingId : ushort + { + Unicode_1_0 = 0, // deprecated + Unicode_1_1 = 1, // deprecated + IsoIec_10646 = 2, // deprecated + Unicode_2_0_Bmp = 3, + Unicode_2_0_Full = 4, + UnicodeVariationSequences = 5, + UnicodeFullRepertoire = 6, + } + + private enum WindowsEncodingId : ushort + { + Symbol = 0, + UnicodeBmp = 1, + ShiftJis = 2, + Prc = 3, + Big5 = 4, + Wansung = 5, + Johab = 6, + UnicodeFullRepertoire = 10, + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Files.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Files.cs new file mode 100644 index 000000000..3d89dd806 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Files.cs @@ -0,0 +1,148 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +[SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "TrueType specification defined fields")] +[SuppressMessage("ReSharper", "UnusedType.Local", Justification = "TrueType specification defined types")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Internal")] +[SuppressMessage( + "StyleCop.CSharp.NamingRules", + "SA1310:Field names should not contain underscore", + Justification = "Version name")] +[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Version name")] +internal static partial class TrueTypeUtils +{ + private readonly struct SfntFile : IReadOnlyDictionary> + { + // http://formats.kaitai.io/ttf/ttf.svg + + public static readonly TagStruct FileTagTrueType1 = new('1', '\0', '\0', '\0'); + public static readonly TagStruct FileTagType1 = new('t', 'y', 'p', '1'); + public static readonly TagStruct FileTagOpenTypeWithCff = new('O', 'T', 'T', 'O'); + public static readonly TagStruct FileTagOpenType1_0 = new('\0', '\x01', '\0', '\0'); + public static readonly TagStruct FileTagTrueTypeApple = new('t', 'r', 'u', 'e'); + + public readonly PointerSpan Memory; + public readonly int OffsetInCollection; + public readonly ushort TableCount; + + public SfntFile(PointerSpan memory, int offsetInCollection = 0) + { + var span = memory.Span; + this.Memory = memory; + this.OffsetInCollection = offsetInCollection; + this.TableCount = BinaryPrimitives.ReadUInt16BigEndian(span[4..]); + } + + public int Count => this.TableCount; + + public IEnumerable Keys => this.Select(x => x.Key); + + public IEnumerable> Values => this.Select(x => x.Value); + + public PointerSpan this[TagStruct key] => this.First(x => x.Key == key).Value; + + public IEnumerator>> GetEnumerator() + { + var offset = 12; + for (var i = 0; i < this.TableCount; i++) + { + var dte = new DirectoryTableEntry(this.Memory[offset..]); + yield return new(dte.Tag, this.Memory.Slice(dte.Offset - this.OffsetInCollection, dte.Length)); + + offset += Unsafe.SizeOf(); + } + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + public bool ContainsKey(TagStruct key) => this.Any(x => x.Key == key); + + public bool TryGetValue(TagStruct key, out PointerSpan value) + { + foreach (var (k, v) in this) + { + if (k == key) + { + value = v; + return true; + } + } + + value = default; + return false; + } + + public readonly struct DirectoryTableEntry + { + public readonly PointerSpan Memory; + + public DirectoryTableEntry(PointerSpan span) => this.Memory = span; + + public TagStruct Tag => new(this.Memory); + + public uint Checksum => this.Memory.ReadU32Big(4); + + public int Offset => this.Memory.ReadI32Big(8); + + public int Length => this.Memory.ReadI32Big(12); + } + } + + private readonly struct TtcFile : IReadOnlyList + { + public static readonly TagStruct FileTag = new('t', 't', 'c', 'f'); + + public readonly PointerSpan Memory; + public readonly TagStruct Tag; + public readonly ushort MajorVersion; + public readonly ushort MinorVersion; + public readonly int FontCount; + + public TtcFile(PointerSpan memory) + { + var span = memory.Span; + this.Memory = memory; + this.Tag = new(span); + if (this.Tag != FileTag) + throw new InvalidOperationException(); + + this.MajorVersion = BinaryPrimitives.ReadUInt16BigEndian(span[4..]); + this.MinorVersion = BinaryPrimitives.ReadUInt16BigEndian(span[6..]); + this.FontCount = BinaryPrimitives.ReadInt32BigEndian(span[8..]); + } + + public int Count => this.FontCount; + + public SfntFile this[int index] + { + get + { + if (index < 0 || index >= this.FontCount) + { + throw new IndexOutOfRangeException( + $"The requested font #{index} does not exist in this .ttc file."); + } + + var offset = BinaryPrimitives.ReadInt32BigEndian(this.Memory.Span[(12 + 4 * index)..]); + return new(this.Memory[offset..], offset); + } + } + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < this.FontCount; i++) + yield return this[i]; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.GposGsub.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.GposGsub.cs new file mode 100644 index 000000000..d200de47b --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.GposGsub.cs @@ -0,0 +1,259 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + [Flags] + private enum LookupFlags : byte + { + RightToLeft = 1 << 0, + IgnoreBaseGlyphs = 1 << 1, + IgnoreLigatures = 1 << 2, + IgnoreMarks = 1 << 3, + UseMarkFilteringSet = 1 << 4, + } + + private enum LookupType : ushort + { + SingleAdjustment = 1, + PairAdjustment = 2, + CursiveAttachment = 3, + MarkToBaseAttachment = 4, + MarkToLigatureAttachment = 5, + MarkToMarkAttachment = 6, + ContextPositioning = 7, + ChainedContextPositioning = 8, + ExtensionPositioning = 9, + } + + private readonly struct ClassDefTable + { + public readonly PointerSpan Memory; + + public ClassDefTable(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public Format1ClassArray Format1 => new(this.Memory); + + public Format2ClassRanges Format2 => new(this.Memory); + + public IEnumerable<(ushort Class, ushort GlyphId)> Enumerate() + { + switch (this.Format) + { + case 1: + { + var format1 = this.Format1; + var startId = format1.StartGlyphId; + var count = format1.GlyphCount; + var classes = format1.ClassValueArray; + for (var i = 0; i < count; i++) + yield return (classes[i], (ushort)(i + startId)); + + break; + } + + case 2: + { + foreach (var range in this.Format2.ClassValueArray) + { + var @class = range.Class; + var startId = range.StartGlyphId; + var count = range.EndGlyphId - startId + 1; + for (var i = 0; i < count; i++) + yield return (@class, (ushort)(startId + i)); + } + + break; + } + } + } + + [Pure] + public ushort GetClass(ushort glyphId) + { + switch (this.Format) + { + case 1: + { + var format1 = this.Format1; + var startId = format1.StartGlyphId; + if (startId <= glyphId && glyphId < startId + format1.GlyphCount) + return this.Format1.ClassValueArray[glyphId - startId]; + + break; + } + + case 2: + { + var rangeSpan = this.Format2.ClassValueArray; + var i = rangeSpan.BinarySearch(new Format2ClassRanges.ClassRangeRecord { EndGlyphId = glyphId }); + if (i >= 0 && rangeSpan[i].ContainsGlyph(glyphId)) + return rangeSpan[i].Class; + + break; + } + } + + return 0; + } + + public readonly struct Format1ClassArray + { + public readonly PointerSpan Memory; + + public Format1ClassArray(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort StartGlyphId => this.Memory.ReadU16Big(2); + + public ushort GlyphCount => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan ClassValueArray => new( + this.Memory[6..].As(this.GlyphCount), + BinaryPrimitives.ReverseEndianness); + } + + public readonly struct Format2ClassRanges + { + public readonly PointerSpan Memory; + + public Format2ClassRanges(PointerSpan memory) => this.Memory = memory; + + public ushort ClassRangeCount => this.Memory.ReadU16Big(2); + + public BigEndianPointerSpan ClassValueArray => new( + this.Memory[4..].As(this.ClassRangeCount), + ClassRangeRecord.ReverseEndianness); + + public struct ClassRangeRecord : IComparable + { + public ushort StartGlyphId; + public ushort EndGlyphId; + public ushort Class; + + public static ClassRangeRecord ReverseEndianness(ClassRangeRecord value) => new() + { + StartGlyphId = BinaryPrimitives.ReverseEndianness(value.StartGlyphId), + EndGlyphId = BinaryPrimitives.ReverseEndianness(value.EndGlyphId), + Class = BinaryPrimitives.ReverseEndianness(value.Class), + }; + + public int CompareTo(ClassRangeRecord other) => this.EndGlyphId.CompareTo(other.EndGlyphId); + + public bool ContainsGlyph(ushort glyphId) => + this.StartGlyphId <= glyphId && glyphId <= this.EndGlyphId; + } + } + } + + private readonly struct CoverageTable + { + public readonly PointerSpan Memory; + + public CoverageTable(PointerSpan memory) => this.Memory = memory; + + public enum CoverageFormat : ushort + { + Glyphs = 1, + RangeRecords = 2, + } + + public CoverageFormat Format => this.Memory.ReadEnumBig(0); + + public ushort Count => this.Memory.ReadU16Big(2); + + public BigEndianPointerSpan Glyphs => + this.Format == CoverageFormat.Glyphs + ? new(this.Memory[4..].As(this.Count), BinaryPrimitives.ReverseEndianness) + : default(BigEndianPointerSpan); + + public BigEndianPointerSpan RangeRecords => + this.Format == CoverageFormat.RangeRecords + ? new(this.Memory[4..].As(this.Count), RangeRecord.ReverseEndianness) + : default(BigEndianPointerSpan); + + public int GetCoverageIndex(ushort glyphId) + { + switch (this.Format) + { + case CoverageFormat.Glyphs: + return this.Glyphs.BinarySearch(glyphId); + + case CoverageFormat.RangeRecords: + { + var index = this.RangeRecords.BinarySearch( + (in RangeRecord record) => glyphId.CompareTo(record.EndGlyphId)); + + if (index >= 0 && this.RangeRecords[index].ContainsGlyph(glyphId)) + return index; + + return -1; + } + + default: + return -1; + } + } + + public struct RangeRecord + { + public ushort StartGlyphId; + public ushort EndGlyphId; + public ushort StartCoverageIndex; + + public static RangeRecord ReverseEndianness(RangeRecord value) => new() + { + StartGlyphId = BinaryPrimitives.ReverseEndianness(value.StartGlyphId), + EndGlyphId = BinaryPrimitives.ReverseEndianness(value.EndGlyphId), + StartCoverageIndex = BinaryPrimitives.ReverseEndianness(value.StartCoverageIndex), + }; + + public bool ContainsGlyph(ushort glyphId) => + this.StartGlyphId <= glyphId && glyphId <= this.EndGlyphId; + } + } + + private readonly struct LookupTable : IEnumerable> + { + public readonly PointerSpan Memory; + + public LookupTable(PointerSpan memory) => this.Memory = memory; + + public LookupType Type => this.Memory.ReadEnumBig(0); + + public byte MarkAttachmentType => this.Memory[2]; + + public LookupFlags Flags => (LookupFlags)this.Memory[3]; + + public ushort SubtableCount => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan SubtableOffsets => new( + this.Memory[6..].As(this.SubtableCount), + BinaryPrimitives.ReverseEndianness); + + public PointerSpan this[int index] => this.Memory[this.SubtableOffsets[this.EnsureIndex(index)] ..]; + + public IEnumerator> GetEnumerator() + { + foreach (var i in Enumerable.Range(0, this.SubtableCount)) + yield return this.Memory[this.SubtableOffsets[i] ..]; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private int EnsureIndex(int index) => index >= 0 && index < this.SubtableCount + ? index + : throw new IndexOutOfRangeException(); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.PointerSpan.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.PointerSpan.cs new file mode 100644 index 000000000..c91df4ff2 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.PointerSpan.cs @@ -0,0 +1,443 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Reactive.Disposables; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + private delegate int BinarySearchComparer(in T value); + + private static IDisposable CreatePointerSpan(this T[] data, out PointerSpan pointerSpan) + where T : unmanaged + { + var gchandle = GCHandle.Alloc(data, GCHandleType.Pinned); + pointerSpan = new(gchandle.AddrOfPinnedObject(), data.Length); + return Disposable.Create(() => gchandle.Free()); + } + + private static int BinarySearch(this IReadOnlyList span, in T value) + where T : unmanaged, IComparable + { + var l = 0; + var r = span.Count - 1; + while (l <= r) + { + var i = (int)(((uint)r + (uint)l) >> 1); + var c = value.CompareTo(span[i]); + switch (c) + { + case 0: + return i; + case > 0: + l = i + 1; + break; + default: + r = i - 1; + break; + } + } + + return ~l; + } + + private static int BinarySearch(this IReadOnlyList span, BinarySearchComparer comparer) + where T : unmanaged + { + var l = 0; + var r = span.Count - 1; + while (l <= r) + { + var i = (int)(((uint)r + (uint)l) >> 1); + var c = comparer(span[i]); + switch (c) + { + case 0: + return i; + case > 0: + l = i + 1; + break; + default: + r = i - 1; + break; + } + } + + return ~l; + } + + private static short ReadI16Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadInt16BigEndian(ps.Span[offset..]); + + private static int ReadI32Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadInt32BigEndian(ps.Span[offset..]); + + private static long ReadI64Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadInt64BigEndian(ps.Span[offset..]); + + private static ushort ReadU16Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadUInt16BigEndian(ps.Span[offset..]); + + private static uint ReadU32Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadUInt32BigEndian(ps.Span[offset..]); + + private static ulong ReadU64Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadUInt64BigEndian(ps.Span[offset..]); + + private static Half ReadF16Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadHalfBigEndian(ps.Span[offset..]); + + private static float ReadF32Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadSingleBigEndian(ps.Span[offset..]); + + private static double ReadF64Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadDoubleBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out short value) => + value = BinaryPrimitives.ReadInt16BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out int value) => + value = BinaryPrimitives.ReadInt32BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out long value) => + value = BinaryPrimitives.ReadInt64BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out ushort value) => + value = BinaryPrimitives.ReadUInt16BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out uint value) => + value = BinaryPrimitives.ReadUInt32BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out ulong value) => + value = BinaryPrimitives.ReadUInt64BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out Half value) => + value = BinaryPrimitives.ReadHalfBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out float value) => + value = BinaryPrimitives.ReadSingleBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out double value) => + value = BinaryPrimitives.ReadDoubleBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, ref int offset, out short value) + { + ps.ReadBig(offset, out value); + offset += 2; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out int value) + { + ps.ReadBig(offset, out value); + offset += 4; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out long value) + { + ps.ReadBig(offset, out value); + offset += 8; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out ushort value) + { + ps.ReadBig(offset, out value); + offset += 2; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out uint value) + { + ps.ReadBig(offset, out value); + offset += 4; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out ulong value) + { + ps.ReadBig(offset, out value); + offset += 8; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out Half value) + { + ps.ReadBig(offset, out value); + offset += 2; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out float value) + { + ps.ReadBig(offset, out value); + offset += 4; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out double value) + { + ps.ReadBig(offset, out value); + offset += 8; + } + + private static unsafe T ReadEnumBig(this PointerSpan ps, int offset) where T : unmanaged, Enum + { + switch (Marshal.SizeOf(Enum.GetUnderlyingType(typeof(T)))) + { + case 1: + var b1 = ps.Span[offset]; + return *(T*)&b1; + case 2: + var b2 = ps.ReadU16Big(offset); + return *(T*)&b2; + case 4: + var b4 = ps.ReadU32Big(offset); + return *(T*)&b4; + case 8: + var b8 = ps.ReadU64Big(offset); + return *(T*)&b8; + default: + throw new ArgumentException("Enum is not of size 1, 2, 4, or 8.", nameof(T), null); + } + } + + private static void ReadBig(this PointerSpan ps, int offset, out T value) where T : unmanaged, Enum => + value = ps.ReadEnumBig(offset); + + private static void ReadBig(this PointerSpan ps, ref int offset, out T value) where T : unmanaged, Enum + { + value = ps.ReadEnumBig(offset); + offset += Unsafe.SizeOf(); + } + + private readonly unsafe struct PointerSpan : IList, IReadOnlyList, ICollection + where T : unmanaged + { + public readonly T* Pointer; + + public PointerSpan(T* pointer, int count) + { + this.Pointer = pointer; + this.Count = count; + } + + public PointerSpan(nint pointer, int count) + : this((T*)pointer, count) + { + } + + public Span Span => new(this.Pointer, this.Count); + + public bool IsEmpty => this.Count == 0; + + public int Count { get; } + + public int Length => this.Count; + + public int ByteCount => sizeof(T) * this.Count; + + bool ICollection.IsSynchronized => false; + + object ICollection.SyncRoot => this; + + bool ICollection.IsReadOnly => false; + + public ref T this[int index] => ref this.Pointer[this.EnsureIndex(index)]; + + public PointerSpan this[Range range] => this.Slice(range.GetOffsetAndLength(this.Count)); + + T IList.this[int index] + { + get => this.Pointer[this.EnsureIndex(index)]; + set => this.Pointer[this.EnsureIndex(index)] = value; + } + + T IReadOnlyList.this[int index] => this.Pointer[this.EnsureIndex(index)]; + + public bool ContainsPointer(T2* obj) where T2 : unmanaged => + (T*)obj >= this.Pointer && (T*)(obj + 1) <= this.Pointer + this.Count; + + public PointerSpan Slice(int offset, int count) => new(this.Pointer + offset, count); + + public PointerSpan Slice((int Offset, int Count) offsetAndCount) + => this.Slice(offsetAndCount.Offset, offsetAndCount.Count); + + public PointerSpan As(int count) + where T2 : unmanaged => + count > this.Count / sizeof(T2) + ? throw new ArgumentOutOfRangeException( + nameof(count), + count, + $"Wanted {count} items; had {this.Count / sizeof(T2)} items") + : new((T2*)this.Pointer, count); + + public PointerSpan As() + where T2 : unmanaged => + new((T2*)this.Pointer, this.Count / sizeof(T2)); + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < this.Count; i++) + yield return this[i]; + } + + void ICollection.Add(T item) => throw new NotSupportedException(); + + void ICollection.Clear() => throw new NotSupportedException(); + + bool ICollection.Contains(T item) + { + for (var i = 0; i < this.Count; i++) + { + if (Equals(this.Pointer[i], item)) + return true; + } + + return false; + } + + void ICollection.CopyTo(T[] array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array[arrayIndex + i] = this.Pointer[i]; + } + + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + int IList.IndexOf(T item) + { + for (var i = 0; i < this.Count; i++) + { + if (Equals(this.Pointer[i], item)) + return i; + } + + return -1; + } + + void IList.Insert(int index, T item) => throw new NotSupportedException(); + + void IList.RemoveAt(int index) => throw new NotSupportedException(); + + void ICollection.CopyTo(Array array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array.SetValue(this.Pointer[i], arrayIndex + i); + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private int EnsureIndex(int index) => + index >= 0 && index < this.Count ? index : throw new IndexOutOfRangeException(); + } + + private readonly unsafe struct BigEndianPointerSpan + : IList, IReadOnlyList, ICollection + where T : unmanaged + { + public readonly T* Pointer; + + private readonly Func reverseEndianness; + + public BigEndianPointerSpan(PointerSpan pointerSpan, Func reverseEndianness) + { + this.reverseEndianness = reverseEndianness; + this.Pointer = pointerSpan.Pointer; + this.Count = pointerSpan.Count; + } + + public int Count { get; } + + public int Length => this.Count; + + public int ByteCount => sizeof(T) * this.Count; + + public bool IsSynchronized => true; + + public object SyncRoot => this; + + public bool IsReadOnly => true; + + public T this[int index] + { + get => + BitConverter.IsLittleEndian + ? this.reverseEndianness(this.Pointer[this.EnsureIndex(index)]) + : this.Pointer[this.EnsureIndex(index)]; + set => this.Pointer[this.EnsureIndex(index)] = + BitConverter.IsLittleEndian + ? this.reverseEndianness(value) + : value; + } + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < this.Count; i++) + yield return this[i]; + } + + void ICollection.Add(T item) => throw new NotSupportedException(); + + void ICollection.Clear() => throw new NotSupportedException(); + + bool ICollection.Contains(T item) => throw new NotSupportedException(); + + void ICollection.CopyTo(T[] array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array[arrayIndex + i] = this[i]; + } + + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + int IList.IndexOf(T item) + { + for (var i = 0; i < this.Count; i++) + { + if (Equals(this[i], item)) + return i; + } + + return -1; + } + + void IList.Insert(int index, T item) => throw new NotSupportedException(); + + void IList.RemoveAt(int index) => throw new NotSupportedException(); + + void ICollection.CopyTo(Array array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array.SetValue(this[i], arrayIndex + i); + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private int EnsureIndex(int index) => + index >= 0 && index < this.Count ? index : throw new IndexOutOfRangeException(); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Tables.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Tables.cs new file mode 100644 index 000000000..80cf4b7da --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Tables.cs @@ -0,0 +1,1391 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +[SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "TrueType specification defined fields")] +[SuppressMessage("ReSharper", "UnusedType.Local", Justification = "TrueType specification defined types")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Internal")] +internal static partial class TrueTypeUtils +{ + [Flags] + private enum ValueFormat : ushort + { + PlacementX = 1 << 0, + PlacementY = 1 << 1, + AdvanceX = 1 << 2, + AdvanceY = 1 << 3, + PlacementDeviceOffsetX = 1 << 4, + PlacementDeviceOffsetY = 1 << 5, + AdvanceDeviceOffsetX = 1 << 6, + AdvanceDeviceOffsetY = 1 << 7, + + ValidBits = 0 + | PlacementX | PlacementY + | AdvanceX | AdvanceY + | PlacementDeviceOffsetX | PlacementDeviceOffsetY + | AdvanceDeviceOffsetX | AdvanceDeviceOffsetY, + } + + private static int NumBytes(this ValueFormat value) => + ushort.PopCount((ushort)(value & ValueFormat.ValidBits)) * 2; + + private readonly struct Cmap + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/cmap + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6cmap.html + + public static readonly TagStruct DirectoryTableTag = new('c', 'm', 'a', 'p'); + + public readonly PointerSpan Memory; + + public Cmap(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Cmap(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort RecordCount => this.Memory.ReadU16Big(2); + + public BigEndianPointerSpan Records => new( + this.Memory[4..].As(this.RecordCount), + EncodingRecord.ReverseEndianness); + + public EncodingRecord? UnicodeEncodingRecord => + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Unicode, UnicodeEncoding: UnicodeEncodingId.Unicode_2_0_Bmp }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Unicode, UnicodeEncoding: UnicodeEncodingId.Unicode_2_0_Full }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Unicode, UnicodeEncoding: UnicodeEncodingId.UnicodeFullRepertoire }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Windows, WindowsEncoding: WindowsEncodingId.UnicodeBmp }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Windows, WindowsEncoding: WindowsEncodingId.UnicodeFullRepertoire }); + + public CmapFormat? UnicodeTable => this.GetTable(this.UnicodeEncodingRecord); + + public CmapFormat? GetTable(EncodingRecord? encodingRecord) => + encodingRecord is { } record + ? this.Memory.ReadU16Big(record.SubtableOffset) switch + { + 0 => new CmapFormat0(this.Memory[record.SubtableOffset..]), + 2 => new CmapFormat2(this.Memory[record.SubtableOffset..]), + 4 => new CmapFormat4(this.Memory[record.SubtableOffset..]), + 6 => new CmapFormat6(this.Memory[record.SubtableOffset..]), + 8 => new CmapFormat8(this.Memory[record.SubtableOffset..]), + 10 => new CmapFormat10(this.Memory[record.SubtableOffset..]), + 12 or 13 => new CmapFormat12And13(this.Memory[record.SubtableOffset..]), + _ => null, + } + : null; + + public struct EncodingRecord + { + public PlatformAndEncoding PlatformAndEncoding; + public int SubtableOffset; + + public EncodingRecord(PointerSpan span) + { + this.PlatformAndEncoding = new(span); + var offset = Unsafe.SizeOf(); + span.ReadBig(ref offset, out this.SubtableOffset); + } + + public static EncodingRecord ReverseEndianness(EncodingRecord value) => new() + { + PlatformAndEncoding = PlatformAndEncoding.ReverseEndianness(value.PlatformAndEncoding), + SubtableOffset = BinaryPrimitives.ReverseEndianness(value.SubtableOffset), + }; + } + + public struct MapGroup : IComparable + { + public int StartCharCode; + public int EndCharCode; + public int GlyphId; + + public MapGroup(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.StartCharCode); + span.ReadBig(ref offset, out this.EndCharCode); + span.ReadBig(ref offset, out this.GlyphId); + } + + public static MapGroup ReverseEndianness(MapGroup obj) => new() + { + StartCharCode = BinaryPrimitives.ReverseEndianness(obj.StartCharCode), + EndCharCode = BinaryPrimitives.ReverseEndianness(obj.EndCharCode), + GlyphId = BinaryPrimitives.ReverseEndianness(obj.GlyphId), + }; + + public int CompareTo(MapGroup other) + { + var endCharCodeComparison = this.EndCharCode.CompareTo(other.EndCharCode); + if (endCharCodeComparison != 0) return endCharCodeComparison; + + var startCharCodeComparison = this.StartCharCode.CompareTo(other.StartCharCode); + if (startCharCodeComparison != 0) return startCharCodeComparison; + + return this.GlyphId.CompareTo(other.GlyphId); + } + } + + public abstract class CmapFormat : IReadOnlyDictionary + { + public int Count => this.Count(x => x.Value != 0); + + public IEnumerable Keys => this.Select(x => x.Key); + + public IEnumerable Values => this.Select(x => x.Value); + + public ushort this[int key] => throw new NotImplementedException(); + + public abstract ushort CharToGlyph(int c); + + public abstract IEnumerator> GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + public bool ContainsKey(int key) => this.CharToGlyph(key) != 0; + + public bool TryGetValue(int key, out ushort value) + { + value = this.CharToGlyph(key); + return value != 0; + } + } + + public class CmapFormat0 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat0(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public PointerSpan GlyphIdArray => this.Memory.Slice(6, 256); + + public override ushort CharToGlyph(int c) => c is >= 0 and < 256 ? this.GlyphIdArray[c] : (byte)0; + + public override IEnumerator> GetEnumerator() + { + for (var codepoint = 0; codepoint < 256; codepoint++) + { + if (this.GlyphIdArray[codepoint] is var glyphId and not 0) + yield return new(codepoint, glyphId); + } + } + } + + public class CmapFormat2 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat2(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan SubHeaderKeys => new( + this.Memory[6..].As(256), + BinaryPrimitives.ReverseEndianness); + + public PointerSpan Data => this.Memory[518..]; + + public bool TryGetSubHeader( + int keyIndex, out SubHeader subheader, out BigEndianPointerSpan glyphSpan) + { + if (keyIndex < 0 || keyIndex >= this.SubHeaderKeys.Count) + { + subheader = default; + glyphSpan = default; + return false; + } + + var offset = this.SubHeaderKeys[keyIndex]; + if (offset + Unsafe.SizeOf() > this.Data.Length) + { + subheader = default; + glyphSpan = default; + return false; + } + + subheader = new(this.Data[offset..]); + glyphSpan = new( + this.Data[(offset + Unsafe.SizeOf() + subheader.IdRangeOffset)..] + .As(subheader.EntryCount), + BinaryPrimitives.ReverseEndianness); + + return true; + } + + public override ushort CharToGlyph(int c) + { + if (!this.TryGetSubHeader(c >> 8, out var sh, out var glyphSpan)) + return 0; + + c = (c & 0xFF) - sh.FirstCode; + if (c > 0 || c >= glyphSpan.Count) + return 0; + + var res = glyphSpan[c]; + return res == 0 ? (ushort)0 : unchecked((ushort)(res + sh.IdDelta)); + } + + public override IEnumerator> GetEnumerator() + { + for (var i = 0; i < this.SubHeaderKeys.Count; i++) + { + if (!this.TryGetSubHeader(i, out var sh, out var glyphSpan)) + continue; + + for (var j = 0; j < glyphSpan.Count; j++) + { + var res = glyphSpan[j]; + if (res == 0) + continue; + + var glyphId = unchecked((ushort)(res + sh.IdDelta)); + if (glyphId == 0) + continue; + + var codepoint = (i << 8) | (sh.FirstCode + j); + yield return new(codepoint, glyphId); + } + } + } + + public struct SubHeader + { + public ushort FirstCode; + public ushort EntryCount; + public ushort IdDelta; + public ushort IdRangeOffset; + + public SubHeader(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.FirstCode); + span.ReadBig(ref offset, out this.EntryCount); + span.ReadBig(ref offset, out this.IdDelta); + span.ReadBig(ref offset, out this.IdRangeOffset); + } + } + } + + public class CmapFormat4 : CmapFormat + { + public const int EndCodesOffset = 14; + + public readonly PointerSpan Memory; + + public CmapFormat4(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public ushort SegCountX2 => this.Memory.ReadU16Big(6); + + public ushort SearchRange => this.Memory.ReadU16Big(8); + + public ushort EntrySelector => this.Memory.ReadU16Big(10); + + public ushort RangeShift => this.Memory.ReadU16Big(12); + + public BigEndianPointerSpan EndCodes => new( + this.Memory.Slice(EndCodesOffset, this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan StartCodes => new( + this.Memory.Slice(EndCodesOffset + 2 + (1 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan IdDeltas => new( + this.Memory.Slice(EndCodesOffset + 2 + (2 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan IdRangeOffsets => new( + this.Memory.Slice(EndCodesOffset + 2 + (3 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan GlyphIds => new( + this.Memory.Slice(EndCodesOffset + 2 + (4 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + if (c is < 0 or >= 0x10000) + return 0; + + var i = this.EndCodes.BinarySearch((ushort)c); + if (i < 0) + return 0; + + var startCode = this.StartCodes[i]; + var endCode = this.EndCodes[i]; + if (c < startCode || c > endCode) + return 0; + + var idRangeOffset = this.IdRangeOffsets[i]; + var idDelta = this.IdDeltas[i]; + if (idRangeOffset == 0) + return unchecked((ushort)(c + idDelta)); + + var ptr = EndCodesOffset + 2 + (3 * this.SegCountX2) + i * 2 + idRangeOffset; + if (ptr > this.Memory.Length) + return 0; + + var glyphs = new BigEndianPointerSpan( + this.Memory[ptr..].As(endCode - startCode + 1), + BinaryPrimitives.ReverseEndianness); + + var glyph = glyphs[c - startCode]; + return unchecked(glyph == 0 ? (ushort)0 : (ushort)(idDelta + glyph)); + } + + public override IEnumerator> GetEnumerator() + { + var startCodes = this.StartCodes; + var endCodes = this.EndCodes; + var idDeltas = this.IdDeltas; + var idRangeOffsets = this.IdRangeOffsets; + + for (var i = 0; i < this.SegCountX2 / 2; i++) + { + var startCode = startCodes[i]; + var endCode = endCodes[i]; + var idRangeOffset = idRangeOffsets[i]; + var idDelta = idDeltas[i]; + + if (idRangeOffset == 0) + { + for (var c = (int)startCode; c <= endCode; c++) + yield return new(c, (ushort)(c + idDelta)); + } + else + { + var ptr = EndCodesOffset + 2 + (3 * this.SegCountX2) + i * 2 + idRangeOffset; + if (ptr >= this.Memory.Length) + continue; + + var glyphs = new BigEndianPointerSpan( + this.Memory[ptr..].As(endCode - startCode + 1), + BinaryPrimitives.ReverseEndianness); + + for (var j = 0; j < glyphs.Count; j++) + { + var glyphId = glyphs[j]; + if (glyphId == 0) + continue; + + glyphId += idDelta; + if (glyphId == 0) + continue; + + yield return new(startCode + j, glyphId); + } + } + } + } + } + + public class CmapFormat6 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat6(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public ushort FirstCode => this.Memory.ReadU16Big(6); + + public ushort EntryCount => this.Memory.ReadU16Big(8); + + public BigEndianPointerSpan GlyphIds => new( + this.Memory[10..].As(this.EntryCount), + BinaryPrimitives.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + var glyphIds = this.GlyphIds; + if (c < this.FirstCode || c >= this.FirstCode + this.GlyphIds.Count) + return 0; + + return glyphIds[c - this.FirstCode]; + } + + public override IEnumerator> GetEnumerator() + { + var glyphIds = this.GlyphIds; + for (var i = 0; i < this.GlyphIds.Length; i++) + { + var g = glyphIds[i]; + if (g != 0) + yield return new(this.FirstCode + i, g); + } + } + } + + public class CmapFormat8 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat8(PointerSpan memory) => this.Memory = memory; + + public int Format => this.Memory.ReadI32Big(0); + + public int Length => this.Memory.ReadI32Big(4); + + public int Language => this.Memory.ReadI32Big(8); + + public PointerSpan Is32 => this.Memory.Slice(12, 8192); + + public int NumGroups => this.Memory.ReadI32Big(8204); + + public BigEndianPointerSpan Groups => + new(this.Memory[8208..].As(), MapGroup.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + var groups = this.Groups; + + var i = groups.BinarySearch((in MapGroup value) => c.CompareTo(value.EndCharCode)); + if (i < 0) + return 0; + + var group = groups[i]; + if (c < group.StartCharCode || c > group.EndCharCode) + return 0; + + return unchecked((ushort)(group.GlyphId + c - group.StartCharCode)); + } + + public override IEnumerator> GetEnumerator() + { + foreach (var group in this.Groups) + { + for (var j = group.StartCharCode; j <= group.EndCharCode; j++) + { + var glyphId = (ushort)(group.GlyphId + j - group.StartCharCode); + if (glyphId == 0) + continue; + + yield return new(j, glyphId); + } + } + } + } + + public class CmapFormat10 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat10(PointerSpan memory) => this.Memory = memory; + + public int Format => this.Memory.ReadI32Big(0); + + public int Length => this.Memory.ReadI32Big(4); + + public int Language => this.Memory.ReadI32Big(8); + + public int StartCharCode => this.Memory.ReadI32Big(12); + + public int NumChars => this.Memory.ReadI32Big(16); + + public BigEndianPointerSpan GlyphIdArray => new( + this.Memory.Slice(20, this.NumChars * 2).As(), + BinaryPrimitives.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + if (c < this.StartCharCode || c >= this.StartCharCode + this.GlyphIdArray.Count) + return 0; + + return this.GlyphIdArray[c]; + } + + public override IEnumerator> GetEnumerator() + { + for (var i = 0; i < this.GlyphIdArray.Count; i++) + { + var glyph = this.GlyphIdArray[i]; + if (glyph != 0) + yield return new(this.StartCharCode + i, glyph); + } + } + } + + public class CmapFormat12And13 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat12And13(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public int Length => this.Memory.ReadI32Big(4); + + public int Language => this.Memory.ReadI32Big(8); + + public int NumGroups => this.Memory.ReadI32Big(12); + + public BigEndianPointerSpan Groups => new( + this.Memory[16..].As(this.NumGroups), + MapGroup.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + var groups = this.Groups; + + var i = groups.BinarySearch(new MapGroup() { EndCharCode = c }); + if (i < 0) + return 0; + + var group = groups[i]; + if (c < group.StartCharCode || c > group.EndCharCode) + return 0; + + if (this.Format == 12) + return (ushort)(group.GlyphId + c - group.StartCharCode); + else + return (ushort)group.GlyphId; + } + + public override IEnumerator> GetEnumerator() + { + var groups = this.Groups; + if (this.Format == 12) + { + foreach (var group in groups) + { + for (var j = group.StartCharCode; j <= group.EndCharCode; j++) + { + var glyphId = (ushort)(group.GlyphId + j - group.StartCharCode); + if (glyphId == 0) + continue; + + yield return new(j, glyphId); + } + } + } + else + { + foreach (var group in groups) + { + if (group.GlyphId == 0) + continue; + + for (var j = group.StartCharCode; j <= group.EndCharCode; j++) + yield return new(j, (ushort)group.GlyphId); + } + } + } + } + } + + private readonly struct Gpos + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos + + public static readonly TagStruct DirectoryTableTag = new('G', 'P', 'O', 'S'); + + public readonly PointerSpan Memory; + + public Gpos(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Gpos(PointerSpan memory) => this.Memory = memory; + + public Fixed Version => new(this.Memory); + + public ushort ScriptListOffset => this.Memory.ReadU16Big(4); + + public ushort FeatureListOffset => this.Memory.ReadU16Big(6); + + public ushort LookupListOffset => this.Memory.ReadU16Big(8); + + public uint FeatureVariationsOffset => this.Version.CompareTo(new(1, 1)) >= 0 + ? this.Memory.ReadU32Big(10) + : 0; + + public BigEndianPointerSpan LookupOffsetList => new( + this.Memory[(this.LookupListOffset + 2)..].As( + this.Memory.ReadU16Big(this.LookupListOffset)), + BinaryPrimitives.ReverseEndianness); + + public IEnumerable EnumerateLookupTables() + { + foreach (var offset in this.LookupOffsetList) + yield return new(this.Memory[(this.LookupListOffset + offset)..]); + } + + public IEnumerable ExtractAdvanceX() => + this.EnumerateLookupTables() + .SelectMany( + lookupTable => lookupTable.Type switch + { + LookupType.PairAdjustment => + lookupTable.SelectMany(y => new PairAdjustmentPositioning(y).ExtractAdvanceX()), + LookupType.ExtensionPositioning => + lookupTable + .Where(y => y.ReadU16Big(0) == 1) + .Select(y => new ExtensionPositioningSubtableFormat1(y)) + .Where(y => y.ExtensionLookupType == LookupType.PairAdjustment) + .SelectMany(y => new PairAdjustmentPositioning(y.ExtensionData).ExtractAdvanceX()), + _ => Array.Empty(), + }); + + public struct ValueRecord + { + public short PlacementX; + public short PlacementY; + public short AdvanceX; + public short AdvanceY; + public short PlacementDeviceOffsetX; + public short PlacementDeviceOffsetY; + public short AdvanceDeviceOffsetX; + public short AdvanceDeviceOffsetY; + + public ValueRecord(PointerSpan pointerSpan, ValueFormat valueFormat) + { + var offset = 0; + if ((valueFormat & ValueFormat.PlacementX) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementX); + + if ((valueFormat & ValueFormat.PlacementY) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementY); + + if ((valueFormat & ValueFormat.AdvanceX) != 0) pointerSpan.ReadBig(ref offset, out this.AdvanceX); + if ((valueFormat & ValueFormat.AdvanceY) != 0) pointerSpan.ReadBig(ref offset, out this.AdvanceY); + if ((valueFormat & ValueFormat.PlacementDeviceOffsetX) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementDeviceOffsetX); + + if ((valueFormat & ValueFormat.PlacementDeviceOffsetY) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementDeviceOffsetY); + + if ((valueFormat & ValueFormat.AdvanceDeviceOffsetX) != 0) + pointerSpan.ReadBig(ref offset, out this.AdvanceDeviceOffsetX); + + if ((valueFormat & ValueFormat.AdvanceDeviceOffsetY) != 0) + pointerSpan.ReadBig(ref offset, out this.AdvanceDeviceOffsetY); + } + } + + public readonly struct PairAdjustmentPositioning + { + public readonly PointerSpan Memory; + + public PairAdjustmentPositioning(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public IEnumerable ExtractAdvanceX() => this.Format switch + { + 1 => new Format1(this.Memory).ExtractAdvanceX(), + 2 => new Format2(this.Memory).ExtractAdvanceX(), + _ => Array.Empty(), + }; + + public readonly struct Format1 + { + public readonly PointerSpan Memory; + + public Format1(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort CoverageOffset => this.Memory.ReadU16Big(2); + + public ValueFormat ValueFormat1 => this.Memory.ReadEnumBig(4); + + public ValueFormat ValueFormat2 => this.Memory.ReadEnumBig(6); + + public ushort PairSetCount => this.Memory.ReadU16Big(8); + + public BigEndianPointerSpan PairSetOffsets => new( + this.Memory[10..].As(this.PairSetCount), + BinaryPrimitives.ReverseEndianness); + + public CoverageTable CoverageTable => new(this.Memory[this.CoverageOffset..]); + + public PairSet this[int index] => new( + this.Memory[this.PairSetOffsets[index] ..], + this.ValueFormat1, + this.ValueFormat2); + + public IEnumerable ExtractAdvanceX() + { + if ((this.ValueFormat1 & ValueFormat.AdvanceX) == 0 && + (this.ValueFormat2 & ValueFormat.AdvanceX) == 0) + { + yield break; + } + + var coverageTable = this.CoverageTable; + switch (coverageTable.Format) + { + case CoverageTable.CoverageFormat.Glyphs: + { + var glyphSpan = coverageTable.Glyphs; + foreach (var coverageIndex in Enumerable.Range(0, glyphSpan.Count)) + { + var glyph1Id = glyphSpan[coverageIndex]; + PairSet pairSetView; + try + { + pairSetView = this[coverageIndex]; + } + catch (ArgumentOutOfRangeException) + { + yield break; + } + catch (IndexOutOfRangeException) + { + yield break; + } + + foreach (var pairIndex in Enumerable.Range(0, pairSetView.Count)) + { + var pair = pairSetView[pairIndex]; + var adj = (short)(pair.Record1.AdvanceX + pair.Record2.PlacementX); + if (adj >= 10000) + System.Diagnostics.Debugger.Break(); + + if (adj != 0) + yield return new(glyph1Id, pair.SecondGlyph, adj); + } + } + + break; + } + + case CoverageTable.CoverageFormat.RangeRecords: + { + foreach (var rangeRecord in coverageTable.RangeRecords) + { + var startGlyphId = rangeRecord.StartGlyphId; + var endGlyphId = rangeRecord.EndGlyphId; + var startCoverageIndex = rangeRecord.StartCoverageIndex; + var glyphCount = endGlyphId - startGlyphId + 1; + foreach (var glyph1Id in Enumerable.Range(startGlyphId, glyphCount)) + { + PairSet pairSetView; + try + { + pairSetView = this[startCoverageIndex + glyph1Id - startGlyphId]; + } + catch (ArgumentOutOfRangeException) + { + yield break; + } + catch (IndexOutOfRangeException) + { + yield break; + } + + foreach (var pairIndex in Enumerable.Range(0, pairSetView.Count)) + { + var pair = pairSetView[pairIndex]; + var adj = (short)(pair.Record1.AdvanceX + pair.Record2.PlacementX); + if (adj != 0) + yield return new((ushort)glyph1Id, pair.SecondGlyph, adj); + } + } + } + + break; + } + } + } + + public readonly struct PairSet + { + public readonly PointerSpan Memory; + public readonly ValueFormat ValueFormat1; + public readonly ValueFormat ValueFormat2; + public readonly int PairValue1Size; + public readonly int PairValue2Size; + public readonly int PairSize; + + public PairSet( + PointerSpan memory, + ValueFormat valueFormat1, + ValueFormat valueFormat2) + { + this.Memory = memory; + this.ValueFormat1 = valueFormat1; + this.ValueFormat2 = valueFormat2; + this.PairValue1Size = this.ValueFormat1.NumBytes(); + this.PairValue2Size = this.ValueFormat2.NumBytes(); + this.PairSize = 2 + this.PairValue1Size + this.PairValue2Size; + } + + public ushort Count => this.Memory.ReadU16Big(0); + + public PairValueRecord this[int index] + { + get + { + var pvr = this.Memory.Slice(2 + (this.PairSize * index), this.PairSize); + return new() + { + SecondGlyph = pvr.ReadU16Big(0), + Record1 = new(pvr.Slice(2, this.PairValue1Size), this.ValueFormat1), + Record2 = new( + pvr.Slice(2 + this.PairValue1Size, this.PairValue2Size), + this.ValueFormat2), + }; + } + } + + public struct PairValueRecord + { + public ushort SecondGlyph; + public ValueRecord Record1; + public ValueRecord Record2; + } + } + } + + public readonly struct Format2 + { + public readonly PointerSpan Memory; + public readonly int PairValue1Size; + public readonly int PairValue2Size; + public readonly int PairSize; + + public Format2(PointerSpan memory) + { + this.Memory = memory; + this.PairValue1Size = this.ValueFormat1.NumBytes(); + this.PairValue2Size = this.ValueFormat2.NumBytes(); + this.PairSize = this.PairValue1Size + this.PairValue2Size; + } + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort CoverageOffset => this.Memory.ReadU16Big(2); + + public ValueFormat ValueFormat1 => this.Memory.ReadEnumBig(4); + + public ValueFormat ValueFormat2 => this.Memory.ReadEnumBig(6); + + public ushort ClassDef1Offset => this.Memory.ReadU16Big(8); + + public ushort ClassDef2Offset => this.Memory.ReadU16Big(10); + + public ushort Class1Count => this.Memory.ReadU16Big(12); + + public ushort Class2Count => this.Memory.ReadU16Big(14); + + public ClassDefTable ClassDefTable1 => new(this.Memory[this.ClassDef1Offset..]); + + public ClassDefTable ClassDefTable2 => new(this.Memory[this.ClassDef2Offset..]); + + public (ValueRecord Record1, ValueRecord Record2) this[(int Class1Index, int Class2Index) v] => + this[v.Class1Index, v.Class2Index]; + + public (ValueRecord Record1, ValueRecord Record2) this[int class1Index, int class2Index] + { + get + { + if (class1Index < 0 || class1Index >= this.Class1Count) + throw new IndexOutOfRangeException(); + + if (class2Index < 0 || class2Index >= this.Class2Count) + throw new IndexOutOfRangeException(); + + var offset = 16 + (this.PairSize * ((class1Index * this.Class2Count) + class2Index)); + return ( + new(this.Memory.Slice(offset, this.PairValue1Size), this.ValueFormat1), + new( + this.Memory.Slice(offset + this.PairValue1Size, this.PairValue2Size), + this.ValueFormat2)); + } + } + + public IEnumerable ExtractAdvanceX() + { + if ((this.ValueFormat1 & ValueFormat.AdvanceX) == 0 && + (this.ValueFormat2 & ValueFormat.AdvanceX) == 0) + { + yield break; + } + + var classes1 = this.ClassDefTable1.Enumerate() + .GroupBy(x => x.Class, x => x.GlyphId) + .ToImmutableDictionary(x => x.Key, x => x.ToImmutableSortedSet()); + + var classes2 = this.ClassDefTable2.Enumerate() + .GroupBy(x => x.Class, x => x.GlyphId) + .ToImmutableDictionary(x => x.Key, x => x.ToImmutableSortedSet()); + + foreach (var class1 in Enumerable.Range(0, this.Class1Count)) + { + if (!classes1.TryGetValue((ushort)class1, out var glyphs1)) + continue; + + foreach (var class2 in Enumerable.Range(0, this.Class2Count)) + { + if (!classes2.TryGetValue((ushort)class2, out var glyphs2)) + continue; + + (ValueRecord, ValueRecord) record; + try + { + record = this[class1, class2]; + } + catch (ArgumentOutOfRangeException) + { + yield break; + } + catch (IndexOutOfRangeException) + { + yield break; + } + + var val = record.Item1.AdvanceX + record.Item2.PlacementX; + if (val == 0) + continue; + + foreach (var glyph1 in glyphs1) + { + foreach (var glyph2 in glyphs2) + { + yield return new(glyph1, glyph2, (short)val); + } + } + } + } + } + } + } + + public readonly struct ExtensionPositioningSubtableFormat1 + { + public readonly PointerSpan Memory; + + public ExtensionPositioningSubtableFormat1(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public LookupType ExtensionLookupType => this.Memory.ReadEnumBig(2); + + public int ExtensionOffset => this.Memory.ReadI32Big(4); + + public PointerSpan ExtensionData => this.Memory[this.ExtensionOffset..]; + } + } + + private readonly struct Head + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/head + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6head.html + + public const uint MagicNumberValue = 0x5F0F3CF5; + public static readonly TagStruct DirectoryTableTag = new('h', 'e', 'a', 'd'); + + public readonly PointerSpan Memory; + + public Head(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Head(PointerSpan memory) => this.Memory = memory; + + [Flags] + public enum HeadFlags : ushort + { + BaselineForFontAtZeroY = 1 << 0, + LeftSideBearingAtZeroX = 1 << 1, + InstructionsDependOnPointSize = 1 << 2, + ForcePpemsInteger = 1 << 3, + InstructionsAlterAdvanceWidth = 1 << 4, + VerticalLayout = 1 << 5, + Reserved6 = 1 << 6, + RequiresLayoutForCorrectLinguisticRendering = 1 << 7, + IsAatFont = 1 << 8, + ContainsRtlGlyph = 1 << 9, + ContainsIndicStyleRearrangementEffects = 1 << 10, + Lossless = 1 << 11, + ProduceCompatibleMetrics = 1 << 12, + OptimizedForClearType = 1 << 13, + IsLastResortFont = 1 << 14, + Reserved15 = 1 << 15, + } + + [Flags] + public enum MacStyleFlags : ushort + { + Bold = 1 << 0, + Italic = 1 << 1, + Underline = 1 << 2, + Outline = 1 << 3, + Shadow = 1 << 4, + Condensed = 1 << 5, + Extended = 1 << 6, + } + + public Fixed Version => new(this.Memory); + + public Fixed FontRevision => new(this.Memory[4..]); + + public uint ChecksumAdjustment => this.Memory.ReadU32Big(8); + + public uint MagicNumber => this.Memory.ReadU32Big(12); + + public HeadFlags Flags => this.Memory.ReadEnumBig(16); + + public ushort UnitsPerEm => this.Memory.ReadU16Big(18); + + public ulong CreatedTimestamp => this.Memory.ReadU64Big(20); + + public ulong ModifiedTimestamp => this.Memory.ReadU64Big(28); + + public ushort MinX => this.Memory.ReadU16Big(36); + + public ushort MinY => this.Memory.ReadU16Big(38); + + public ushort MaxX => this.Memory.ReadU16Big(40); + + public ushort MaxY => this.Memory.ReadU16Big(42); + + public MacStyleFlags MacStyle => this.Memory.ReadEnumBig(44); + + public ushort LowestRecommendedPpem => this.Memory.ReadU16Big(46); + + public ushort FontDirectionHint => this.Memory.ReadU16Big(48); + + public ushort IndexToLocFormat => this.Memory.ReadU16Big(50); + + public ushort GlyphDataFormat => this.Memory.ReadU16Big(52); + } + + private readonly struct Kern + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/kern + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6kern.html + + public static readonly TagStruct DirectoryTableTag = new('k', 'e', 'r', 'n'); + + public readonly PointerSpan Memory; + + public Kern(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Kern(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public IEnumerable EnumerateHorizontalPairs() => this.Version switch + { + 0 => new Version0(this.Memory).EnumerateHorizontalPairs(), + 1 => new Version1(this.Memory).EnumerateHorizontalPairs(), + _ => Array.Empty(), + }; + + public readonly struct Format0 + { + public readonly PointerSpan Memory; + + public Format0(PointerSpan memory) => this.Memory = memory; + + public ushort PairCount => this.Memory.ReadU16Big(0); + + public ushort SearchRange => this.Memory.ReadU16Big(2); + + public ushort EntrySelector => this.Memory.ReadU16Big(4); + + public ushort RangeShift => this.Memory.ReadU16Big(6); + + public BigEndianPointerSpan Pairs => new( + this.Memory[8..].As(this.PairCount), + KerningPair.ReverseEndianness); + } + + public readonly struct Version0 + { + public readonly PointerSpan Memory; + + public Version0(PointerSpan memory) => this.Memory = memory; + + [Flags] + public enum CoverageFlags : byte + { + Horizontal = 1 << 0, + Minimum = 1 << 1, + CrossStream = 1 << 2, + Override = 1 << 3, + } + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort NumSubtables => this.Memory.ReadU16Big(2); + + public PointerSpan Data => this.Memory[4..]; + + public IEnumerable EnumerateSubtables() + { + var data = this.Data; + for (var i = 0; i < this.NumSubtables && !data.IsEmpty; i++) + { + var st = new Subtable(data); + data = data[st.Length..]; + yield return st; + } + } + + public IEnumerable EnumerateHorizontalPairs() + { + var accumulator = new Dictionary<(ushort Left, ushort Right), short>(); + foreach (var subtable in this.EnumerateSubtables()) + { + var isOverride = (subtable.Flags & CoverageFlags.Override) != 0; + var isMinimum = (subtable.Flags & CoverageFlags.Minimum) != 0; + foreach (var t in subtable.EnumeratePairs()) + { + if (isOverride) + { + accumulator[(t.Left, t.Right)] = t.Value; + } + else if (isMinimum) + { + accumulator[(t.Left, t.Right)] = Math.Max( + accumulator.GetValueOrDefault((t.Left, t.Right), t.Value), + t.Value); + } + else + { + accumulator[(t.Left, t.Right)] = (short)( + accumulator.GetValueOrDefault( + (t.Left, t.Right)) + t.Value); + } + } + } + + return accumulator.Select( + x => new KerningPair { Left = x.Key.Left, Right = x.Key.Right, Value = x.Value }); + } + + public readonly struct Subtable + { + public readonly PointerSpan Memory; + + public Subtable(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public byte Format => this.Memory[4]; + + public CoverageFlags Flags => this.Memory.ReadEnumBig(5); + + public PointerSpan Data => this.Memory[6..]; + + public IEnumerable EnumeratePairs() => this.Format switch + { + 0 => new Format0(this.Data).Pairs, + _ => Array.Empty(), + }; + } + } + + public readonly struct Version1 + { + public readonly PointerSpan Memory; + + public Version1(PointerSpan memory) => this.Memory = memory; + + [Flags] + public enum CoverageFlags : byte + { + Vertical = 1 << 0, + CrossStream = 1 << 1, + Variation = 1 << 2, + } + + public Fixed Version => new(this.Memory); + + public int NumSubtables => this.Memory.ReadI16Big(4); + + public PointerSpan Data => this.Memory[8..]; + + public IEnumerable EnumerateSubtables() + { + var data = this.Data; + for (var i = 0; i < this.NumSubtables && !data.IsEmpty; i++) + { + var st = new Subtable(data); + data = data[st.Length..]; + yield return st; + } + } + + public IEnumerable EnumerateHorizontalPairs() => this + .EnumerateSubtables() + .Where(x => x.Flags == 0) + .SelectMany(x => x.EnumeratePairs()); + + public readonly struct Subtable + { + public readonly PointerSpan Memory; + + public Subtable(PointerSpan memory) => this.Memory = memory; + + public int Length => this.Memory.ReadI32Big(0); + + public byte Format => this.Memory[4]; + + public CoverageFlags Flags => this.Memory.ReadEnumBig(5); + + public ushort TupleIndex => this.Memory.ReadU16Big(6); + + public PointerSpan Data => this.Memory[8..]; + + public IEnumerable EnumeratePairs() => this.Format switch + { + 0 => new Format0(this.Data).Pairs, + _ => Array.Empty(), + }; + } + } + } + + private readonly struct Name + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/name + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6name.html + + public static readonly TagStruct DirectoryTableTag = new('n', 'a', 'm', 'e'); + + public readonly PointerSpan Memory; + + public Name(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Name(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort Count => this.Memory.ReadU16Big(2); + + public ushort StorageOffset => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan NameRecords => new( + this.Memory[6..].As(this.Count), + NameRecord.ReverseEndianness); + + public ushort LanguageCount => + this.Version == 0 ? (ushort)0 : this.Memory.ReadU16Big(6 + this.NameRecords.ByteCount); + + public BigEndianPointerSpan LanguageRecords => this.Version == 0 + ? default + : new( + this.Memory[ + (8 + this.NameRecords + .ByteCount)..] + .As( + this.LanguageCount), + LanguageRecord.ReverseEndianness); + + public PointerSpan Storage => this.Memory[this.StorageOffset..]; + + public string this[in NameRecord record] => + record.PlatformAndEncoding.Decode(this.Storage.Span.Slice(record.StringOffset, record.Length)); + + public string this[in LanguageRecord record] => + Encoding.ASCII.GetString(this.Storage.Span.Slice(record.LanguageTagOffset, record.Length)); + + public struct NameRecord + { + public PlatformAndEncoding PlatformAndEncoding; + public ushort LanguageId; + public NameId NameId; + public ushort Length; + public ushort StringOffset; + + public NameRecord(PointerSpan span) + { + this.PlatformAndEncoding = new(span); + var offset = Unsafe.SizeOf(); + span.ReadBig(ref offset, out this.LanguageId); + span.ReadBig(ref offset, out this.NameId); + span.ReadBig(ref offset, out this.Length); + span.ReadBig(ref offset, out this.StringOffset); + } + + public static NameRecord ReverseEndianness(NameRecord value) => new() + { + PlatformAndEncoding = PlatformAndEncoding.ReverseEndianness(value.PlatformAndEncoding), + LanguageId = BinaryPrimitives.ReverseEndianness(value.LanguageId), + NameId = (NameId)BinaryPrimitives.ReverseEndianness((ushort)value.NameId), + Length = BinaryPrimitives.ReverseEndianness(value.Length), + StringOffset = BinaryPrimitives.ReverseEndianness(value.StringOffset), + }; + } + + public struct LanguageRecord + { + public ushort Length; + public ushort LanguageTagOffset; + + public LanguageRecord(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.Length); + span.ReadBig(ref offset, out this.LanguageTagOffset); + } + + public static LanguageRecord ReverseEndianness(LanguageRecord value) => new() + { + Length = BinaryPrimitives.ReverseEndianness(value.Length), + LanguageTagOffset = BinaryPrimitives.ReverseEndianness(value.LanguageTagOffset), + }; + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.cs new file mode 100644 index 000000000..1d437d56d --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.cs @@ -0,0 +1,135 @@ +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + /// + /// Checks whether the given will fail in , + /// and throws an appropriate exception if it is the case. + /// + /// The font config. + public static unsafe void CheckImGuiCompatibleOrThrow(in ImFontConfig fontConfig) + { + var ranges = fontConfig.GlyphRanges; + var sfnt = AsSfntFile(fontConfig); + var cmap = new Cmap(sfnt); + if (cmap.UnicodeTable is not { } unicodeTable) + throw new NotSupportedException("The font does not have a compatible Unicode character mapping table."); + if (unicodeTable.All(x => !ImGuiHelpers.IsCodepointInSuppliedGlyphRangesUnsafe(x.Key, ranges))) + throw new NotSupportedException("The font does not have any glyph that falls under the requested range."); + } + + /// + /// Enumerates through horizontal pair adjustments of a kern and gpos tables. + /// + /// The font config. + /// The enumerable of pair adjustments. Distance values need to be multiplied by font size in pixels. + public static IEnumerable<(char Left, char Right, float Distance)> ExtractHorizontalPairAdjustments( + ImFontConfig fontConfig) + { + float multiplier; + Dictionary glyphToCodepoints; + Gpos gpos = default; + Kern kern = default; + + try + { + var sfnt = AsSfntFile(fontConfig); + var head = new Head(sfnt); + multiplier = 3f / 4 / head.UnitsPerEm; + + if (new Cmap(sfnt).UnicodeTable is not { } table) + yield break; + + if (sfnt.ContainsKey(Kern.DirectoryTableTag)) + kern = new(sfnt); + else if (sfnt.ContainsKey(Gpos.DirectoryTableTag)) + gpos = new(sfnt); + else + yield break; + + glyphToCodepoints = table + .GroupBy(x => x.Value, x => x.Key) + .OrderBy(x => x.Key) + .ToDictionary( + x => x.Key, + x => x.Where(y => y <= ushort.MaxValue) + .Select(y => (char)y) + .ToArray()); + } + catch + { + // don't care; give up + yield break; + } + + if (kern.Memory.Count != 0) + { + foreach (var pair in kern.EnumerateHorizontalPairs()) + { + if (!glyphToCodepoints.TryGetValue(pair.Left, out var leftChars)) + continue; + if (!glyphToCodepoints.TryGetValue(pair.Right, out var rightChars)) + continue; + + foreach (var l in leftChars) + { + foreach (var r in rightChars) + yield return (l, r, pair.Value * multiplier); + } + } + } + else if (gpos.Memory.Count != 0) + { + foreach (var pair in gpos.ExtractAdvanceX()) + { + if (!glyphToCodepoints.TryGetValue(pair.Left, out var leftChars)) + continue; + if (!glyphToCodepoints.TryGetValue(pair.Right, out var rightChars)) + continue; + + foreach (var l in leftChars) + { + foreach (var r in rightChars) + yield return (l, r, pair.Value * multiplier); + } + } + } + } + + private static unsafe SfntFile AsSfntFile(in ImFontConfig fontConfig) + { + var memory = new PointerSpan((byte*)fontConfig.FontData, fontConfig.FontDataSize); + if (memory.Length < 4) + throw new NotSupportedException("File is too short to even have a magic."); + + var magic = memory.ReadU32Big(0); + if (BitConverter.IsLittleEndian) + magic = BinaryPrimitives.ReverseEndianness(magic); + + if (magic == SfntFile.FileTagTrueType1.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagType1.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagOpenTypeWithCff.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagOpenType1_0.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagTrueTypeApple.NativeValue) + return new(memory); + if (magic == TtcFile.FileTag.NativeValue) + return new TtcFile(memory)[fontConfig.FontNo]; + + throw new NotSupportedException($"The given file with the magic 0x{magic:X08} is not supported."); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/SafeFontConfig.cs b/Dalamud/Interface/ManagedFontAtlas/SafeFontConfig.cs new file mode 100644 index 000000000..caa686856 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/SafeFontConfig.cs @@ -0,0 +1,306 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Text; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Managed version of , to avoid unnecessary heap allocation and use of unsafe blocks. +/// +public struct SafeFontConfig +{ + /// + /// The raw config. + /// + public ImFontConfig Raw; + + /// + /// Initializes a new instance of the struct. + /// + public SafeFontConfig() + { + this.OversampleH = 1; + this.OversampleV = 1; + this.PixelSnapH = true; + this.GlyphMaxAdvanceX = float.MaxValue; + this.RasterizerMultiply = 1f; + this.RasterizerGamma = 1.7f; + this.EllipsisChar = unchecked((char)-1); + this.Raw.FontDataOwnedByAtlas = 1; + } + + /// + /// Initializes a new instance of the struct, + /// copying applicable values from an existing instance of . + /// + /// Config to copy from. + public unsafe SafeFontConfig(ImFontConfigPtr config) + : this() + { + if (config.NativePtr is not null) + { + this.Raw = *config.NativePtr; + this.Raw.GlyphRanges = null; + } + } + + /// + /// Gets or sets the index of font within a TTF/OTF file. + /// + public int FontNo + { + get => this.Raw.FontNo; + set => this.Raw.FontNo = EnsureRange(value, 0, int.MaxValue); + } + + /// + /// Gets or sets the desired size of the new font, in pixels.
+ /// Effectively, this is the line height.
+ /// Value is tied with . + ///
+ public float SizePx + { + get => this.Raw.SizePixels; + set => this.Raw.SizePixels = EnsureRange(value, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets the desired size of the new font, in points.
+ /// Effectively, this is the line height.
+ /// Value is tied with . + ///
+ public float SizePt + { + get => (this.Raw.SizePixels * 3) / 4; + set => this.Raw.SizePixels = EnsureRange((value * 4) / 3, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets the horizontal oversampling pixel count.
+ /// Rasterize at higher quality for sub-pixel positioning.
+ /// Note the difference between 2 and 3 is minimal so you can reduce this to 2 to save memory.
+ /// Read https://github.com/nothings/stb/blob/master/tests/oversample/README.md for details. + ///
+ public int OversampleH + { + get => this.Raw.OversampleH; + set => this.Raw.OversampleH = EnsureRange(value, 1, int.MaxValue); + } + + /// + /// Gets or sets the vertical oversampling pixel count.
+ /// Rasterize at higher quality for sub-pixel positioning.
+ /// This is not really useful as we don't use sub-pixel positions on the Y axis. + ///
+ public int OversampleV + { + get => this.Raw.OversampleV; + set => this.Raw.OversampleV = EnsureRange(value, 1, int.MaxValue); + } + + /// + /// Gets or sets a value indicating whether to align every glyph to pixel boundary.
+ /// Useful e.g. if you are merging a non-pixel aligned font with the default font.
+ /// If enabled, you can set and to 1. + ///
+ public bool PixelSnapH + { + get => this.Raw.PixelSnapH != 0; + set => this.Raw.PixelSnapH = value ? (byte)1 : (byte)0; + } + + /// + /// Gets or sets the extra spacing (in pixels) between glyphs.
+ /// Only X axis is supported for now.
+ /// Effectively, it is the letter spacing. + ///
+ public Vector2 GlyphExtraSpacing + { + get => this.Raw.GlyphExtraSpacing; + set => this.Raw.GlyphExtraSpacing = new( + EnsureRange(value.X, float.MinValue, float.MaxValue), + EnsureRange(value.Y, float.MinValue, float.MaxValue)); + } + + /// + /// Gets or sets the offset all glyphs from this font input.
+ /// Use this to offset fonts vertically when merging multiple fonts. + ///
+ public Vector2 GlyphOffset + { + get => this.Raw.GlyphOffset; + set => this.Raw.GlyphOffset = new( + EnsureRange(value.X, float.MinValue, float.MaxValue), + EnsureRange(value.Y, float.MinValue, float.MaxValue)); + } + + /// + /// Gets or sets the glyph ranges, which is a user-provided list of Unicode range. + /// Each range has 2 values, and values are inclusive.
+ /// The list must be zero-terminated.
+ /// If empty or null, then all the glyphs from the font that is in the range of UCS-2 will be added. + ///
+ public ushort[]? GlyphRanges { get; set; } + + /// + /// Gets or sets the minimum AdvanceX for glyphs.
+ /// Set only to align font icons.
+ /// Set both / to enforce mono-space font. + ///
+ public float GlyphMinAdvanceX + { + get => this.Raw.GlyphMinAdvanceX; + set => this.Raw.GlyphMinAdvanceX = + float.IsFinite(value) + ? value + : throw new ArgumentOutOfRangeException( + nameof(value), + value, + $"{nameof(this.GlyphMinAdvanceX)} must be a finite number."); + } + + /// + /// Gets or sets the maximum AdvanceX for glyphs. + /// + public float GlyphMaxAdvanceX + { + get => this.Raw.GlyphMaxAdvanceX; + set => this.Raw.GlyphMaxAdvanceX = + float.IsFinite(value) + ? value + : throw new ArgumentOutOfRangeException( + nameof(value), + value, + $"{nameof(this.GlyphMaxAdvanceX)} must be a finite number."); + } + + /// + /// Gets or sets a value that either brightens (>1.0f) or darkens (<1.0f) the font output.
+ /// Brightening small fonts may be a good workaround to make them more readable. + ///
+ public float RasterizerMultiply + { + get => this.Raw.RasterizerMultiply; + set => this.Raw.RasterizerMultiply = EnsureRange(value, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets the gamma value for fonts. + /// + public float RasterizerGamma + { + get => this.Raw.RasterizerGamma; + set => this.Raw.RasterizerGamma = EnsureRange(value, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets a value explicitly specifying unicode codepoint of the ellipsis character.
+ /// When fonts are being merged first specified ellipsis will be used. + ///
+ public char EllipsisChar + { + get => (char)this.Raw.EllipsisChar; + set => this.Raw.EllipsisChar = value; + } + + /// + /// Gets or sets the desired name of the new font. Names longer than 40 bytes will be partially lost. + /// + public unsafe string Name + { + get + { + fixed (void* pName = this.Raw.Name) + { + var span = new ReadOnlySpan(pName, 40); + var firstNull = span.IndexOf((byte)0); + if (firstNull != -1) + span = span[..firstNull]; + return Encoding.UTF8.GetString(span); + } + } + + set + { + fixed (void* pName = this.Raw.Name) + { + var span = new Span(pName, 40); + Encoding.UTF8.GetBytes(value, span); + } + } + } + + /// + /// Gets or sets the desired font to merge with, if set. + /// + public unsafe ImFontPtr MergeFont + { + get => this.Raw.DstFont is not null ? this.Raw.DstFont : default; + set + { + this.Raw.MergeMode = value.NativePtr is null ? (byte)0 : (byte)1; + this.Raw.DstFont = value.NativePtr is null ? default : value.NativePtr; + } + } + + /// + /// Throws with appropriate messages, + /// if this has invalid values. + /// + public readonly void ThrowOnInvalidValues() + { + if (!(this.Raw.FontNo >= 0)) + throw new ArgumentException($"{nameof(this.FontNo)} must not be a negative number."); + + if (!(this.Raw.SizePixels > 0)) + throw new ArgumentException($"{nameof(this.SizePx)} must be a positive number."); + + if (!(this.Raw.OversampleH >= 1)) + throw new ArgumentException($"{nameof(this.OversampleH)} must be a negative number."); + + if (!(this.Raw.OversampleV >= 1)) + throw new ArgumentException($"{nameof(this.OversampleV)} must be a negative number."); + + if (!float.IsFinite(this.Raw.GlyphMinAdvanceX)) + throw new ArgumentException($"{nameof(this.GlyphMinAdvanceX)} must be a finite number."); + + if (!float.IsFinite(this.Raw.GlyphMaxAdvanceX)) + throw new ArgumentException($"{nameof(this.GlyphMaxAdvanceX)} must be a finite number."); + + if (!(this.Raw.RasterizerMultiply > 0)) + throw new ArgumentException($"{nameof(this.RasterizerMultiply)} must be a positive number."); + + if (!(this.Raw.RasterizerGamma > 0)) + throw new ArgumentException($"{nameof(this.RasterizerGamma)} must be a positive number."); + + if (this.GlyphRanges is { Length: > 0 } ranges) + { + if (ranges[0] == 0) + { + throw new ArgumentException( + "Font ranges cannot start with 0.", + nameof(this.GlyphRanges)); + } + + if (ranges[(ranges.Length - 1) & ~1] != 0) + { + throw new ArgumentException( + "Font ranges must terminate with a zero at even indices.", + nameof(this.GlyphRanges)); + } + } + } + + private static T EnsureRange(T value, T min, T max, [CallerMemberName] string callerName = "") + where T : INumber + { + if (value < min) + throw new ArgumentOutOfRangeException(callerName, value, $"{callerName} cannot be less than {min}."); + if (value > max) + throw new ArgumentOutOfRangeException(callerName, value, $"{callerName} cannot be more than {max}."); + + return value; + } +} diff --git a/Dalamud/Interface/TitleScreenMenu.cs b/Dalamud/Interface/TitleScreenMenu.cs deleted file mode 100644 index 7b3897fdb..000000000 --- a/Dalamud/Interface/TitleScreenMenu.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -using Dalamud.IoC; -using Dalamud.IoC.Internal; -using ImGuiScene; - -namespace Dalamud.Interface; - -/// -/// Class responsible for managing elements in the title screen menu. -/// -[PluginInterface] -[InterfaceVersion("1.0")] -[ServiceManager.BlockingEarlyLoadedService] -public class TitleScreenMenu : IServiceType -{ - /// - /// Gets the texture size needed for title screen menu logos. - /// - internal const uint TextureSize = 64; - - private readonly List entries = new(); - - [ServiceManager.ServiceConstructor] - private TitleScreenMenu() - { - } - - /// - /// Gets the list of entries in the title screen menu. - /// - public IReadOnlyList Entries => this.entries; - - /// - /// Adds a new entry to the title screen menu. - /// - /// The text to show. - /// The texture to show. - /// The action to execute when the option is selected. - /// A object that can be used to manage the entry. - /// Thrown when the texture provided does not match the required resolution(64x64). - public TitleScreenMenuEntry AddEntry(string text, TextureWrap texture, Action onTriggered) - { - if (texture.Height != TextureSize || texture.Width != TextureSize) - { - throw new ArgumentException("Texture must be 64x64"); - } - - lock (this.entries) - { - var entriesOfAssembly = this.entries.Where(x => x.CallingAssembly == Assembly.GetCallingAssembly()).ToList(); - var priority = entriesOfAssembly.Any() - ? unchecked(entriesOfAssembly.Select(x => x.Priority).Max() + 1) - : 0; - var entry = new TitleScreenMenuEntry(Assembly.GetCallingAssembly(), priority, text, texture, onTriggered); - var i = this.entries.BinarySearch(entry); - if (i < 0) - i = ~i; - this.entries.Insert(i, entry); - return entry; - } - } - - /// - /// Adds a new entry to the title screen menu. - /// - /// Priority of the entry. - /// The text to show. - /// The texture to show. - /// The action to execute when the option is selected. - /// A object that can be used to manage the entry. - /// Thrown when the texture provided does not match the required resolution(64x64). - public TitleScreenMenuEntry AddEntry(ulong priority, string text, TextureWrap texture, Action onTriggered) - { - if (texture.Height != TextureSize || texture.Width != TextureSize) - { - throw new ArgumentException("Texture must be 64x64"); - } - - lock (this.entries) - { - var entry = new TitleScreenMenuEntry(Assembly.GetCallingAssembly(), priority, text, texture, onTriggered); - var i = this.entries.BinarySearch(entry); - if (i < 0) - i = ~i; - this.entries.Insert(i, entry); - return entry; - } - } - - /// - /// Remove an entry from the title screen menu. - /// - /// The entry to remove. - public void RemoveEntry(TitleScreenMenuEntry entry) - { - lock (this.entries) - { - this.entries.Remove(entry); - } - } - - /// - /// Adds a new entry to the title screen menu. - /// - /// Priority of the entry. - /// The text to show. - /// The texture to show. - /// The action to execute when the option is selected. - /// A object that can be used to manage the entry. - /// Thrown when the texture provided does not match the required resolution(64x64). - internal TitleScreenMenuEntry AddEntryCore(ulong priority, string text, TextureWrap texture, Action onTriggered) - { - if (texture.Height != TextureSize || texture.Width != TextureSize) - { - throw new ArgumentException("Texture must be 64x64"); - } - - lock (this.entries) - { - var entry = new TitleScreenMenuEntry(null, priority, text, texture, onTriggered); - this.entries.Add(entry); - return entry; - } - } - - /// - /// Adds a new entry to the title screen menu. - /// - /// The text to show. - /// The texture to show. - /// The action to execute when the option is selected. - /// A object that can be used to manage the entry. - /// Thrown when the texture provided does not match the required resolution(64x64). - internal TitleScreenMenuEntry AddEntryCore(string text, TextureWrap texture, Action onTriggered) - { - if (texture.Height != TextureSize || texture.Width != TextureSize) - { - throw new ArgumentException("Texture must be 64x64"); - } - - lock (this.entries) - { - var entriesOfAssembly = this.entries.Where(x => x.CallingAssembly == null).ToList(); - var priority = entriesOfAssembly.Any() - ? unchecked(entriesOfAssembly.Select(x => x.Priority).Max() + 1) - : 0; - var entry = new TitleScreenMenuEntry(null, priority, text, texture, onTriggered); - this.entries.Add(entry); - return entry; - } - } - - /// - /// Class representing an entry in the title screen menu. - /// - public class TitleScreenMenuEntry : IComparable - { - private readonly Action onTriggered; - - /// - /// Initializes a new instance of the class. - /// - /// The calling assembly. - /// The priority of this entry. - /// The text to show. - /// The texture to show. - /// The action to execute when the option is selected. - internal TitleScreenMenuEntry(Assembly? callingAssembly, ulong priority, string text, TextureWrap texture, Action onTriggered) - { - this.CallingAssembly = callingAssembly; - this.Priority = priority; - this.Name = text; - this.Texture = texture; - this.onTriggered = onTriggered; - } - - /// - /// Gets the priority of this entry. - /// - public ulong Priority { get; init; } - - /// - /// Gets or sets the name of this entry. - /// - public string Name { get; set; } - - /// - /// Gets or sets the texture of this entry. - /// - public TextureWrap Texture { get; set; } - - /// - /// Gets the calling assembly of this entry. - /// - internal Assembly? CallingAssembly { get; init; } - - /// - /// Gets the internal ID of this entry. - /// - internal Guid Id { get; init; } = Guid.NewGuid(); - - /// - public int CompareTo(TitleScreenMenuEntry? other) - { - if (other == null) - return 1; - if (this.CallingAssembly != other.CallingAssembly) - { - if (this.CallingAssembly == null && other.CallingAssembly == null) - return 0; - if (this.CallingAssembly == null && other.CallingAssembly != null) - return -1; - if (this.CallingAssembly != null && other.CallingAssembly == null) - return 1; - return string.Compare( - this.CallingAssembly!.FullName!, - other.CallingAssembly!.FullName!, - StringComparison.CurrentCultureIgnoreCase); - } - - if (this.Priority != other.Priority) - return this.Priority.CompareTo(other.Priority); - if (this.Name != other.Name) - return string.Compare(this.Name, other.Name, StringComparison.InvariantCultureIgnoreCase); - return string.Compare(this.Name, other.Name, StringComparison.InvariantCulture); - } - - /// - /// Trigger the action associated with this entry. - /// - internal void Trigger() - { - this.onTriggered(); - } - } -} diff --git a/Dalamud/Interface/TitleScreenMenu/TitleScreenMenu.cs b/Dalamud/Interface/TitleScreenMenu/TitleScreenMenu.cs new file mode 100644 index 000000000..6fbc0b4f3 --- /dev/null +++ b/Dalamud/Interface/TitleScreenMenu/TitleScreenMenu.cs @@ -0,0 +1,239 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +using Dalamud.Game.ClientState.Keys; +using Dalamud.Interface.Internal; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Services; +using Dalamud.Utility; + +namespace Dalamud.Interface; + +/// +/// Class responsible for managing elements in the title screen menu. +/// +[InterfaceVersion("1.0")] +[ServiceManager.BlockingEarlyLoadedService] +internal class TitleScreenMenu : IServiceType, ITitleScreenMenu +{ + /// + /// Gets the texture size needed for title screen menu logos. + /// + internal const uint TextureSize = 64; + + private readonly List entries = new(); + private TitleScreenMenuEntry[]? entriesView; + + [ServiceManager.ServiceConstructor] + private TitleScreenMenu() + { + } + + /// + /// Event to be called when the entry list has been changed. + /// + internal event Action? EntryListChange; + + /// + public IReadOnlyList Entries + { + get + { + lock (this.entries) + { + if (!this.entries.Any()) + return Array.Empty(); + + return this.entriesView ??= this.entries.OrderByDescending(x => x.IsInternal).ToArray(); + } + } + } + + /// + public TitleScreenMenuEntry AddEntry(string text, IDalamudTextureWrap texture, Action onTriggered) + { + if (texture.Height != TextureSize || texture.Width != TextureSize) + { + throw new ArgumentException("Texture must be 64x64"); + } + + TitleScreenMenuEntry entry; + lock (this.entries) + { + var entriesOfAssembly = this.entries.Where(x => x.CallingAssembly == Assembly.GetCallingAssembly()).ToList(); + var priority = entriesOfAssembly.Any() + ? unchecked(entriesOfAssembly.Select(x => x.Priority).Max() + 1) + : 0; + entry = new(Assembly.GetCallingAssembly(), priority, text, texture, onTriggered); + var i = this.entries.BinarySearch(entry); + if (i < 0) + i = ~i; + this.entries.Insert(i, entry); + this.entriesView = null; + } + + this.EntryListChange?.InvokeSafely(); + return entry; + } + + /// + public TitleScreenMenuEntry AddEntry(ulong priority, string text, IDalamudTextureWrap texture, Action onTriggered) + { + if (texture.Height != TextureSize || texture.Width != TextureSize) + { + throw new ArgumentException("Texture must be 64x64"); + } + + TitleScreenMenuEntry entry; + lock (this.entries) + { + entry = new(Assembly.GetCallingAssembly(), priority, text, texture, onTriggered); + var i = this.entries.BinarySearch(entry); + if (i < 0) + i = ~i; + this.entries.Insert(i, entry); + this.entriesView = null; + } + + this.EntryListChange?.InvokeSafely(); + return entry; + } + + /// + public void RemoveEntry(TitleScreenMenuEntry entry) + { + lock (this.entries) + { + this.entries.Remove(entry); + this.entriesView = null; + } + + this.EntryListChange?.InvokeSafely(); + } + + /// + /// Adds a new entry to the title screen menu. + /// + /// Priority of the entry. + /// The text to show. + /// The texture to show. + /// The action to execute when the option is selected. + /// A object that can be used to manage the entry. + /// Thrown when the texture provided does not match the required resolution(64x64). + internal TitleScreenMenuEntry AddEntryCore(ulong priority, string text, IDalamudTextureWrap texture, Action onTriggered) + { + if (texture.Height != TextureSize || texture.Width != TextureSize) + { + throw new ArgumentException("Texture must be 64x64"); + } + + TitleScreenMenuEntry entry; + lock (this.entries) + { + entry = new(null, priority, text, texture, onTriggered) + { + IsInternal = true, + }; + this.entries.Add(entry); + this.entriesView = null; + } + + this.EntryListChange?.InvokeSafely(); + return entry; + } + + /// + /// Adds a new entry to the title screen menu. + /// + /// The text to show. + /// The texture to show. + /// The action to execute when the option is selected. + /// The keys that have to be held to display the menu. + /// A object that can be used to manage the entry. + /// Thrown when the texture provided does not match the required resolution(64x64). + internal TitleScreenMenuEntry AddEntryCore( + string text, + IDalamudTextureWrap texture, + Action onTriggered, + params VirtualKey[] showConditionKeys) + { + if (texture.Height != TextureSize || texture.Width != TextureSize) + { + throw new ArgumentException("Texture must be 64x64"); + } + + TitleScreenMenuEntry entry; + lock (this.entries) + { + var entriesOfAssembly = this.entries.Where(x => x.CallingAssembly == null).ToList(); + var priority = entriesOfAssembly.Any() + ? unchecked(entriesOfAssembly.Select(x => x.Priority).Max() + 1) + : 0; + entry = new(null, priority, text, texture, onTriggered, showConditionKeys) + { + IsInternal = true, + }; + this.entries.Add(entry); + this.entriesView = null; + } + + this.EntryListChange?.InvokeSafely(); + return entry; + } +} + +/// +/// Plugin-scoped version of a TitleScreenMenu service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class TitleScreenMenuPluginScoped : IInternalDisposableService, ITitleScreenMenu +{ + [ServiceManager.ServiceDependency] + private readonly TitleScreenMenu titleScreenMenuService = Service.Get(); + + private readonly List pluginEntries = new(); + + /// + public IReadOnlyList? Entries => this.titleScreenMenuService.Entries; + + /// + void IInternalDisposableService.DisposeService() + { + foreach (var entry in this.pluginEntries) + { + this.titleScreenMenuService.RemoveEntry(entry); + } + } + + /// + public TitleScreenMenuEntry AddEntry(string text, IDalamudTextureWrap texture, Action onTriggered) + { + var entry = this.titleScreenMenuService.AddEntry(text, texture, onTriggered); + this.pluginEntries.Add(entry); + + return entry; + } + + /// + public TitleScreenMenuEntry AddEntry(ulong priority, string text, IDalamudTextureWrap texture, Action onTriggered) + { + var entry = this.titleScreenMenuService.AddEntry(priority, text, texture, onTriggered); + this.pluginEntries.Add(entry); + + return entry; + } + + /// + public void RemoveEntry(TitleScreenMenuEntry entry) + { + this.pluginEntries.Remove(entry); + this.titleScreenMenuService.RemoveEntry(entry); + } +} diff --git a/Dalamud/Interface/TitleScreenMenu/TitleScreenMenuEntry.cs b/Dalamud/Interface/TitleScreenMenu/TitleScreenMenuEntry.cs new file mode 100644 index 000000000..8a400db7c --- /dev/null +++ b/Dalamud/Interface/TitleScreenMenu/TitleScreenMenuEntry.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; + +using Dalamud.Game.ClientState.Keys; +using Dalamud.Interface.Internal; + +namespace Dalamud.Interface; + +/// +/// Class representing an entry in the title screen menu. +/// +public class TitleScreenMenuEntry : IComparable +{ + private readonly Action onTriggered; + + /// + /// Initializes a new instance of the class. + /// + /// The calling assembly. + /// The priority of this entry. + /// The text to show. + /// The texture to show. + /// The action to execute when the option is selected. + /// The keys that have to be held to display the menu. + internal TitleScreenMenuEntry( + Assembly? callingAssembly, + ulong priority, + string text, + IDalamudTextureWrap texture, + Action onTriggered, + IEnumerable? showConditionKeys = null) + { + this.CallingAssembly = callingAssembly; + this.Priority = priority; + this.Name = text; + this.Texture = texture; + this.onTriggered = onTriggered; + this.ShowConditionKeys = (showConditionKeys ?? Array.Empty()).ToImmutableSortedSet(); + } + + /// + /// Gets the priority of this entry. + /// + public ulong Priority { get; init; } + + /// + /// Gets or sets the name of this entry. + /// + public string Name { get; set; } + + /// + /// Gets or sets the texture of this entry. + /// + public IDalamudTextureWrap Texture { get; set; } + + /// + /// Gets or sets a value indicating whether or not this entry is internal. + /// + internal bool IsInternal { get; set; } + + /// + /// Gets the calling assembly of this entry. + /// + internal Assembly? CallingAssembly { get; init; } + + /// + /// Gets the internal ID of this entry. + /// + internal Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// Gets the keys that have to be pressed to show the menu. + /// + internal IReadOnlySet ShowConditionKeys { get; init; } + + /// + public int CompareTo(TitleScreenMenuEntry? other) + { + if (other == null) + return 1; + if (this.CallingAssembly != other.CallingAssembly) + { + if (this.CallingAssembly == null && other.CallingAssembly == null) + return 0; + if (this.CallingAssembly == null && other.CallingAssembly != null) + return -1; + if (this.CallingAssembly != null && other.CallingAssembly == null) + return 1; + return string.Compare( + this.CallingAssembly!.FullName!, + other.CallingAssembly!.FullName!, + StringComparison.CurrentCultureIgnoreCase); + } + + if (this.Priority != other.Priority) + return this.Priority.CompareTo(other.Priority); + if (this.Name != other.Name) + return string.Compare(this.Name, other.Name, StringComparison.InvariantCultureIgnoreCase); + return 0; + } + + /// + /// Determines the displaying condition of this menu entry is met. + /// + /// True if met. + internal bool IsShowConditionSatisfied() => + this.ShowConditionKeys.All(x => Service.GetNullable()?[x] is true); + + /// + /// Trigger the action associated with this entry. + /// + internal void Trigger() + { + this.onTriggered(); + } +} diff --git a/Dalamud/Interface/UiBuilder.cs b/Dalamud/Interface/UiBuilder.cs index eca0f64a0..2c2ca9725 100644 --- a/Dalamud/Interface/UiBuilder.cs +++ b/Dalamud/Interface/UiBuilder.cs @@ -1,16 +1,25 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; using Dalamud.Configuration.Internal; using Dalamud.Game; +using Dalamud.Game.ClientState; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.Gui; +using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.ManagedAsserts; using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Plugin; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; using Dalamud.Utility; using ImGuiNET; using ImGuiScene; @@ -25,33 +34,61 @@ namespace Dalamud.Interface; ///
public sealed class UiBuilder : IDisposable { + private readonly LocalPlugin localPlugin; private readonly Stopwatch stopwatch; private readonly HitchDetector hitchDetector; private readonly string namespaceName; private readonly InterfaceManager interfaceManager = Service.Get(); - private readonly GameFontManager gameFontManager = Service.Get(); + private readonly Framework framework = Service.Get(); + private readonly ConcurrentDictionary notifications = new(); [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private bool hasErrorWindow = false; private bool lastFrameUiHideState = false; + private IFontHandle? defaultFontHandle; + private IFontHandle? iconFontHandle; + private IFontHandle? monoFontHandle; + /// /// Initializes a new instance of the class and registers it. /// You do not have to call this manually. /// /// The plugin namespace. - internal UiBuilder(string namespaceName) + /// The relevant local plugin. + internal UiBuilder(string namespaceName, LocalPlugin localPlugin) { - this.stopwatch = new Stopwatch(); - this.hitchDetector = new HitchDetector($"UiBuilder({namespaceName})", this.configuration.UiBuilderHitch); - this.namespaceName = namespaceName; + this.localPlugin = localPlugin; + try + { + this.stopwatch = new Stopwatch(); + this.hitchDetector = new HitchDetector($"UiBuilder({namespaceName})", this.configuration.UiBuilderHitch); + this.namespaceName = namespaceName; - this.interfaceManager.Draw += this.OnDraw; - this.interfaceManager.BuildFonts += this.OnBuildFonts; - this.interfaceManager.AfterBuildFonts += this.OnAfterBuildFonts; - this.interfaceManager.ResizeBuffers += this.OnResizeBuffers; + this.interfaceManager.Draw += this.OnDraw; + this.scopedFinalizer.Add(() => this.interfaceManager.Draw -= this.OnDraw); + + this.interfaceManager.ResizeBuffers += this.OnResizeBuffers; + this.scopedFinalizer.Add(() => this.interfaceManager.ResizeBuffers -= this.OnResizeBuffers); + + this.FontAtlas = + this.scopedFinalizer + .Add( + Service + .Get() + .CreateFontAtlas(namespaceName, FontAtlasAutoRebuildMode.Disable)); + this.FontAtlas.BuildStepChange += this.PrivateAtlasOnBuildStepChange; + this.FontAtlas.RebuildRecommend += this.RebuildFonts; + } + catch + { + this.scopedFinalizer.Dispose(); + throw; + } } /// @@ -69,24 +106,67 @@ public sealed class UiBuilder : IDisposable /// Event that is fired when the plugin should open its configuration interface. /// public event Action OpenConfigUi; + + /// + /// Event that is fired when the plugin should open its main interface. + /// + public event Action OpenMainUi; /// /// Gets or sets an action that is called any time ImGui fonts need to be rebuilt.
- /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt + /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt /// (at any time), so you should both reload your custom fonts and restore those - /// pointers inside this handler.
- /// PLEASE remove this handler inside Dispose, or when you no longer need your fonts! + /// pointers inside this handler. ///
- public event Action BuildFonts; + /// + /// To add your custom font, use . or + /// .
+ /// To be notified on font changes after fonts are built, use + /// ..
+ /// For all other purposes, use ..
+ ///
+ /// Note that you will be calling above functions once, instead of every time inside a build step change callback. + /// For example, you can make all font handles from your plugin constructor, and then use the created handles during + /// event, by using in a scope.
+ /// You may dispose your font handle anytime, as long as it's not in use in . + /// Font handles may be constructed anytime, as long as the owner or + /// is not disposed.
+ ///
+ /// If you were storing , consider if the job can be achieved solely by using + /// without directly using an instance of .
+ /// If you do need it, evaluate if you need to access fonts outside the main thread.
+ /// If it is the case, use to obtain a safe-to-access instance of + /// , once resolves.
+ /// Otherwise, use , and obtain the instance of via + /// . Do not let the escape the using scope.
+ ///
+ /// If your plugin sets to a non-default value, then + /// should be accessed using + /// , as the font handle member variables are only available + /// once drawing facilities are available.
+ ///
+ /// Examples:
+ /// * .
+ /// * .
+ /// * ctor.
+ /// * : + /// note how the construction of a new instance of and + /// call of are done in different functions, + /// without having to manually initiate font rebuild process. + ///
+ [Obsolete("See remarks.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public event Action? BuildFonts; /// /// Gets or sets an action that is called any time right after ImGui fonts are rebuilt.
- /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt + /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt /// (at any time), so you should both reload your custom fonts and restore those - /// pointers inside this handler.
- /// PLEASE remove this handler inside Dispose, or when you no longer need your fonts! + /// pointers inside this handler. ///
- public event Action AfterBuildFonts; + [Obsolete($"See remarks for {nameof(BuildFonts)}.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public event Action? AfterBuildFonts; /// /// Gets or sets an action that is called when plugin UI or interface modifications are supposed to be shown. @@ -101,20 +181,98 @@ public sealed class UiBuilder : IDisposable public event Action HideUi; /// - /// Gets the default Dalamud font based on Noto Sans CJK Medium in 17pt - supporting all game languages and icons. + /// Gets the default Dalamud font size in points. + /// + public static float DefaultFontSizePt => Service.Get().DefaultFontSpec.SizePt; + + /// + /// Gets the default Dalamud font size in pixels. + /// + public static float DefaultFontSizePx => Service.Get().DefaultFontSpec.SizePx; + + /// + /// Gets the default Dalamud font - supporting all game languages and icons.
+ /// Accessing this static property outside of is dangerous and not supported. ///
public static ImFontPtr DefaultFont => InterfaceManager.DefaultFont; /// - /// Gets the default Dalamud icon font based on FontAwesome 5 Free solid in 17pt. + /// Gets the default Dalamud icon font based on FontAwesome 5 Free solid.
+ /// Accessing this static property outside of is dangerous and not supported. ///
public static ImFontPtr IconFont => InterfaceManager.IconFont; /// - /// Gets the default Dalamud monospaced font based on Inconsolata Regular in 16pt. + /// Gets the default Dalamud monospaced font based on Inconsolata Regular.
+ /// Accessing this static property outside of is dangerous and not supported. ///
public static ImFontPtr MonoFont => InterfaceManager.MonoFont; + /// + /// Gets the default font specifications. + /// + public IFontSpec DefaultFontSpec => Service.Get().DefaultFontSpec; + + /// + /// Gets the handle to the default Dalamud font - supporting all game languages and icons. + /// + /// + /// A font handle corresponding to this font can be obtained with: + /// + /// fontAtlas.NewDelegateFontHandle( + /// e => e.OnPreBuild( + /// tk => tk.AddDalamudDefaultFont(UiBuilder.DefaultFontSizePx))); + /// + /// + public IFontHandle DefaultFontHandle => + this.defaultFontHandle ??= + this.scopedFinalizer.Add( + new FontHandleWrapper( + this.InterfaceManagerWithScene?.DefaultFontHandle + ?? throw new InvalidOperationException("Scene is not yet ready."))); + + /// + /// Gets the default Dalamud icon font based on FontAwesome 5 Free solid. + /// + /// + /// A font handle corresponding to this font can be obtained with: + /// + /// fontAtlas.NewDelegateFontHandle( + /// e => e.OnPreBuild( + /// tk => tk.AddFontAwesomeIconFont(new() { SizePt = UiBuilder.DefaultFontSizePt }))); + /// // or use + /// tk => tk.AddFontAwesomeIconFont(new() { SizePx = UiBuilder.DefaultFontSizePx }))); + /// + /// + public IFontHandle IconFontHandle => + this.iconFontHandle ??= + this.scopedFinalizer.Add( + new FontHandleWrapper( + this.InterfaceManagerWithScene?.IconFontHandle + ?? throw new InvalidOperationException("Scene is not yet ready."))); + + /// + /// Gets the default Dalamud monospaced font based on Inconsolata Regular. + /// + /// + /// A font handle corresponding to this font can be obtained with: + /// + /// fontAtlas.NewDelegateFontHandle( + /// e => e.OnPreBuild( + /// tk => tk.AddDalamudAssetFont( + /// DalamudAsset.InconsolataRegular, + /// new() { SizePt = UiBuilder.DefaultFontSizePt }))); + /// // or use + /// new() { SizePx = UiBuilder.DefaultFontSizePx }))); + /// + /// + public IFontHandle MonoFontHandle => + this.monoFontHandle ??= + this.scopedFinalizer.Add( + new FontHandleWrapper( + this.InterfaceManagerWithScene?.MonoFontHandle + ?? throw new InvalidOperationException("Scene is not yet ready."))); + /// /// Gets the game's active Direct3D device. /// @@ -174,20 +332,6 @@ public sealed class UiBuilder : IDisposable } } - /// - /// Gets a value indicating whether or not gpose is active. - /// - public bool GposeActive - { - get - { - var condition = Service.GetNullable(); - if (condition == null) - return false; - return condition[ConditionFlag.WatchingCutscene]; - } - } - /// /// Gets a value indicating whether this plugin should modify the game's interface at this time. /// @@ -198,6 +342,11 @@ public sealed class UiBuilder : IDisposable ///
public bool UiPrepared => Service.GetNullable() != null; + /// + /// Gets the plugin-private font atlas. + /// + public IFontAtlas FontAtlas { get; } + /// /// Gets or sets a value indicating whether statistics about UI draw time should be collected. /// @@ -212,6 +361,11 @@ public sealed class UiBuilder : IDisposable ///
internal bool HasConfigUi => this.OpenConfigUi != null; + /// + /// Gets a value indicating whether this UiBuilder has a configuration UI registered. + /// + internal bool HasMainUi => this.OpenMainUi != null; + /// /// Gets or sets the time this plugin took to draw on the last frame. /// @@ -238,7 +392,7 @@ public sealed class UiBuilder : IDisposable ///
/// The full filepath to the image. /// A object wrapping the created image. Use inside ImGui.Image(). - public TextureWrap LoadImage(string filePath) + public IDalamudTextureWrap LoadImage(string filePath) => this.InterfaceManagerWithScene?.LoadImage(filePath) ?? throw new InvalidOperationException("Load failed."); @@ -247,7 +401,7 @@ public sealed class UiBuilder : IDisposable ///
/// A byte array containing the raw image data. /// A object wrapping the created image. Use inside ImGui.Image(). - public TextureWrap LoadImage(byte[] imageData) + public IDalamudTextureWrap LoadImage(byte[] imageData) => this.InterfaceManagerWithScene?.LoadImage(imageData) ?? throw new InvalidOperationException("Load failed."); @@ -259,7 +413,7 @@ public sealed class UiBuilder : IDisposable /// The height of the image contained in . /// The number of channels (bytes per pixel) of the image contained in . This should usually be 4. /// A object wrapping the created image. Use inside ImGui.Image(). - public TextureWrap LoadImageRaw(byte[] imageData, int width, int height, int numChannels) + public IDalamudTextureWrap LoadImageRaw(byte[] imageData, int width, int height, int numChannels) => this.InterfaceManagerWithScene?.LoadImageRaw(imageData, width, height, numChannels) ?? throw new InvalidOperationException("Load failed."); @@ -276,7 +430,7 @@ public sealed class UiBuilder : IDisposable ///
/// The full filepath to the image. /// A object wrapping the created image. Use inside ImGui.Image(). - public Task LoadImageAsync(string filePath) => Task.Run( + public Task LoadImageAsync(string filePath) => Task.Run( async () => (await this.InterfaceManagerWithSceneAsync).LoadImage(filePath) ?? throw new InvalidOperationException("Load failed.")); @@ -286,7 +440,7 @@ public sealed class UiBuilder : IDisposable ///
/// A byte array containing the raw image data. /// A object wrapping the created image. Use inside ImGui.Image(). - public Task LoadImageAsync(byte[] imageData) => Task.Run( + public Task LoadImageAsync(byte[] imageData) => Task.Run( async () => (await this.InterfaceManagerWithSceneAsync).LoadImage(imageData) ?? throw new InvalidOperationException("Load failed.")); @@ -299,7 +453,7 @@ public sealed class UiBuilder : IDisposable /// The height of the image contained in . /// The number of channels (bytes per pixel) of the image contained in . This should usually be 4. /// A object wrapping the created image. Use inside ImGui.Image(). - public Task LoadImageRawAsync(byte[] imageData, int width, int height, int numChannels) => Task.Run( + public Task LoadImageRawAsync(byte[] imageData, int width, int height, int numChannels) => Task.Run( async () => (await this.InterfaceManagerWithSceneAsync).LoadImageRaw(imageData, width, height, numChannels) ?? throw new InvalidOperationException("Load failed.")); @@ -322,7 +476,7 @@ public sealed class UiBuilder : IDisposable if (runInFrameworkThread) { return this.InterfaceManagerWithSceneAsync - .ContinueWith(_ => Service.Get().RunOnFrameworkThread(func)) + .ContinueWith(_ => this.framework.RunOnFrameworkThread(func)) .Unwrap(); } else @@ -344,7 +498,7 @@ public sealed class UiBuilder : IDisposable if (runInFrameworkThread) { return this.InterfaceManagerWithSceneAsync - .ContinueWith(_ => Service.Get().RunOnFrameworkThread(func)) + .ContinueWith(_ => this.framework.RunOnFrameworkThread(func)) .Unwrap(); } else @@ -360,19 +514,57 @@ public sealed class UiBuilder : IDisposable ///
/// Font to get. /// Handle to the game font which may or may not be available for use yet. - public GameFontHandle GetGameFontHandle(GameFontStyle style) => this.gameFontManager.NewFontRef(style); + [Obsolete($"Use {nameof(this.FontAtlas)}.{nameof(IFontAtlas.NewGameFontHandle)} instead.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public GameFontHandle GetGameFontHandle(GameFontStyle style) + { + var prevValue = FontAtlasFactory.IsBuildInProgressForTask.Value; + FontAtlasFactory.IsBuildInProgressForTask.Value = false; + var v = new GameFontHandle( + (GamePrebakedFontHandle)this.FontAtlas.NewGameFontHandle(style), + Service.Get()); + FontAtlasFactory.IsBuildInProgressForTask.Value = prevValue; + return v; + } /// /// Call this to queue a rebuild of the font atlas.
- /// This will invoke any handlers and ensure that any loaded fonts are - /// ready to be used on the next UI frame. + /// This will invoke any and handlers and ensure that any + /// loaded fonts are ready to be used on the next UI frame. ///
public void RebuildFonts() { Log.Verbose("[FONT] {0} plugin is initiating FONT REBUILD", this.namespaceName); - this.interfaceManager.RebuildFonts(); + if (this.AfterBuildFonts is null && this.BuildFonts is null) + this.FontAtlas.BuildFontsAsync(); + else + this.FontAtlas.BuildFontsOnNextFrame(); } + /// + /// Creates an isolated . + /// + /// Specify when and how to rebuild this atlas. + /// Whether the fonts in the atlas is global scaled. + /// Name for debugging purposes. + /// A new instance of . + /// + /// Use this to create extra font atlases, if you want to create and dispose fonts without having to rebuild all + /// other fonts together.
+ /// If is not , + /// the font rebuilding functions must be called manually. + ///
+ public IFontAtlas CreateFontAtlas( + FontAtlasAutoRebuildMode autoRebuildMode, + bool isGlobalScaled = true, + string? debugName = null) => + this.scopedFinalizer.Add(Service + .Get() + .CreateFontAtlas( + this.namespaceName + ":" + (debugName ?? "custom"), + autoRebuildMode, + isGlobalScaled)); + /// /// Add a notification to the notification queue. /// @@ -380,16 +572,26 @@ public sealed class UiBuilder : IDisposable /// The title of the notification. /// The type of the notification. /// The time the notification should be displayed for. - public void AddNotification( - string content, string? title = null, NotificationType type = NotificationType.None, uint msDelay = 3000) + [Obsolete($"Use {nameof(INotificationManager)}.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public async void AddNotification( + string content, + string? title = null, + NotificationType type = NotificationType.None, + uint msDelay = 3000) { - Service - .GetAsync() - .ContinueWith(task => + var nm = await Service.GetAsync(); + var an = nm.AddNotification( + new() { - if (task.IsCompletedSuccessfully) - task.Result.AddNotification(content, title, type, msDelay); - }); + Content = content, + Title = title, + Type = type, + InitialDuration = TimeSpan.FromMilliseconds(msDelay), + }, + this.localPlugin); + _ = this.notifications.TryAdd(an, 0); + an.Dismiss += a => this.notifications.TryRemove(a.Notification, out _); } /// @@ -397,11 +599,24 @@ public sealed class UiBuilder : IDisposable /// void IDisposable.Dispose() { - this.interfaceManager.Draw -= this.OnDraw; - this.interfaceManager.BuildFonts -= this.OnBuildFonts; - this.interfaceManager.ResizeBuffers -= this.OnResizeBuffers; + this.scopedFinalizer.Dispose(); + + // Taken from NotificationManagerPluginScoped. + // TODO: remove on API 10. + while (!this.notifications.IsEmpty) + { + foreach (var n in this.notifications.Keys) + { + this.notifications.TryRemove(n, out _); + ((ActiveNotification)n).RemoveNonDalamudInvocations(); + } + } } + /// Clean up resources allocated by this instance of . + /// Dalamud internal use only. + internal void DisposeInternal() => this.scopedFinalizer.Dispose(); + /// /// Open the registered configuration UI, if it exists. /// @@ -409,6 +624,14 @@ public sealed class UiBuilder : IDisposable { this.OpenConfigUi?.InvokeSafely(); } + + /// + /// Open the registered configuration UI, if it exists. + /// + internal void OpenMain() + { + this.OpenMainUi?.InvokeSafely(); + } /// /// Notify this UiBuilder about plugin UI being hidden. @@ -430,6 +653,7 @@ public sealed class UiBuilder : IDisposable { this.hitchDetector.Start(); + var clientState = Service.Get(); var configuration = Service.Get(); var gameGui = Service.GetNullable(); if (gameGui == null) @@ -439,7 +663,7 @@ public sealed class UiBuilder : IDisposable !(this.DisableUserUiHide || this.DisableAutomaticUiHide)) || (this.CutsceneActive && configuration.ToggleUiHideDuringCutscenes && !(this.DisableCutsceneUiHide || this.DisableAutomaticUiHide)) || - (this.GposeActive && configuration.ToggleUiHideDuringGpose && + (clientState.IsGPosing && configuration.ToggleUiHideDuringGpose && !(this.DisableGposeUiHide || this.DisableAutomaticUiHide))) { if (!this.lastFrameUiHideState) @@ -457,8 +681,12 @@ public sealed class UiBuilder : IDisposable this.ShowUi?.InvokeSafely(); } - if (!this.interfaceManager.FontsReady) + // just in case, if something goes wrong, prevent drawing; otherwise it probably will crash. + if (!this.FontAtlas.BuildTask.IsCompletedSuccessfully + && (this.BuildFonts is not null || this.AfterBuildFonts is not null)) + { return; + } ImGui.PushID(this.namespaceName); if (DoStats) @@ -520,18 +748,89 @@ public sealed class UiBuilder : IDisposable this.hitchDetector.Stop(); } - private void OnBuildFonts() + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + private unsafe void PrivateAtlasOnBuildStepChange(IFontAtlasBuildToolkit e) { - this.BuildFonts?.InvokeSafely(); - } + if (e.IsAsyncBuildOperation) + return; - private void OnAfterBuildFonts() - { - this.AfterBuildFonts?.InvokeSafely(); + ThreadSafety.AssertMainThread(); + + if (this.BuildFonts is not null) + { + e.OnPreBuild( + _ => + { + var prev = ImGui.GetIO().NativePtr->Fonts; + ImGui.GetIO().NativePtr->Fonts = e.NewImAtlas.NativePtr; + ((IFontAtlasBuildToolkit.IApi9Compat)e) + .FromUiBuilderObsoleteEventHandlers(() => this.BuildFonts?.InvokeSafely()); + ImGui.GetIO().NativePtr->Fonts = prev; + }); + } + + if (this.AfterBuildFonts is not null) + { + e.OnPostBuild( + _ => + { + var prev = ImGui.GetIO().NativePtr->Fonts; + ImGui.GetIO().NativePtr->Fonts = e.NewImAtlas.NativePtr; + ((IFontAtlasBuildToolkit.IApi9Compat)e) + .FromUiBuilderObsoleteEventHandlers(() => this.AfterBuildFonts?.InvokeSafely()); + ImGui.GetIO().NativePtr->Fonts = prev; + }); + } } private void OnResizeBuffers() { this.ResizeBuffers?.InvokeSafely(); } + + private class FontHandleWrapper : IFontHandle + { + private IFontHandle? wrapped; + + public FontHandleWrapper(IFontHandle wrapped) + { + this.wrapped = wrapped; + this.wrapped.ImFontChanged += this.WrappedOnImFontChanged; + } + + public event IFontHandle.ImFontChangedDelegate? ImFontChanged; + + public Exception? LoadException => this.WrappedNotDisposed.LoadException; + + public bool Available => this.WrappedNotDisposed.Available; + + private IFontHandle WrappedNotDisposed => + this.wrapped ?? throw new ObjectDisposedException(nameof(FontHandleWrapper)); + + public void Dispose() + { + if (this.wrapped is not { } w) + return; + + this.wrapped = null; + w.ImFontChanged -= this.WrappedOnImFontChanged; + // Note: do not dispose w; we do not own it + } + + public ILockedImFont Lock() => + this.wrapped?.Lock() ?? throw new ObjectDisposedException(nameof(FontHandleWrapper)); + + public IDisposable Push() => this.WrappedNotDisposed.Push(); + + public void Pop() => this.WrappedNotDisposed.Pop(); + + public Task WaitAsync() => + this.WrappedNotDisposed.WaitAsync().ContinueWith(_ => (IFontHandle)this); + + public override string ToString() => + $"{nameof(FontHandleWrapper)}({this.wrapped?.ToString() ?? "disposed"})"; + + private void WrappedOnImFontChanged(IFontHandle obj, ILockedImFont lockedFont) => + this.ImFontChanged?.Invoke(obj, lockedFont); + } } diff --git a/Dalamud/Interface/UldWrapper.cs b/Dalamud/Interface/UldWrapper.cs index d41256fa2..dd8986bed 100644 --- a/Dalamud/Interface/UldWrapper.cs +++ b/Dalamud/Interface/UldWrapper.cs @@ -1,10 +1,10 @@ -using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Dalamud.Data; +using Dalamud.Interface.Internal; using Dalamud.Utility; -using ImGuiScene; using Lumina.Data.Files; using Lumina.Data.Parsing.Uld; @@ -38,7 +38,7 @@ public class UldWrapper : IDisposable /// The path of the requested texture. /// The index of the desired icon. /// A TextureWrap containing the requested part if it exists and null otherwise. - public TextureWrap? LoadTexturePart(string texturePath, int part) + public IDalamudTextureWrap? LoadTexturePart(string texturePath, int part) { if (!this.Valid) { @@ -67,7 +67,7 @@ public class UldWrapper : IDisposable this.Uld = null; } - private TextureWrap? CreateTexture(uint id, int width, int height, bool hd, byte[] rgbaData, int partIdx) + private IDalamudTextureWrap? CreateTexture(uint id, int width, int height, bool hd, byte[] rgbaData, int partIdx) { var idx = 0; UldRoot.PartData? partData = null; @@ -105,9 +105,9 @@ public class UldWrapper : IDisposable return this.CopyRect(width, height, rgbaData, d); } - private TextureWrap? CopyRect(int width, int height, byte[] rgbaData, UldRoot.PartData part) + private IDalamudTextureWrap? CopyRect(int width, int height, byte[] rgbaData, UldRoot.PartData part) { - if (part.V + part.W > width || part.U + part.H > height) + if (part.U + part.W > width || part.V + part.H > height) { return null; } @@ -155,20 +155,27 @@ public class UldWrapper : IDisposable // Try to load HD textures first. var hrPath = texturePath.Replace(".tex", "_hr1.tex"); + var substitution = Service.Get(); + hrPath = substitution.GetSubstitutedPath(hrPath); var hd = true; - var file = this.data.GetFile(hrPath); - if (file == null) + var tex = Path.IsPathRooted(hrPath) + ? this.data.GameData.GetFileFromDisk(hrPath) + : this.data.GetFile(hrPath); + if (tex == null) { hd = false; - file = this.data.GetFile(texturePath); + texturePath = substitution.GetSubstitutedPath(texturePath); + tex = Path.IsPathRooted(texturePath) + ? this.data.GameData.GetFileFromDisk(texturePath) + : this.data.GetFile(texturePath); // Neither texture could be loaded. - if (file == null) + if (tex == null) { return null; } } - return (id, file.Header.Width, file.Header.Height, hd, file.GetRgbaImageData()); + return (id, tex.Header.Width, tex.Header.Height, hd, tex.GetRgbaImageData()); } } diff --git a/Dalamud.Interface/ImGuiClip.cs b/Dalamud/Interface/Utility/ImGuiClip.cs similarity index 73% rename from Dalamud.Interface/ImGuiClip.cs rename to Dalamud/Interface/Utility/ImGuiClip.cs index dc1845a35..c9321fe4c 100644 --- a/Dalamud.Interface/ImGuiClip.cs +++ b/Dalamud/Interface/Utility/ImGuiClip.cs @@ -1,8 +1,11 @@ +using System.Collections.Generic; +using System.Linq; using System.Numerics; -using Dalamud.Interface.Raii; + +using Dalamud.Interface.Utility.Raii; using ImGuiNET; -namespace Dalamud.Interface; +namespace Dalamud.Interface.Utility; public static class ImGuiClip { @@ -56,6 +59,56 @@ public static class ImGuiClip clipper.Destroy(); } + /// + /// Draws the enumerable data with number of items per line. + /// + /// Enumerable containing data to draw. + /// The function to draw a single item. + /// How many items to draw per line. + /// How tall each line is. + /// The type of data to draw. + public static void ClippedDraw(IReadOnlyList data, Action draw, int itemsPerLine, float lineHeight) + { + ImGuiListClipperPtr clipper; + unsafe + { + clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + } + + var maxRows = (int)MathF.Ceiling((float)data.Count / itemsPerLine); + + clipper.Begin(maxRows, lineHeight); + while (clipper.Step()) + { + for (var actualRow = clipper.DisplayStart; actualRow < clipper.DisplayEnd; actualRow++) + { + if (actualRow >= maxRows) + return; + + if (actualRow < 0) + continue; + + var itemsForRow = data + .Skip(actualRow * itemsPerLine) + .Take(itemsPerLine); + + var currentIndex = 0; + foreach (var item in itemsForRow) + { + if (currentIndex++ != 0 && currentIndex < itemsPerLine + 1) + { + ImGui.SameLine(); + } + + draw(item); + } + } + } + + clipper.End(); + clipper.Destroy(); + } + // Draw a clipped random-access collection of consistent height lineHeight. // Uses ImGuiListClipper and thus handles start- and end-dummies itself, but acts on type and index. public static void ClippedDraw(IReadOnlyList data, Action draw, float lineHeight) @@ -132,7 +185,6 @@ public static class ImGuiClip return ~idx; } - // Draw non-random-access data that gets filtered without storing state. // Use GetNecessarySkips first and use its return value for skips. // checkFilter should return true for items that should be displayed and false for those that should be skipped. diff --git a/Dalamud/Interface/ImGuiExtensions.cs b/Dalamud/Interface/Utility/ImGuiExtensions.cs similarity index 98% rename from Dalamud/Interface/ImGuiExtensions.cs rename to Dalamud/Interface/Utility/ImGuiExtensions.cs index be1b99430..21a0d3747 100644 --- a/Dalamud/Interface/ImGuiExtensions.cs +++ b/Dalamud/Interface/Utility/ImGuiExtensions.cs @@ -1,10 +1,9 @@ -using System; using System.Numerics; using System.Text; using ImGuiNET; -namespace Dalamud.Interface; +namespace Dalamud.Interface.Utility; /// /// Class containing various extensions to ImGui, aiding with building custom widgets. diff --git a/Dalamud/Interface/Utility/ImGuiHelpers.cs b/Dalamud/Interface/Utility/ImGuiHelpers.cs new file mode 100644 index 000000000..639b0315d --- /dev/null +++ b/Dalamud/Interface/Utility/ImGuiHelpers.cs @@ -0,0 +1,839 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using System.Reactive.Disposables; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Unicode; + +using Dalamud.Configuration.Internal; +using Dalamud.Game.ClientState.Keys; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility.Raii; +using ImGuiNET; +using ImGuiScene; + +namespace Dalamud.Interface.Utility; + +/// +/// Class containing various helper methods for use with ImGui inside Dalamud. +/// +public static class ImGuiHelpers +{ + /// + /// Gets the main viewport. + /// + public static ImGuiViewportPtr MainViewport { get; internal set; } + + /// + /// Gets the global Dalamud scale. + /// + public static float GlobalScale { get; private set; } + + /// + /// Gets a value indicating whether ImGui is initialized and ready for use.
+ /// This does not necessarily mean you can call drawing functions. + ///
+ public static unsafe bool IsImGuiInitialized => + ImGui.GetCurrentContext() != nint.Zero && ImGui.GetIO().NativePtr is not null; + + /// + /// Gets the global Dalamud scale; even available before drawing is ready.
+ /// If you are sure that drawing is ready, at the point of using this, use instead. + ///
+ public static float GlobalScaleSafe => + IsImGuiInitialized ? ImGui.GetIO().FontGlobalScale : Service.Get().GlobalUiScale; + + /// + /// Check if the current ImGui window is on the main viewport. + /// Only valid within a window. + /// + /// Whether the window is on the main viewport. + public static bool CheckIsWindowOnMainViewport() => MainViewport.ID == ImGui.GetWindowViewport().ID; + + /// + /// Gets a that is pre-scaled with the multiplier. + /// + /// Vector2 X/Y parameter. + /// A scaled Vector2. + public static Vector2 ScaledVector2(float x) => new Vector2(x, x) * GlobalScale; + + /// + /// Gets a that is pre-scaled with the multiplier. + /// + /// Vector2 X parameter. + /// Vector2 Y parameter. + /// A scaled Vector2. + public static Vector2 ScaledVector2(float x, float y) => new Vector2(x, y) * GlobalScale; + + /// + /// Gets a that is pre-scaled with the multiplier. + /// + /// Vector4 X parameter. + /// Vector4 Y parameter. + /// Vector4 Z parameter. + /// Vector4 W parameter. + /// A scaled Vector2. + public static Vector4 ScaledVector4(float x, float y, float z, float w) => new Vector4(x, y, z, w) * GlobalScale; + + /// + /// Force the next ImGui window to stay inside the main game window. + /// + public static void ForceNextWindowMainViewport() => ImGui.SetNextWindowViewport(MainViewport.ID); + + /// + /// Create a dummy scaled by the global Dalamud scale. + /// + /// The size of the dummy. + public static void ScaledDummy(float size) => ScaledDummy(size, size); + + /// + /// Create a dummy scaled by the global Dalamud scale. + /// + /// Vector2 X parameter. + /// Vector2 Y parameter. + public static void ScaledDummy(float x, float y) => ScaledDummy(new Vector2(x, y)); + + /// + /// Create a dummy scaled by the global Dalamud scale. + /// + /// The size of the dummy. + public static void ScaledDummy(Vector2 size) => ImGui.Dummy(size * GlobalScale); + + /// + /// Create an indent scaled by the global Dalamud scale. + /// + /// The size of the indent. + public static void ScaledIndent(float size) => ImGui.Indent(size * GlobalScale); + + /// + /// Use a relative ImGui.SameLine() from your current cursor position, scaled by the Dalamud global scale. + /// + /// The offset from your current cursor position. + /// The spacing to use. + public static void ScaledRelativeSameLine(float offset, float spacing = -1.0f) + => ImGui.SameLine(ImGui.GetCursorPosX() + (offset * GlobalScale), spacing); + + /// + /// Set the position of the next window relative to the main viewport. + /// + /// The position of the next window. + /// When to set the position. + /// The pivot to set the position around. + public static void SetNextWindowPosRelativeMainViewport(Vector2 position, ImGuiCond condition = ImGuiCond.None, Vector2 pivot = default) + => ImGui.SetNextWindowPos(position + MainViewport.Pos, condition, pivot); + + /// + /// Set the position of a window relative to the main viewport. + /// + /// The name/ID of the window. + /// The position of the window. + /// When to set the position. + public static void SetWindowPosRelativeMainViewport(string name, Vector2 position, ImGuiCond condition = ImGuiCond.None) + => ImGui.SetWindowPos(name, position + MainViewport.Pos, condition); + + /// + /// Creates default color palette for use with color pickers. + /// + /// The total number of swatches to use. + /// Default color palette. + public static List DefaultColorPalette(int swatchCount = 32) + { + var colorPalette = new List(); + for (var i = 0; i < swatchCount; i++) + { + ImGui.ColorConvertHSVtoRGB(i / 31.0f, 0.7f, 0.8f, out var r, out var g, out var b); + colorPalette.Add(new Vector4(r, g, b, 1.0f)); + } + + return colorPalette; + } + + /// + /// Get the size of a button considering the default frame padding. + /// + /// Text in the button. + /// with the size of the button. + public static Vector2 GetButtonSize(string text) => ImGui.CalcTextSize(text) + (ImGui.GetStyle().FramePadding * 2); + + /// + /// Print out text that can be copied when clicked. + /// + /// The text to show. + /// The text to copy when clicked. + public static void ClickToCopyText(string text, string? textCopy = null) + { + textCopy ??= text; + ImGui.Text($"{text}"); + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + if (textCopy != text) ImGui.SetTooltip(textCopy); + } + + if (ImGui.IsItemClicked()) ImGui.SetClipboardText($"{textCopy}"); + } + + /// + /// Write unformatted text wrapped. + /// + /// The text to write. + public static void SafeTextWrapped(string text) => ImGui.TextWrapped(text.Replace("%", "%%")); + + /// + /// Write unformatted text wrapped. + /// + /// The color of the text. + /// The text to write. + public static void SafeTextColoredWrapped(Vector4 color, string text) + { + using (ImRaii.PushColor(ImGuiCol.Text, color)) + { + ImGui.TextWrapped(text.Replace("%", "%%")); + } + } + + /// + /// Unscales fonts after they have been rendered onto atlas. + /// + /// Font to scale. + /// Scale. + /// If a positive number is given, numbers will be rounded to this. + public static unsafe void AdjustGlyphMetrics(this ImFontPtr fontPtr, float scale, float round = 0f) + { + Func rounder = round > 0 ? x => MathF.Round(x / round) * round : x => x; + + var font = fontPtr.NativePtr; + font->FontSize = rounder(font->FontSize * scale); + font->Ascent = rounder(font->Ascent * scale); + font->Descent = font->FontSize - font->Ascent; + if (font->ConfigData != null) + font->ConfigData->SizePixels = rounder(font->ConfigData->SizePixels * scale); + + foreach (ref var glyphHotDataReal in new Span( + (void*)font->IndexedHotData.Data, + font->IndexedHotData.Size)) + { + glyphHotDataReal.AdvanceX = rounder(glyphHotDataReal.AdvanceX * scale); + glyphHotDataReal.OccupiedWidth = rounder(glyphHotDataReal.OccupiedWidth * scale); + } + + foreach (ref var glyphReal in new Span((void*)font->Glyphs.Data, font->Glyphs.Size)) + { + glyphReal.X0 *= scale; + glyphReal.X1 *= scale; + glyphReal.Y0 *= scale; + glyphReal.Y1 *= scale; + glyphReal.AdvanceX = rounder(glyphReal.AdvanceX * scale); + } + + foreach (ref var kp in new Span((void*)font->KerningPairs.Data, font->KerningPairs.Size)) + kp.AdvanceXAdjustment = rounder(kp.AdvanceXAdjustment * scale); + + foreach (ref var fkp in new Span((void*)font->FrequentKerningPairs.Data, font->FrequentKerningPairs.Size)) + fkp = rounder(fkp * scale); + } + + /// + /// Fills missing glyphs in target font from source font, if both are not null. + /// + /// Source font. + /// Target font. + /// Whether to copy missing glyphs only. + /// Whether to call target.BuildLookupTable(). + /// Low codepoint range to copy. + /// High codepoing range to copy. + [Obsolete("Use the non-nullable variant.", true)] + public static void CopyGlyphsAcrossFonts( + ImFontPtr? source, + ImFontPtr? target, + bool missingOnly, + bool rebuildLookupTable = true, + int rangeLow = 32, + int rangeHigh = 0xFFFE) => + CopyGlyphsAcrossFonts( + source ?? default, + target ?? default, + missingOnly, + rebuildLookupTable, + rangeLow, + rangeHigh); + + /// + /// Fills missing glyphs in target font from source font, if both are not null. + /// + /// Source font. + /// Target font. + /// Whether to copy missing glyphs only. + /// Whether to call target.BuildLookupTable(). + /// Low codepoint range to copy. + /// High codepoing range to copy. + public static unsafe void CopyGlyphsAcrossFonts( + ImFontPtr source, + ImFontPtr target, + bool missingOnly, + bool rebuildLookupTable = true, + int rangeLow = 32, + int rangeHigh = 0xFFFE) + { + if (!source.IsNotNullAndLoaded() || !target.IsNotNullAndLoaded()) + return; + + var changed = false; + var scale = target.FontSize / source.FontSize; + var addedCodepoints = new HashSet(); + + if (source.Glyphs.Size == 0) + return; + + var glyphs = (ImFontGlyphReal*)source.Glyphs.Data; + if (glyphs is null) + throw new InvalidOperationException("Glyphs data is empty but size is >0?"); + + for (int j = 0, k = source.Glyphs.Size; j < k; j++) + { + var glyph = &glyphs![j]; + if (glyph->Codepoint < rangeLow || glyph->Codepoint > rangeHigh) + continue; + + var prevGlyphPtr = (ImFontGlyphReal*)target.FindGlyphNoFallback((ushort)glyph->Codepoint).NativePtr; + if ((IntPtr)prevGlyphPtr == IntPtr.Zero) + { + addedCodepoints.Add(glyph->Codepoint); + target.AddGlyph( + target.ConfigData, + (ushort)glyph->Codepoint, + glyph->TextureIndex, + glyph->X0 * scale, + ((glyph->Y0 - source.Ascent) * scale) + target.Ascent, + glyph->X1 * scale, + ((glyph->Y1 - source.Ascent) * scale) + target.Ascent, + glyph->U0, + glyph->V0, + glyph->U1, + glyph->V1, + glyph->AdvanceX * scale); + target.Mark4KPageUsedAfterGlyphAdd((ushort)glyph->Codepoint); + changed = true; + } + else if (!missingOnly) + { + addedCodepoints.Add(glyph->Codepoint); + prevGlyphPtr->TextureIndex = glyph->TextureIndex; + prevGlyphPtr->X0 = glyph->X0 * scale; + prevGlyphPtr->Y0 = ((glyph->Y0 - source.Ascent) * scale) + target.Ascent; + prevGlyphPtr->X1 = glyph->X1 * scale; + prevGlyphPtr->Y1 = ((glyph->Y1 - source.Ascent) * scale) + target.Ascent; + prevGlyphPtr->U0 = glyph->U0; + prevGlyphPtr->V0 = glyph->V0; + prevGlyphPtr->U1 = glyph->U1; + prevGlyphPtr->V1 = glyph->V1; + prevGlyphPtr->AdvanceX = glyph->AdvanceX * scale; + } + } + + if (target.Glyphs.Size == 0) + return; + + var kernPairs = source.KerningPairs; + for (int j = 0, k = kernPairs.Size; j < k; j++) + { + if (!addedCodepoints.Contains(kernPairs[j].Left)) + continue; + if (!addedCodepoints.Contains(kernPairs[j].Right)) + continue; + target.AddKerningPair(kernPairs[j].Left, kernPairs[j].Right, kernPairs[j].AdvanceXAdjustment); + changed = true; + } + + if (changed && rebuildLookupTable) + { + // ImGui resolves ' ' with FindGlyph, which uses FallbackGlyph. + // FallbackGlyph is resolved after resolving ' '. + // On the first call of BuildLookupTable, called from BuildFonts, FallbackGlyph is set to null, + // making FindGlyph return nullptr. + // On our secondary calls of BuildLookupTable, FallbackGlyph is set to some value that is not null, + // making ImGui attempt to treat whatever was there as a ' '. + // This may cause random glyphs to be sized randomly, if not an access violation exception. + target.NativePtr->FallbackGlyph = null; + + target.BuildLookupTable(); + } + } + + /// + /// Map a VirtualKey keycode to an ImGuiKey enum value. + /// + /// The VirtualKey value to retrieve the ImGuiKey counterpart for. + /// The ImGuiKey that corresponds to this VirtualKey, or ImGuiKey.None otherwise. + public static ImGuiKey VirtualKeyToImGuiKey(VirtualKey key) + { + return ImGui_Input_Impl_Direct.VirtualKeyToImGuiKey((int)key); + } + + /// + /// Map an ImGuiKey enum value to a VirtualKey code. + /// + /// The ImGuiKey value to retrieve the VirtualKey counterpart for. + /// The VirtualKey that corresponds to this ImGuiKey, or VirtualKey.NO_KEY otherwise. + public static VirtualKey ImGuiKeyToVirtualKey(ImGuiKey key) + { + return (VirtualKey)ImGui_Input_Impl_Direct.ImGuiKeyToVirtualKey(key); + } + + /// + /// Show centered text. + /// + /// Text to show. + public static void CenteredText(string text) + { + CenterCursorForText(text); + ImGui.TextUnformatted(text); + } + + /// + /// Center the ImGui cursor for a certain text. + /// + /// The text to center for. + public static void CenterCursorForText(string text) => CenterCursorFor(ImGui.CalcTextSize(text).X); + + /// + /// Center the ImGui cursor for an item with a certain width. + /// + /// The width to center for. + public static void CenterCursorFor(float itemWidth) => + ImGui.SetCursorPosX((int)((ImGui.GetWindowWidth() - itemWidth) / 2)); + + /// + /// Allocates memory on the heap using
+ /// Memory must be freed using . + ///
+ /// Note that null is a valid return value when is 0. + ///
+ /// The length of allocated memory. + /// The allocated memory. + /// If returns null. + public static unsafe void* AllocateMemory(int length) + { + // TODO: igMemAlloc takes size_t, which is nint; ImGui.NET apparently interpreted that as uint. + // fix that in ImGui.NET. + switch (length) + { + case 0: + return null; + case < 0: + throw new ArgumentOutOfRangeException( + nameof(length), + length, + $"{nameof(length)} cannot be a negative number."); + default: + var memory = ImGuiNative.igMemAlloc((uint)length); + if (memory is null) + { + throw new OutOfMemoryException( + $"Failed to allocate {length} bytes using {nameof(ImGuiNative.igMemAlloc)}"); + } + + return memory; + } + } + + /// + /// Creates a new instance of with a natively backed memory. + /// + /// The created instance. + /// Disposable you can call. + public static unsafe IDisposable NewFontGlyphRangeBuilderPtrScoped(out ImFontGlyphRangesBuilderPtr builder) + { + builder = new(ImGuiNative.ImFontGlyphRangesBuilder_ImFontGlyphRangesBuilder()); + var ptr = builder.NativePtr; + return Disposable.Create(() => + { + if (ptr != null) + ImGuiNative.ImFontGlyphRangesBuilder_destroy(ptr); + ptr = null; + }); + } + + /// + /// Builds ImGui Glyph Ranges for use with . + /// + /// The builder. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// When disposed, the resource allocated for the range will be freed. + public static unsafe ushort[] BuildRangesToArray( + this ImFontGlyphRangesBuilderPtr builder, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) + { + if (addFallbackCodepoints) + builder.AddText(FontAtlasFactory.FallbackCodepoints); + if (addEllipsisCodepoints) + { + builder.AddText(FontAtlasFactory.EllipsisCodepoints); + builder.AddChar('.'); + } + + builder.BuildRanges(out var vec); + return new ReadOnlySpan((void*)vec.Data, vec.Size).ToArray(); + } + + /// + public static ushort[] CreateImGuiRangesFrom(params UnicodeRange[] ranges) + => CreateImGuiRangesFrom((IEnumerable)ranges); + + /// + /// Creates glyph ranges from .
+ /// Use values from . + ///
+ /// The unicode ranges. + /// The range array that can be used for . + public static ushort[] CreateImGuiRangesFrom(IEnumerable ranges) => + ranges + .Select(x => (First: Math.Max(x.FirstCodePoint, 1), Last: x.FirstCodePoint + x.Length)) + .Where(x => x.First <= ushort.MaxValue && x.First <= x.Last) + .SelectMany( + x => new[] + { + (ushort)Math.Min(x.First, ushort.MaxValue), + (ushort)Math.Min(x.Last, ushort.MaxValue), + }) + .Append((ushort)0) + .ToArray(); + + /// + /// Determines whether is empty. + /// + /// The pointer. + /// Whether it is empty. + public static unsafe bool IsNull(this ImFontPtr ptr) => ptr.NativePtr == null; + + /// + /// Determines whether is empty. + /// + /// The pointer. + /// Whether it is empty. + public static unsafe bool IsNotNullAndLoaded(this ImFontPtr ptr) => ptr.NativePtr != null && ptr.IsLoaded(); + + /// + /// Determines whether is empty. + /// + /// The pointer. + /// Whether it is empty. + public static unsafe bool IsNull(this ImFontAtlasPtr ptr) => ptr.NativePtr == null; + + /// + /// If is default, then returns . + /// + /// The self. + /// The other. + /// if it is not default; otherwise, . + public static unsafe ImFontPtr OrElse(this ImFontPtr self, ImFontPtr other) => + self.NativePtr is null ? other : self; + + /// + /// Mark 4K page as used, after adding a codepoint to a font. + /// + /// The font. + /// The codepoint. + internal static unsafe void Mark4KPageUsedAfterGlyphAdd(this ImFontPtr font, ushort codepoint) + { + // Mark 4K page as used + var pageIndex = unchecked((ushort)(codepoint / 4096)); + font.NativePtr->Used4kPagesMap[pageIndex >> 3] |= unchecked((byte)(1 << (pageIndex & 7))); + } + + /// + /// Sets the text for a text input, during the callback. + /// + /// The callback data. + /// The new text. + internal static unsafe void SetTextFromCallback(ImGuiInputTextCallbackData* data, string s) + { + if (data->BufTextLen != 0) + ImGuiNative.ImGuiInputTextCallbackData_DeleteChars(data, 0, data->BufTextLen); + + var len = Encoding.UTF8.GetByteCount(s); + var buf = len < 1024 ? stackalloc byte[len] : new byte[len]; + Encoding.UTF8.GetBytes(s, buf); + fixed (byte* pBuf = buf) + ImGuiNative.ImGuiInputTextCallbackData_InsertChars(data, 0, pBuf, pBuf + len); + ImGuiNative.ImGuiInputTextCallbackData_SelectAll(data); + } + + /// + /// Finds the corresponding ImGui viewport ID for the given window handle. + /// + /// The window handle. + /// The viewport ID, or -1 if not found. + internal static unsafe int FindViewportId(nint hwnd) + { + if (!IsImGuiInitialized) + return -1; + + var viewports = new ImVectorWrapper(&ImGui.GetPlatformIO().NativePtr->Viewports); + for (var i = 0; i < viewports.LengthUnsafe; i++) + { + if (viewports.DataUnsafe[i].PlatformHandle == hwnd) + return i; + } + + return -1; + } + + /// + /// Attempts to validate that is valid. + /// + /// The font pointer. + /// The exception, if any occurred during validation. + internal static unsafe Exception? ValidateUnsafe(this ImFontPtr fontPtr) + { + try + { + var font = fontPtr.NativePtr; + if (font is null) + throw new NullReferenceException("The font is null."); + + _ = Marshal.ReadIntPtr((nint)font); + if (font->IndexedHotData.Data != 0) + _ = Marshal.ReadIntPtr(font->IndexedHotData.Data); + if (font->FrequentKerningPairs.Data != 0) + _ = Marshal.ReadIntPtr(font->FrequentKerningPairs.Data); + if (font->IndexLookup.Data != 0) + _ = Marshal.ReadIntPtr(font->IndexLookup.Data); + if (font->Glyphs.Data != 0) + _ = Marshal.ReadIntPtr(font->Glyphs.Data); + if (font->KerningPairs.Data != 0) + _ = Marshal.ReadIntPtr(font->KerningPairs.Data); + if (font->ConfigDataCount == 0 && font->ConfigData is not null) + throw new InvalidOperationException("ConfigDataCount == 0 but ConfigData is not null?"); + if (font->ConfigDataCount != 0 && font->ConfigData is null) + throw new InvalidOperationException("ConfigDataCount != 0 but ConfigData is null?"); + if (font->ConfigData is not null) + _ = Marshal.ReadIntPtr((nint)font->ConfigData); + if (font->FallbackGlyph is not null + && ((nint)font->FallbackGlyph < font->Glyphs.Data || (nint)font->FallbackGlyph >= font->Glyphs.Data)) + throw new InvalidOperationException("FallbackGlyph is not in range of Glyphs.Data"); + if (font->FallbackHotData is not null + && ((nint)font->FallbackHotData < font->IndexedHotData.Data + || (nint)font->FallbackHotData >= font->IndexedHotData.Data)) + throw new InvalidOperationException("FallbackGlyph is not in range of Glyphs.Data"); + if (font->ContainerAtlas is not null) + _ = Marshal.ReadIntPtr((nint)font->ContainerAtlas); + } + catch (Exception e) + { + return e; + } + + return null; + } + + /// + /// Updates the fallback char of . + /// + /// The font. + /// The fallback character. + internal static unsafe void UpdateFallbackChar(this ImFontPtr font, char c) + { + font.FallbackChar = c; + font.NativePtr->FallbackHotData = + (ImFontGlyphHotData*)((ImFontGlyphHotDataReal*)font.IndexedHotData.Data + font.FallbackChar); + } + + /// + /// Determines if the supplied codepoint is inside the given range, + /// in format of . + /// + /// The codepoint. + /// The ranges. + /// Whether it is the case. + internal static unsafe bool IsCodepointInSuppliedGlyphRangesUnsafe(int codepoint, ushort* rangePtr) + { + if (codepoint is <= 0 or >= ushort.MaxValue) + return false; + + while (*rangePtr != 0) + { + var from = *rangePtr++; + var to = *rangePtr++; + if (from <= codepoint && codepoint <= to) + return true; + } + + return false; + } + + /// + /// Get data needed for each new frame. + /// + internal static void NewFrame() + { + GlobalScale = ImGui.GetIO().FontGlobalScale; + } + + /// + /// ImFontGlyph the correct version. + /// + [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "ImGui internals")] + [StructLayout(LayoutKind.Explicit, Size = 40)] + public struct ImFontGlyphReal + { + [FieldOffset(0)] + public uint ColoredVisibleTextureIndexCodepoint; + + [FieldOffset(4)] + public float AdvanceX; + + [FieldOffset(8)] + public float X0; + + [FieldOffset(12)] + public float Y0; + + [FieldOffset(16)] + public float X1; + + [FieldOffset(20)] + public float Y1; + + [FieldOffset(24)] + public float U0; + + [FieldOffset(28)] + public float V0; + + [FieldOffset(32)] + public float U1; + + [FieldOffset(36)] + public float V1; + + [FieldOffset(8)] + public Vector2 XY0; + + [FieldOffset(16)] + public Vector2 XY1; + + [FieldOffset(24)] + public Vector2 UV0; + + [FieldOffset(32)] + public Vector2 UV1; + + [FieldOffset(8)] + public Vector4 XY; + + [FieldOffset(24)] + public Vector4 UV; + + private const uint ColoredMask /*****/ = 0b_00000000_00000000_00000000_00000001u; + private const uint VisibleMask /*****/ = 0b_00000000_00000000_00000000_00000010u; + private const uint TextureMask /*****/ = 0b_00000000_00000000_00000111_11111100u; + private const uint CodepointMask /***/ = 0b_11111111_11111111_11111000_00000000u; + + private const int ColoredShift = 0; + private const int VisibleShift = 1; + private const int TextureShift = 2; + private const int CodepointShift = 11; + + public bool Colored + { + get => (int)((this.ColoredVisibleTextureIndexCodepoint & ColoredMask) >> ColoredShift) != 0; + set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~ColoredMask) | (value ? 1u << ColoredShift : 0u); + } + + public bool Visible + { + get => (int)((this.ColoredVisibleTextureIndexCodepoint & VisibleMask) >> VisibleShift) != 0; + set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~VisibleMask) | (value ? 1u << VisibleShift : 0u); + } + + public int TextureIndex + { + get => (int)(this.ColoredVisibleTextureIndexCodepoint & TextureMask) >> TextureShift; + set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~TextureMask) | ((uint)value << TextureShift); + } + + public int Codepoint + { + get => (int)(this.ColoredVisibleTextureIndexCodepoint & CodepointMask) >> CodepointShift; + set => this.ColoredVisibleTextureIndexCodepoint = (this.ColoredVisibleTextureIndexCodepoint & ~CodepointMask) | ((uint)value << CodepointShift); + } + } + + /// + /// ImFontGlyphHotData the correct version. + /// + [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "ImGui internals")] + public struct ImFontGlyphHotDataReal + { + public float AdvanceX; + public float OccupiedWidth; + public uint KerningPairInfo; + + private const uint UseBisectMask /***/ = 0b_00000000_00000000_00000000_00000001u; + private const uint OffsetMask /******/ = 0b_00000000_00001111_11111111_11111110u; + private const uint CountMask /*******/ = 0b_11111111_11110000_00000000_00000000u; + + private const int UseBisectShift = 0; + private const int OffsetShift = 1; + private const int CountShift = 20; + + public bool UseBisect + { + get => (int)((this.KerningPairInfo & UseBisectMask) >> UseBisectShift) != 0; + set => this.KerningPairInfo = (this.KerningPairInfo & ~UseBisectMask) | (value ? 1u << UseBisectShift : 0u); + } + + public bool Offset + { + get => (int)((this.KerningPairInfo & OffsetMask) >> OffsetShift) != 0; + set => this.KerningPairInfo = (this.KerningPairInfo & ~OffsetMask) | (value ? 1u << OffsetShift : 0u); + } + + public int Count + { + get => (int)(this.KerningPairInfo & CountMask) >> CountShift; + set => this.KerningPairInfo = (this.KerningPairInfo & ~CountMask) | ((uint)value << CountShift); + } + } + + /// + /// ImFontAtlasCustomRect the correct version. + /// + [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "ImGui internals")] + [StructLayout(LayoutKind.Sequential)] + public unsafe struct ImFontAtlasCustomRectReal + { + public ushort Width; + public ushort Height; + public ushort X; + public ushort Y; + public uint TextureIndexAndGlyphId; + public float GlyphAdvanceX; + public Vector2 GlyphOffset; + public ImFont* Font; + + private const uint TextureIndexMask /***/ = 0b_00000000_00000000_00000111_11111100u; + private const uint GlyphIdMask /********/ = 0b_11111111_11111111_11111000_00000000u; + + private const int TextureIndexShift = 2; + private const int GlyphIdShift = 11; + + public int TextureIndex + { + get => (int)(this.TextureIndexAndGlyphId & TextureIndexMask) >> TextureIndexShift; + set => this.TextureIndexAndGlyphId = (this.TextureIndexAndGlyphId & ~TextureIndexMask) | ((uint)value << TextureIndexShift); + } + + public int GlyphId + { + get => (int)(this.TextureIndexAndGlyphId & GlyphIdMask) >> GlyphIdShift; + set => this.TextureIndexAndGlyphId = (this.TextureIndexAndGlyphId & ~GlyphIdMask) | ((uint)value << GlyphIdShift); + } + } +} diff --git a/Dalamud/Interface/Utility/ImGuiTable.cs b/Dalamud/Interface/Utility/ImGuiTable.cs new file mode 100644 index 000000000..c74bc0a2f --- /dev/null +++ b/Dalamud/Interface/Utility/ImGuiTable.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; + +using Dalamud.Interface.Utility.Raii; +using ImGuiNET; + +namespace Dalamud.Interface.Utility; + +#pragma warning disable SA1618 // GenericTypeParametersMustBeDocumented +#pragma warning disable SA1611 // ElementParametersMustBeDocumented + +/// +/// Helpers for drawing tables. +/// +public static class ImGuiTable +{ + /// + /// Draw a simple table with the given data using the drawRow action. + /// Headers and thus columns and column count are defined by columnTitles. + /// + public static void DrawTable(string label, IEnumerable data, Action drawRow, ImGuiTableFlags flags = ImGuiTableFlags.None, + params string[] columnTitles) + { + if (columnTitles.Length == 0) + return; + + using var table = ImRaii.Table(label, columnTitles.Length, flags); + if (!table) + return; + + foreach (var title in columnTitles) + { + ImGui.TableNextColumn(); + ImGui.TableHeader(title); + } + + foreach (var datum in data) + { + ImGui.TableNextRow(); + drawRow(datum); + } + } + + /// + /// Draw a simple table with the given data using the drawRow action inside a collapsing header. + /// Headers and thus columns and column count are defined by columnTitles. + /// + public static void DrawTabbedTable(string label, IEnumerable data, Action drawRow, ImGuiTableFlags flags = ImGuiTableFlags.None, + params string[] columnTitles) + { + if (ImGui.CollapsingHeader(label)) + DrawTable($"{label}##Table", data, drawRow, flags, columnTitles); + } +} + +#pragma warning restore SA1611 // ElementParametersMustBeDocumented +#pragma warning restore SA1618 // GenericTypeParametersMustBeDocumented diff --git a/Dalamud/Interface/Utility/ImVectorWrapper.cs b/Dalamud/Interface/Utility/ImVectorWrapper.cs new file mode 100644 index 000000000..5ba1aec2f --- /dev/null +++ b/Dalamud/Interface/Utility/ImVectorWrapper.cs @@ -0,0 +1,746 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Numerics; + +using ImGuiNET; + +using JetBrains.Annotations; + +namespace Dalamud.Interface.Utility; + +/// +/// Utility methods for . +/// +public static class ImVectorWrapper +{ + /// + /// Creates a new instance of the struct, initialized with + /// .
+ /// You must call after use. + ///
+ /// The item type. + /// The initial data. + /// The destroyer function to call on item removal. + /// The minimum capacity of the new vector. + /// The new wrapped vector, that has to be disposed after use. + public static ImVectorWrapper CreateFromEnumerable( + IEnumerable sourceEnumerable, + ImVectorWrapper.ImGuiNativeDestroyDelegate? destroyer = null, + int minCapacity = 0) + where T : unmanaged + { + var res = new ImVectorWrapper(0, destroyer); + try + { + switch (sourceEnumerable) + { + case T[] c: + res.SetCapacity(Math.Max(minCapacity, c.Length + 1)); + res.LengthUnsafe = c.Length; + c.AsSpan().CopyTo(res.DataSpan); + break; + case ICollection c: + res.SetCapacity(Math.Max(minCapacity, c.Count + 1)); + res.AddRange(sourceEnumerable); + break; + case ICollection c: + res.SetCapacity(Math.Max(minCapacity, c.Count + 1)); + res.AddRange(sourceEnumerable); + break; + default: + res.SetCapacity(minCapacity); + res.AddRange(sourceEnumerable); + res.EnsureCapacity(res.LengthUnsafe + 1); + break; + } + + // Null termination + Debug.Assert(res.LengthUnsafe < res.CapacityUnsafe, "Capacity must be more than source length + 1"); + res.StorageSpan[res.LengthUnsafe] = default; + + return res; + } + catch + { + res.Dispose(); + throw; + } + } + + /// + /// Creates a new instance of the struct, initialized with + /// .
+ /// You must call after use. + ///
+ /// The item type. + /// The initial data. + /// The destroyer function to call on item removal. + /// The minimum capacity of the new vector. + /// The new wrapped vector, that has to be disposed after use. + public static ImVectorWrapper CreateFromSpan( + ReadOnlySpan sourceSpan, + ImVectorWrapper.ImGuiNativeDestroyDelegate? destroyer = null, + int minCapacity = 0) + where T : unmanaged + { + var res = new ImVectorWrapper(Math.Max(minCapacity, sourceSpan.Length + 1), destroyer); + try + { + res.LengthUnsafe = sourceSpan.Length; + sourceSpan.CopyTo(res.DataSpan); + + // Null termination + Debug.Assert(res.LengthUnsafe < res.CapacityUnsafe, "Capacity must be more than source length + 1"); + res.StorageSpan[res.LengthUnsafe] = default; + return res; + } + catch + { + res.Dispose(); + throw; + } + } + + /// + /// Wraps into a .
+ /// This does not need to be disposed. + ///
+ /// The owner object. + /// The wrapped vector. + public static unsafe ImVectorWrapper ConfigDataWrapped(this ImFontAtlasPtr obj) => + obj.NativePtr is null + ? throw new NullReferenceException() + : new(&obj.NativePtr->ConfigData, ImGuiNative.ImFontConfig_destroy); + + /// + /// Wraps into a .
+ /// This does not need to be disposed. + ///
+ /// The owner object. + /// The wrapped vector. + public static unsafe ImVectorWrapper FontsWrapped(this ImFontAtlasPtr obj) => + obj.NativePtr is null + ? throw new NullReferenceException() + : new(&obj.NativePtr->Fonts, x => ImGuiNative.ImFont_destroy(x->NativePtr)); + + /// + /// Wraps into a .
+ /// This does not need to be disposed. + ///
+ /// The owner object. + /// The wrapped vector. + public static unsafe ImVectorWrapper TexturesWrapped(this ImFontAtlasPtr obj) => + obj.NativePtr is null + ? throw new NullReferenceException() + : new(&obj.NativePtr->Textures); + + /// + /// Wraps into a .
+ /// This does not need to be disposed. + ///
+ /// The owner object. + /// The wrapped vector. + public static unsafe ImVectorWrapper GlyphsWrapped(this ImFontPtr obj) => + obj.NativePtr is null + ? throw new NullReferenceException() + : new(&obj.NativePtr->Glyphs); + + /// + /// Wraps into a .
+ /// This does not need to be disposed. + ///
+ /// The owner object. + /// The wrapped vector. + public static unsafe ImVectorWrapper IndexedHotDataWrapped(this ImFontPtr obj) + => obj.NativePtr is null + ? throw new NullReferenceException() + : new(&obj.NativePtr->IndexedHotData); + + /// + /// Wraps into a .
+ /// This does not need to be disposed. + ///
+ /// The owner object. + /// The wrapped vector. + public static unsafe ImVectorWrapper IndexLookupWrapped(this ImFontPtr obj) => + obj.NativePtr is null + ? throw new NullReferenceException() + : new(&obj.NativePtr->IndexLookup); +} + +/// +/// Wrapper for ImVector. +/// +/// Contained type. +public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDisposable + where T : unmanaged +{ + private ImVector* vector; + private ImGuiNativeDestroyDelegate? destroyer; + + /// + /// Initializes a new instance of the struct.
+ /// If is set to true, you must call after use, + /// and the underlying memory for must have been allocated using + /// . Otherwise, it will crash. + ///
+ /// The underlying vector. + /// The destroyer function to call on item removal. + /// Whether this wrapper owns the vector. + public ImVectorWrapper( + [NotNull] ImVector* vector, + ImGuiNativeDestroyDelegate? destroyer = null, + bool ownership = false) + { + if (vector is null) + throw new ArgumentException($"{nameof(vector)} cannot be null.", nameof(this.vector)); + + this.vector = vector; + this.destroyer = destroyer; + this.HasOwnership = ownership; + } + + /// + /// Initializes a new instance of the struct.
+ /// You must call after use. + ///
+ /// The initial capacity. + /// The destroyer function to call on item removal. + public ImVectorWrapper(int initialCapacity, ImGuiNativeDestroyDelegate? destroyer = null) + { + if (initialCapacity < 0) + { + throw new ArgumentOutOfRangeException( + nameof(initialCapacity), + initialCapacity, + $"{nameof(initialCapacity)} cannot be a negative number."); + } + + this.vector = (ImVector*)ImGuiNative.igMemAlloc((uint)sizeof(ImVector)); + if (this.vector is null) + throw new OutOfMemoryException(); + *this.vector = default; + this.HasOwnership = true; + this.destroyer = destroyer; + + try + { + this.EnsureCapacity(initialCapacity); + } + catch + { + ImGuiNative.igMemFree(this.vector); + this.vector = null; + this.HasOwnership = false; + this.destroyer = null; + throw; + } + } + + /// + /// Destroy callback for items. + /// + /// Pointer to self. + public delegate void ImGuiNativeDestroyDelegate(T* self); + + /// + /// Gets the raw vector. + /// + public ImVector* RawVector => this.vector; + + /// + /// Gets a view of the underlying ImVector{T}, for the range of . + /// + public Span DataSpan => new(this.DataUnsafe, this.LengthUnsafe); + + /// + /// Gets a view of the underlying ImVector{T}, for the range of . + /// + public Span StorageSpan => new(this.DataUnsafe, this.CapacityUnsafe); + + /// + /// Gets a value indicating whether this is disposed. + /// + public bool IsDisposed => this.vector is null; + + /// + /// Gets a value indicating whether this has the ownership of the underlying + /// . + /// + public bool HasOwnership { get; private set; } + + /// + /// Gets the underlying . + /// + public ImVector* Vector => + this.vector is null ? throw new ObjectDisposedException(nameof(ImVectorWrapper)) : this.vector; + + /// + /// Gets the number of items contained inside the underlying ImVector{T}. + /// + public int Length => this.LengthUnsafe; + + /// + /// Gets the number of items that can be contained inside the underlying ImVector{T}. + /// + public int Capacity => this.CapacityUnsafe; + + /// + /// Gets the pointer to the first item in the data inside underlying ImVector{T}. + /// + public T* Data => this.DataUnsafe; + + /// + /// Gets the reference to the number of items contained inside the underlying ImVector{T}. + /// + public ref int LengthUnsafe => ref *&this.Vector->Size; + + /// + /// Gets the reference to the number of items that can be contained inside the underlying ImVector{T}. + /// + public ref int CapacityUnsafe => ref *&this.Vector->Capacity; + + /// + /// Gets the reference to the pointer to the first item in the data inside underlying ImVector{T}. + /// + /// This may be null, if is zero. + public ref T* DataUnsafe => ref *(T**)&this.Vector->Data; + + /// + public bool IsReadOnly => false; + + /// + int ICollection.Count => this.LengthUnsafe; + + /// + bool ICollection.IsSynchronized => false; + + /// + object ICollection.SyncRoot { get; } = new(); + + /// + int ICollection.Count => this.LengthUnsafe; + + /// + int IReadOnlyCollection.Count => this.LengthUnsafe; + + /// + bool IList.IsFixedSize => false; + + /// + /// Gets the element at the specified index as a reference. + /// + /// Index of the item. + /// If is out of range. + public ref T this[int index] => ref this.DataUnsafe[this.EnsureIndex(index)]; + + /// + T IReadOnlyList.this[int index] => this[index]; + + /// + object? IList.this[int index] + { + get => this[index]; + set => this[index] = value is null ? default : (T)value; + } + + /// + T IList.this[int index] + { + get => this[index]; + set => this[index] = value; + } + + /// + public void Dispose() + { + if (this.HasOwnership) + { + this.Clear(); + this.SetCapacity(0); + Debug.Assert(this.vector->Data == 0, "SetCapacity(0) did not free the data"); + ImGuiNative.igMemFree(this.vector); + } + + this.vector = null; + this.HasOwnership = false; + this.destroyer = null; + } + + /// + public IEnumerator GetEnumerator() + { + foreach (var i in Enumerable.Range(0, this.LengthUnsafe)) + yield return this[i]; + } + + /// + public void Add(in T item) + { + this.EnsureCapacityExponential(this.LengthUnsafe + 1); + this.DataUnsafe[this.LengthUnsafe++] = item; + } + + /// + public void AddRange(IEnumerable items) + { + if (items is ICollection { Count: var count }) + this.EnsureCapacityExponential(this.LengthUnsafe + count); + + foreach (var item in items) + this.Add(item); + } + + /// + public void AddRange(ReadOnlySpan items) + { + this.EnsureCapacityExponential(this.LengthUnsafe + items.Length); + foreach (var item in items) + this.Add(item); + } + + /// + public void Clear() => this.Clear(false); + + /// + /// Clears this vector, optionally skipping destroyer invocation. + /// + /// Whether to skip destroyer invocation. + public void Clear(bool skipDestroyer) + { + if (this.destroyer != null && !skipDestroyer) + { + foreach (var i in Enumerable.Range(0, this.LengthUnsafe)) + this.destroyer(&this.DataUnsafe[i]); + } + + this.LengthUnsafe = 0; + } + + /// + public bool Contains(in T item) => this.IndexOf(in item) != -1; + + /// + /// Size down the underlying ImVector{T}. + /// + /// Capacity to reserve. + /// Whether the capacity has been changed. + public bool Compact(int reservation) => this.SetCapacity(Math.Max(reservation, this.LengthUnsafe)); + + /// + public void CopyTo(T[] array, int arrayIndex) + { + if (arrayIndex < 0) + { + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + $"{nameof(arrayIndex)} is less than 0."); + } + + if (array.Length - arrayIndex < this.LengthUnsafe) + { + throw new ArgumentException( + "The number of elements in the source ImVectorWrapper is greater than the available space from arrayIndex to the end of the destination array.", + nameof(array)); + } + + fixed (void* p = array) + Buffer.MemoryCopy(this.DataUnsafe, p, this.LengthUnsafe * sizeof(T), this.LengthUnsafe * sizeof(T)); + } + + /// + /// Ensures that the capacity of this list is at least the specified .
+ /// On growth, the new capacity exactly matches . + ///
+ /// The minimum capacity to ensure. + /// Whether the capacity has been changed. + public bool EnsureCapacity(int capacity) => this.CapacityUnsafe < capacity && this.SetCapacity(capacity); + + /// + /// Ensures that the capacity of this list is at least the specified .
+ /// On growth, the new capacity may exceed . + ///
+ /// The minimum capacity to ensure. + /// Whether the capacity has been changed. + public bool EnsureCapacityExponential(int capacity) + => this.EnsureCapacity(1 << ((sizeof(int) * 8) - BitOperations.LeadingZeroCount((uint)capacity))); + + /// + /// Resizes the underlying array and fills with zeroes if grown. + /// + /// New size. + /// New default value. + /// Whether to skip calling destroyer function. + public void Resize(int size, in T defaultValue = default, bool skipDestroyer = false) + { + this.EnsureCapacity(size); + var old = this.LengthUnsafe; + if (old > size && !skipDestroyer && this.destroyer is not null) + { + foreach (var v in this.DataSpan[size..]) + this.destroyer(&v); + } + + this.LengthUnsafe = size; + if (old < size) + this.DataSpan[old..].Fill(defaultValue); + } + + /// + public bool Remove(in T item) + { + var index = this.IndexOf(item); + if (index == -1) + return false; + + this.RemoveAt(index); + return true; + } + + /// + public int IndexOf(in T item) + { + foreach (var i in Enumerable.Range(0, this.LengthUnsafe)) + { + if (Equals(item, this.DataUnsafe[i])) + return i; + } + + return -1; + } + + /// + public void Insert(int index, in T item) + { + // Note: index == this.LengthUnsafe is okay; we're just adding to the end then + if (index < 0 || index > this.LengthUnsafe) + throw new IndexOutOfRangeException(); + + this.EnsureCapacityExponential(this.LengthUnsafe + 1); + var num = this.LengthUnsafe - index; + Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + 1, num * sizeof(T), num * sizeof(T)); + this.DataUnsafe[index] = item; + this.LengthUnsafe += 1; + } + + /// + public void InsertRange(int index, IEnumerable items) + { + if (items is ICollection { Count: var count }) + { + this.EnsureCapacityExponential(this.LengthUnsafe + count); + var num = this.LengthUnsafe - index; + Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + count, num * sizeof(T), num * sizeof(T)); + foreach (var item in items) + this.DataUnsafe[index++] = item; + this.LengthUnsafe += count; + } + else + { + foreach (var item in items) + this.Insert(index++, item); + } + } + + /// + public void InsertRange(int index, ReadOnlySpan items) + { + this.EnsureCapacityExponential(this.LengthUnsafe + items.Length); + var num = this.LengthUnsafe - index; + Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + items.Length, num * sizeof(T), num * sizeof(T)); + foreach (var item in items) + this.DataUnsafe[index++] = item; + this.LengthUnsafe += items.Length; + } + + /// + /// Removes the element at the given index. + /// + /// The index. + /// Whether to skip calling the destroyer function. + public void RemoveAt(int index, bool skipDestroyer = false) => this.RemoveRange(index, 1, skipDestroyer); + + /// + void IList.RemoveAt(int index) => this.RemoveAt(index); + + /// + void IList.RemoveAt(int index) => this.RemoveAt(index); + + /// + /// Removes elements at the given index. + /// + /// The index of the first item to remove. + /// Number of items to remove. + /// Whether to skip calling the destroyer function. + public void RemoveRange(int index, int count, bool skipDestroyer = false) + { + this.EnsureIndex(index); + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Must be positive."); + if (count == 0) + return; + + if (!skipDestroyer && this.destroyer is { } d) + { + for (var i = 0; i < count; i++) + d(this.DataUnsafe + index + i); + } + + var numItemsToMove = this.LengthUnsafe - index - count; + var numBytesToMove = numItemsToMove * sizeof(T); + Buffer.MemoryCopy(this.DataUnsafe + index + count, this.DataUnsafe + index, numBytesToMove, numBytesToMove); + this.LengthUnsafe -= count; + } + + /// + /// Replaces a sequence at given offset of items with + /// . + /// + /// The index of the first item to be replaced. + /// The number of items to be replaced. + /// The replacement. + /// Whether to skip calling the destroyer function. + public void ReplaceRange(int index, int count, ReadOnlySpan replacement, bool skipDestroyer = false) + { + this.EnsureIndex(index); + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Must be positive."); + if (count == 0) + return; + + // Ensure the capacity first, so that we can safely destroy the items first. + this.EnsureCapacityExponential((this.LengthUnsafe + replacement.Length) - count); + + if (!skipDestroyer && this.destroyer is { } d) + { + for (var i = 0; i < count; i++) + d(this.DataUnsafe + index + i); + } + + if (count == replacement.Length) + { + replacement.CopyTo(this.DataSpan[index..]); + } + else if (count > replacement.Length) + { + replacement.CopyTo(this.DataSpan[index..]); + this.RemoveRange(index + replacement.Length, count - replacement.Length); + } + else + { + replacement[..count].CopyTo(this.DataSpan[index..]); + this.InsertRange(index + count, replacement[count..]); + } + } + + /// + /// Sets the capacity exactly as requested. + /// + /// New capacity. + /// Whether the capacity has been changed. + /// If is less than . + /// If memory for the requested capacity cannot be allocated. + public bool SetCapacity(int capacity) + { + if (capacity < this.LengthUnsafe) + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, null); + + if (capacity == this.LengthUnsafe) + { + if (capacity == 0 && this.DataUnsafe is not null) + { + ImGuiNative.igMemFree(this.DataUnsafe); + this.DataUnsafe = null; + } + + return false; + } + + var oldAlloc = this.DataUnsafe; + var oldSpan = new Span(oldAlloc, this.CapacityUnsafe); + + var newAlloc = (T*)(capacity == 0 + ? null + : ImGuiNative.igMemAlloc(checked((uint)(capacity * sizeof(T))))); + + if (newAlloc is null && capacity > 0) + throw new OutOfMemoryException(); + + var newSpan = new Span(newAlloc, capacity); + + if (!oldSpan.IsEmpty && !newSpan.IsEmpty) + oldSpan[..this.LengthUnsafe].CopyTo(newSpan); + + if (oldAlloc != null) + ImGuiNative.igMemFree(oldAlloc); + + this.DataUnsafe = newAlloc; + this.CapacityUnsafe = capacity; + + return true; + } + + /// + void ICollection.Add(T item) => this.Add(in item); + + /// + bool ICollection.Contains(T item) => this.Contains(in item); + + /// + void ICollection.CopyTo(Array array, int index) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException( + nameof(index), + index, + $"{nameof(index)} is less than 0."); + } + + if (array.Length - index < this.LengthUnsafe) + { + throw new ArgumentException( + "The number of elements in the source ImVectorWrapper is greater than the available space from arrayIndex to the end of the destination array.", + nameof(array)); + } + + foreach (var i in Enumerable.Range(0, this.LengthUnsafe)) + array.SetValue(this.DataUnsafe[i], index); + } + + /// + bool ICollection.Remove(T item) => this.Remove(in item); + + /// + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + /// + int IList.Add(object? value) + { + this.Add(value is null ? default : (T)value); + return this.LengthUnsafe - 1; + } + + /// + bool IList.Contains(object? value) => this.Contains(value is null ? default : (T)value); + + /// + int IList.IndexOf(object? value) => this.IndexOf(value is null ? default : (T)value); + + /// + void IList.Insert(int index, object? value) => this.Insert(index, value is null ? default : (T)value); + + /// + void IList.Remove(object? value) => this.Remove(value is null ? default : (T)value); + + /// + int IList.IndexOf(T item) => this.IndexOf(in item); + + /// + void IList.Insert(int index, T item) => this.Insert(index, in item); + + private int EnsureIndex(int i) => i >= 0 && i < this.LengthUnsafe ? i : throw new IndexOutOfRangeException(); +} diff --git a/Dalamud.Interface/Raii/Color.cs b/Dalamud/Interface/Utility/Raii/Color.cs similarity index 83% rename from Dalamud.Interface/Raii/Color.cs rename to Dalamud/Interface/Utility/Raii/Color.cs index 388e6e737..3cf93b65c 100644 --- a/Dalamud.Interface/Raii/Color.cs +++ b/Dalamud/Interface/Utility/Raii/Color.cs @@ -1,7 +1,10 @@ +using System.Collections.Generic; +using System.Linq; using System.Numerics; + using ImGuiNET; -namespace Dalamud.Interface.Raii; +namespace Dalamud.Interface.Utility.Raii; // Push an arbitrary amount of colors into an object that are all popped when it is disposed. // If condition is false, no color is pushed. @@ -26,7 +29,7 @@ public static partial class ImRaii public sealed class Color : IDisposable { internal static readonly List<(ImGuiCol, uint)> Stack = new(); - private int _count; + private int count; public Color Push(ImGuiCol idx, uint color, bool condition = true) { @@ -34,7 +37,7 @@ public static partial class ImRaii { Stack.Add((idx, ImGui.GetColorU32(idx))); ImGui.PushStyleColor(idx, color); - ++this._count; + ++this.count; } return this; @@ -46,7 +49,7 @@ public static partial class ImRaii { Stack.Add((idx, ImGui.GetColorU32(idx))); ImGui.PushStyleColor(idx, color); - ++this._count; + ++this.count; } return this; @@ -54,13 +57,13 @@ public static partial class ImRaii public void Pop(int num = 1) { - num = Math.Min(num, this._count); - this._count -= num; + num = Math.Min(num, this.count); + this.count -= num; ImGui.PopStyleColor(num); Stack.RemoveRange(Stack.Count - num, num); } public void Dispose() - => this.Pop(this._count); + => this.Pop(this.count); } } diff --git a/Dalamud.Interface/Raii/EndObjects.cs b/Dalamud/Interface/Utility/Raii/EndObjects.cs similarity index 94% rename from Dalamud.Interface/Raii/EndObjects.cs rename to Dalamud/Interface/Utility/Raii/EndObjects.cs index 032f09621..3f2a016b3 100644 --- a/Dalamud.Interface/Raii/EndObjects.cs +++ b/Dalamud/Interface/Utility/Raii/EndObjects.cs @@ -1,13 +1,14 @@ using System.Numerics; + using ImGuiNET; -namespace Dalamud.Interface.Raii; +namespace Dalamud.Interface.Utility.Raii; // Most ImGui widgets with IDisposable interface that automatically destroys them // when created with using variables. public static partial class ImRaii { - private static int _disabledCount = 0; + private static int disabledCount = 0; public static IEndObject Child(string strId) => new EndUnconditionally(ImGui.EndChild, ImGui.BeginChild(strId)); @@ -120,7 +121,7 @@ public static partial class ImRaii public static IEndObject Disabled() { ImGui.BeginDisabled(); - ++_disabledCount; + ++disabledCount; return DisabledEnd(); } @@ -130,24 +131,24 @@ public static partial class ImRaii return new EndConditionally(Nop, false); ImGui.BeginDisabled(); - ++_disabledCount; + ++disabledCount; return DisabledEnd(); } public static IEndObject Enabled() { - var oldCount = _disabledCount; + var oldCount = disabledCount; if (oldCount == 0) return new EndConditionally(Nop, false); void Restore() { - _disabledCount += oldCount; + disabledCount += oldCount; while (--oldCount >= 0) ImGui.BeginDisabled(); } - for (; _disabledCount > 0; --_disabledCount) + for (; disabledCount > 0; --disabledCount) ImGui.EndDisabled(); return new EndUnconditionally(Restore, true); @@ -156,7 +157,7 @@ public static partial class ImRaii private static IEndObject DisabledEnd() => new EndUnconditionally(() => { - --_disabledCount; + --disabledCount; ImGui.EndDisabled(); }, true); @@ -173,6 +174,11 @@ public static partial class ImRaii return new EndUnconditionally(Widget.EndFramedGroup, true); } */ + + // Used to avoid tree pops when flag for no push is set. + private static void Nop() + { + } // Exported interface for RAII. public interface IEndObject : IDisposable @@ -203,7 +209,9 @@ public static partial class ImRaii private struct EndUnconditionally : IEndObject { private Action EndAction { get; } + public bool Success { get; } + public bool Disposed { get; private set; } public EndUnconditionally(Action endAction, bool success) @@ -226,16 +234,18 @@ public static partial class ImRaii // Use end-function only on success. private struct EndConditionally : IEndObject { - private Action EndAction { get; } - public bool Success { get; } - public bool Disposed { get; private set; } - public EndConditionally(Action endAction, bool success) { this.EndAction = endAction; - this.Success = success; - this.Disposed = false; + this.Success = success; + this.Disposed = false; } + + public bool Success { get; } + + public bool Disposed { get; private set; } + + private Action EndAction { get; } public void Dispose() { @@ -247,8 +257,4 @@ public static partial class ImRaii this.Disposed = true; } } - - // Used to avoid tree pops when flag for no push is set. - private static void Nop() - { } } diff --git a/Dalamud.Interface/Raii/Font.cs b/Dalamud/Interface/Utility/Raii/Font.cs similarity index 81% rename from Dalamud.Interface/Raii/Font.cs rename to Dalamud/Interface/Utility/Raii/Font.cs index cdecf457c..2d11bb071 100644 --- a/Dalamud.Interface/Raii/Font.cs +++ b/Dalamud/Interface/Utility/Raii/Font.cs @@ -1,6 +1,6 @@ using ImGuiNET; -namespace Dalamud.Interface.Raii; +namespace Dalamud.Interface.Utility.Raii; // Push an arbitrary amount of fonts into an object that are all popped when it is disposed. // If condition is false, no font is pushed. @@ -18,10 +18,10 @@ public static partial class ImRaii internal static int FontPushCounter = 0; internal static ImFontPtr DefaultPushed; - private int _count; + private int count; public Font() - => this._count = 0; + => this.count = 0; public Font Push(ImFontPtr font, bool condition = true) { @@ -30,7 +30,7 @@ public static partial class ImRaii if (FontPushCounter++ == 0) DefaultPushed = ImGui.GetFont(); ImGui.PushFont(font); - ++this._count; + ++this.count; } return this; @@ -38,14 +38,14 @@ public static partial class ImRaii public void Pop(int num = 1) { - num = Math.Min(num, this._count); - this._count -= num; + num = Math.Min(num, this.count); + this.count -= num; FontPushCounter -= num; while (num-- > 0) ImGui.PopFont(); } public void Dispose() - => this.Pop(this._count); + => this.Pop(this.count); } } diff --git a/Dalamud.Interface/Raii/Id.cs b/Dalamud/Interface/Utility/Raii/Id.cs similarity index 82% rename from Dalamud.Interface/Raii/Id.cs rename to Dalamud/Interface/Utility/Raii/Id.cs index 1248b92f3..51c6438c4 100644 --- a/Dalamud.Interface/Raii/Id.cs +++ b/Dalamud/Interface/Utility/Raii/Id.cs @@ -1,6 +1,6 @@ using ImGuiNET; -namespace Dalamud.Interface.Raii; +namespace Dalamud.Interface.Utility.Raii; // Push an arbitrary amount of ids into an object that are all popped when it is disposed. // If condition is false, no id is pushed. @@ -17,14 +17,14 @@ public static partial class ImRaii public sealed class Id : IDisposable { - private int _count; + private int count; public Id Push(string id, bool condition = true) { if (condition) { ImGui.PushID(id); - ++this._count; + ++this.count; } return this; @@ -35,7 +35,7 @@ public static partial class ImRaii if (condition) { ImGui.PushID(id); - ++this._count; + ++this.count; } return this; @@ -46,7 +46,7 @@ public static partial class ImRaii if (condition) { ImGui.PushID(id); - ++this._count; + ++this.count; } return this; @@ -54,13 +54,13 @@ public static partial class ImRaii public void Pop(int num = 1) { - num = Math.Min(num, this._count); - this._count -= num; + num = Math.Min(num, this.count); + this.count -= num; while (num-- > 0) ImGui.PopID(); } public void Dispose() - => this.Pop(this._count); + => this.Pop(this.count); } } diff --git a/Dalamud.Interface/Raii/Indent.cs b/Dalamud/Interface/Utility/Raii/Indent.cs similarity index 91% rename from Dalamud.Interface/Raii/Indent.cs rename to Dalamud/Interface/Utility/Raii/Indent.cs index 99eab8783..3c8f0f1da 100644 --- a/Dalamud.Interface/Raii/Indent.cs +++ b/Dalamud/Interface/Utility/Raii/Indent.cs @@ -1,6 +1,6 @@ using ImGuiNET; -namespace Dalamud.Interface.Raii; +namespace Dalamud.Interface.Utility.Raii; public static partial class ImRaii { @@ -19,7 +19,7 @@ public static partial class ImRaii if (condition) { if (scaled) - indent *= InterfaceHelpers.GlobalScale; + indent *= ImGuiHelpers.GlobalScale; IndentInternal(indent); this.Indentation += indent; @@ -43,7 +43,7 @@ public static partial class ImRaii public void Pop(float indent, bool scaled = true) { if (scaled) - indent *= InterfaceHelpers.GlobalScale; + indent *= ImGuiHelpers.GlobalScale; IndentInternal(-indent); this.Indentation -= indent; diff --git a/Dalamud.Interface/Raii/Style.cs b/Dalamud/Interface/Utility/Raii/Style.cs similarity index 95% rename from Dalamud.Interface/Raii/Style.cs rename to Dalamud/Interface/Utility/Raii/Style.cs index 2f1fea538..82f51bf88 100644 --- a/Dalamud.Interface/Raii/Style.cs +++ b/Dalamud/Interface/Utility/Raii/Style.cs @@ -1,7 +1,10 @@ +using System.Collections.Generic; +using System.Linq; using System.Numerics; + using ImGuiNET; -namespace Dalamud.Interface.Raii; +namespace Dalamud.Interface.Utility.Raii; // Push an arbitrary amount of styles into an object that are all popped when it is disposed. // If condition is false, no style is pushed. @@ -17,7 +20,7 @@ public static partial class ImRaii // Push styles that revert all current style changes made temporarily. public static Style DefaultStyle() { - var ret = new Style(); + var ret = new Style(); var reverseStack = Style.Stack.GroupBy(p => p.Item1).Select(p => (p.Key, p.First().Item2)).ToArray(); foreach (var (idx, val) in reverseStack) { @@ -34,7 +37,7 @@ public static partial class ImRaii { internal static readonly List<(ImGuiStyleVar, Vector2)> Stack = new(); - private int _count; + private int count; [System.Diagnostics.Conditional("DEBUG")] private static void CheckStyleIdx(ImGuiStyleVar idx, Type type) @@ -115,7 +118,7 @@ public static partial class ImRaii CheckStyleIdx(idx, typeof(float)); Stack.Add((idx, GetStyle(idx))); ImGui.PushStyleVar(idx, value); - ++this._count; + ++this.count; return this; } @@ -128,20 +131,20 @@ public static partial class ImRaii CheckStyleIdx(idx, typeof(Vector2)); Stack.Add((idx, GetStyle(idx))); ImGui.PushStyleVar(idx, value); - ++this._count; + ++this.count; return this; } public void Pop(int num = 1) { - num = Math.Min(num, this._count); - this._count -= num; + num = Math.Min(num, this.count); + this.count -= num; ImGui.PopStyleVar(num); Stack.RemoveRange(Stack.Count - num, num); } public void Dispose() - => this.Pop(this._count); + => this.Pop(this.count); } } diff --git a/Dalamud.Interface/Table/Column.cs b/Dalamud/Interface/Utility/Table/Column.cs similarity index 92% rename from Dalamud.Interface/Table/Column.cs rename to Dalamud/Interface/Utility/Table/Column.cs index 7460ec189..412ba87dc 100644 --- a/Dalamud.Interface/Table/Column.cs +++ b/Dalamud/Interface/Utility/Table/Column.cs @@ -1,6 +1,6 @@ using ImGuiNET; -namespace Dalamud.Interface.Table; +namespace Dalamud.Interface.Utility.Table; public class Column { @@ -27,7 +27,8 @@ public class Column => 0; public virtual void DrawColumn(TItem item, int idx) - { } + { + } public int CompareInv(TItem lhs, TItem rhs) => this.Compare(rhs, lhs); diff --git a/Dalamud.Interface/Table/ColumnFlags.cs b/Dalamud/Interface/Utility/Table/ColumnFlags.cs similarity index 89% rename from Dalamud.Interface/Table/ColumnFlags.cs rename to Dalamud/Interface/Utility/Table/ColumnFlags.cs index 815ddcf76..24670adfc 100644 --- a/Dalamud.Interface/Table/ColumnFlags.cs +++ b/Dalamud/Interface/Utility/Table/ColumnFlags.cs @@ -1,7 +1,9 @@ -using ImGuiNET; -using ImRaii = Dalamud.Interface.Raii.ImRaii; +using System.Collections.Generic; -namespace Dalamud.Interface.Table; +using Dalamud.Interface.Utility.Raii; +using ImGuiNET; + +namespace Dalamud.Interface.Utility.Table; public class ColumnFlags : Column where T : struct, Enum { @@ -17,13 +19,14 @@ public class ColumnFlags : Column where T : struct, Enum => default; protected virtual void SetValue(T value, bool enable) - { } + { + } public override bool DrawFilter() { using var id = ImRaii.PushId(this.FilterLabel); using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0); - ImGui.SetNextItemWidth(-Table.ArrowWidth * InterfaceHelpers.GlobalScale); + ImGui.SetNextItemWidth(-Table.ArrowWidth * ImGuiHelpers.GlobalScale); var all = this.FilterValue.HasFlag(this.AllFlags); using var color = ImRaii.PushColor(ImGuiCol.FrameBg, 0x803030A0, !all); using var combo = ImRaii.Combo(string.Empty, this.Label, ImGuiComboFlags.NoArrowButton); diff --git a/Dalamud.Interface/Table/ColumnSelect.cs b/Dalamud/Interface/Utility/Table/ColumnSelect.cs similarity index 66% rename from Dalamud.Interface/Table/ColumnSelect.cs rename to Dalamud/Interface/Utility/Table/ColumnSelect.cs index 5ef276b06..fb463700c 100644 --- a/Dalamud.Interface/Table/ColumnSelect.cs +++ b/Dalamud/Interface/Utility/Table/ColumnSelect.cs @@ -1,7 +1,9 @@ -using ImGuiNET; -using ImRaii = Dalamud.Interface.Raii.ImRaii; +using System.Collections.Generic; -namespace Dalamud.Interface.Table; +using Dalamud.Interface.Utility.Raii; +using ImGuiNET; + +namespace Dalamud.Interface.Utility.Table; public class ColumnSelect : Column where T : struct, Enum, IEquatable { @@ -18,26 +20,26 @@ public class ColumnSelect : Column where T : struct, Enum, IEqu => this.FilterValue = value; public T FilterValue; - protected int Idx = -1; + protected int idx = -1; public override bool DrawFilter() { using var id = ImRaii.PushId(this.FilterLabel); using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0); - ImGui.SetNextItemWidth(-Table.ArrowWidth * InterfaceHelpers.GlobalScale); - using var combo = ImRaii.Combo(string.Empty, this.Idx < 0 ? this.Label : this.Names[this.Idx]); - if(!combo) + ImGui.SetNextItemWidth(-Table.ArrowWidth * ImGuiHelpers.GlobalScale); + using var combo = ImRaii.Combo(string.Empty, this.idx < 0 ? this.Label : this.Names[this.idx]); + if (!combo) return false; var ret = false; for (var i = 0; i < this.Names.Length; ++i) { if (this.FilterValue.Equals(this.Values[i])) - this.Idx = i; - if (!ImGui.Selectable(this.Names[i], this.Idx == i) || this.Idx == i) + this.idx = i; + if (!ImGui.Selectable(this.Names[i], this.idx == i) || this.idx == i) continue; - this.Idx = i; + this.idx = i; this.SetValue(this.Values[i]); ret = true; } diff --git a/Dalamud.Interface/Table/ColumnString.cs b/Dalamud/Interface/Utility/Table/ColumnString.cs similarity index 75% rename from Dalamud.Interface/Table/ColumnString.cs rename to Dalamud/Interface/Utility/Table/ColumnString.cs index dcd43b23c..3f9d2df91 100644 --- a/Dalamud.Interface/Table/ColumnString.cs +++ b/Dalamud/Interface/Utility/Table/ColumnString.cs @@ -1,8 +1,9 @@ using System.Text.RegularExpressions; -using Dalamud.Interface.Raii; + +using Dalamud.Interface.Utility.Raii; using ImGuiNET; -namespace Dalamud.Interface.Table; +namespace Dalamud.Interface.Utility.Table; public class ColumnString : Column { @@ -10,7 +11,7 @@ public class ColumnString : Column => this.Flags &= ~ImGuiTableColumnFlags.NoResize; public string FilterValue = string.Empty; - protected Regex? FilterRegex; + protected Regex? filterRegex; public virtual string ToName(TItem item) => item!.ToString() ?? string.Empty; @@ -22,7 +23,7 @@ public class ColumnString : Column { using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0); - ImGui.SetNextItemWidth(-Table.ArrowWidth * InterfaceHelpers.GlobalScale); + ImGui.SetNextItemWidth(-Table.ArrowWidth * ImGuiHelpers.GlobalScale); var tmp = this.FilterValue; if (!ImGui.InputTextWithHint(this.FilterLabel, this.Label, ref tmp, 256) || tmp == this.FilterValue) return false; @@ -30,11 +31,11 @@ public class ColumnString : Column this.FilterValue = tmp; try { - this.FilterRegex = new Regex(this.FilterValue, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + this.filterRegex = new Regex(this.FilterValue, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } catch { - this.FilterRegex = null; + this.filterRegex = null; } return true; @@ -46,10 +47,10 @@ public class ColumnString : Column if (this.FilterValue.Length == 0) return true; - return this.FilterRegex?.IsMatch(name) ?? name.Contains(this.FilterValue, StringComparison.OrdinalIgnoreCase); + return this.filterRegex?.IsMatch(name) ?? name.Contains(this.FilterValue, StringComparison.OrdinalIgnoreCase); } - public override void DrawColumn(TItem item, int _) + public override void DrawColumn(TItem item, int idx) { ImGui.TextUnformatted(this.ToName(item)); } diff --git a/Dalamud.Interface/Table/Table.cs b/Dalamud/Interface/Utility/Table/Table.cs similarity index 64% rename from Dalamud.Interface/Table/Table.cs rename to Dalamud/Interface/Utility/Table/Table.cs index 74fb0bc5c..86653e834 100644 --- a/Dalamud.Interface/Table/Table.cs +++ b/Dalamud/Interface/Utility/Table/Table.cs @@ -1,8 +1,12 @@ +using System.Collections.Generic; +using System.Linq; using System.Numerics; -using ImGuiNET; -using ImRaii = Dalamud.Interface.Raii.ImRaii; -namespace Dalamud.Interface.Table; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Utility; +using ImGuiNET; + +namespace Dalamud.Interface.Utility.Table; public static class Table { @@ -11,18 +15,20 @@ public static class Table public class Table { - protected bool FilterDirty = true; - protected bool SortDirty = true; protected readonly ICollection Items; - internal readonly List<(T, int)> FilteredItems; + internal readonly List<(T, int)> FilteredItems; - protected readonly string Label; + protected readonly string Label; protected readonly Column[] Headers; - protected float ItemHeight { get; set; } - public float ExtraHeight { get; set; } = 0; + protected bool filterDirty = true; + protected bool sortDirty = true; - private int _currentIdx = 0; + protected float ItemHeight { get; set; } + + public float ExtraHeight { get; set; } = 0; + + private int currentIdx = 0; protected bool Sortable { @@ -30,7 +36,7 @@ public class Table set => this.Flags = value ? this.Flags | ImGuiTableFlags.Sortable : this.Flags & ~ImGuiTableFlags.Sortable; } - protected int SortIdx = -1; + protected int sortIdx = -1; public ImGuiTableFlags Flags = ImGuiTableFlags.RowBg | ImGuiTableFlags.Sortable @@ -54,10 +60,10 @@ public class Table public Table(string label, ICollection items, params Column[] headers) { - this.Label = label; - this.Items = items; - this.Headers = headers; - this.FilteredItems = new List<(T, int)>(this.Items.Count); + this.Label = label; + this.Items = items; + this.Headers = headers; + this.FilteredItems = new List<(T, int)>(this.Items.Count); this.VisibleColumns = this.Headers.Length; } @@ -73,7 +79,8 @@ public class Table => throw new NotImplementedException(); protected virtual void PreDraw() - { } + { + } private void SortInternal() { @@ -81,29 +88,30 @@ public class Table return; var sortSpecs = ImGui.TableGetSortSpecs(); - this.SortDirty |= sortSpecs.SpecsDirty; + this.sortDirty |= sortSpecs.SpecsDirty; - if (!this.SortDirty) + if (!this.sortDirty) return; - this.SortIdx = sortSpecs.Specs.ColumnIndex; + this.sortIdx = sortSpecs.Specs.ColumnIndex; - if (this.Headers.Length <= this.SortIdx) - this.SortIdx = 0; + if (this.Headers.Length <= this.sortIdx) + this.sortIdx = 0; - if (sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - this.FilteredItems.StableSort((a, b) => this.Headers[this.SortIdx].Compare(a.Item1, b.Item1)); - else if (sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - this.FilteredItems.StableSort((a, b) => this.Headers[this.SortIdx].CompareInv(a.Item1, b.Item1)); - else - this.SortIdx = -1; - this.SortDirty = false; - sortSpecs.SpecsDirty = false; + if (sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) + this.FilteredItems.StableSort((a, b) => this.Headers[this.sortIdx].Compare(a.Item1, b.Item1)); + else if (sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) + this.FilteredItems.StableSort((a, b) => this.Headers[this.sortIdx].CompareInv(a.Item1, b.Item1)); + else + this.sortIdx = -1; + + this.sortDirty = false; + sortSpecs.SpecsDirty = false; } private void UpdateFilter() { - if (!this.FilterDirty) + if (!this.filterDirty) return; this.FilteredItems.Clear(); @@ -115,20 +123,20 @@ public class Table idx++; } - this.FilterDirty = false; - this.SortDirty = true; + this.filterDirty = false; + this.sortDirty = true; } - private void DrawItem((T, int) pair) + private void DrawItem((T Item, int Index) pair) { - var column = 0; - using var id = ImRaii.PushId(this._currentIdx); - this._currentIdx = pair.Item2; + var column = 0; + using var id = ImRaii.PushId(this.currentIdx); + this.currentIdx = pair.Index; foreach (var header in this.Headers) { id.Push(column++); if (ImGui.TableNextColumn()) - header.DrawColumn(pair.Item1, pair.Item2); + header.DrawColumn(pair.Item, pair.Index); id.Pop(); } } @@ -136,7 +144,7 @@ public class Table private void DrawTableInternal() { using var table = ImRaii.Table("Table", this.Headers.Length, this.Flags, - ImGui.GetContentRegionAvail() - this.ExtraHeight * Vector2.UnitY * InterfaceHelpers.GlobalScale); + ImGui.GetContentRegionAvail() - this.ExtraHeight * Vector2.UnitY * ImGuiHelpers.GlobalScale); if (!table) return; @@ -162,11 +170,11 @@ public class Table ImGui.SameLine(); style.Pop(); if (header.DrawFilter()) - this.FilterDirty = true; + this.filterDirty = true; } this.SortInternal(); - this._currentIdx = 0; + this.currentIdx = 0; ImGuiClip.ClippedDraw(this.FilteredItems, this.DrawItem, this.ItemHeight); } } diff --git a/Dalamud/Interface/Windowing/Window.cs b/Dalamud/Interface/Windowing/Window.cs index 39c61566b..a7565c294 100644 --- a/Dalamud/Interface/Windowing/Window.cs +++ b/Dalamud/Interface/Windowing/Window.cs @@ -1,9 +1,19 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Numerics; +using System.Runtime.InteropServices; +using CheapLoc; using Dalamud.Configuration.Internal; using Dalamud.Game.ClientState.Keys; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; using FFXIVClientStructs.FFXIV.Client.UI; using ImGuiNET; +using PInvoke; namespace Dalamud.Interface.Windowing; @@ -12,10 +22,16 @@ namespace Dalamud.Interface.Windowing; ///
public abstract class Window { - private static bool wasEscPressedLastFrame = false; + private static readonly ModuleLog Log = new("WindowSystem"); + private static bool wasEscPressedLastFrame = false; + private bool internalLastIsOpen = false; private bool internalIsOpen = false; + private bool internalIsPinned = false; + private bool internalIsClickthrough = false; + private bool didPushInternalAlpha = false; + private float? internalAlpha = null; private bool nextFrameBringToFront = false; /// @@ -126,6 +142,25 @@ public abstract class Window /// public bool ShowCloseButton { get; set; } = true; + /// + /// Gets or sets a value indicating whether or not this window should offer to be pinned via the window's titlebar context menu. + /// + public bool AllowPinning { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not this window should offer to be made click-through via the window's titlebar context menu. + /// + public bool AllowClickthrough { get; set; } = true; + + /// + /// Gets or sets a list of available title bar buttons. + /// + /// If or are set to true, and this features is not + /// disabled globally by the user, an internal title bar button to manage these is added when drawing, but it will + /// not appear in this collection. If you wish to remove this button, set both of these values to false. + /// + public List TitleBarButtons { get; set; } = new(); + /// /// Gets or sets a value indicating whether or not this window will stay open. /// @@ -134,6 +169,8 @@ public abstract class Window get => this.internalIsOpen; set => this.internalIsOpen = value; } + + private bool CanShowCloseButton => this.ShowCloseButton && !this.internalIsClickthrough; /// /// Toggle window is open state. @@ -181,6 +218,11 @@ public abstract class Window /// public virtual void PreDraw() { + if (this.internalAlpha.HasValue) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, this.internalAlpha.Value); + this.didPushInternalAlpha = true; + } } /// @@ -188,6 +230,11 @@ public abstract class Window /// public virtual void PostDraw() { + if (this.didPushInternalAlpha) + { + ImGui.PopStyleVar(); + this.didPushInternalAlpha = false; + } } /// @@ -219,10 +266,11 @@ public abstract class Window public virtual void Update() { } - + /// /// Draw the window via ImGui. /// + /// Configuration instance used to check if certain window management features should be enabled. internal void DrawInternal(DalamudConfiguration? configuration) { this.PreOpenCheck(); @@ -252,13 +300,7 @@ public abstract class Window if (hasNamespace) ImGui.PushID(this.Namespace); - - this.PreDraw(); - this.ApplyConditionals(); - - if (this.ForceMainWindow) - ImGuiHelpers.ForceNextWindowMainViewport(); - + if (this.internalLastIsOpen != this.internalIsOpen && this.internalIsOpen) { this.internalLastIsOpen = this.internalIsOpen; @@ -267,6 +309,12 @@ public abstract class Window if (doSoundEffects && !this.DisableWindowSounds) UIModule.PlaySound(this.OnOpenSfxId, 0, 0, 0); } + this.PreDraw(); + this.ApplyConditionals(); + + if (this.ForceMainWindow) + ImGuiHelpers.ForceNextWindowMainViewport(); + var wasFocused = this.IsFocused; if (wasFocused) { @@ -281,10 +329,124 @@ public abstract class Window this.nextFrameBringToFront = false; } - if (this.ShowCloseButton ? ImGui.Begin(this.WindowName, ref this.internalIsOpen, this.Flags) : ImGui.Begin(this.WindowName, this.Flags)) + var flags = this.Flags; + + if (this.internalIsPinned || this.internalIsClickthrough) + flags |= ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize; + + if (this.internalIsClickthrough) + flags |= ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoMouseInputs; + + if (this.CanShowCloseButton ? ImGui.Begin(this.WindowName, ref this.internalIsOpen, flags) : ImGui.Begin(this.WindowName, flags)) { // Draw the actual window contents - this.Draw(); + try + { + this.Draw(); + } + catch (Exception ex) + { + Log.Error(ex, $"Error during Draw(): {this.WindowName}"); + } + } + + var additionsPopupName = "WindowSystemContextActions"; + var flagsApplicableForTitleBarIcons = !flags.HasFlag(ImGuiWindowFlags.NoDecoration) && + !flags.HasFlag(ImGuiWindowFlags.NoTitleBar); + var showAdditions = (this.AllowPinning || this.AllowClickthrough) && + (configuration?.EnablePluginUiAdditionalOptions ?? true) && + flagsApplicableForTitleBarIcons; + if (showAdditions) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 1f); + + if (ImGui.BeginPopup(additionsPopupName, ImGuiWindowFlags.NoMove)) + { + var isAvailable = ImGuiHelpers.CheckIsWindowOnMainViewport(); + + if (!isAvailable) + ImGui.BeginDisabled(); + + if (this.internalIsClickthrough) + ImGui.BeginDisabled(); + + if (this.AllowPinning) + { + var showAsPinned = this.internalIsPinned || this.internalIsClickthrough; + if (ImGui.Checkbox(Loc.Localize("WindowSystemContextActionPin", "Pin Window"), ref showAsPinned)) + this.internalIsPinned = showAsPinned; + } + + if (this.internalIsClickthrough) + ImGui.EndDisabled(); + + if (this.AllowClickthrough) + ImGui.Checkbox(Loc.Localize("WindowSystemContextActionClickthrough", "Make clickthrough"), ref this.internalIsClickthrough); + + var alpha = (this.internalAlpha ?? ImGui.GetStyle().Alpha) * 100f; + if (ImGui.SliderFloat(Loc.Localize("WindowSystemContextActionAlpha", "Opacity"), ref alpha, 20f, + 100f)) + { + this.internalAlpha = alpha / 100f; + } + + ImGui.SameLine(); + if (ImGui.Button(Loc.Localize("WindowSystemContextActionReset", "Reset"))) + { + this.internalAlpha = null; + } + + if (isAvailable) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, + Loc.Localize("WindowSystemContextActionClickthroughDisclaimer", + "Open this menu again to disable clickthrough.")); + ImGui.TextColored(ImGuiColors.DalamudGrey, + Loc.Localize("WindowSystemContextActionDisclaimer", + "These options may not work for all plugins at the moment.")); + } + else + { + ImGui.TextColored(ImGuiColors.DalamudGrey, + Loc.Localize("WindowSystemContextActionViewportDisclaimer", + "These features are only available if this window is inside the game window.")); + } + + if (!isAvailable) + ImGui.EndDisabled(); + + ImGui.EndPopup(); + } + + ImGui.PopStyleVar(); + } + + var titleBarRect = Vector4.Zero; + unsafe + { + var window = ImGuiNativeAdditions.igGetCurrentWindow(); + ImGuiNativeAdditions.ImGuiWindow_TitleBarRect(&titleBarRect, window); + + var additionsButton = new TitleBarButton + { + Icon = FontAwesomeIcon.Bars, + IconOffset = new Vector2(2.5f, 1), + Click = _ => + { + this.internalIsClickthrough = false; + ImGui.OpenPopup(additionsPopupName); + }, + Priority = int.MinValue, + AvailableClickthrough = true, + }; + + if (flagsApplicableForTitleBarIcons) + { + this.DrawTitleBarButtons(window, flags, titleBarRect, + showAdditions + ? this.TitleBarButtons.Append(additionsButton) + : this.TitleBarButtons); + } } if (wasFocused) @@ -317,21 +479,6 @@ public abstract class Window ImGui.PopID(); } - // private void CheckState() - // { - // if (this.internalLastIsOpen != this.internalIsOpen) - // { - // if (this.internalIsOpen) - // { - // this.OnOpen(); - // } - // else - // { - // this.OnClose(); - // } - // } - // } - private void ApplyConditionals() { if (this.Position.HasValue) @@ -363,6 +510,112 @@ public abstract class Window { ImGui.SetNextWindowBgAlpha(this.BgAlpha.Value); } + + // Manually set alpha takes precedence, if devs don't want that, they should turn it off + if (this.internalAlpha.HasValue) + { + ImGui.SetNextWindowBgAlpha(this.internalAlpha.Value); + } + } + + private unsafe void DrawTitleBarButtons(void* window, ImGuiWindowFlags flags, Vector4 titleBarRect, IEnumerable buttons) + { + ImGui.PushClipRect(ImGui.GetWindowPos(), ImGui.GetWindowPos() + ImGui.GetWindowSize(), false); + + var style = ImGui.GetStyle(); + var fontSize = ImGui.GetFontSize(); + var drawList = ImGui.GetWindowDrawList(); + + var padR = 0f; + var buttonSize = ImGui.GetFontSize(); + + var numNativeButtons = 0; + if (this.CanShowCloseButton) + numNativeButtons++; + + if (!flags.HasFlag(ImGuiWindowFlags.NoCollapse) && style.WindowMenuButtonPosition == ImGuiDir.Right) + numNativeButtons++; + + // If there are no native buttons, pad from the right to make some space + if (numNativeButtons == 0) + padR += style.FramePadding.X; + + // Pad to the left, to get out of the way of the native buttons + padR += numNativeButtons * (buttonSize + style.ItemInnerSpacing.X); + + Vector2 GetCenter(Vector4 rect) => new((rect.X + rect.Z) * 0.5f, (rect.Y + rect.W) * 0.5f); + + var numButtons = 0; + bool DrawButton(TitleBarButton button, Vector2 pos) + { + var id = ImGui.GetID($"###CustomTbButton{numButtons}"); + numButtons++; + + var min = pos; + var max = pos + new Vector2(fontSize, fontSize); + Vector4 bb = new(min.X, min.Y, max.X, max.Y); + var isClipped = !ImGuiNativeAdditions.igItemAdd(bb, id, null, 0); + bool hovered, held; + var pressed = false; + + if (this.internalIsClickthrough) + { + hovered = false; + held = false; + + // ButtonBehavior does not function if the window is clickthrough, so we have to do it ourselves + if (ImGui.IsMouseHoveringRect(min, max)) + { + hovered = true; + + // We can't use ImGui native functions here, because they don't work with clickthrough + if ((User32.GetKeyState((int)VirtualKey.LBUTTON) & 0x8000) != 0) + { + held = true; + pressed = true; + } + } + } + else + { + pressed = ImGuiNativeAdditions.igButtonBehavior(bb, id, &hovered, &held, ImGuiButtonFlags.None); + } + + if (isClipped) + return pressed; + + // Render + var bgCol = ImGui.GetColorU32((held && hovered) ? ImGuiCol.ButtonActive : hovered ? ImGuiCol.ButtonHovered : ImGuiCol.Button); + var textCol = ImGui.GetColorU32(ImGuiCol.Text); + if (hovered || held) + drawList.AddCircleFilled(GetCenter(bb) + new Vector2(0.0f, -0.5f), (fontSize * 0.5f) + 1.0f, bgCol); + + var offset = button.IconOffset * ImGuiHelpers.GlobalScale; + drawList.AddText(InterfaceManager.IconFont, (float)(fontSize * 0.8), new Vector2(bb.X + offset.X, bb.Y + offset.Y), textCol, button.Icon.ToIconString()); + + if (hovered) + button.ShowTooltip?.Invoke(); + + // Switch to moving the window after mouse is moved beyond the initial drag threshold + if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left) && !this.internalIsClickthrough) + ImGuiNativeAdditions.igStartMouseMovingWindow(window); + + return pressed; + } + + foreach (var button in buttons.OrderBy(x => x.Priority)) + { + if (this.internalIsClickthrough && !button.AvailableClickthrough) + return; + + Vector2 position = new(titleBarRect.Z - padR - buttonSize, titleBarRect.Y + style.FramePadding.Y); + padR += buttonSize + style.ItemInnerSpacing.X; + + if (DrawButton(button, position)) + button.Click?.Invoke(ImGuiMouseButton.Left); + } + + ImGui.PopClipRect(); } /// @@ -370,14 +623,96 @@ public abstract class Window /// public struct WindowSizeConstraints { + private Vector2 internalMaxSize = new(float.MaxValue); + + /// + /// Initializes a new instance of the struct. + /// + public WindowSizeConstraints() + { + } + /// /// Gets or sets the minimum size of the window. /// - public Vector2 MinimumSize { get; set; } - + public Vector2 MinimumSize { get; set; } = new(0); + /// /// Gets or sets the maximum size of the window. /// - public Vector2 MaximumSize { get; set; } + public Vector2 MaximumSize + { + get => this.GetSafeMaxSize(); + set => this.internalMaxSize = value; + } + + private Vector2 GetSafeMaxSize() + { + var currentMin = this.MinimumSize; + + if (this.internalMaxSize.X < currentMin.X || this.internalMaxSize.Y < currentMin.Y) + return new Vector2(float.MaxValue); + + return this.internalMaxSize; + } + } + + /// + /// Structure describing a title bar button. + /// + public class TitleBarButton + { + /// + /// Gets or sets the icon of the button. + /// + public FontAwesomeIcon Icon { get; set; } + + /// + /// Gets or sets a vector by which the position of the icon within the button shall be offset. + /// Automatically scaled by the global font scale for you. + /// + public Vector2 IconOffset { get; set; } + + /// + /// Gets or sets an action that is called when a tooltip shall be drawn. + /// May be null if no tooltip shall be drawn. + /// + public Action? ShowTooltip { get; set; } + + /// + /// Gets or sets an action that is called when the button is clicked. + /// + public Action Click { get; set; } + + /// + /// Gets or sets the priority the button shall be shown in. + /// Lower = closer to ImGui default buttons. + /// + public int Priority { get; set; } + + /// + /// Gets or sets a value indicating whether or not the button shall be clickable + /// when the respective window is set to clickthrough. + /// + public bool AvailableClickthrough { get; set; } + } + + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "imports")] + private static unsafe class ImGuiNativeAdditions + { + [DllImport("cimgui", CallingConvention = CallingConvention.Cdecl)] + public static extern bool igItemAdd(Vector4 bb, uint id, Vector4* navBb, uint flags); + + [DllImport("cimgui", CallingConvention = CallingConvention.Cdecl)] + public static extern bool igButtonBehavior(Vector4 bb, uint id, bool* outHovered, bool* outHeld, ImGuiButtonFlags flags); + + [DllImport("cimgui", CallingConvention = CallingConvention.Cdecl)] + public static extern void* igGetCurrentWindow(); + + [DllImport("cimgui", CallingConvention = CallingConvention.Cdecl)] + public static extern void igStartMouseMovingWindow(void* window); + + [DllImport("cimgui", CallingConvention = CallingConvention.Cdecl)] + public static extern void ImGuiWindow_TitleBarRect(Vector4* pOut, void* window); } } diff --git a/Dalamud/Interface/Windowing/WindowSystem.cs b/Dalamud/Interface/Windowing/WindowSystem.cs index 8e12d8f68..3e2a95a8d 100644 --- a/Dalamud/Interface/Windowing/WindowSystem.cs +++ b/Dalamud/Interface/Windowing/WindowSystem.cs @@ -94,14 +94,6 @@ public class WindowSystem /// public void RemoveAllWindows() => this.windows.Clear(); - /// - /// Get a window by name. - /// - /// The name of the . - /// The object with matching name or null. - [Obsolete("WindowSystem does not own your window - you should store a reference to it and use that instead.")] - public Window? GetWindow(string windowName) => this.windows.FirstOrDefault(w => w.WindowName == windowName); - /// /// Draw all registered windows using ImGui. /// diff --git a/Dalamud/IoC/Internal/ServiceContainer.cs b/Dalamud/IoC/Internal/ServiceContainer.cs index db748303e..5b141979e 100644 --- a/Dalamud/IoC/Internal/ServiceContainer.cs +++ b/Dalamud/IoC/Internal/ServiceContainer.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -16,6 +15,7 @@ namespace Dalamud.IoC.Internal; /// This is only used to resolve dependencies for plugins. /// Dalamud services are constructed via Service{T}.ConstructObject at the moment. ///
+[ServiceManager.ProvidedService] internal class ServiceContainer : IServiceProvider, IServiceType { private static readonly ModuleLog Log = new("SERVICECONTAINER"); @@ -29,6 +29,16 @@ internal class ServiceContainer : IServiceProvider, IServiceType public ServiceContainer() { } + + /// + /// Gets a dictionary of all registered instances. + /// + public IReadOnlyDictionary Instances => this.instances; + + /// + /// Gets a dictionary mapping interfaces to their implementations. + /// + public IReadOnlyDictionary InterfaceToTypeMap => this.interfaceToTypeMap; /// /// Register a singleton object of any type into the current IOC container. @@ -43,7 +53,6 @@ internal class ServiceContainer : IServiceProvider, IServiceType } this.instances[typeof(T)] = new(instance.ContinueWith(x => new WeakReference(x.Result)), typeof(T)); - this.RegisterInterfaces(typeof(T)); } /// @@ -59,7 +68,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType foreach (var resolvableType in resolveViaTypes) { Log.Verbose("=> {InterfaceName} provides for {TName}", resolvableType.FullName ?? "???", type.FullName ?? "???"); - + Debug.Assert(!this.interfaceToTypeMap.ContainsKey(resolvableType), "A service already implements this interface, this is not allowed"); Debug.Assert(type.IsAssignableTo(resolvableType), "Service does not inherit from indicated ResolveVia type"); @@ -218,7 +227,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType if (this.interfaceToTypeMap.TryGetValue(serviceType, out var implementingType)) serviceType = implementingType; - if (serviceType.GetCustomAttribute() != null) + if (serviceType.GetCustomAttribute() != null) { if (scope == null) { @@ -289,7 +298,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType var contains = types.Any(x => x.IsAssignableTo(type)); // Scoped services are created on-demand - return contains || type.GetCustomAttribute() != null; + return contains || type.GetCustomAttribute() != null; } var parameters = ctor.GetParameters(); diff --git a/Dalamud/IoC/Internal/ServiceScope.cs b/Dalamud/IoC/Internal/ServiceScope.cs index 01c18a8b2..9fcf1af3c 100644 --- a/Dalamud/IoC/Internal/ServiceScope.cs +++ b/Dalamud/IoC/Internal/ServiceScope.cs @@ -96,6 +96,17 @@ internal class ServiceScopeImpl : IServiceScope /// public void Dispose() { - foreach (var createdObject in this.scopeCreatedObjects.OfType()) createdObject.Dispose(); + foreach (var createdObject in this.scopeCreatedObjects) + { + switch (createdObject) + { + case IInternalDisposableService d: + d.DisposeService(); + break; + case IDisposable d: + d.Dispose(); + break; + } + } } } diff --git a/Dalamud/Localization.cs b/Dalamud/Localization.cs index b180f113a..a9b0cf93d 100644 --- a/Dalamud/Localization.cs +++ b/Dalamud/Localization.cs @@ -36,6 +36,7 @@ public class Localization : IServiceType /// Use embedded loc resource files. public Localization(string locResourceDirectory, string locResourcePrefix = "", bool useEmbedded = false) { + this.DalamudLanguageCultureInfo = CultureInfo.InvariantCulture; this.locResourceDirectory = locResourceDirectory; this.locResourcePrefix = locResourcePrefix; this.useEmbedded = useEmbedded; @@ -61,7 +62,24 @@ public class Localization : IServiceType /// /// Event that occurs when the language is changed. /// - public event LocalizationChangedDelegate LocalizationChanged; + public event LocalizationChangedDelegate? LocalizationChanged; + + /// + /// Gets an instance of that corresponds to the language configured from Dalamud Settings. + /// + public CultureInfo DalamudLanguageCultureInfo { get; private set; } + + /// + /// Gets an instance of that corresponds to . + /// + /// The language code which should be in . + /// The corresponding instance of . + public static CultureInfo GetCultureInfoFromLangCode(string langCode) => + CultureInfo.GetCultureInfo(langCode switch + { + "tw" => "zh-tw", + _ => langCode, + }); /// /// Search the set-up localization data for the provided assembly for the given string key and return it. @@ -108,6 +126,7 @@ public class Localization : IServiceType /// public void SetupWithFallbacks() { + this.DalamudLanguageCultureInfo = CultureInfo.InvariantCulture; this.LocalizationChanged?.Invoke(FallbackLangCode); Loc.SetupWithFallbacks(this.assembly); } @@ -124,6 +143,7 @@ public class Localization : IServiceType return; } + this.DalamudLanguageCultureInfo = GetCultureInfoFromLangCode(langCode); this.LocalizationChanged?.Invoke(langCode); try diff --git a/Dalamud/Logging/Internal/ModuleLog.cs b/Dalamud/Logging/Internal/ModuleLog.cs index c6c66e81a..1fe955294 100644 --- a/Dalamud/Logging/Internal/ModuleLog.cs +++ b/Dalamud/Logging/Internal/ModuleLog.cs @@ -1,6 +1,5 @@ -using System; - using Serilog; +using Serilog.Core; using Serilog.Events; namespace Dalamud.Logging.Internal; @@ -12,6 +11,10 @@ public class ModuleLog { private readonly string moduleName; private readonly ILogger moduleLogger; + + // FIXME (v9): Deprecate this class in favor of using contextualized ILoggers with proper formatting. + // We can keep this class around as a Serilog helper, but ModuleLog should no longer be a returned + // type, instead returning a (prepared) ILogger appropriately. /// /// Initializes a new instance of the class. @@ -20,10 +23,8 @@ public class ModuleLog /// The module name. public ModuleLog(string? moduleName) { - // FIXME: Should be namespaced better, e.g. `Dalamud.PluginLoader`, but that becomes a relatively large - // change. this.moduleName = moduleName ?? "DalamudInternal"; - this.moduleLogger = Log.ForContext("SourceContext", this.moduleName); + this.moduleLogger = Log.ForContext("Dalamud.ModuleName", this.moduleName); } /// @@ -31,7 +32,8 @@ public class ModuleLog /// /// The message template. /// Values to log. - public void Verbose(string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Verbose(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Verbose, messageTemplate, null, values); /// @@ -40,7 +42,8 @@ public class ModuleLog /// The exception that caused the error. /// The message template. /// Values to log. - public void Verbose(Exception exception, string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Verbose(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Verbose, messageTemplate, exception, values); /// @@ -48,7 +51,8 @@ public class ModuleLog /// /// The message template. /// Values to log. - public void Debug(string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Debug(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Debug, messageTemplate, null, values); /// @@ -57,7 +61,8 @@ public class ModuleLog /// The exception that caused the error. /// The message template. /// Values to log. - public void Debug(Exception exception, string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Debug(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Debug, messageTemplate, exception, values); /// @@ -65,7 +70,8 @@ public class ModuleLog /// /// The message template. /// Values to log. - public void Information(string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Information(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Information, messageTemplate, null, values); /// @@ -74,7 +80,8 @@ public class ModuleLog /// The exception that caused the error. /// The message template. /// Values to log. - public void Information(Exception exception, string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Information(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Information, messageTemplate, exception, values); /// @@ -82,7 +89,8 @@ public class ModuleLog /// /// The message template. /// Values to log. - public void Warning(string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Warning(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Warning, messageTemplate, null, values); /// @@ -91,7 +99,8 @@ public class ModuleLog /// The exception that caused the error. /// The message template. /// Values to log. - public void Warning(Exception exception, string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Warning(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Warning, messageTemplate, exception, values); /// @@ -99,7 +108,8 @@ public class ModuleLog /// /// The message template. /// Values to log. - public void Error(string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Error(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Error, messageTemplate, null, values); /// @@ -108,7 +118,8 @@ public class ModuleLog /// The exception that caused the error. /// The message template. /// Values to log. - public void Error(Exception? exception, string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Error(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Error, messageTemplate, exception, values); /// @@ -116,7 +127,8 @@ public class ModuleLog /// /// The message template. /// Values to log. - public void Fatal(string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Fatal(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Fatal, messageTemplate, null, values); /// @@ -125,10 +137,13 @@ public class ModuleLog /// The exception that caused the error. /// The message template. /// Values to log. - public void Fatal(Exception exception, string messageTemplate, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + public void Fatal(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Fatal, messageTemplate, exception, values); - private void WriteLog(LogEventLevel level, string messageTemplate, Exception? exception = null, params object[] values) + [MessageTemplateFormatMethod("messageTemplate")] + private void WriteLog( + LogEventLevel level, string messageTemplate, Exception? exception = null, params object?[] values) { // FIXME: Eventually, the `pluginName` tag should be removed from here and moved over to the actual log // formatter. diff --git a/Dalamud/Logging/Internal/TaskTracker.cs b/Dalamud/Logging/Internal/TaskTracker.cs index a8729893f..9ecabe6c7 100644 --- a/Dalamud/Logging/Internal/TaskTracker.cs +++ b/Dalamud/Logging/Internal/TaskTracker.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -6,6 +5,7 @@ using System.Reflection; using System.Threading.Tasks; using Dalamud.Game; +using Dalamud.Plugin.Services; namespace Dalamud.Logging.Internal; @@ -13,7 +13,7 @@ namespace Dalamud.Logging.Internal; /// Class responsible for tracking asynchronous tasks. /// [ServiceManager.EarlyLoadedService] -internal class TaskTracker : IDisposable, IServiceType +internal class TaskTracker : IInternalDisposableService { private static readonly ModuleLog Log = new("TT"); private static readonly List TrackedTasksInternal = new(); @@ -119,7 +119,7 @@ internal class TaskTracker : IDisposable, IServiceType } /// - public void Dispose() + void IInternalDisposableService.DisposeService() { this.scheduleAndStartHook?.Dispose(); @@ -141,7 +141,7 @@ internal class TaskTracker : IDisposable, IServiceType return true; } - private void FrameworkOnUpdate(Framework framework) + private void FrameworkOnUpdate(IFramework framework) { UpdateData(); } diff --git a/Dalamud/Logging/PluginLog.cs b/Dalamud/Logging/PluginLog.cs index acbd663e7..e3744c617 100644 --- a/Dalamud/Logging/PluginLog.cs +++ b/Dalamud/Logging/PluginLog.cs @@ -1,9 +1,8 @@ -using System; using System.Reflection; -using Dalamud.IoC; -using Dalamud.IoC.Internal; -using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; +using Dalamud.Utility; + using Serilog; using Serilog.Events; @@ -12,29 +11,15 @@ namespace Dalamud.Logging; /// /// Class offering various static methods to allow for logging in plugins. /// -[PluginInterface] -[InterfaceVersion("1.0")] -[ServiceManager.ScopedService] -public class PluginLog : IServiceType, IDisposable +/// +/// PluginLog has been obsoleted and replaced by the service. Developers are encouraged to +/// move over as soon as reasonably possible for performance reasons. +/// +[Obsolete("Static PluginLog will be removed in API 10. Developers should use IPluginLog.")] +[Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] +public static class PluginLog { - private readonly LocalPlugin plugin; - - /// - /// Initializes a new instance of the class. - /// Do not use this ctor, inject PluginLog instead. - /// - /// The plugin this service is scoped for. - internal PluginLog(LocalPlugin plugin) - { - this.plugin = plugin; - } - - /// - /// Gets or sets a prefix appended to log messages. - /// - public string? LogPrefix { get; set; } = null; - - #region Legacy static "Log" prefixed Serilog style methods + #region "Log" prefixed Serilog style methods /// /// Log a templated message to the in-game debug log. @@ -157,7 +142,7 @@ public class PluginLog : IServiceType, IDisposable #endregion - #region Legacy static Serilog style methods + #region Serilog style methods /// /// Log a templated verbose message to the in-game debug log. @@ -277,28 +262,9 @@ public class PluginLog : IServiceType, IDisposable public static void LogRaw(LogEventLevel level, Exception? exception, string messageTemplate, params object[] values) => WriteLog(Assembly.GetCallingAssembly().GetName().Name, level, messageTemplate, exception, values); - /// - void IDisposable.Dispose() - { - // ignored - } - - #region New instanced methods - - /// - /// Log some information. - /// - /// The message. - internal void Information(string message) - { - Serilog.Log.Information($"[{this.plugin.InternalName}] {this.LogPrefix} {message}"); - } - - #endregion - private static ILogger GetPluginLogger(string? pluginName) { - return Serilog.Log.ForContext("SourceContext", pluginName ?? string.Empty); + return Serilog.Log.ForContext("Dalamud.PluginName", pluginName ?? string.Empty); } private static void WriteLog(string? pluginName, LogEventLevel level, string messageTemplate, Exception? exception = null, params object[] values) @@ -314,24 +280,3 @@ public class PluginLog : IServiceType, IDisposable values); } } - -/// -/// Class offering logging services, for a specific type. -/// -/// The type to log for. -[PluginInterface] -[InterfaceVersion("1.0")] -[ServiceManager.ScopedService] -public class PluginLog : PluginLog -{ - /// - /// Initializes a new instance of the class. - /// Do not use this ctor, inject PluginLog instead. - /// - /// The plugin this service is scoped for. - internal PluginLog(LocalPlugin plugin) - : base(plugin) - { - this.LogPrefix = typeof(T).Name; - } -} diff --git a/Dalamud/Logging/ScopedPluginLogService.cs b/Dalamud/Logging/ScopedPluginLogService.cs new file mode 100644 index 000000000..0c044f2c2 --- /dev/null +++ b/Dalamud/Logging/ScopedPluginLogService.cs @@ -0,0 +1,132 @@ +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace Dalamud.Logging; + +/// +/// Implementation of . +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class ScopedPluginLogService : IServiceType, IPluginLog +{ + private readonly LocalPlugin localPlugin; + + private readonly LoggingLevelSwitch levelSwitch; + + /// + /// Initializes a new instance of the class. + /// + /// The plugin that owns this service. + internal ScopedPluginLogService(LocalPlugin localPlugin) + { + this.localPlugin = localPlugin; + + this.levelSwitch = new LoggingLevelSwitch(this.GetDefaultLevel()); + + var loggerConfiguration = new LoggerConfiguration() + .Enrich.WithProperty("Dalamud.PluginName", localPlugin.InternalName) + .MinimumLevel.ControlledBy(this.levelSwitch) + .WriteTo.Logger(Log.Logger); + + this.Logger = loggerConfiguration.CreateLogger(); + } + + /// + public LogEventLevel MinimumLogLevel + { + get => this.levelSwitch.MinimumLevel; + set => this.levelSwitch.MinimumLevel = value; + } + + /// + /// Gets a logger that may be exposed to plugins some day. + /// + public ILogger Logger { get; } + + /// + public void Fatal(string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Fatal, null, messageTemplate, values); + + /// + public void Fatal(Exception? exception, string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Fatal, exception, messageTemplate, values); + + /// + public void Error(string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Error, null, messageTemplate, values); + + /// + public void Error(Exception? exception, string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Error, exception, messageTemplate, values); + + /// + public void Warning(string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Warning, null, messageTemplate, values); + + /// + public void Warning(Exception? exception, string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Warning, exception, messageTemplate, values); + + /// + public void Information(string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Information, null, messageTemplate, values); + + /// + public void Information(Exception? exception, string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Information, exception, messageTemplate, values); + + /// + public void Info(string messageTemplate, params object[] values) => + this.Information(messageTemplate, values); + + /// + public void Info(Exception? exception, string messageTemplate, params object[] values) => + this.Information(exception, messageTemplate, values); + + /// + public void Debug(string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Debug, null, messageTemplate, values); + + /// + public void Debug(Exception? exception, string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Debug, exception, messageTemplate, values); + + /// + public void Verbose(string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Verbose, null, messageTemplate, values); + + /// + public void Verbose(Exception? exception, string messageTemplate, params object[] values) => + this.Write(LogEventLevel.Verbose, exception, messageTemplate, values); + + /// + public void Write(LogEventLevel level, Exception? exception, string messageTemplate, params object[] values) + { + this.Logger.Write( + level, + exception: exception, + messageTemplate: $"[{this.localPlugin.InternalName}] {messageTemplate}", + values); + } + + /// + /// Gets the default log level for this plugin. + /// + /// A log level. + private LogEventLevel GetDefaultLevel() + { + // TODO: Add some way to save log levels to a config. Or let plugins handle it? + + return this.localPlugin.IsDev ? LogEventLevel.Verbose : LogEventLevel.Debug; + } +} diff --git a/Dalamud/Memory/MemoryHelper.cs b/Dalamud/Memory/MemoryHelper.cs index 5b640f64c..09f45e2d3 100644 --- a/Dalamud/Memory/MemoryHelper.cs +++ b/Dalamud/Memory/MemoryHelper.cs @@ -1,15 +1,21 @@ -using System; +using System.Buffers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Memory.Exceptions; + using FFXIVClientStructs.FFXIV.Client.System.Memory; using FFXIVClientStructs.FFXIV.Client.System.String; +using Microsoft.Extensions.ObjectPool; + using static Dalamud.NativeFunctions; +using LPayloadType = Lumina.Text.Payloads.PayloadType; +using LSeString = Lumina.Text.SeString; + // Heavily inspired from Reloaded (https://github.com/Reloaded-Project/Reloaded.Memory) namespace Dalamud.Memory; @@ -19,6 +25,47 @@ namespace Dalamud.Memory; /// public static unsafe class MemoryHelper { + private static readonly ObjectPool StringBuilderPool = + ObjectPool.Create(new StringBuilderPooledObjectPolicy()); + + #region Cast + + /// Casts the given memory address as the reference to the live object. + /// The memory address. + /// The unmanaged type. + /// The reference to the live object. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ref T Cast(nint memoryAddress) where T : unmanaged => ref *(T*)memoryAddress; + + /// Casts the given memory address as the span of the live object(s). + /// The memory address. + /// The number of items. + /// The unmanaged type. + /// The span containing reference to the live object(s). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Span Cast(nint memoryAddress, int length) where T : unmanaged => + new((void*)memoryAddress, length); + + /// Casts the given memory address as the span of the live object(s), until it encounters a zero. + /// The memory address. + /// The maximum number of items. + /// The unmanaged type. + /// The span containing reference to the live object(s). + /// If is byte or char and is not + /// specified, consider using or + /// . + public static Span CastNullTerminated(nint memoryAddress, int maxLength = int.MaxValue) + where T : unmanaged, IEquatable + { + var typedPointer = (T*)memoryAddress; + var length = 0; + while (length < maxLength && !default(T).Equals(*typedPointer++)) + length++; + return new((void*)memoryAddress, length); + } + + #endregion + #region Read /// @@ -27,7 +74,9 @@ public static unsafe class MemoryHelper /// An individual struct type of a class with an explicit StructLayout.LayoutKind attribute. /// The memory address to read from. /// The read in struct. - public static T Read(IntPtr memoryAddress) where T : unmanaged + /// If you do not need to make a copy, use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T Read(nint memoryAddress) where T : unmanaged => Read(memoryAddress, false); /// @@ -37,12 +86,13 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// Set this to true to enable struct marshalling. /// The read in struct. - public static T Read(IntPtr memoryAddress, bool marshal) - { - return marshal - ? Marshal.PtrToStructure(memoryAddress) - : Unsafe.Read((void*)memoryAddress); - } + /// If you do not need to make a copy and is false, + /// use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T Read(nint memoryAddress, bool marshal) => + marshal + ? Marshal.PtrToStructure(memoryAddress) + : Unsafe.Read((void*)memoryAddress); /// /// Reads a byte array from a specified memory address. @@ -50,12 +100,9 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The amount of bytes to read starting from the memoryAddress. /// The read in byte array. - public static byte[] ReadRaw(IntPtr memoryAddress, int length) - { - var value = new byte[length]; - Marshal.Copy(memoryAddress, value, 0, value.Length); - return value; - } + /// If you do not need to make a copy, use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte[] ReadRaw(nint memoryAddress, int length) => Cast(memoryAddress, length).ToArray(); /// /// Reads a generic type array from a specified memory address. @@ -64,8 +111,10 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The amount of array items to read. /// The read in struct array. - public static T[] Read(IntPtr memoryAddress, int arrayLength) where T : unmanaged - => Read(memoryAddress, arrayLength, false); + /// If you do not need to make a copy, use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T[] Read(nint memoryAddress, int arrayLength) where T : unmanaged + => Cast(memoryAddress, arrayLength).ToArray(); /// /// Reads a generic type array from a specified memory address. @@ -75,16 +124,18 @@ public static unsafe class MemoryHelper /// The amount of array items to read. /// Set this to true to enable struct marshalling. /// The read in struct array. - public static T[] Read(IntPtr memoryAddress, int arrayLength, bool marshal) + /// If you do not need to make a copy and is false, + /// use instead. + public static T[] Read(nint memoryAddress, int arrayLength, bool marshal) { var structSize = SizeOf(marshal); var value = new T[arrayLength]; for (var i = 0; i < arrayLength; i++) { - var address = memoryAddress + (structSize * i); - Read(address, out T result, marshal); + Read(memoryAddress, out T result, marshal); value[i] = result; + memoryAddress += structSize; } return value; @@ -95,16 +146,10 @@ public static unsafe class MemoryHelper /// /// The memory address to read from. /// The read in byte array. - public static unsafe byte[] ReadRawNullTerminated(IntPtr memoryAddress) - { - var byteCount = 0; - while (*(byte*)(memoryAddress + byteCount) != 0x00) - { - byteCount++; - } - - return ReadRaw(memoryAddress, byteCount); - } + /// If you do not need to make a copy, use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte[] ReadRawNullTerminated(nint memoryAddress) => + MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)memoryAddress).ToArray(); #endregion @@ -116,7 +161,9 @@ public static unsafe class MemoryHelper /// An individual struct type of a class with an explicit StructLayout.LayoutKind attribute. /// The memory address to read from. /// Local variable to receive the read in struct. - public static void Read(IntPtr memoryAddress, out T value) where T : unmanaged + /// If you do not need to make a copy, use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Read(nint memoryAddress, out T value) where T : unmanaged => value = Read(memoryAddress); /// @@ -126,7 +173,10 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// Local variable to receive the read in struct. /// Set this to true to enable struct marshalling. - public static void Read(IntPtr memoryAddress, out T value, bool marshal) + /// If you do not need to make a copy and is false, + /// use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Read(nint memoryAddress, out T value, bool marshal) => value = Read(memoryAddress, marshal); /// @@ -135,7 +185,9 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The amount of bytes to read starting from the memoryAddress. /// Local variable to receive the read in bytes. - public static void ReadRaw(IntPtr memoryAddress, int length, out byte[] value) + /// If you do not need to make a copy, use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadRaw(nint memoryAddress, int length, out byte[] value) => value = ReadRaw(memoryAddress, length); /// @@ -145,7 +197,9 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The amount of array items to read. /// The read in struct array. - public static void Read(IntPtr memoryAddress, int arrayLength, out T[] value) where T : unmanaged + /// If you do not need to make a copy, use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Read(nint memoryAddress, int arrayLength, out T[] value) where T : unmanaged => value = Read(memoryAddress, arrayLength); /// @@ -156,7 +210,10 @@ public static unsafe class MemoryHelper /// The amount of array items to read. /// Set this to true to enable struct marshalling. /// The read in struct array. - public static void Read(IntPtr memoryAddress, int arrayLength, bool marshal, out T[] value) + /// If you do not need to make a copy and is false, + /// use instead. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Read(nint memoryAddress, int arrayLength, bool marshal, out T[] value) => value = Read(memoryAddress, arrayLength, marshal); #endregion @@ -164,63 +221,139 @@ public static unsafe class MemoryHelper #region ReadString /// - /// Read a UTF-8 encoded string from a specified memory address. + /// Compares if the given char span equals to the null-terminated string at . /// - /// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. - /// - /// The memory address to read from. - /// The read in string. - public static string ReadStringNullTerminated(IntPtr memoryAddress) - => ReadStringNullTerminated(memoryAddress, Encoding.UTF8); - - /// - /// Read a string with the given encoding from a specified memory address. - /// - /// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. - /// - /// The memory address to read from. - /// The encoding to use to decode the string. - /// The read in string. - public static string ReadStringNullTerminated(IntPtr memoryAddress, Encoding encoding) + /// The character span. + /// The address of null-terminated string. + /// The encoding of the null-terminated string. + /// The maximum length of the null-terminated string. + /// Whether they are equal. + public static bool EqualsZeroTerminatedString( + ReadOnlySpan charSpan, + nint memoryAddress, + Encoding? encoding = null, + int maxLength = int.MaxValue) { - var buffer = ReadRawNullTerminated(memoryAddress); - return encoding.GetString(buffer); + encoding ??= Encoding.UTF8; + maxLength = Math.Min(maxLength, charSpan.Length + 4); + + var pmem = ((byte*)memoryAddress)!; + var length = 0; + while (length < maxLength && pmem[length] != 0) + length++; + + var mem = new Span(pmem, length); + var memCharCount = encoding.GetCharCount(mem); + if (memCharCount != charSpan.Length) + return false; + + if (memCharCount < 1024) + { + Span chars = stackalloc char[memCharCount]; + encoding.GetChars(mem, chars); + return charSpan.SequenceEqual(chars); + } + else + { + var rented = ArrayPool.Shared.Rent(memCharCount); + var chars = rented.AsSpan(0, memCharCount); + encoding.GetChars(mem, chars); + var equals = charSpan.SequenceEqual(chars); + ArrayPool.Shared.Return(rented); + return equals; + } } /// /// Read a UTF-8 encoded string from a specified memory address. /// /// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to read from. - /// The maximum length of the string. /// The read in string. - public static string ReadString(IntPtr memoryAddress, int maxLength) - => ReadString(memoryAddress, Encoding.UTF8, maxLength); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ReadStringNullTerminated(nint memoryAddress) + => Encoding.UTF8.GetString(MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)memoryAddress)); /// /// Read a string with the given encoding from a specified memory address. /// /// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to read from. /// The encoding to use to decode the string. - /// The maximum length of the string. /// The read in string. - public static string ReadString(IntPtr memoryAddress, Encoding encoding, int maxLength) + public static string ReadStringNullTerminated(nint memoryAddress, Encoding encoding) + { + switch (encoding) + { + case UTF8Encoding: + case var _ when encoding.IsSingleByte: + return encoding.GetString(MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)memoryAddress)); + case UnicodeEncoding: + // Note that it may be in little or big endian, so using `new string(...)` is not always correct. + return encoding.GetString( + MemoryMarshal.Cast( + MemoryMarshal.CreateReadOnlySpanFromNullTerminated((char*)memoryAddress))); + case UTF32Encoding: + return encoding.GetString(MemoryMarshal.Cast(CastNullTerminated(memoryAddress))); + default: + // For correctness' sake; if there does not exist an encoding which will contain a (byte)0 for a + // non-null character, then this branch can be merged with UTF8Encoding one. + return encoding.GetString(ReadRawNullTerminated(memoryAddress)); + } + } + + /// + /// Read a UTF-8 encoded string from a specified memory address. + /// + /// + /// Attention! If this is an , use the applicable helper methods to decode. + /// + /// The memory address to read from. + /// The maximum number of bytes to read. + /// Note that this is NOT the maximum length of the returned string. + /// The read in string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ReadString(nint memoryAddress, int maxLength) + => Encoding.UTF8.GetString(CastNullTerminated(memoryAddress, maxLength)); + + /// + /// Read a string with the given encoding from a specified memory address. + /// + /// + /// Attention! If this is an , use the applicable helper methods to decode. + /// + /// The memory address to read from. + /// The encoding to use to decode the string. + /// The maximum number of bytes to read. + /// Note that this is NOT the maximum length of the returned string. + /// The read in string. + public static string ReadString(nint memoryAddress, Encoding encoding, int maxLength) { if (maxLength <= 0) return string.Empty; - ReadRaw(memoryAddress, maxLength, out var buffer); - - var data = encoding.GetString(buffer); - var eosPos = data.IndexOf('\0'); - return eosPos >= 0 ? data.Substring(0, eosPos) : data; + switch (encoding) + { + case UTF8Encoding: + case var _ when encoding.IsSingleByte: + return encoding.GetString(CastNullTerminated(memoryAddress, maxLength)); + case UnicodeEncoding: + return encoding.GetString( + MemoryMarshal.Cast(CastNullTerminated(memoryAddress, maxLength / 2))); + case UTF32Encoding: + return encoding.GetString( + MemoryMarshal.Cast(CastNullTerminated(memoryAddress, maxLength / 4))); + default: + // For correctness' sake; if there does not exist an encoding which will contain a (byte)0 for a + // non-null character, then this branch can be merged with UTF8Encoding one. + var data = encoding.GetString(Cast(memoryAddress, maxLength)); + var eosPos = data.IndexOf('\0'); + return eosPos >= 0 ? data[..eosPos] : data; + } } /// @@ -228,11 +361,9 @@ public static unsafe class MemoryHelper /// /// The memory address to read from. /// The read in string. - public static SeString ReadSeStringNullTerminated(IntPtr memoryAddress) - { - var buffer = ReadRawNullTerminated(memoryAddress); - return SeString.Parse(buffer); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static SeString ReadSeStringNullTerminated(nint memoryAddress) => + SeString.Parse(MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)memoryAddress)); /// /// Read an SeString from a specified memory address. @@ -240,40 +371,165 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The maximum length of the string. /// The read in string. - public static SeString ReadSeString(IntPtr memoryAddress, int maxLength) - { - ReadRaw(memoryAddress, maxLength, out var buffer); - - var eos = Array.IndexOf(buffer, (byte)0); - if (eos < 0) - { - return SeString.Parse(buffer); - } - else - { - var newBuffer = new byte[eos]; - Buffer.BlockCopy(buffer, 0, newBuffer, 0, eos); - return SeString.Parse(newBuffer); - } - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static SeString ReadSeString(nint memoryAddress, int maxLength) => + // Note that a valid SeString never contains a null character, other than for the sequence terminator purpose. + SeString.Parse(CastNullTerminated(memoryAddress, maxLength)); /// /// Read an SeString from a specified Utf8String structure. /// /// The memory address to read from. /// The read in string. - public static unsafe SeString ReadSeString(Utf8String* utf8String) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static SeString ReadSeString(Utf8String* utf8String) => + utf8String == null ? string.Empty : SeString.Parse(utf8String->AsSpan()); + + /// + /// Reads an SeString from a specified memory address, and extracts the outermost string.
+ /// If the SeString is malformed, behavior is undefined. + ///
+ /// Whether the SeString contained a non-represented payload. + /// The memory address to read from. + /// The maximum length of the string. + /// Stop reading on encountering the first non-represented payload. + /// What payloads are represented via this function may change. + /// Replacement for non-represented payloads. + /// The read in string. + public static string ReadSeStringAsString( + out bool containsNonRepresentedPayload, + nint memoryAddress, + int maxLength = int.MaxValue, + bool stopOnFirstNonRepresentedPayload = false, + string nonRepresentedPayloadReplacement = "*") { - if (utf8String == null) - return string.Empty; + var sb = StringBuilderPool.Get(); + sb.EnsureCapacity(maxLength = CastNullTerminated(memoryAddress, maxLength).Length); - var ptr = utf8String->StringPtr; - if (ptr == null) - return string.Empty; + // 1 utf-8 codepoint can spill up to 2 characters. + Span tmp = stackalloc char[2]; - var len = Math.Max(utf8String->BufUsed, utf8String->StringLength); + var pin = (byte*)memoryAddress; + containsNonRepresentedPayload = false; + while (*pin != 0 && maxLength > 0) + { + if (*pin != LSeString.StartByte) + { + var len = *pin switch + { + < 0x80 => 1, + >= 0b11000000 and <= 0b11011111 => 2, + >= 0b11100000 and <= 0b11101111 => 3, + >= 0b11110000 and <= 0b11110111 => 4, + _ => 0, + }; + if (len == 0 || len > maxLength) + break; - return ReadSeString((IntPtr)ptr, (int)len); + var numChars = Encoding.UTF8.GetChars(new(pin, len), tmp); + sb.Append(tmp[..numChars]); + pin += len; + maxLength -= len; + continue; + } + + // Start byte + ++pin; + --maxLength; + + // Payload type + var payloadType = (LPayloadType)(*pin++); + + // Payload length + if (!ReadIntExpression(ref pin, ref maxLength, out var expressionLength)) + break; + if (expressionLength > maxLength) + break; + pin += expressionLength; + maxLength -= unchecked((int)expressionLength); + + // End byte + if (*pin++ != LSeString.EndByte) + break; + --maxLength; + + switch (payloadType) + { + case LPayloadType.NewLine: + sb.AppendLine(); + break; + case LPayloadType.Hyphen: + sb.Append('–'); + break; + case LPayloadType.SoftHyphen: + sb.Append('\u00AD'); + break; + default: + sb.Append(nonRepresentedPayloadReplacement); + containsNonRepresentedPayload = true; + if (stopOnFirstNonRepresentedPayload) + maxLength = 0; + break; + } + } + + var res = sb.ToString(); + StringBuilderPool.Return(sb); + return res; + + static bool ReadIntExpression(ref byte* p, ref int maxLength, out uint value) + { + if (maxLength <= 0) + { + value = 0; + return false; + } + + var typeByte = *p++; + --maxLength; + + switch (typeByte) + { + case > 0 and < 0xD0: + value = (uint)typeByte - 1; + return true; + case >= 0xF0 and <= 0xFE: + ++typeByte; + value = 0u; + if ((typeByte & 8) != 0) + { + if (maxLength <= 0 || *p == 0) + return false; + value |= (uint)*p++ << 24; + } + + if ((typeByte & 4) != 0) + { + if (maxLength <= 0 || *p == 0) + return false; + value |= (uint)*p++ << 16; + } + + if ((typeByte & 2) != 0) + { + if (maxLength <= 0 || *p == 0) + return false; + value |= (uint)*p++ << 8; + } + + if ((typeByte & 1) != 0) + { + if (maxLength <= 0 || *p == 0) + return false; + value |= *p++; + } + + return true; + default: + value = 0; + return false; + } + } } #endregion @@ -284,48 +540,52 @@ public static unsafe class MemoryHelper /// Read a UTF-8 encoded string from a specified memory address. ///
/// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to read from. /// The read in string. - public static void ReadStringNullTerminated(IntPtr memoryAddress, out string value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadStringNullTerminated(nint memoryAddress, out string value) => value = ReadStringNullTerminated(memoryAddress); /// /// Read a string with the given encoding from a specified memory address. /// /// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to read from. /// The encoding to use to decode the string. /// The read in string. - public static void ReadStringNullTerminated(IntPtr memoryAddress, Encoding encoding, out string value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadStringNullTerminated(nint memoryAddress, Encoding encoding, out string value) => value = ReadStringNullTerminated(memoryAddress, encoding); /// /// Read a UTF-8 encoded string from a specified memory address. /// /// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to read from. /// The read in string. /// The maximum length of the string. - public static void ReadString(IntPtr memoryAddress, out string value, int maxLength) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadString(nint memoryAddress, out string value, int maxLength) => value = ReadString(memoryAddress, maxLength); /// /// Read a string with the given encoding from a specified memory address. /// /// - /// Attention! If this is an SeString, use the to decode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to read from. /// The encoding to use to decode the string. /// The maximum length of the string. /// The read in string. - public static void ReadString(IntPtr memoryAddress, Encoding encoding, int maxLength, out string value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadString(nint memoryAddress, Encoding encoding, int maxLength, out string value) => value = ReadString(memoryAddress, encoding, maxLength); /// @@ -333,7 +593,8 @@ public static unsafe class MemoryHelper /// /// The memory address to read from. /// The read in SeString. - public static void ReadSeStringNullTerminated(IntPtr memoryAddress, out SeString value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadSeStringNullTerminated(nint memoryAddress, out SeString value) => value = ReadSeStringNullTerminated(memoryAddress); /// @@ -342,7 +603,8 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The maximum length of the string. /// The read in SeString. - public static void ReadSeString(IntPtr memoryAddress, int maxLength, out SeString value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadSeString(nint memoryAddress, int maxLength, out SeString value) => value = ReadSeString(memoryAddress, maxLength); /// @@ -350,6 +612,7 @@ public static unsafe class MemoryHelper /// /// The memory address to read from. /// The read in string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static unsafe void ReadSeString(Utf8String* utf8String, out SeString value) => value = ReadSeString(utf8String); @@ -363,7 +626,8 @@ public static unsafe class MemoryHelper /// An individual struct type of a class with an explicit StructLayout.LayoutKind attribute. /// The memory address to read from. /// The item to write to the address. - public static void Write(IntPtr memoryAddress, T item) where T : unmanaged + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Write(nint memoryAddress, T item) where T : unmanaged => Write(memoryAddress, item, false); /// @@ -373,7 +637,7 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The item to write to the address. /// Set this to true to enable struct marshalling. - public static void Write(IntPtr memoryAddress, T item, bool marshal) + public static void Write(nint memoryAddress, T item, bool marshal) { if (marshal) Marshal.StructureToPtr(item, memoryAddress, false); @@ -386,10 +650,8 @@ public static unsafe class MemoryHelper /// /// The memory address to read from. /// The bytes to write to memoryAddress. - public static void WriteRaw(IntPtr memoryAddress, byte[] data) - { - Marshal.Copy(data, 0, memoryAddress, data.Length); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteRaw(nint memoryAddress, byte[] data) => Marshal.Copy(data, 0, memoryAddress, data.Length); /// /// Writes a generic type array to a specified memory address. @@ -397,7 +659,8 @@ public static unsafe class MemoryHelper /// An individual struct type of a class with an explicit StructLayout.LayoutKind attribute. /// The memory address to write to. /// The array of items to write to the address. - public static void Write(IntPtr memoryAddress, T[] items) where T : unmanaged + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Write(nint memoryAddress, T[] items) where T : unmanaged => Write(memoryAddress, items, false); /// @@ -407,7 +670,8 @@ public static unsafe class MemoryHelper /// The memory address to write to. /// The array of items to write to the address. /// Set this to true to enable struct marshalling. - public static void Write(IntPtr memoryAddress, T[] items, bool marshal) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Write(nint memoryAddress, T[] items, bool marshal) { var structSize = SizeOf(marshal); @@ -426,30 +690,29 @@ public static unsafe class MemoryHelper /// Write a UTF-8 encoded string to a specified memory address. /// /// - /// Attention! If this is an SeString, use the to encode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to write to. /// The string to write. - public static void WriteString(IntPtr memoryAddress, string value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteString(nint memoryAddress, string? value) => WriteString(memoryAddress, value, Encoding.UTF8); /// /// Write a string with the given encoding to a specified memory address. /// /// - /// Attention! If this is an SeString, use the to encode or the applicable helper method. + /// Attention! If this is an , use the applicable helper methods to decode. /// /// The memory address to write to. /// The string to write. /// The encoding to use. - public static void WriteString(IntPtr memoryAddress, string value, Encoding encoding) + public static void WriteString(nint memoryAddress, string? value, Encoding encoding) { - if (string.IsNullOrEmpty(value)) - return; - - var bytes = encoding.GetBytes(value + '\0'); - - WriteRaw(memoryAddress, bytes); + var ptr = 0; + if (value is not null) + ptr = encoding.GetBytes(value, Cast(memoryAddress, encoding.GetMaxByteCount(value.Length))); + encoding.GetBytes("\0", Cast(memoryAddress + ptr, 4)); } /// @@ -457,7 +720,8 @@ public static unsafe class MemoryHelper /// /// The memory address to write to. /// The SeString to write. - public static void WriteSeString(IntPtr memoryAddress, SeString value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteSeString(nint memoryAddress, SeString? value) { if (value is null) return; @@ -475,15 +739,16 @@ public static unsafe class MemoryHelper /// /// Amount of bytes to be allocated. /// Address to the newly allocated memory. - public static IntPtr Allocate(int length) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static nint Allocate(int length) { var address = VirtualAlloc( - IntPtr.Zero, - (UIntPtr)length, + nint.Zero, + (nuint)length, AllocationType.Commit | AllocationType.Reserve, MemoryProtection.ExecuteReadWrite); - if (address == IntPtr.Zero) + if (address == nint.Zero) throw new MemoryAllocationException($"Unable to allocate {length} bytes."); return address; @@ -495,7 +760,8 @@ public static unsafe class MemoryHelper /// /// Amount of bytes to be allocated. /// Address to the newly allocated memory. - public static void Allocate(int length, out IntPtr memoryAddress) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Allocate(int length, out nint memoryAddress) => memoryAddress = Allocate(length); /// @@ -503,9 +769,10 @@ public static unsafe class MemoryHelper /// /// The address of the memory to free. /// True if the operation is successful. - public static bool Free(IntPtr memoryAddress) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool Free(nint memoryAddress) { - return VirtualFree(memoryAddress, UIntPtr.Zero, AllocationType.Release); + return VirtualFree(memoryAddress, nuint.Zero, AllocationType.Release); } /// @@ -515,9 +782,9 @@ public static unsafe class MemoryHelper /// The region size for which to change permissions for. /// The new permissions to set. /// The old page permissions. - public static MemoryProtection ChangePermission(IntPtr memoryAddress, int length, MemoryProtection newPermissions) + public static MemoryProtection ChangePermission(nint memoryAddress, int length, MemoryProtection newPermissions) { - var result = VirtualProtect(memoryAddress, (UIntPtr)length, newPermissions, out var oldPermissions); + var result = VirtualProtect(memoryAddress, (nuint)length, newPermissions, out var oldPermissions); if (!result) throw new MemoryPermissionException($"Unable to change permissions at 0x{memoryAddress.ToInt64():X} of length {length} and permission {newPermissions} (result={result})"); @@ -536,7 +803,9 @@ public static unsafe class MemoryHelper /// The region size for which to change permissions for. /// The new permissions to set. /// The old page permissions. - public static void ChangePermission(IntPtr memoryAddress, int length, MemoryProtection newPermissions, out MemoryProtection oldPermissions) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ChangePermission( + nint memoryAddress, int length, MemoryProtection newPermissions, out MemoryProtection oldPermissions) => oldPermissions = ChangePermission(memoryAddress, length, newPermissions); /// @@ -548,7 +817,9 @@ public static unsafe class MemoryHelper /// The new permissions to set. /// Set to true to calculate the size of the struct after marshalling instead of before. /// The old page permissions. - public static MemoryProtection ChangePermission(IntPtr memoryAddress, ref T baseElement, MemoryProtection newPermissions, bool marshal) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static MemoryProtection ChangePermission( + nint memoryAddress, ref T baseElement, MemoryProtection newPermissions, bool marshal) => ChangePermission(memoryAddress, SizeOf(marshal), newPermissions); /// @@ -558,7 +829,8 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The amount of bytes to read starting from the memoryAddress. /// The read in bytes. - public static byte[] ReadProcessMemory(IntPtr memoryAddress, int length) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static byte[] ReadProcessMemory(nint memoryAddress, int length) { var value = new byte[length]; ReadProcessMemory(memoryAddress, ref value); @@ -572,7 +844,8 @@ public static unsafe class MemoryHelper /// The memory address to read from. /// The amount of bytes to read starting from the memoryAddress. /// The read in bytes. - public static void ReadProcessMemory(IntPtr memoryAddress, int length, out byte[] value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ReadProcessMemory(nint memoryAddress, int length, out byte[] value) => value = ReadProcessMemory(memoryAddress, length); /// @@ -581,12 +854,12 @@ public static unsafe class MemoryHelper /// /// The memory address to read from. /// The read in bytes. - public static void ReadProcessMemory(IntPtr memoryAddress, ref byte[] value) + public static void ReadProcessMemory(nint memoryAddress, ref byte[] value) { unchecked { var length = value.Length; - var result = NativeFunctions.ReadProcessMemory((IntPtr)0xFFFFFFFF, memoryAddress, value, length, out _); + var result = NativeFunctions.ReadProcessMemory((nint)0xFFFFFFFF, memoryAddress, value, length, out _); if (!result) throw new MemoryReadException($"Unable to read memory at 0x{memoryAddress.ToInt64():X} of length {length} (result={result})"); @@ -603,12 +876,12 @@ public static unsafe class MemoryHelper /// /// The memory address to write to. /// The bytes to write to memoryAddress. - public static void WriteProcessMemory(IntPtr memoryAddress, byte[] data) + public static void WriteProcessMemory(nint memoryAddress, byte[] data) { unchecked { var length = data.Length; - var result = NativeFunctions.WriteProcessMemory((IntPtr)0xFFFFFFFF, memoryAddress, data, length, out _); + var result = NativeFunctions.WriteProcessMemory((nint)0xFFFFFFFF, memoryAddress, data, length, out _); if (!result) throw new MemoryWriteException($"Unable to write memory at 0x{memoryAddress.ToInt64():X} of length {length} (result={result})"); @@ -628,6 +901,7 @@ public static unsafe class MemoryHelper /// /// An individual struct type of a class with an explicit StructLayout.LayoutKind attribute. /// The size of the primitive or struct. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int SizeOf() => SizeOf(false); @@ -637,6 +911,7 @@ public static unsafe class MemoryHelper /// An individual struct type of a class with an explicit StructLayout.LayoutKind attribute. /// If set to true; will return the size of an element after marshalling. /// The size of the primitive or struct. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int SizeOf(bool marshal) => marshal ? Marshal.SizeOf() : Unsafe.SizeOf(); @@ -646,6 +921,7 @@ public static unsafe class MemoryHelper /// An individual struct type of a class with an explicit StructLayout.LayoutKind attribute. /// The number of array elements present. /// The size of the primitive or struct array. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int SizeOf(int elementCount) where T : unmanaged => SizeOf() * elementCount; @@ -656,6 +932,7 @@ public static unsafe class MemoryHelper /// The number of array elements present. /// If set to true; will return the size of an element after marshalling. /// The size of the primitive or struct array. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int SizeOf(int elementCount, bool marshal) => SizeOf(marshal) * elementCount; @@ -669,9 +946,10 @@ public static unsafe class MemoryHelper /// Amount of bytes to allocate. /// The alignment of the allocation. /// Pointer to the allocated region. - public static IntPtr GameAllocateUi(ulong size, ulong alignment = 0) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static nint GameAllocateUi(ulong size, ulong alignment = 0) { - return new IntPtr(IMemorySpace.GetUISpace()->Malloc(size, alignment)); + return new nint(IMemorySpace.GetUISpace()->Malloc(size, alignment)); } /// @@ -680,9 +958,10 @@ public static unsafe class MemoryHelper /// Amount of bytes to allocate. /// The alignment of the allocation. /// Pointer to the allocated region. - public static IntPtr GameAllocateDefault(ulong size, ulong alignment = 0) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static nint GameAllocateDefault(ulong size, ulong alignment = 0) { - return new IntPtr(IMemorySpace.GetDefaultSpace()->Malloc(size, alignment)); + return new nint(IMemorySpace.GetDefaultSpace()->Malloc(size, alignment)); } /// @@ -691,9 +970,10 @@ public static unsafe class MemoryHelper /// Amount of bytes to allocate. /// The alignment of the allocation. /// Pointer to the allocated region. - public static IntPtr GameAllocateAnimation(ulong size, ulong alignment = 0) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static nint GameAllocateAnimation(ulong size, ulong alignment = 0) { - return new IntPtr(IMemorySpace.GetAnimationSpace()->Malloc(size, alignment)); + return new nint(IMemorySpace.GetAnimationSpace()->Malloc(size, alignment)); } /// @@ -702,9 +982,10 @@ public static unsafe class MemoryHelper /// Amount of bytes to allocate. /// The alignment of the allocation. /// Pointer to the allocated region. - public static IntPtr GameAllocateApricot(ulong size, ulong alignment = 0) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static nint GameAllocateApricot(ulong size, ulong alignment = 0) { - return new IntPtr(IMemorySpace.GetApricotSpace()->Malloc(size, alignment)); + return new nint(IMemorySpace.GetApricotSpace()->Malloc(size, alignment)); } /// @@ -713,9 +994,10 @@ public static unsafe class MemoryHelper /// Amount of bytes to allocate. /// The alignment of the allocation. /// Pointer to the allocated region. - public static IntPtr GameAllocateSound(ulong size, ulong alignment = 0) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static nint GameAllocateSound(ulong size, ulong alignment = 0) { - return new IntPtr(IMemorySpace.GetSoundSpace()->Malloc(size, alignment)); + return new nint(IMemorySpace.GetSoundSpace()->Malloc(size, alignment)); } /// @@ -724,15 +1006,15 @@ public static unsafe class MemoryHelper /// The memory you are freeing must be allocated with game allocators. /// Position at which the memory to be freed is located. /// Amount of bytes to free. - public static void GameFree(ref IntPtr ptr, ulong size) + public static void GameFree(ref nint ptr, ulong size) { - if (ptr == IntPtr.Zero) + if (ptr == nint.Zero) { return; } IMemorySpace.Free((void*)ptr, size); - ptr = IntPtr.Zero; + ptr = nint.Zero; } #endregion diff --git a/Dalamud/NativeMethods.txt b/Dalamud/NativeMethods.txt new file mode 100644 index 000000000..18143e1af --- /dev/null +++ b/Dalamud/NativeMethods.txt @@ -0,0 +1,5 @@ +CreateFile +FILE_ACCESS_RIGHTS +MoveFileEx +FlushFileBuffers +WriteFile diff --git a/Dalamud/Networking/Http/HappyHttpClient.cs b/Dalamud/Networking/Http/HappyHttpClient.cs index 8459f1453..23c6e3899 100644 --- a/Dalamud/Networking/Http/HappyHttpClient.cs +++ b/Dalamud/Networking/Http/HappyHttpClient.cs @@ -1,6 +1,9 @@ using System; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; + +using Dalamud.Utility; namespace Dalamud.Networking.Http; @@ -9,7 +12,7 @@ namespace Dalamud.Networking.Http; /// awareness. /// [ServiceManager.BlockingEarlyLoadedService] -internal class HappyHttpClient : IDisposable, IServiceType +internal class HappyHttpClient : IInternalDisposableService { /// /// Initializes a new instance of the class. @@ -25,7 +28,16 @@ internal class HappyHttpClient : IDisposable, IServiceType { AutomaticDecompression = DecompressionMethods.All, ConnectCallback = this.SharedHappyEyeballsCallback.ConnectCallback, - }); + }) + { + DefaultRequestHeaders = + { + UserAgent = + { + new ProductInfoHeaderValue("Dalamud", Util.AssemblyVersion), + }, + }, + }; } /// @@ -46,7 +58,7 @@ internal class HappyHttpClient : IDisposable, IServiceType public HappyEyeballsCallback SharedHappyEyeballsCallback { get; } /// - void IDisposable.Dispose() + void IInternalDisposableService.DisposeService() { this.SharedHttpClient.Dispose(); this.SharedHappyEyeballsCallback.Dispose(); diff --git a/Dalamud/Plugin/DalamudPluginInterface.cs b/Dalamud/Plugin/DalamudPluginInterface.cs index 2b58c21cc..135cf89ea 100644 --- a/Dalamud/Plugin/DalamudPluginInterface.cs +++ b/Dalamud/Plugin/DalamudPluginInterface.cs @@ -52,11 +52,11 @@ public sealed class DalamudPluginInterface : IDisposable var dataManager = Service.Get(); var localization = Service.Get(); - this.UiBuilder = new UiBuilder(plugin.Name); + this.UiBuilder = new UiBuilder(plugin.Name, plugin); this.configs = Service.Get().PluginConfigs; this.Reason = reason; - this.SourceRepository = this.IsDev ? LocalPluginManifest.FlagDevPlugin : plugin.Manifest.InstalledFromUrl; + this.SourceRepository = this.IsDev ? SpecialPluginSource.DevPlugin : plugin.Manifest.InstalledFromUrl; this.IsTesting = plugin.IsTesting; this.LoadTime = DateTime.Now; @@ -118,8 +118,8 @@ public sealed class DalamudPluginInterface : IDisposable /// Gets the repository from which this plugin was installed. /// /// If a plugin was installed from the official/main repository, this will return the value of - /// . Developer plugins will return the value of - /// . + /// . Developer plugins will return the value of + /// . /// public string SourceRepository { get; } @@ -206,18 +206,6 @@ public sealed class DalamudPluginInterface : IDisposable /// public XivChatType GeneralChatType { get; private set; } - /// - /// Gets a list of installed plugin names. - /// - [Obsolete($"This property is obsolete. Use {nameof(InstalledPlugins)} instead.")] - public List PluginNames => Service.Get().InstalledPlugins.Select(p => p.Manifest.Name).ToList(); - - /// - /// Gets a list of installed plugin internal names. - /// - [Obsolete($"This property is obsolete. Use {nameof(InstalledPlugins)} instead.")] - public List PluginInternalNames => Service.Get().InstalledPlugins.Select(p => p.Manifest.InternalName).ToList(); - /// /// Gets a list of installed plugins along with their current state. /// @@ -235,7 +223,7 @@ public sealed class DalamudPluginInterface : IDisposable return false; } - dalamudInterface.OpenPluginInstallerPluginInstalled(); + dalamudInterface.OpenPluginInstallerTo(PluginInstallerWindow.PluginInstallerOpenKind.InstalledPlugins); dalamudInterface.SetPluginInstallerSearchText(this.plugin.InternalName); return true; @@ -355,7 +343,7 @@ public sealed class DalamudPluginInterface : IDisposable if (currentConfig == null) return; - this.configs.Save(currentConfig, this.plugin.InternalName); + this.configs.Save(currentConfig, this.plugin.InternalName, this.plugin.Manifest.WorkingPluginId); } /// @@ -382,7 +370,7 @@ public sealed class DalamudPluginInterface : IDisposable } // this shouldn't be a thing, I think, but just in case - return this.configs.Load(this.plugin.InternalName); + return this.configs.Load(this.plugin.InternalName, this.plugin.Manifest.WorkingPluginId); } /// @@ -464,26 +452,28 @@ public sealed class DalamudPluginInterface : IDisposable #endregion - /// - /// Unregister your plugin and dispose all references. - /// + /// void IDisposable.Dispose() { - this.UiBuilder.ExplicitDispose(); - Service.Get().RemoveChatLinkHandler(this.plugin.InternalName); - Service.Get().LocalizationChanged -= this.OnLocalizationChanged; - Service.Get().DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; } - /// - /// Obsolete implicit dispose implementation. Should not be used. - /// - [Obsolete("Do not dispose \"DalamudPluginInterface\".", true)] + /// This function will do nothing. Dalamud will dispose this object on plugin unload. + [Obsolete("This function will do nothing. Dalamud will dispose this object on plugin unload.", true)] public void Dispose() { // ignored } + /// Unregister the plugin and dispose all references. + /// Dalamud internal use only. + internal void DisposeInternal() + { + Service.Get().RemoveChatLinkHandler(this.plugin.InternalName); + Service.Get().LocalizationChanged -= this.OnLocalizationChanged; + Service.Get().DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; + this.UiBuilder.DisposeInternal(); + } + /// /// Dispatch the active plugins changed event. /// diff --git a/Dalamud/Plugin/IDalamudPlugin.cs b/Dalamud/Plugin/IDalamudPlugin.cs index c752df3d6..b48d55d1c 100644 --- a/Dalamud/Plugin/IDalamudPlugin.cs +++ b/Dalamud/Plugin/IDalamudPlugin.cs @@ -7,8 +7,4 @@ namespace Dalamud.Plugin; /// public interface IDalamudPlugin : IDisposable { - /// - /// Gets the name of the plugin. - /// - string Name { get; } } diff --git a/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs b/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs index 851e5be33..1119a8c4e 100644 --- a/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs +++ b/Dalamud/Plugin/Internal/Exceptions/BannedPluginException.cs @@ -3,19 +3,14 @@ namespace Dalamud.Plugin.Internal.Exceptions; /// /// This represents a banned plugin that attempted an operation. /// -internal class BannedPluginException : PluginException +internal class BannedPluginException : PluginPreconditionFailedException { /// /// Initializes a new instance of the class. /// /// The message describing the invalid operation. public BannedPluginException(string message) + : base(message) { - this.Message = message; } - - /// - /// Gets the message describing the invalid operation. - /// - public override string Message { get; } } diff --git a/Dalamud/Plugin/Internal/Exceptions/PluginPreconditionFailedException.cs b/Dalamud/Plugin/Internal/Exceptions/PluginPreconditionFailedException.cs new file mode 100644 index 000000000..c1bb58d0d --- /dev/null +++ b/Dalamud/Plugin/Internal/Exceptions/PluginPreconditionFailedException.cs @@ -0,0 +1,16 @@ +namespace Dalamud.Plugin.Internal.Exceptions; + +/// +/// An exception to be thrown when policy blocks a plugin from loading. +/// +internal class PluginPreconditionFailedException : InvalidPluginOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The message to associate with this exception. + public PluginPreconditionFailedException(string message) + : base(message) + { + } +} diff --git a/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs b/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs index b7a2ffe2e..1a6830a3a 100644 --- a/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs +++ b/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs @@ -131,9 +131,16 @@ internal class AssemblyLoadContextBuilder /// or the default app context. /// /// The name of the assembly. + /// Pull assmeblies recursively. /// The builder. - public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName) + public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName, bool recursive) { + if (!recursive) + { + this.defaultAssemblies.Add(assemblyName.Name); + return this; + } + var names = new Queue(new[] { assemblyName }); while (names.TryDequeue(out var name)) diff --git a/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs b/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs index d3fcdc99e..0b2150069 100644 --- a/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs +++ b/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs @@ -46,7 +46,7 @@ internal class LoaderConfig /// Gets a list of assemblies which should be unified between the host and the plugin. /// /// what-are-shared-types - public ICollection SharedAssemblies { get; } = new List(); + public ICollection<(AssemblyName Name, bool Recursive)> SharedAssemblies { get; } = new List<(AssemblyName Name, bool Recursive)>(); /// /// Gets or sets a value indicating whether attempt to unify all types from a plugin with the host. diff --git a/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs b/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs index 4bb326ce4..e0629217a 100644 --- a/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs +++ b/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs @@ -194,7 +194,18 @@ internal class ManagedLoadContext : AssemblyLoadContext } } - return null; + // https://learn.microsoft.com/en-us/dotnet/core/dependency-loading/loading-managed#algorithm + // > These assemblies are loaded (load-by-name) as needed by the runtime. + // For load-by-name assembiles, the following will happen in order: + // (1) this.Load will be called. + // (2) AssemblyLoadContext.Default's cache will be referred for lookup. + // (3) Default probing will be done from PLATFORM_RESOURCE_ROOTS and APP_PATHS. + // https://learn.microsoft.com/en-us/dotnet/core/dependency-loading/default-probing#managed-assembly-default-probing + // > TRUSTED_PLATFORM_ASSEMBLIES: List of platform and application assembly file paths. + // > APP_PATHS: is not populated by default and is omitted for most applications. + // If we return null here, if the assembly has not been already loaded, the resolution will fail. + // Therefore as the final attempt, we try loading from the default load context. + return this.defaultLoadContext.LoadFromAssemblyName(assemblyName); } /// diff --git a/Dalamud/Plugin/Internal/Loader/PluginLoader.cs b/Dalamud/Plugin/Internal/Loader/PluginLoader.cs index 5c03c32b8..63b47cf17 100644 --- a/Dalamud/Plugin/Internal/Loader/PluginLoader.cs +++ b/Dalamud/Plugin/Internal/Loader/PluginLoader.cs @@ -1,7 +1,7 @@ // Copyright (c) Nate McMaster, Dalamud team. // Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. -using System; +using System.IO; using System.Reflection; using System.Runtime.Loader; @@ -146,11 +146,15 @@ internal class PluginLoader : IDisposable builder.ShadowCopyNativeLibraries(); } - foreach (var assemblyName in config.SharedAssemblies) + foreach (var (assemblyName, recursive) in config.SharedAssemblies) { - builder.PreferDefaultLoadContextAssembly(assemblyName); + builder.PreferDefaultLoadContextAssembly(assemblyName, recursive); } + // Note: not adding Dalamud path here as a probing path. + // It will be dealt as the last resort from ManagedLoadContext.Load. + // See there for more details. + return builder; } diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index 58e122c3e..b815ac036 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -1,10 +1,11 @@ -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -19,6 +20,8 @@ using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.Windows.PluginInstaller; +using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Networking.Http; @@ -27,6 +30,7 @@ using Dalamud.Plugin.Internal.Profiles; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types.Manifest; using Dalamud.Plugin.Ipc.Internal; +using Dalamud.Support; using Dalamud.Utility; using Dalamud.Utility.Timing; using Newtonsoft.Json; @@ -37,7 +41,7 @@ namespace Dalamud.Plugin.Internal; /// Class responsible for loading and unloading plugins. /// NOTE: ALL plugin exposed services are marked as dependencies for PluginManager in Service{T}. /// -[ServiceManager.EarlyLoadedService] +[ServiceManager.BlockingEarlyLoadedService] #pragma warning disable SA1015 // DalamudTextureWrap registers textures to dispose with IM @@ -51,13 +55,8 @@ namespace Dalamud.Plugin.Internal; [InherentDependency] #pragma warning restore SA1015 -internal partial class PluginManager : IDisposable, IServiceType +internal partial class PluginManager : IInternalDisposableService { - /// - /// The current Dalamud API level, used to handle breaking changes. Only plugins with this level will be loaded. - /// - public const int DalamudApiLevel = 8; - /// /// Default time to wait between plugin unload and plugin assembly unload. /// @@ -79,7 +78,7 @@ internal partial class PluginManager : IDisposable, IServiceType private readonly DalamudConfiguration configuration = Service.Get(); [ServiceManager.ServiceDependency] - private readonly DalamudStartInfo startInfo = Service.Get(); + private readonly Dalamud dalamud = Service.Get(); [ServiceManager.ServiceDependency] private readonly ProfileManager profileManager = Service.Get(); @@ -87,15 +86,25 @@ internal partial class PluginManager : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly HappyHttpClient happyHttpClient = Service.Get(); - [ServiceManager.ServiceConstructor] - private PluginManager() + [ServiceManager.ServiceDependency] + private readonly ChatGui chatGui = Service.Get(); + + static PluginManager() { - this.pluginDirectory = new DirectoryInfo(this.startInfo.PluginDirectory!); + DalamudApiLevel = typeof(PluginManager).Assembly.GetName().Version!.Major; + } + + [ServiceManager.ServiceConstructor] + private PluginManager( + ServiceManager.RegisterStartupBlockerDelegate registerStartupBlocker, + ServiceManager.RegisterUnloadAfterDelegate registerUnloadAfter) + { + this.pluginDirectory = new DirectoryInfo(this.dalamud.StartInfo.PluginDirectory!); if (!this.pluginDirectory.Exists) this.pluginDirectory.Create(); - this.SafeMode = EnvironmentConfiguration.DalamudNoPlugins || this.configuration.PluginSafeMode || this.startInfo.NoLoadPlugins; + this.SafeMode = EnvironmentConfiguration.DalamudNoPlugins || this.configuration.PluginSafeMode || this.dalamud.StartInfo.NoLoadPlugins; try { @@ -119,23 +128,32 @@ internal partial class PluginManager : IDisposable, IServiceType this.configuration.QueueSave(); } - this.PluginConfigs = new PluginConfigurations(Path.Combine(Path.GetDirectoryName(this.startInfo.ConfigurationPath) ?? string.Empty, "pluginConfigs")); + this.PluginConfigs = new PluginConfigurations(Path.Combine(Path.GetDirectoryName(this.dalamud.StartInfo.ConfigurationPath) ?? string.Empty, "pluginConfigs")); - var bannedPluginsJson = File.ReadAllText(Path.Combine(this.startInfo.AssetDirectory!, "UIRes", "bannedplugin.json")); + var bannedPluginsJson = File.ReadAllText(Path.Combine(this.dalamud.StartInfo.AssetDirectory!, "UIRes", "bannedplugin.json")); this.bannedPlugins = JsonConvert.DeserializeObject(bannedPluginsJson); if (this.bannedPlugins == null) { throw new InvalidDataException("Couldn't deserialize banned plugins manifest."); } - this.openInstallerWindowPluginChangelogsLink = Service.Get().AddChatLinkHandler("Dalamud", 1003, (_, _) => + this.openInstallerWindowPluginChangelogsLink = this.chatGui.AddChatLinkHandler("Dalamud", 1003, (_, _) => { - Service.GetNullable()?.OpenPluginInstallerPluginChangelogs(); + Service.GetNullable()?.OpenPluginInstallerTo(PluginInstallerWindow.PluginInstallerOpenKind.Changelogs); }); - this.configuration.PluginTestingOptIns ??= new List(); + this.configuration.PluginTestingOptIns ??= new(); + this.MainRepo = PluginRepository.CreateMainRepo(this.happyHttpClient); this.ApplyPatches(); + + registerStartupBlocker( + Task.Run(this.LoadAndStartLoadSyncPlugins), + "Waiting for plugins that asked to be loaded before the game."); + + registerUnloadAfter( + ResolvePossiblePluginDependencyServices(), + "See the attached comment for the called function."); } /// @@ -148,6 +166,12 @@ internal partial class PluginManager : IDisposable, IServiceType /// public event Action? OnAvailablePluginsChanged; + /// + /// Gets the current Dalamud API level, used to handle breaking changes. Only plugins with this level will be loaded. + /// As of Dalamud 9.x, this always matches the major version number of Dalamud. + /// + public static int DalamudApiLevel { get; private set; } + /// /// Gets a copy of the list of all loaded plugins. /// @@ -190,6 +214,11 @@ internal partial class PluginManager : IDisposable, IServiceType } } + /// + /// Gets the main repository. + /// + public PluginRepository MainRepo { get; } + /// /// Gets a list of all plugin repositories. The main repo should always be first. /// @@ -275,11 +304,9 @@ internal partial class PluginManager : IDisposable, IServiceType /// The header text to send to chat prior to any update info. public void PrintUpdatedPlugins(List? updateMetadata, string header) { - var chatGui = Service.Get(); - if (updateMetadata is { Count: > 0 }) { - chatGui.PrintChat(new XivChatEntry + this.chatGui.Print(new XivChatEntry { Message = new SeString(new List() { @@ -296,15 +323,15 @@ internal partial class PluginManager : IDisposable, IServiceType foreach (var metadata in updateMetadata) { - if (metadata.WasUpdated) + if (metadata.Status == PluginUpdateStatus.StatusKind.Success) { - chatGui.Print(Locs.DalamudPluginUpdateSuccessful(metadata.Name, metadata.Version)); + this.chatGui.Print(Locs.DalamudPluginUpdateSuccessful(metadata.Name, metadata.Version)); } else { - chatGui.PrintChat(new XivChatEntry + this.chatGui.Print(new XivChatEntry { - Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version), + Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version, PluginUpdateStatus.LocalizeUpdateStatusKind(metadata.Status)), Type = XivChatType.Urgent, }); } @@ -343,7 +370,7 @@ internal partial class PluginManager : IDisposable, IServiceType } /// - public void Dispose() + void IInternalDisposableService.DisposeService() { var disposablePlugins = this.installedPluginsList.Where(plugin => plugin.State is PluginState.Loaded or PluginState.LoadError).ToArray(); @@ -383,7 +410,16 @@ internal partial class PluginManager : IDisposable, IServiceType // Now that we've waited enough, dispose the whole plugin. // Since plugins should have been unloaded above, this should be done quickly. foreach (var plugin in disposablePlugins) - plugin.ExplicitDisposeIgnoreExceptions($"Error disposing {plugin.Name}", Log); + { + try + { + plugin.Dispose(); + } + catch (Exception e) + { + Log.Error(e, $"Error disposing {plugin.Name}"); + } + } } this.assemblyLocationMonoHook?.Dispose(); @@ -398,10 +434,10 @@ internal partial class PluginManager : IDisposable, IServiceType /// A representing the asynchronous operation. public async Task SetPluginReposFromConfigAsync(bool notify) { - var repos = new List() { PluginRepository.MainRepo }; + var repos = new List { this.MainRepo }; repos.AddRange(this.configuration.ThirdRepoList .Where(repo => repo.IsEnabled) - .Select(repo => new PluginRepository(repo.Url, repo.IsEnabled))); + .Select(repo => new PluginRepository(this.happyHttpClient, repo.Url, repo.IsEnabled))); this.Repos = repos; await this.ReloadPluginMastersAsync(notify); @@ -438,6 +474,11 @@ internal partial class PluginManager : IDisposable, IServiceType continue; var manifest = LocalPluginManifest.Load(manifestFile); + if (manifest == null) + { + Log.Error("Manifest for plugin at {Path} was null", dllFile.FullName); + continue; + } if (manifest.IsTestingExclusive && this.configuration.PluginTestingOptIns!.All(x => x.InternalName != manifest.InternalName)) this.configuration.PluginTestingOptIns.Add(new PluginTestingOptIn(manifest.InternalName)); @@ -484,9 +525,20 @@ internal partial class PluginManager : IDisposable, IServiceType { try { - // Manifests are not required for devPlugins. the Plugin type will handle any null manifests. + // Manifests are now required for devPlugins var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - var manifest = manifestFile.Exists ? LocalPluginManifest.Load(manifestFile) : null; + if (!manifestFile.Exists) + { + Log.Information("DLL at {DllPath} has no manifest, this is no longer valid", dllFile.FullName); + continue; + } + + var manifest = LocalPluginManifest.Load(manifestFile); + if (manifest == null) + { + Log.Information("Could not deserialize manifest for DLL at {DllPath}", dllFile.FullName); + continue; + } if (manifest != null && manifest.InternalName.IsNullOrEmpty()) { @@ -617,31 +669,23 @@ internal partial class PluginManager : IDisposable, IServiceType Log.Error(e, "Failed to load at least one plugin"); } - var sigScanner = await Service.GetAsync().ConfigureAwait(false); + var sigScanner = await Service.GetAsync().ConfigureAwait(false); this.PluginsReady = true; this.NotifyinstalledPluginsListChanged(); sigScanner.Save(); + + try + { + this.ParanoiaValidatePluginsAndProfiles(); + } + catch (Exception ex) + { + Log.Error(ex, "Plugin and profile validation failed!"); + } }, tokenSource.Token); } - /// - /// Reload all loaded plugins. - /// - /// A task. - [Obsolete("This method should no longer be used and will be removed in a future release.")] - public Task ReloadAllPluginsAsync() - { - lock (this.pluginListLock) - { - return Task.WhenAll(this.installedPluginsList - .Where(x => x.IsLoaded) - .ToList() - .Select(x => Task.Run(async () => await x.ReloadAsync())) - .ToList()); - } - } - /// /// Reload the PluginMaster for each repo, filter, and event that the list has updated. /// @@ -732,9 +776,20 @@ internal partial class PluginManager : IDisposable, IServiceType continue; } - // Manifests are not required for devPlugins. the Plugin type will handle any null manifests. + // Manifests are now required for devPlugins var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - var manifest = manifestFile.Exists ? LocalPluginManifest.Load(manifestFile) : null; + if (!manifestFile.Exists) + { + Log.Information("DLL at {DllPath} has no manifest, this is no longer valid", dllFile.FullName); + continue; + } + + var manifest = LocalPluginManifest.Load(manifestFile); + if (manifest == null) + { + Log.Information("Could not deserialize manifest for DLL at {DllPath}", dllFile.FullName); + continue; + } try { @@ -749,7 +804,7 @@ internal partial class PluginManager : IDisposable, IServiceType } catch (Exception ex) { - Log.Error(ex, $"During devPlugin scan, an unexpected error occurred"); + Log.Error(ex, "During devPlugin scan, an unexpected error occurred"); } } @@ -765,126 +820,14 @@ internal partial class PluginManager : IDisposable, IServiceType /// The reason this plugin was loaded. /// WorkingPluginId this plugin should inherit. /// A representing the asynchronous operation. - public async Task InstallPluginAsync(RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, Guid? inheritedWorkingPluginId = null) + public async Task InstallPluginAsync( + RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, + Guid? inheritedWorkingPluginId = null) { - Log.Debug($"Installing plugin {repoManifest.Name} (testing={useTesting})"); - - // Ensure that we have a testing opt-in for this plugin if we are installing a testing version - if (useTesting && this.configuration.PluginTestingOptIns!.All(x => x.InternalName != repoManifest.InternalName)) - { - // TODO: this isn't safe - this.configuration.PluginTestingOptIns.Add(new PluginTestingOptIn(repoManifest.InternalName)); - this.configuration.QueueSave(); - } - - var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; - var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; - - var response = await this.happyHttpClient.SharedHttpClient.GetAsync(downloadUrl); - response.EnsureSuccessStatusCode(); - - var outputDir = new DirectoryInfo(Path.Combine(this.pluginDirectory.FullName, repoManifest.InternalName, version?.ToString() ?? string.Empty)); - - try - { - if (outputDir.Exists) - outputDir.Delete(true); - - outputDir.Create(); - } - catch - { - // ignored, since the plugin may be loaded already - } - - Log.Debug($"Extracting to {outputDir}"); - // This throws an error, even with overwrite=false - // ZipFile.ExtractToDirectory(tempZip.FullName, outputDir.FullName, false); - using (var archive = new ZipArchive(await response.Content.ReadAsStreamAsync())) - { - foreach (var zipFile in archive.Entries) - { - var outputFile = new FileInfo(Path.GetFullPath(Path.Combine(outputDir.FullName, zipFile.FullName))); - - if (!outputFile.FullName.StartsWith(outputDir.FullName, StringComparison.OrdinalIgnoreCase)) - { - throw new IOException("Trying to extract file outside of destination directory. See this link for more info: https://snyk.io/research/zip-slip-vulnerability"); - } - - if (outputFile.Directory == null) - { - throw new IOException("Output directory invalid."); - } - - if (zipFile.Name.IsNullOrEmpty()) - { - // Assuming Empty for Directory - Log.Verbose($"ZipFile name is null or empty, treating as a directory: {outputFile.Directory.FullName}"); - Directory.CreateDirectory(outputFile.Directory.FullName); - continue; - } - - // Ensure directory is created - Directory.CreateDirectory(outputFile.Directory.FullName); - - try - { - zipFile.ExtractToFile(outputFile.FullName, true); - } - catch (Exception ex) - { - if (outputFile.Extension.EndsWith("dll")) - { - throw new IOException($"Could not overwrite {zipFile.Name}: {ex.Message}"); - } - - Log.Error($"Could not overwrite {zipFile.Name}: {ex.Message}"); - } - } - } - - var dllFile = LocalPluginManifest.GetPluginFile(outputDir, repoManifest); - var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - - // We need to save the repoManifest due to how the repo fills in some fields that authors are not expected to use. - File.WriteAllText(manifestFile.FullName, JsonConvert.SerializeObject(repoManifest, Formatting.Indented)); - - // Reload as a local manifest, add some attributes, and save again. - var manifest = LocalPluginManifest.Load(manifestFile); - - if (manifest == null) - throw new Exception("Plugin had no valid manifest"); - - if (manifest.InternalName != repoManifest.InternalName) - { - Directory.Delete(outputDir.FullName, true); - throw new Exception( - $"Distributed internal name does not match repo internal name: {manifest.InternalName} - {repoManifest.InternalName}"); - } - - if (manifest.WorkingPluginId != Guid.Empty) - throw new Exception("Plugin shall not specify a WorkingPluginId"); - - manifest.WorkingPluginId = inheritedWorkingPluginId ?? Guid.NewGuid(); - - if (useTesting) - { - manifest.Testing = true; - } - - // Document the url the plugin was installed from - manifest.InstalledFromUrl = repoManifest.SourceRepo.IsThirdParty ? repoManifest.SourceRepo.PluginMasterUrl : LocalPluginManifest.FlagMainRepo; - - manifest.Save(manifestFile, "installation"); - - Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); - - var plugin = await this.LoadPluginAsync(dllFile, manifest, reason); - - this.NotifyinstalledPluginsListChanged(); - return plugin; + var stream = await this.DownloadPluginAsync(repoManifest, useTesting); + return await this.InstallPluginInternalAsync(repoManifest, useTesting, reason, stream, inheritedWorkingPluginId); } - + /// /// Remove a plugin. /// @@ -1033,7 +976,7 @@ internal partial class PluginManager : IDisposable, IServiceType autoUpdate ? PluginListInvalidationKind.AutoUpdate : PluginListInvalidationKind.Update, updatedList.Select(x => x.InternalName)); - Log.Information("Plugin update OK."); + Log.Information("Plugin update OK. {updateCount} plugins updated.", updatedList.Length); return updatedList; } @@ -1060,12 +1003,25 @@ internal partial class PluginManager : IDisposable, IServiceType Version = (metadata.UseTesting ? metadata.UpdateManifest.TestingAssemblyVersion : metadata.UpdateManifest.AssemblyVersion)!, - WasUpdated = true, + Status = PluginUpdateStatus.StatusKind.Success, HasChangelog = !metadata.UpdateManifest.Changelog.IsNullOrWhitespace(), }; if (!dryRun) { + // Download the update before unloading + Stream updateStream; + try + { + updateStream = await this.DownloadPluginAsync(metadata.UpdateManifest, metadata.UseTesting); + } + catch (Exception ex) + { + Log.Error(ex, "Error during download (update)"); + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedDownload; + return updateStatus; + } + // Unload if loaded if (plugin.State is PluginState.Loaded or PluginState.LoadError or PluginState.DependencyResolutionFailed) { @@ -1076,7 +1032,7 @@ internal partial class PluginManager : IDisposable, IServiceType catch (Exception ex) { Log.Error(ex, "Error during unload (update)"); - updateStatus.WasUpdated = false; + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedUnload; return updateStatus; } } @@ -1101,8 +1057,8 @@ internal partial class PluginManager : IDisposable, IServiceType } catch (Exception ex) { - Log.Error(ex, "Error during disable (update)"); - updateStatus.WasUpdated = false; + Log.Error(ex, "Error during remove from plugin list (update)"); + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedUnload; return updateStatus; } @@ -1112,17 +1068,17 @@ internal partial class PluginManager : IDisposable, IServiceType try { - await this.InstallPluginAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update, workingPluginId); + await this.InstallPluginInternalAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update, updateStream, workingPluginId); } catch (Exception ex) { Log.Error(ex, "Error during install (update)"); - updateStatus.WasUpdated = false; + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedLoad; return updateStatus; } } - if (notify && updateStatus.WasUpdated) + if (notify && updateStatus.Status == PluginUpdateStatus.StatusKind.Success) this.NotifyinstalledPluginsListChanged(); return updateStatus; @@ -1147,7 +1103,7 @@ internal partial class PluginManager : IDisposable, IServiceType { try { - this.PluginConfigs.Delete(plugin.Name); + this.PluginConfigs.Delete(plugin.Manifest.InternalName); break; } catch (IOException) @@ -1179,7 +1135,7 @@ internal partial class PluginManager : IDisposable, IServiceType } // Applicable version - if (manifest.ApplicableVersion < this.startInfo.GameVersion) + if (manifest.ApplicableVersion < this.dalamud.StartInfo.GameVersion) { Log.Verbose($"Game version: {manifest.InternalName} - {manifest.AssemblyVersion} - {manifest.TestingAssemblyVersion}"); return false; @@ -1275,6 +1231,236 @@ internal partial class PluginManager : IDisposable, IServiceType /// The calling plugin, or null. public LocalPlugin? FindCallingPlugin() => this.FindCallingPlugin(new StackTrace()); + /// + /// Resolves the services that a plugin may have a dependency on.
+ /// This is required, as the lifetime of a plugin cannot be longer than PluginManager, + /// and we want to ensure that dependency services to be kept alive at least until all the plugins, and thus + /// PluginManager to be gone. + ///
+ /// The dependency services. + private static IEnumerable ResolvePossiblePluginDependencyServices() + { + foreach (var serviceType in ServiceManager.GetConcreteServiceTypes()) + { + if (serviceType == typeof(PluginManager)) + continue; + + // Scoped plugin services lifetime is tied to their scopes. They go away when LocalPlugin goes away. + // Nonetheless, their direct dependencies must be considered. + if (serviceType.GetServiceKind() == ServiceManager.ServiceKind.ScopedService) + { + var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); + var dependencies = ServiceHelpers.GetDependencies(typeAsServiceT, false); + ServiceManager.Log.Verbose("Found dependencies of scoped plugin service {Type} ({Cnt})", serviceType.FullName!, dependencies!.Count); + + foreach (var scopedDep in dependencies) + { + if (scopedDep == typeof(PluginManager)) + throw new Exception("Scoped plugin services cannot depend on PluginManager."); + + ServiceManager.Log.Verbose("PluginManager MUST depend on {Type} via {BaseType}", scopedDep.FullName!, serviceType.FullName!); + yield return scopedDep; + } + + continue; + } + + var pluginInterfaceAttribute = serviceType.GetCustomAttribute(true); + if (pluginInterfaceAttribute == null) + continue; + + ServiceManager.Log.Verbose("PluginManager MUST depend on {Type}", serviceType.FullName!); + yield return serviceType; + } + } + + /// + /// Check if there are any inconsistencies with our plugins, their IDs, and our profiles. + /// + private void ParanoiaValidatePluginsAndProfiles() + { + var seenIds = new List(); + + foreach (var installedPlugin in this.InstalledPlugins) + { + if (installedPlugin.Manifest.WorkingPluginId == Guid.Empty) + throw new Exception($"{(installedPlugin is LocalDevPlugin ? "DevPlugin" : "Plugin")} '{installedPlugin.Manifest.InternalName}' has an empty WorkingPluginId."); + + if (seenIds.Contains(installedPlugin.Manifest.WorkingPluginId)) + { + throw new Exception( + $"{(installedPlugin is LocalDevPlugin ? "DevPlugin" : "Plugin")} '{installedPlugin.Manifest.InternalName}' has a duplicate WorkingPluginId '{installedPlugin.Manifest.WorkingPluginId}'"); + } + + seenIds.Add(installedPlugin.Manifest.WorkingPluginId); + } + + this.profileManager.ParanoiaValidateProfiles(); + } + + private async Task DownloadPluginAsync(RemotePluginManifest repoManifest, bool useTesting) + { + var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; + var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl) + { + Headers = + { + Accept = + { + new MediaTypeWithQualityHeaderValue("application/zip"), + }, + }, + }; + var response = await this.happyHttpClient.SharedHttpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(); + } + + /// + /// Install a plugin from a repository and load it. + /// + /// The plugin definition. + /// If the testing version should be used. + /// The reason this plugin was loaded. + /// Stream of the ZIP archive containing the plugin that is about to be installed. + /// WorkingPluginId this plugin should inherit. + /// A representing the asynchronous operation. + private async Task InstallPluginInternalAsync(RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, Stream zipStream, Guid? inheritedWorkingPluginId = null) + { + var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; + Log.Debug($"Installing plugin {repoManifest.Name} (testing={useTesting}, version={version}, reason={reason})"); + + // If this plugin is in the default profile for whatever reason, delete the state + // If it was in multiple profiles and is still, the user uninstalled it and chose to keep it in there, + // or the user removed the plugin manually in which case we don't care + if (reason == PluginLoadReason.Installer) + { + try + { + // We don't need to apply, it doesn't matter + await this.profileManager.DefaultProfile.RemoveByInternalNameAsync(repoManifest.InternalName, false); + } + catch (ProfileOperationException) + { + // ignored + } + } + else + { + // If we are doing anything other than a fresh install, not having a workingPluginId is an error that must be fixed + Debug.Assert(inheritedWorkingPluginId != null, "inheritedWorkingPluginId != null"); + } + + // Ensure that we have a testing opt-in for this plugin if we are installing a testing version + if (useTesting && this.configuration.PluginTestingOptIns!.All(x => x.InternalName != repoManifest.InternalName)) + { + // TODO: this isn't safe + this.configuration.PluginTestingOptIns.Add(new PluginTestingOptIn(repoManifest.InternalName)); + this.configuration.QueueSave(); + } + + var outputDir = new DirectoryInfo(Path.Combine(this.pluginDirectory.FullName, repoManifest.InternalName, version?.ToString() ?? string.Empty)); + + try + { + if (outputDir.Exists) + outputDir.Delete(true); + + outputDir.Create(); + } + catch + { + // ignored, since the plugin may be loaded already + } + + Log.Debug($"Extracting to {outputDir}"); + + using (var archive = new ZipArchive(zipStream)) + { + foreach (var zipFile in archive.Entries) + { + var outputFile = new FileInfo(Path.GetFullPath(Path.Combine(outputDir.FullName, zipFile.FullName))); + + if (!outputFile.FullName.StartsWith(outputDir.FullName, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("Trying to extract file outside of destination directory. See this link for more info: https://snyk.io/research/zip-slip-vulnerability"); + } + + if (outputFile.Directory == null) + { + throw new IOException("Output directory invalid."); + } + + if (zipFile.Name.IsNullOrEmpty()) + { + // Assuming Empty for Directory + Log.Verbose($"ZipFile name is null or empty, treating as a directory: {outputFile.Directory.FullName}"); + Directory.CreateDirectory(outputFile.Directory.FullName); + continue; + } + + // Ensure directory is created + Directory.CreateDirectory(outputFile.Directory.FullName); + + try + { + zipFile.ExtractToFile(outputFile.FullName, true); + } + catch (Exception ex) + { + if (outputFile.Extension.EndsWith("dll")) + { + throw new IOException($"Could not overwrite {zipFile.Name}: {ex.Message}"); + } + + Log.Error($"Could not overwrite {zipFile.Name}: {ex.Message}"); + } + } + } + + var dllFile = LocalPluginManifest.GetPluginFile(outputDir, repoManifest); + var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); + + // We need to save the repoManifest due to how the repo fills in some fields that authors are not expected to use. + Util.WriteAllTextSafe(manifestFile.FullName, JsonConvert.SerializeObject(repoManifest, Formatting.Indented)); + + // Reload as a local manifest, add some attributes, and save again. + var manifest = LocalPluginManifest.Load(manifestFile); + + if (manifest == null) + throw new Exception("Plugin had no valid manifest"); + + if (manifest.InternalName != repoManifest.InternalName) + { + Directory.Delete(outputDir.FullName, true); + throw new Exception( + $"Distributed internal name does not match repo internal name: {manifest.InternalName} - {repoManifest.InternalName}"); + } + + if (manifest.WorkingPluginId != Guid.Empty) + throw new Exception("Plugin shall not specify a WorkingPluginId"); + + manifest.WorkingPluginId = inheritedWorkingPluginId ?? Guid.NewGuid(); + + if (useTesting) + { + manifest.Testing = true; + } + + // Document the url the plugin was installed from + manifest.InstalledFromUrl = repoManifest.SourceRepo.IsThirdParty ? repoManifest.SourceRepo.PluginMasterUrl : SpecialPluginSource.MainRepo; + + manifest.Save(manifestFile, "installation"); + + Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); + + var plugin = await this.LoadPluginAsync(dllFile, manifest, reason); + + this.NotifyinstalledPluginsListChanged(); + return plugin; + } + /// /// Load a plugin. /// @@ -1285,7 +1471,7 @@ internal partial class PluginManager : IDisposable, IServiceType /// If this plugin is being loaded at boot. /// Don't load the plugin, just don't do it. /// The loaded plugin. - private async Task LoadPluginAsync(FileInfo dllFile, LocalPluginManifest? manifest, PluginLoadReason reason, bool isDev = false, bool isBoot = false, bool doNotLoad = false) + private async Task LoadPluginAsync(FileInfo dllFile, LocalPluginManifest manifest, PluginLoadReason reason, bool isDev = false, bool isBoot = false, bool doNotLoad = false) { var name = manifest?.Name ?? dllFile.Name; var loadPlugin = !doNotLoad; @@ -1301,73 +1487,98 @@ internal partial class PluginManager : IDisposable, IServiceType if (isDev) { Log.Information($"Loading dev plugin {name}"); - var devPlugin = new LocalDevPlugin(dllFile, manifest); - loadPlugin &= !isBoot; - - var probablyInternalNameForThisPurpose = manifest?.InternalName ?? dllFile.Name; - - var wantsInDefaultProfile = - this.profileManager.DefaultProfile.WantsPlugin(probablyInternalNameForThisPurpose); - if (wantsInDefaultProfile == null) - { - // We don't know about this plugin, so we don't want to do anything here. - // The code below will take care of it and add it with the default value. - } - else if (wantsInDefaultProfile == false && devPlugin.StartOnBoot) - { - // We didn't want this plugin, and StartOnBoot is on. That means we don't want it and it should stay off until manually enabled. - Log.Verbose("DevPlugin {Name} disabled and StartOnBoot => disable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, false, false); - loadPlugin = false; - } - else if (wantsInDefaultProfile == true && devPlugin.StartOnBoot) - { - // We wanted this plugin, and StartOnBoot is on. That means we actually do want it. - Log.Verbose("DevPlugin {Name} enabled and StartOnBoot => enable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, true, false); - loadPlugin = !doNotLoad; - } - else if (wantsInDefaultProfile == true && !devPlugin.StartOnBoot) - { - // We wanted this plugin, but StartOnBoot is off. This means we don't want it anymore. - Log.Verbose("DevPlugin {Name} enabled and !StartOnBoot => disable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, false, false); - loadPlugin = false; - } - else if (wantsInDefaultProfile == false && !devPlugin.StartOnBoot) - { - // We didn't want this plugin, and StartOnBoot is off. We don't want it. - Log.Verbose("DevPlugin {Name} disabled and !StartOnBoot => disable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, false, false); - loadPlugin = false; - } - - plugin = devPlugin; + plugin = new LocalDevPlugin(dllFile, manifest); } else { Log.Information($"Loading plugin {name}"); plugin = new LocalPlugin(dllFile, manifest); } + + // Perform a migration from InternalName to GUIDs. The plugin should definitely have a GUID here. + // This will also happen if you are installing a plugin with the installer, and that's intended! + // It means that, if you have a profile which has unsatisfied plugins, installing a matching plugin will + // enter it into the profiles it can match. + if (plugin.Manifest.WorkingPluginId == Guid.Empty) + throw new Exception("Plugin should have a WorkingPluginId at this point"); + this.profileManager.MigrateProfilesToGuidsForPlugin(plugin.Manifest.InternalName, plugin.Manifest.WorkingPluginId); + + var wantedByAnyProfile = false; + + // Now, if this is a devPlugin, figure out if we want to load it + if (isDev) + { + var devPlugin = (LocalDevPlugin)plugin; + loadPlugin &= !isBoot; + + var wantsInDefaultProfile = + this.profileManager.DefaultProfile.WantsPlugin(plugin.Manifest.WorkingPluginId); + if (wantsInDefaultProfile == null) + { + // We don't know about this plugin, so we don't want to do anything here. + // The code below will take care of it and add it with the default value. + Log.Verbose("DevPlugin {Name} not wanted in default plugin", plugin.Manifest.InternalName); + + // Check if any profile wants this plugin. We need to do this here, since we want to allow loading a dev plugin if a non-default profile wants it active. + // Note that this will not add the plugin to the default profile. That's done below in any other case. + wantedByAnyProfile = await this.profileManager.GetWantStateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + + // If it is wanted by any other profile, we do want to load it. + if (wantedByAnyProfile) + loadPlugin = true; + } + else if (wantsInDefaultProfile == false && devPlugin.StartOnBoot) + { + // We didn't want this plugin, and StartOnBoot is on. That means we don't want it and it should stay off until manually enabled. + Log.Verbose("DevPlugin {Name} disabled and StartOnBoot => disable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + loadPlugin = false; + } + else if (wantsInDefaultProfile == true && devPlugin.StartOnBoot) + { + // We wanted this plugin, and StartOnBoot is on. That means we actually do want it. + Log.Verbose("DevPlugin {Name} enabled and StartOnBoot => enable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false); + loadPlugin = !doNotLoad; + } + else if (wantsInDefaultProfile == true && !devPlugin.StartOnBoot) + { + // We wanted this plugin, but StartOnBoot is off. This means we don't want it anymore. + Log.Verbose("DevPlugin {Name} enabled and !StartOnBoot => disable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + loadPlugin = false; + } + else if (wantsInDefaultProfile == false && !devPlugin.StartOnBoot) + { + // We didn't want this plugin, and StartOnBoot is off. We don't want it. + Log.Verbose("DevPlugin {Name} disabled and !StartOnBoot => disable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + loadPlugin = false; + } + + plugin = devPlugin; + } #pragma warning disable CS0618 var defaultState = manifest?.Disabled != true && loadPlugin; #pragma warning restore CS0618 - - // Need to do this here, so plugins that don't load are still added to the default profile - var wantToLoad = await this.profileManager.GetWantStateAsync(plugin.Manifest.InternalName, defaultState); - + + // Plugins that aren't in any profile will be added to the default profile with this call. + // We are skipping a double-lookup for dev plugins that are wanted by non-default profiles, as noted above. + wantedByAnyProfile = wantedByAnyProfile || await this.profileManager.GetWantStateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, defaultState); + Log.Information("{Name} defaultState: {State} wantedByAnyProfile: {WantedByAny} loadPlugin: {LoadPlugin}", plugin.Manifest.InternalName, defaultState, wantedByAnyProfile, loadPlugin); + if (loadPlugin) { try { - if (wantToLoad && !plugin.IsOrphaned) + if (wantedByAnyProfile && !plugin.IsOrphaned) { await plugin.LoadAsync(reason); } else { - Log.Verbose($"{name} not loaded, wantToLoad:{wantToLoad} orphaned:{plugin.IsOrphaned}"); + Log.Verbose($"{name} not loaded, wantToLoad:{wantedByAnyProfile} orphaned:{plugin.IsOrphaned}"); } } catch (InvalidPluginException) @@ -1437,6 +1648,8 @@ internal partial class PluginManager : IDisposable, IServiceType private void DetectAvailablePluginUpdates() { + Log.Debug("Starting plugin update check..."); + lock (this.pluginListLock) { this.updatablePluginsList.Clear(); @@ -1471,10 +1684,12 @@ internal partial class PluginManager : IDisposable, IServiceType } } } + + Log.Debug("Update check found {updateCount} available updates.", this.updatablePluginsList.Count); } private void NotifyAvailablePluginsChanged() - { + { this.DetectAvailablePluginUpdates(); this.OnAvailablePluginsChanged?.InvokeSafely(); @@ -1501,11 +1716,43 @@ internal partial class PluginManager : IDisposable, IServiceType } } + private void LoadAndStartLoadSyncPlugins() + { + try + { + using (Timings.Start("PM Load Plugin Repos")) + { + _ = this.SetPluginReposFromConfigAsync(false); + this.OnInstalledPluginsChanged += () => Task.Run(Troubleshooting.LogTroubleshooting); + + Log.Information("[T3] PM repos OK!"); + } + + using (Timings.Start("PM Cleanup Plugins")) + { + this.CleanupPlugins(); + Log.Information("[T3] PMC OK!"); + } + + using (Timings.Start("PM Load Sync Plugins")) + { + this.LoadAllPlugins().Wait(); + Log.Information("[T3] PML OK!"); + } + + _ = Task.Run(Troubleshooting.LogTroubleshooting); + } + catch (Exception ex) + { + Log.Error(ex, "Plugin load failed"); + } + } + private static class Locs { public static string DalamudPluginUpdateSuccessful(string name, Version version) => Loc.Localize("DalamudPluginUpdateSuccessful", " 》 {0} updated to v{1}.").Format(name, version); - public static string DalamudPluginUpdateFailed(string name, Version version) => Loc.Localize("DalamudPluginUpdateFailed", " 》 {0} update to v{1} failed.").Format(name, version); + public static string DalamudPluginUpdateFailed(string name, Version version, string why) => Loc.Localize("DalamudPluginUpdateFailed", " 》 {0} update to v{1} failed ({2}).").Format(name, version, why); } } diff --git a/Dalamud/Plugin/Internal/Profiles/Profile.cs b/Dalamud/Plugin/Internal/Profiles/Profile.cs index ac46d9153..df5b045e2 100644 --- a/Dalamud/Plugin/Internal/Profiles/Profile.cs +++ b/Dalamud/Plugin/Internal/Profiles/Profile.cs @@ -102,7 +102,7 @@ internal class Profile /// Gets all plugins declared in this profile. ///
public IEnumerable Plugins => - this.modelV1.Plugins.Select(x => new ProfilePluginEntry(x.InternalName, x.IsEnabled)); + this.modelV1.Plugins.Select(x => new ProfilePluginEntry(x.InternalName, x.WorkingPluginId, x.IsEnabled)); /// /// Gets this profile's underlying model. @@ -142,13 +142,13 @@ internal class Profile /// /// Check if this profile contains a specific plugin, and if it is enabled. /// - /// The internal name of the plugin. + /// The ID of the plugin. /// Null if this profile does not declare the plugin, true if the profile declares the plugin and wants it enabled, false if the profile declares the plugin and does not want it enabled. - public bool? WantsPlugin(string internalName) + public bool? WantsPlugin(Guid workingPluginId) { lock (this) { - var entry = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName); + var entry = this.modelV1.Plugins.FirstOrDefault(x => x.WorkingPluginId == workingPluginId); return entry?.IsEnabled; } } @@ -157,17 +157,18 @@ internal class Profile /// Add a plugin to this profile with the desired state, or change the state of a plugin in this profile. /// This will block until all states have been applied. /// - /// The internal name of the plugin. + /// The ID of the plugin. + /// The internal name of the plugin, if available. /// Whether or not the plugin should be enabled. /// Whether or not the current state should immediately be applied. /// A representing the asynchronous operation. - public async Task AddOrUpdateAsync(string internalName, bool state, bool apply = true) + public async Task AddOrUpdateAsync(Guid workingPluginId, string? internalName, bool state, bool apply = true) { - Debug.Assert(!internalName.IsNullOrEmpty(), "!internalName.IsNullOrEmpty()"); - + Debug.Assert(workingPluginId != Guid.Empty, "Trying to add plugin with empty guid"); + lock (this) { - var existing = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName); + var existing = this.modelV1.Plugins.FirstOrDefault(x => x.WorkingPluginId == workingPluginId); if (existing != null) { existing.IsEnabled = state; @@ -177,15 +178,55 @@ internal class Profile this.modelV1.Plugins.Add(new ProfileModelV1.ProfileModelV1Plugin { InternalName = internalName, + WorkingPluginId = workingPluginId, IsEnabled = state, }); } } // We need to remove this plugin from the default profile, if it declares it. - if (!this.IsDefaultProfile && this.manager.DefaultProfile.WantsPlugin(internalName) != null) + if (!this.IsDefaultProfile && this.manager.DefaultProfile.WantsPlugin(workingPluginId) != null) { - await this.manager.DefaultProfile.RemoveAsync(internalName, false); + await this.manager.DefaultProfile.RemoveAsync(workingPluginId, false); + } + + Service.Get().QueueSave(); + + if (apply) + await this.manager.ApplyAllWantStatesAsync(); + } + + /// + /// Remove a plugin from this profile. + /// This will block until all states have been applied. + /// + /// The ID of the plugin. + /// Whether or not the current state should immediately be applied. + /// A representing the asynchronous operation. + public async Task RemoveAsync(Guid workingPluginId, bool apply = true) + { + ProfileModelV1.ProfileModelV1Plugin entry; + lock (this) + { + entry = this.modelV1.Plugins.FirstOrDefault(x => x.WorkingPluginId == workingPluginId); + if (entry == null) + throw new PluginNotFoundException(workingPluginId); + + if (!this.modelV1.Plugins.Remove(entry)) + throw new Exception("Couldn't remove plugin from model collection"); + } + + // We need to add this plugin back to the default profile, if we were the last profile to have it. + if (!this.manager.IsInAnyProfile(workingPluginId)) + { + if (!this.IsDefaultProfile) + { + await this.manager.DefaultProfile.AddOrUpdateAsync(workingPluginId, entry.InternalName, this.IsEnabled && entry.IsEnabled, false); + } + else + { + throw new PluginNotInDefaultProfileException(workingPluginId.ToString()); + } } Service.Get().QueueSave(); @@ -201,38 +242,106 @@ internal class Profile /// The internal name of the plugin. /// Whether or not the current state should immediately be applied. /// A representing the asynchronous operation. - public async Task RemoveAsync(string internalName, bool apply = true) + public async Task RemoveByInternalNameAsync(string internalName, bool apply = true) { - ProfileModelV1.ProfileModelV1Plugin entry; + Guid? pluginToRemove = null; lock (this) { - entry = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName); - if (entry == null) - throw new ArgumentException($"No plugin \"{internalName}\" in profile \"{this.Guid}\""); - - if (!this.modelV1.Plugins.Remove(entry)) - throw new Exception("Couldn't remove plugin from model collection"); + foreach (var plugin in this.Plugins) + { + if (plugin.InternalName.Equals(internalName, StringComparison.Ordinal)) + { + pluginToRemove = plugin.WorkingPluginId; + break; + } + } } - // We need to add this plugin back to the default profile, if we were the last profile to have it. - if (!this.manager.IsInAnyProfile(internalName)) + await this.RemoveAsync(pluginToRemove ?? throw new PluginNotFoundException(internalName), apply); + } + + /// + /// This function tries to migrate all plugins with this internalName which do not have + /// a GUID to the specified GUID. + /// This is best-effort and will probably work well for anyone that only uses regular plugins. + /// + /// InternalName of the plugin to migrate. + /// Guid to use. + public void MigrateProfilesToGuidsForPlugin(string internalName, Guid newGuid) + { + lock (this) { - if (!this.IsDefaultProfile) + foreach (var plugin in this.modelV1.Plugins) { - await this.manager.DefaultProfile.AddOrUpdateAsync(internalName, this.IsEnabled && entry.IsEnabled, false); - } - else - { - throw new Exception("Removed plugin from default profile, but wasn't in any other profile"); + // TODO: What should happen if a profile has a GUID locked in, but the plugin + // is not installed anymore? That probably means that the user uninstalled the plugin + // and is now reinstalling it. We should still satisfy that and update the ID. + + if (plugin.InternalName == internalName && plugin.WorkingPluginId == Guid.Empty) + { + plugin.WorkingPluginId = newGuid; + Log.Information("Migrated profile {Profile} plugin {Name} to guid {Guid}", this, internalName, newGuid); + } } } - + Service.Get().QueueSave(); - - if (apply) - await this.manager.ApplyAllWantStatesAsync(); } /// public override string ToString() => $"{this.Guid} ({this.Name})"; } + +/// +/// Exception indicating an issue during a profile operation. +/// +internal abstract class ProfileOperationException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// Message to pass on. + protected ProfileOperationException(string message) + : base(message) + { + } +} + +/// +/// Exception indicating that a plugin was not found in the default profile. +/// +internal sealed class PluginNotInDefaultProfileException : ProfileOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The internal name of the plugin causing the error. + public PluginNotInDefaultProfileException(string internalName) + : base($"The plugin '{internalName}' is not in the default profile, and cannot be removed") + { + } +} + +/// +/// Exception indicating that the plugin was not found. +/// +internal sealed class PluginNotFoundException : ProfileOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The internal name of the plugin causing the error. + public PluginNotFoundException(string internalName) + : base($"The plugin '{internalName}' was not found in the profile") + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the plugin causing the error. + public PluginNotFoundException(Guid workingPluginId) + : base($"The plugin '{workingPluginId}' was not found in the profile") + { + } +} diff --git a/Dalamud/Plugin/Internal/Profiles/ProfileCommandHandler.cs b/Dalamud/Plugin/Internal/Profiles/ProfileCommandHandler.cs index 8ea55856c..eebb87aaa 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfileCommandHandler.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfileCommandHandler.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -7,6 +6,7 @@ using CheapLoc; using Dalamud.Game; using Dalamud.Game.Command; using Dalamud.Game.Gui; +using Dalamud.Plugin.Services; using Dalamud.Utility; using Serilog; @@ -16,7 +16,7 @@ namespace Dalamud.Plugin.Internal.Profiles; /// Service responsible for profile-related chat commands. ///
[ServiceManager.EarlyLoadedService] -internal class ProfileCommandHandler : IServiceType, IDisposable +internal class ProfileCommandHandler : IInternalDisposableService { private readonly CommandManager cmd; private readonly ProfileManager profileManager; @@ -69,7 +69,7 @@ internal class ProfileCommandHandler : IServiceType, IDisposable } /// - public void Dispose() + void IInternalDisposableService.DisposeService() { this.cmd.RemoveHandler("/xlenablecollection"); this.cmd.RemoveHandler("/xldisablecollection"); @@ -78,7 +78,7 @@ internal class ProfileCommandHandler : IServiceType, IDisposable this.framework.Update += this.FrameworkOnUpdate; } - private void FrameworkOnUpdate(Framework framework1) + private void FrameworkOnUpdate(IFramework framework1) { if (this.profileManager.IsBusy) return; diff --git a/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs b/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs index 46b572c1a..10d94de73 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs @@ -69,11 +69,12 @@ internal class ProfileManager : IServiceType /// /// Check if any enabled profile wants a specific plugin enabled. /// - /// The internal name of the plugin. + /// The ID of the plugin. + /// The internal name of the plugin, if available. /// The state the plugin shall be in, if it needs to be added. /// Whether or not the plugin should be added to the default preset, if it's not present in any preset. /// Whether or not the plugin shall be enabled. - public async Task GetWantStateAsync(string internalName, bool defaultState, bool addIfNotDeclared = true) + public async Task GetWantStateAsync(Guid workingPluginId, string? internalName, bool defaultState, bool addIfNotDeclared = true) { var want = false; var wasInAnyProfile = false; @@ -82,7 +83,7 @@ internal class ProfileManager : IServiceType { foreach (var profile in this.profiles) { - var state = profile.WantsPlugin(internalName); + var state = profile.WantsPlugin(workingPluginId); if (state.HasValue) { want = want || (profile.IsEnabled && state.Value); @@ -93,8 +94,8 @@ internal class ProfileManager : IServiceType if (!wasInAnyProfile && addIfNotDeclared) { - Log.Warning("{Name} was not in any profile, adding to default with {Default}", internalName, defaultState); - await this.DefaultProfile.AddOrUpdateAsync(internalName, defaultState, false); + Log.Warning("'{Guid}'('{InternalName}') was not in any profile, adding to default with {Default}", workingPluginId, internalName, defaultState); + await this.DefaultProfile.AddOrUpdateAsync(workingPluginId, internalName, defaultState, false); return defaultState; } @@ -105,22 +106,22 @@ internal class ProfileManager : IServiceType /// /// Check whether a plugin is declared in any profile. /// - /// The internal name of the plugin. + /// The ID of the plugin. /// Whether or not the plugin is in any profile. - public bool IsInAnyProfile(string internalName) + public bool IsInAnyProfile(Guid workingPluginId) { lock (this.profiles) - return this.profiles.Any(x => x.WantsPlugin(internalName) != null); + return this.profiles.Any(x => x.WantsPlugin(workingPluginId) != null); } /// /// Check whether a plugin is only in the default profile. /// A plugin can never be in the default profile if it is in any other profile. /// - /// The internal name of the plugin. + /// The ID of the plugin. /// Whether or not the plugin is in the default profile. - public bool IsInDefaultProfile(string internalName) - => this.DefaultProfile.WantsPlugin(internalName) != null; + public bool IsInDefaultProfile(Guid workingPluginId) + => this.DefaultProfile.WantsPlugin(workingPluginId) != null; /// /// Add a new profile. @@ -151,7 +152,7 @@ internal class ProfileManager : IServiceType /// The newly cloned profile. public Profile CloneProfile(Profile toClone) { - var newProfile = this.ImportProfile(toClone.Model.Serialize()); + var newProfile = this.ImportProfile(toClone.Model.SerializeForShare()); if (newProfile == null) throw new Exception("New profile was null while cloning"); @@ -172,7 +173,27 @@ internal class ProfileManager : IServiceType newModel.Guid = Guid.NewGuid(); newModel.Name = this.GenerateUniqueProfileName(newModel.Name.IsNullOrEmpty() ? "Unknown Collection" : newModel.Name); if (newModel is ProfileModelV1 modelV1) + { + // Disable it modelV1.IsEnabled = false; + + // Try to find matching plugins for all plugins in the profile + var pm = Service.Get(); + foreach (var plugin in modelV1.Plugins) + { + var installedPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.InternalName == plugin.InternalName); + if (installedPlugin != null) + { + Log.Information("Satisfying plugin {InternalName} for profile {Name} with {Guid}", plugin.InternalName, newModel.Name, installedPlugin.Manifest.WorkingPluginId); + plugin.WorkingPluginId = installedPlugin.Manifest.WorkingPluginId; + } + else + { + Log.Warning("Couldn't find plugin {InternalName} for profile {Name}", plugin.InternalName, newModel.Name); + plugin.WorkingPluginId = Guid.Empty; + } + } + } this.config.SavedProfiles!.Add(newModel); this.config.QueueSave(); @@ -196,19 +217,18 @@ internal class ProfileManager : IServiceType this.isBusy = true; Log.Information("Getting want states..."); - List wantActive; + List wantActive; lock (this.profiles) { wantActive = this.profiles .Where(x => x.IsEnabled) - .SelectMany(profile => profile.Plugins.Where(plugin => plugin.IsEnabled) - .Select(plugin => plugin.InternalName)) + .SelectMany(profile => profile.Plugins.Where(plugin => plugin.IsEnabled)) .Distinct().ToList(); } - foreach (var internalName in wantActive) + foreach (var profilePluginEntry in wantActive) { - Log.Information("\t=> Want {Name}", internalName); + Log.Information("\t=> Want {Name}({WorkingPluginId})", profilePluginEntry.InternalName, profilePluginEntry.WorkingPluginId); } Log.Information("Applying want states..."); @@ -218,7 +238,7 @@ internal class ProfileManager : IServiceType var pm = Service.Get(); foreach (var installedPlugin in pm.InstalledPlugins) { - var wantThis = wantActive.Contains(installedPlugin.Manifest.InternalName); + var wantThis = wantActive.Any(x => x.WorkingPluginId == installedPlugin.Manifest.WorkingPluginId); switch (wantThis) { case true when !installedPlugin.IsLoaded: @@ -267,7 +287,7 @@ internal class ProfileManager : IServiceType // We need to remove all plugins from the profile first, so that they are re-added to the default profile if needed foreach (var plugin in profile.Plugins.ToArray()) { - await profile.RemoveAsync(plugin.InternalName, false); + await profile.RemoveAsync(plugin.WorkingPluginId, false); } if (!this.config.SavedProfiles!.Remove(profile.Model)) @@ -279,6 +299,42 @@ internal class ProfileManager : IServiceType this.config.QueueSave(); } + /// + /// This function tries to migrate all plugins with this internalName which do not have + /// a GUID to the specified GUID. + /// This is best-effort and will probably work well for anyone that only uses regular plugins. + /// + /// InternalName of the plugin to migrate. + /// Guid to use. + public void MigrateProfilesToGuidsForPlugin(string internalName, Guid newGuid) + { + lock (this.profiles) + { + foreach (var profile in this.profiles) + profile.MigrateProfilesToGuidsForPlugin(internalName, newGuid); + } + } + + /// + /// Validate profiles for errors. + /// + /// Thrown when a profile is not sane. + public void ParanoiaValidateProfiles() + { + foreach (var profile in this.profiles) + { + var seenIds = new List(); + + foreach (var pluginEntry in profile.Plugins) + { + if (seenIds.Contains(pluginEntry.WorkingPluginId)) + throw new Exception($"Plugin '{pluginEntry.WorkingPluginId}'('{pluginEntry.InternalName}') is twice in profile '{profile.Guid}'('{profile.Name}')"); + + seenIds.Add(pluginEntry.WorkingPluginId); + } + } + } + private string GenerateUniqueProfileName(string startingWith) { if (this.profiles.All(x => x.Name != startingWith)) diff --git a/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs b/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs index bf2a9c2c9..e3d9e2955 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs @@ -1,7 +1,9 @@ -using System; +using System.Collections.Generic; +using System.Reflection; using Dalamud.Utility; using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; namespace Dalamud.Plugin.Internal.Profiles; @@ -39,11 +41,11 @@ public abstract class ProfileModel } /// - /// Serialize this model into a string usable for sharing. + /// Serialize this model into a string usable for sharing, without including GUIDs. /// /// The serialized representation of the model. /// Thrown when an unsupported model is serialized. - public string Serialize() + public string SerializeForShare() { string prefix; switch (this) @@ -55,6 +57,32 @@ public abstract class ProfileModel throw new ArgumentOutOfRangeException(); } - return prefix + Convert.ToBase64String(Util.CompressString(JsonConvert.SerializeObject(this))); + // HACK: Just filter the ID for now, we should split the sharing + saving model + var serialized = JsonConvert.SerializeObject(this, new JsonSerializerSettings() + { ContractResolver = new IgnorePropertiesResolver(new[] { "WorkingPluginId" }) }); + + return prefix + Convert.ToBase64String(Util.CompressString(serialized)); + } + + // Short helper class to ignore some properties from serialization + private class IgnorePropertiesResolver : DefaultContractResolver + { + private readonly HashSet ignoreProps; + + public IgnorePropertiesResolver(IEnumerable propNamesToIgnore) + { + this.ignoreProps = new HashSet(propNamesToIgnore); + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + if (this.ignoreProps.Contains(property.PropertyName)) + { + property.ShouldSerialize = _ => false; + } + + return property; + } } } diff --git a/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs b/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs index 2a851d234..99da4263b 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs @@ -46,6 +46,11 @@ public class ProfileModelV1 : ProfileModel /// Gets or sets the internal name of the plugin. /// public string? InternalName { get; set; } + + /// + /// Gets or sets an ID uniquely identifying this specific instance of a plugin. + /// + public Guid WorkingPluginId { get; set; } /// /// Gets or sets a value indicating whether or not this entry is enabled. diff --git a/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs b/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs index 0a6f5140b..7909981bc 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs @@ -9,10 +9,12 @@ internal class ProfilePluginEntry /// Initializes a new instance of the class. /// /// The internal name of the plugin. + /// The ID of the plugin. /// A value indicating whether or not this entry is enabled. - public ProfilePluginEntry(string internalName, bool state) + public ProfilePluginEntry(string internalName, Guid workingPluginId, bool state) { this.InternalName = internalName; + this.WorkingPluginId = workingPluginId; this.IsEnabled = state; } @@ -20,6 +22,11 @@ internal class ProfilePluginEntry /// Gets the internal name of the plugin. ///
public string InternalName { get; } + + /// + /// Gets or sets an ID uniquely identifying this specific instance of a plugin. + /// + public Guid WorkingPluginId { get; set; } /// /// Gets a value indicating whether or not this entry is enabled. diff --git a/Dalamud/Plugin/Internal/StartupPluginLoader.cs b/Dalamud/Plugin/Internal/StartupPluginLoader.cs deleted file mode 100644 index 4f68d39fc..000000000 --- a/Dalamud/Plugin/Internal/StartupPluginLoader.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Threading.Tasks; - -using Dalamud.Logging.Internal; -using Dalamud.Support; -using Dalamud.Utility.Timing; - -namespace Dalamud.Plugin.Internal; - -/// -/// Class responsible for loading plugins on startup. -/// -[ServiceManager.BlockingEarlyLoadedService] -public class StartupPluginLoader : IServiceType -{ - private static readonly ModuleLog Log = new("SPL"); - - [ServiceManager.ServiceConstructor] - private StartupPluginLoader(PluginManager pluginManager) - { - try - { - using (Timings.Start("PM Load Plugin Repos")) - { - _ = pluginManager.SetPluginReposFromConfigAsync(false); - pluginManager.OnInstalledPluginsChanged += () => Task.Run(Troubleshooting.LogTroubleshooting); - - Log.Information("[T3] PM repos OK!"); - } - - using (Timings.Start("PM Cleanup Plugins")) - { - pluginManager.CleanupPlugins(); - Log.Information("[T3] PMC OK!"); - } - - using (Timings.Start("PM Load Sync Plugins")) - { - pluginManager.LoadAllPlugins().Wait(); - Log.Information("[T3] PML OK!"); - } - - Task.Run(Troubleshooting.LogTroubleshooting); - } - catch (Exception ex) - { - Log.Error(ex, "Plugin load failed"); - } - } -} diff --git a/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs index 98784ce64..1f9f503e0 100644 --- a/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs +++ b/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs @@ -1,9 +1,11 @@ using System; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; using Dalamud.Configuration.Internal; +using Dalamud.Interface.ImGuiNotification.Internal; using Dalamud.Interface.Internal.Notifications; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal.Types.Manifest; @@ -40,6 +42,22 @@ internal class LocalDevPlugin : LocalPlugin, IDisposable configuration.DevPluginSettings[dllFile.FullName] = this.devSettings = new DevPluginSettings(); configuration.QueueSave(); } + + // Legacy dev plugins might not have this! + if (this.devSettings.WorkingPluginId == Guid.Empty) + { + this.devSettings.WorkingPluginId = Guid.NewGuid(); + Log.Verbose("{InternalName} was assigned new devPlugin GUID {Guid}", this.InternalName, this.devSettings.WorkingPluginId); + configuration.QueueSave(); + } + + // If the ID in the manifest is wrong, force the good one + if (this.DevImposedWorkingPluginId != this.manifest.WorkingPluginId) + { + Debug.Assert(this.DevImposedWorkingPluginId != Guid.Empty, "Empty guid for devPlugin"); + this.manifest.WorkingPluginId = this.DevImposedWorkingPluginId; + this.SaveManifest("dev imposed working plugin id"); + } if (this.AutomaticReload) { @@ -76,6 +94,11 @@ internal class LocalDevPlugin : LocalPlugin, IDisposable } } } + + /// + /// Gets an ID uniquely identifying this specific instance of a devPlugin. + /// + public Guid DevImposedWorkingPluginId => this.devSettings.WorkingPluginId; /// public new void Dispose() diff --git a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs index 115ab0f8d..911bc436d 100644 --- a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs +++ b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs @@ -1,10 +1,10 @@ -using System; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Dalamud.Common.Game; using Dalamud.Configuration.Internal; using Dalamud.Game; using Dalamud.Game.Gui.Dtr; @@ -26,6 +26,13 @@ namespace Dalamud.Plugin.Internal.Types; /// internal class LocalPlugin : IDisposable { + /// + /// The underlying manifest for this plugin. + /// +#pragma warning disable SA1401 + protected LocalPluginManifest manifest; +#pragma warning restore SA1401 + private static readonly ModuleLog Log = new("LOCALPLUGIN"); private readonly FileInfo manifestFile; @@ -39,14 +46,12 @@ internal class LocalPlugin : IDisposable private Type? pluginType; private IDalamudPlugin? instance; - private LocalPluginManifest manifest; - /// /// Initializes a new instance of the class. /// /// Path to the DLL file. /// The plugin manifest. - public LocalPlugin(FileInfo dllFile, LocalPluginManifest? manifest) + public LocalPlugin(FileInfo dllFile, LocalPluginManifest manifest) { if (dllFile.Name == "FFXIVClientStructs.Generators.dll") { @@ -59,80 +64,9 @@ internal class LocalPlugin : IDisposable this.DllFile = dllFile; this.State = PluginState.Unloaded; - try - { - this.loader = PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, SetupLoaderConfig); - } - catch (InvalidOperationException ex) - { - Log.Error(ex, "Loader.CreateFromAssemblyFile() failed"); - this.State = PluginState.DependencyResolutionFailed; - throw; - } - - try - { - this.pluginAssembly = this.loader.LoadDefaultAssembly(); - } - catch (Exception ex) - { - this.pluginAssembly = null; - this.pluginType = null; - this.loader.Dispose(); - - Log.Error(ex, $"Not a plugin: {this.DllFile.FullName}"); - throw new InvalidPluginException(this.DllFile); - } - - try - { - this.pluginType = this.pluginAssembly.GetTypes().FirstOrDefault(type => type.IsAssignableTo(typeof(IDalamudPlugin))); - } - catch (ReflectionTypeLoadException ex) - { - Log.Error(ex, $"Could not load one or more types when searching for IDalamudPlugin: {this.DllFile.FullName}"); - // Something blew up when parsing types, but we still want to look for IDalamudPlugin. Let Load() handle the error. - this.pluginType = ex.Types.FirstOrDefault(type => type != null && type.IsAssignableTo(typeof(IDalamudPlugin))); - } - - if (this.pluginType == default) - { - this.pluginAssembly = null; - this.pluginType = null; - this.loader.Dispose(); - - Log.Error($"Nothing inherits from IDalamudPlugin: {this.DllFile.FullName}"); - throw new InvalidPluginException(this.DllFile); - } - - var assemblyVersion = this.pluginAssembly.GetName().Version; - // Although it is conditionally used here, we need to set the initial value regardless. this.manifestFile = LocalPluginManifest.GetManifestFile(this.DllFile); - - // If the parameter manifest was null - if (manifest == null) - { - this.manifest = new LocalPluginManifest() - { - Author = "developer", - Name = Path.GetFileNameWithoutExtension(this.DllFile.Name), - InternalName = Path.GetFileNameWithoutExtension(this.DllFile.Name), - AssemblyVersion = assemblyVersion ?? new Version("1.0.0.0"), - Description = string.Empty, - ApplicableVersion = GameVersion.Any, - DalamudApiLevel = PluginManager.DalamudApiLevel, - IsHide = false, - }; - - // Save the manifest to disk so there won't be any problems later. - // We'll update the name property after it can be retrieved from the instance. - this.manifest.Save(this.manifestFile, "manifest was null"); - } - else - { - this.manifest = manifest; - } + this.manifest = manifest; var needsSaveDueToLegacyFiles = false; @@ -230,7 +164,7 @@ internal class LocalPlugin : IDisposable /// INCLUDES the default profile. ///
public bool IsWantedByAnyProfile => - Service.Get().GetWantStateAsync(this.manifest.InternalName, false, false).GetAwaiter().GetResult(); + Service.Get().GetWantStateAsync(this.manifest.WorkingPluginId, this.Manifest.InternalName, false, false).GetAwaiter().GetResult(); /// /// Gets a value indicating whether this plugin's API level is out of date. @@ -306,7 +240,7 @@ internal class LocalPlugin : IDisposable this.instance = null; } - this.DalamudInterface?.ExplicitDispose(); + this.DalamudInterface?.DisposeInternal(); this.DalamudInterface = null; this.ServiceScope?.Dispose(); @@ -331,11 +265,7 @@ internal class LocalPlugin : IDisposable var framework = await Service.GetAsync(); var ioc = await Service.GetAsync(); var pluginManager = await Service.GetAsync(); - var startInfo = await Service.GetAsync(); - - // UiBuilder constructor requires the following two. - await Service.GetAsync(); - await Service.GetAsync(); + var dalamud = await Service.GetAsync(); if (this.manifest.LoadRequiredState == 0) _ = await Service.GetAsync(); @@ -385,26 +315,28 @@ internal class LocalPlugin : IDisposable } if (pluginManager.IsManifestBanned(this.manifest) && !this.IsDev) - throw new BannedPluginException($"Unable to load {this.Name}, banned"); + throw new BannedPluginException($"Unable to load {this.Name} as it was banned"); - if (this.manifest.ApplicableVersion < startInfo.GameVersion) - throw new InvalidPluginOperationException($"Unable to load {this.Name}, no applicable version"); + if (this.manifest.ApplicableVersion < dalamud.StartInfo.GameVersion) + throw new PluginPreconditionFailedException($"Unable to load {this.Name}, game is newer than applicable version {this.manifest.ApplicableVersion}"); if (this.manifest.DalamudApiLevel < PluginManager.DalamudApiLevel && !pluginManager.LoadAllApiLevels) - throw new InvalidPluginOperationException($"Unable to load {this.Name}, incompatible API level"); + throw new PluginPreconditionFailedException($"Unable to load {this.Name}, incompatible API level {this.manifest.DalamudApiLevel}"); // We might want to throw here? if (!this.IsWantedByAnyProfile) Log.Warning("{Name} is loading, but isn't wanted by any profile", this.Name); if (this.IsOrphaned) - throw new InvalidPluginOperationException($"Plugin {this.Name} had no associated repo."); + throw new PluginPreconditionFailedException($"Plugin {this.Name} had no associated repo"); if (!this.CheckPolicy()) - throw new InvalidPluginOperationException("Plugin was not loaded as per policy"); + throw new PluginPreconditionFailedException($"Unable to load {this.Name} as a load policy forbids it"); this.State = PluginState.Loading; Log.Information($"Loading {this.DllFile.Name}"); + + this.EnsureLoader(); if (this.DllFile.DirectoryName != null && File.Exists(Path.Combine(this.DllFile.DirectoryName, "Dalamud.dll"))) @@ -494,19 +426,12 @@ internal class LocalPlugin : IDisposable if (this.instance == null) { this.State = PluginState.LoadError; - this.DalamudInterface.ExplicitDispose(); + this.DalamudInterface.DisposeInternal(); Log.Error( $"Error while loading {this.Name}, failed to bind and call the plugin constructor"); return; } - // In-case the manifest name was a placeholder. Can occur when no manifest was included. - if (this.manifest.Name.IsNullOrEmpty() && !this.IsDev) - { - this.manifest.Name = this.instance.Name; - this.manifest.Save(this.manifestFile, "manifest name null or empty"); - } - this.State = PluginState.Loaded; Log.Information($"Finished loading {this.DllFile.Name}"); } @@ -514,7 +439,10 @@ internal class LocalPlugin : IDisposable { this.State = PluginState.LoadError; - if (ex is not BannedPluginException) + // If a precondition fails, don't record it as an error, as it isn't really. + if (ex is PluginPreconditionFailedException) + Log.Warning(ex.Message); + else Log.Error(ex, $"Error while loading {this.Name}"); throw; @@ -571,7 +499,7 @@ internal class LocalPlugin : IDisposable this.instance = null; - this.DalamudInterface?.ExplicitDispose(); + this.DalamudInterface?.DisposeInternal(); this.DalamudInterface = null; this.ServiceScope?.Dispose(); @@ -626,7 +554,7 @@ internal class LocalPlugin : IDisposable /// Whether or not this plugin shouldn't load. public bool CheckPolicy() { - var startInfo = Service.Get(); + var startInfo = Service.Get().StartInfo; var manager = Service.Get(); if (startInfo.NoLoadPlugins) @@ -659,9 +587,11 @@ internal class LocalPlugin : IDisposable var manifestPath = LocalPluginManifest.GetManifestFile(this.DllFile); if (manifestPath.Exists) { - // var isDisabled = this.IsDisabled; // saving the internal state because it could have been deleted + // Save some state that we do actually want to carry over + var guid = this.manifest.WorkingPluginId; + this.manifest = LocalPluginManifest.Load(manifestPath) ?? throw new Exception("Could not reload manifest."); - // this.manifest.Disabled = isDisabled; + this.manifest.WorkingPluginId = guid; this.SaveManifest("dev reload"); } @@ -686,14 +616,80 @@ internal class LocalPlugin : IDisposable }); } + /// + /// Save this plugin manifest. + /// + /// Why it should be saved. + protected void SaveManifest(string reason) => this.manifest.Save(this.manifestFile, reason); + private static void SetupLoaderConfig(LoaderConfig config) { config.IsUnloadable = true; config.LoadInMemory = true; config.PreferSharedTypes = false; - config.SharedAssemblies.Add(typeof(Lumina.GameData).Assembly.GetName()); - config.SharedAssemblies.Add(typeof(Lumina.Excel.ExcelSheetImpl).Assembly.GetName()); + + // Pin Lumina and its dependencies recursively (compatibility behavior). + // It currently only pulls in System.* anyway. + // TODO(api10): Remove this. We don't want to pin Lumina anymore, plugins should be able to provide their own. + config.SharedAssemblies.Add((typeof(Lumina.GameData).Assembly.GetName(), true)); + config.SharedAssemblies.Add((typeof(Lumina.Excel.ExcelSheetImpl).Assembly.GetName(), true)); + + // Make sure that plugins do not load their own Dalamud assembly. + // We do not pin this recursively; if a plugin loads its own assembly of Dalamud, it is always wrong, + // but plugins may load other versions of assemblies that Dalamud depends on. + config.SharedAssemblies.Add((typeof(EntryPoint).Assembly.GetName(), false)); + config.SharedAssemblies.Add((typeof(Common.DalamudStartInfo).Assembly.GetName(), false)); } - private void SaveManifest(string reason) => this.manifest.Save(this.manifestFile, reason); + private void EnsureLoader() + { + if (this.loader != null) + return; + + try + { + this.loader = PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, SetupLoaderConfig); + } + catch (InvalidOperationException ex) + { + Log.Error(ex, "Loader.CreateFromAssemblyFile() failed"); + this.State = PluginState.DependencyResolutionFailed; + throw; + } + + try + { + this.pluginAssembly = this.loader.LoadDefaultAssembly(); + } + catch (Exception ex) + { + this.pluginAssembly = null; + this.pluginType = null; + this.loader.Dispose(); + + Log.Error(ex, $"Not a plugin: {this.DllFile.FullName}"); + throw new InvalidPluginException(this.DllFile); + } + + try + { + this.pluginType = this.pluginAssembly.GetTypes().FirstOrDefault(type => type.IsAssignableTo(typeof(IDalamudPlugin))); + } + catch (ReflectionTypeLoadException ex) + { + Log.Error(ex, $"Could not load one or more types when searching for IDalamudPlugin: {this.DllFile.FullName}"); + // Something blew up when parsing types, but we still want to look for IDalamudPlugin. Let Load() handle the error. + this.pluginType = ex.Types.FirstOrDefault(type => type != null && type.IsAssignableTo(typeof(IDalamudPlugin))); + } + + if (this.pluginType == default) + { + this.pluginAssembly = null; + this.pluginType = null; + this.loader.Dispose(); + + Log.Error($"Nothing inherits from IDalamudPlugin: {this.DllFile.FullName}"); + throw new InvalidPluginException(this.DllFile); + } + } } diff --git a/Dalamud/Plugin/Internal/Types/Manifest/LocalPluginManifest.cs b/Dalamud/Plugin/Internal/Types/Manifest/LocalPluginManifest.cs index 8afbe1aea..b7fe6d062 100644 --- a/Dalamud/Plugin/Internal/Types/Manifest/LocalPluginManifest.cs +++ b/Dalamud/Plugin/Internal/Types/Manifest/LocalPluginManifest.cs @@ -13,18 +13,6 @@ namespace Dalamud.Plugin.Internal.Types.Manifest; /// internal record LocalPluginManifest : PluginManifest, ILocalPluginManifest { - /// - /// Flag indicating that a plugin was installed from the official repo. - /// - [JsonIgnore] - public const string FlagMainRepo = "OFFICIAL"; - - /// - /// Flag indicating that a plugin is a dev plugin.. - /// - [JsonIgnore] - public const string FlagDevPlugin = "DEVPLUGIN"; - /// /// Gets or sets a value indicating whether the plugin is disabled and should not be loaded. /// This value supersedes the ".disabled" file functionality and should not be included in the plugin master. @@ -51,7 +39,7 @@ internal record LocalPluginManifest : PluginManifest, ILocalPluginManifest /// Gets a value indicating whether this manifest is associated with a plugin that was installed from a third party /// repo. Unless the manifest has been manually modified, this is determined by the InstalledFromUrl being null. /// - public bool IsThirdParty => !this.InstalledFromUrl.IsNullOrEmpty() && this.InstalledFromUrl != FlagMainRepo; + public bool IsThirdParty => !this.InstalledFromUrl.IsNullOrEmpty() && this.InstalledFromUrl != SpecialPluginSource.MainRepo; /// /// Gets the effective version of this plugin. diff --git a/Dalamud/Plugin/Internal/Types/Manifest/SpecialPluginSource.cs b/Dalamud/Plugin/Internal/Types/Manifest/SpecialPluginSource.cs new file mode 100644 index 000000000..d6508019d --- /dev/null +++ b/Dalamud/Plugin/Internal/Types/Manifest/SpecialPluginSource.cs @@ -0,0 +1,17 @@ +namespace Dalamud.Plugin.Internal.Types.Manifest; + +/// +/// A fake enum representing "special" sources for plugins. +/// +public static class SpecialPluginSource +{ + /// + /// Indication that this plugin came from the official Dalamud repository. + /// + public const string MainRepo = "OFFICIAL"; + + /// + /// Indication that this plugin is loaded as a dev plugin. See also . + /// + public const string DevPlugin = "DEVPLUGIN"; +} diff --git a/Dalamud/Plugin/Internal/Types/PluginDef.cs b/Dalamud/Plugin/Internal/Types/PluginDef.cs index 049e58d7d..25cd82423 100644 --- a/Dalamud/Plugin/Internal/Types/PluginDef.cs +++ b/Dalamud/Plugin/Internal/Types/PluginDef.cs @@ -15,7 +15,7 @@ internal struct PluginDef /// plugin dll file. /// plugin manifest. /// plugin dev indicator. - public PluginDef(FileInfo dllFile, LocalPluginManifest? manifest, bool isDev) + public PluginDef(FileInfo dllFile, LocalPluginManifest manifest, bool isDev) { this.DllFile = dllFile; this.Manifest = manifest; @@ -30,7 +30,7 @@ internal struct PluginDef /// /// Gets plugin manifest. /// - public LocalPluginManifest? Manifest { get; init; } + public LocalPluginManifest Manifest { get; init; } /// /// Gets a value indicating whether plugin is a dev plugin. diff --git a/Dalamud/Plugin/Internal/Types/PluginManifest.cs b/Dalamud/Plugin/Internal/Types/PluginManifest.cs index 0b5ec26fc..baaf37558 100644 --- a/Dalamud/Plugin/Internal/Types/PluginManifest.cs +++ b/Dalamud/Plugin/Internal/Types/PluginManifest.cs @@ -1,7 +1,6 @@ -using System; using System.Collections.Generic; -using Dalamud.Game; +using Dalamud.Common.Game; using Dalamud.Plugin.Internal.Types.Manifest; using Newtonsoft.Json; @@ -43,11 +42,11 @@ internal record PluginManifest : IPluginManifest public List? CategoryTags { get; init; } /// - /// Gets a value indicating whether or not the plugin is hidden in the plugin installer. + /// Gets or sets a value indicating whether or not the plugin is hidden in the plugin installer. /// This value comes from the plugin master and is in addition to the list of hidden names kept by Dalamud. /// [JsonProperty] - public bool IsHide { get; init; } + public bool IsHide { get; set; } /// [JsonProperty] diff --git a/Dalamud/Plugin/Internal/Types/PluginRepository.cs b/Dalamud/Plugin/Internal/Types/PluginRepository.cs index a1097abce..8de25aa08 100644 --- a/Dalamud/Plugin/Internal/Types/PluginRepository.cs +++ b/Dalamud/Plugin/Internal/Types/PluginRepository.cs @@ -6,12 +6,14 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Threading; using System.Threading.Tasks; using Dalamud.Logging.Internal; using Dalamud.Networking.Http; using Dalamud.Plugin.Internal.Types.Manifest; using Dalamud.Utility; + using Newtonsoft.Json; namespace Dalamud.Plugin.Internal.Types; @@ -26,41 +28,47 @@ internal class PluginRepository /// public const string MainRepoUrl = "https://kamori.goats.dev/Plugin/PluginMaster"; - private static readonly ModuleLog Log = new("PLUGINR"); + private const int HttpRequestTimeoutSeconds = 20; - private static readonly HttpClient HttpClient = new(new SocketsHttpHandler - { - AutomaticDecompression = DecompressionMethods.All, - ConnectCallback = Service.Get().SharedHappyEyeballsCallback.ConnectCallback, - }) - { - Timeout = TimeSpan.FromSeconds(20), - DefaultRequestHeaders = - { - CacheControl = new CacheControlHeaderValue - { - NoCache = true, - }, - }, - }; + private static readonly ModuleLog Log = new("PLUGINR"); + private readonly HttpClient httpClient; /// /// Initializes a new instance of the class. /// + /// An instance of . /// The plugin master URL. /// Whether the plugin repo is enabled. - public PluginRepository(string pluginMasterUrl, bool isEnabled) + public PluginRepository(HappyHttpClient happyHttpClient, string pluginMasterUrl, bool isEnabled) { + this.httpClient = new(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + ConnectCallback = happyHttpClient.SharedHappyEyeballsCallback.ConnectCallback, + }) + { + Timeout = TimeSpan.FromSeconds(20), + DefaultRequestHeaders = + { + Accept = + { + new MediaTypeWithQualityHeaderValue("application/json"), + }, + CacheControl = new CacheControlHeaderValue + { + NoCache = true, + }, + UserAgent = + { + new ProductInfoHeaderValue("Dalamud", Util.AssemblyVersion), + }, + }, + }; this.PluginMasterUrl = pluginMasterUrl; this.IsThirdParty = pluginMasterUrl != MainRepoUrl; this.IsEnabled = isEnabled; } - /// - /// Gets a new instance of the class for the main repo. - /// - public static PluginRepository MainRepo => new(MainRepoUrl, true); - /// /// Gets the pluginmaster.json URL. /// @@ -86,6 +94,14 @@ internal class PluginRepository /// public PluginRepositoryState State { get; private set; } + /// + /// Gets a new instance of the class for the main repo. + /// + /// An instance of . + /// The new instance of main repository. + public static PluginRepository CreateMainRepo(HappyHttpClient happyHttpClient) => + new(happyHttpClient, MainRepoUrl, true); + /// /// Reload the plugin master asynchronously in a task. /// @@ -99,7 +115,8 @@ internal class PluginRepository { Log.Information($"Fetching repo: {this.PluginMasterUrl}"); - using var response = await HttpClient.GetAsync(this.PluginMasterUrl); + using var response = await this.GetPluginMaster(this.PluginMasterUrl); + response.EnsureSuccessStatusCode(); var data = await response.Content.ReadAsStringAsync(); @@ -148,6 +165,15 @@ internal class PluginRepository } this.PluginMaster = pluginMaster.Where(this.IsValidManifest).ToList().AsReadOnly(); + + // API9 HACK: Force IsHide to false, we should remove that + if (!this.IsThirdParty) + { + foreach (var manifest in this.PluginMaster) + { + manifest.IsHide = false; + } + } Log.Information($"Successfully fetched repo: {this.PluginMasterUrl}"); this.State = PluginRepositoryState.Success; @@ -182,4 +208,17 @@ internal class PluginRepository return true; } + + private async Task GetPluginMaster(string url, int timeout = HttpRequestTimeoutSeconds) + { + var httpClient = Service.Get().SharedHttpClient; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true }; + + using var requestCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout)); + + return await httpClient.SendAsync(request, requestCts.Token); + } } diff --git a/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs b/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs index 24ca5fe0f..1f20ad960 100644 --- a/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs +++ b/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs @@ -1,4 +1,4 @@ -using System; +using CheapLoc; namespace Dalamud.Plugin.Internal.Types; @@ -7,6 +7,37 @@ namespace Dalamud.Plugin.Internal.Types; ///
internal class PluginUpdateStatus { + /// + /// Enum containing possible statuses of a plugin update. + /// + public enum StatusKind + { + /// + /// The update is pending. + /// + Pending, + + /// + /// The update failed to download. + /// + FailedDownload, + + /// + /// The outdated plugin did not unload correctly. + /// + FailedUnload, + + /// + /// The updated plugin did not load correctly. + /// + FailedLoad, + + /// + /// The update succeeded. + /// + Success, + } + /// /// Gets the plugin internal name. /// @@ -23,12 +54,27 @@ internal class PluginUpdateStatus public Version Version { get; init; } = null!; /// - /// Gets or sets a value indicating whether the plugin was updated. + /// Gets or sets a value indicating the status of the update. /// - public bool WasUpdated { get; set; } + public StatusKind Status { get; set; } = StatusKind.Pending; /// /// Gets a value indicating whether the plugin has a changelog if it was updated. /// public bool HasChangelog { get; init; } + + /// + /// Get a localized version of the update status. + /// + /// Status to localize. + /// Localized text. + public static string LocalizeUpdateStatusKind(StatusKind status) => status switch + { + StatusKind.Pending => Loc.Localize("InstallerUpdateStatusPending", "Pending"), + StatusKind.FailedDownload => Loc.Localize("InstallerUpdateStatusFailedDownload", "Download failed"), + StatusKind.FailedUnload => Loc.Localize("InstallerUpdateStatusFailedUnload", "Unload failed"), + StatusKind.FailedLoad => Loc.Localize("InstallerUpdateStatusFailedLoad", "Load failed"), + StatusKind.Success => Loc.Localize("InstallerUpdateStatusSuccess", "Success"), + _ => "???", + }; } diff --git a/Dalamud/Plugin/Ipc/Internal/CallGate.cs b/Dalamud/Plugin/Ipc/Internal/CallGate.cs index 7d0f90cb6..fef4b97d0 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGate.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGate.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; namespace Dalamud.Plugin.Ipc.Internal; @@ -10,11 +11,28 @@ internal class CallGate : IServiceType { private readonly Dictionary gates = new(); + private ImmutableDictionary? gatesCopy; + [ServiceManager.ServiceConstructor] private CallGate() { } + /// + /// Gets the thread-safe view of the registered gates. + /// + public IReadOnlyDictionary Gates + { + get + { + var copy = this.gatesCopy; + if (copy is not null) + return copy; + lock (this.gates) + return this.gatesCopy ??= this.gates.ToImmutableDictionary(x => x.Key, x => x.Value); + } + } + /// /// Gets the provider associated with the specified name. /// @@ -22,8 +40,34 @@ internal class CallGate : IServiceType /// A CallGate registered under the given name. public CallGateChannel GetOrCreateChannel(string name) { - if (!this.gates.TryGetValue(name, out var gate)) - gate = this.gates[name] = new CallGateChannel(name); - return gate; + lock (this.gates) + { + if (!this.gates.TryGetValue(name, out var gate)) + { + gate = this.gates[name] = new(name); + this.gatesCopy = null; + } + + return gate; + } + } + + /// + /// Remove empty gates from . + /// + public void PurgeEmptyGates() + { + lock (this.gates) + { + var changed = false; + foreach (var (k, v) in this.Gates) + { + if (v.IsEmpty) + changed |= this.gates.Remove(k); + } + + if (changed) + this.gatesCopy = null; + } } } diff --git a/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs b/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs index 2e2c7249e..54adf2163 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs @@ -1,5 +1,5 @@ -using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Reflection; @@ -14,6 +14,17 @@ namespace Dalamud.Plugin.Ipc.Internal; ///
internal class CallGateChannel { + /// + /// The actual storage. + /// + private readonly HashSet subscriptions = new(); + + /// + /// A copy of the actual storage, that will be cleared and populated depending on changes made to + /// . + /// + private ImmutableList? subscriptionsCopy; + /// /// Initializes a new instance of the class. /// @@ -31,17 +42,52 @@ internal class CallGateChannel /// /// Gets a list of delegate subscriptions for when SendMessage is called. /// - public List Subscriptions { get; } = new(); + public IReadOnlyList Subscriptions + { + get + { + var copy = this.subscriptionsCopy; + if (copy is not null) + return copy; + lock (this.subscriptions) + return this.subscriptionsCopy ??= this.subscriptions.ToImmutableList(); + } + } /// /// Gets or sets an action for when InvokeAction is called. /// - public Delegate Action { get; set; } + public Delegate? Action { get; set; } /// /// Gets or sets a func for when InvokeFunc is called. /// - public Delegate Func { get; set; } + public Delegate? Func { get; set; } + + /// + /// Gets a value indicating whether this is not being used. + /// + public bool IsEmpty => this.Action is null && this.Func is null && this.Subscriptions.Count == 0; + + /// + internal void Subscribe(Delegate action) + { + lock (this.subscriptions) + { + this.subscriptionsCopy = null; + this.subscriptions.Add(action); + } + } + + /// + internal void Unsubscribe(Delegate action) + { + lock (this.subscriptions) + { + this.subscriptionsCopy = null; + this.subscriptions.Remove(action); + } + } /// /// Invoke all actions that have subscribed to this IPC. @@ -49,9 +95,6 @@ internal class CallGateChannel /// Message arguments. internal void SendMessage(object?[]? args) { - if (this.Subscriptions.Count == 0) - return; - foreach (var subscription in this.Subscriptions) { var methodInfo = subscription.GetMethodInfo(); @@ -105,7 +148,14 @@ internal class CallGateChannel var paramTypes = methodInfo.GetParameters() .Select(pi => pi.ParameterType).ToArray(); - if (args?.Length != paramTypes.Length) + if (args is null) + { + if (paramTypes.Length == 0) + return; + throw new IpcLengthMismatchError(this.Name, 0, paramTypes.Length); + } + + if (args.Length != paramTypes.Length) throw new IpcLengthMismatchError(this.Name, args.Length, paramTypes.Length); for (var i = 0; i < args.Length; i++) @@ -137,7 +187,7 @@ internal class CallGateChannel } } - private IEnumerable GenerateTypes(Type type) + private IEnumerable GenerateTypes(Type? type) { while (type != null && type != typeof(object)) { @@ -148,6 +198,9 @@ internal class CallGateChannel private object? ConvertObject(object? obj, Type type) { + if (obj is null) + return null; + var json = JsonConvert.SerializeObject(obj); try diff --git a/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs b/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs index 39d5b9f4d..cc54a563b 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs @@ -1,5 +1,3 @@ -using System; - #pragma warning disable SA1402 // File may only contain a single type namespace Dalamud.Plugin.Ipc.Internal; @@ -37,7 +35,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateProvider base.InvokeAction(); - /// + /// public TRet InvokeFunc() => this.InvokeFunc(); } @@ -75,7 +73,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateProvider< public void InvokeAction(T1 arg1) => base.InvokeAction(arg1); - /// + /// public TRet InvokeFunc(T1 arg1) => this.InvokeFunc(arg1); } @@ -113,7 +111,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateProvi public void InvokeAction(T1 arg1, T2 arg2) => base.InvokeAction(arg1, arg2); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2) => this.InvokeFunc(arg1, arg2); } @@ -151,7 +149,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateP public void InvokeAction(T1 arg1, T2 arg2, T3 arg3) => base.InvokeAction(arg1, arg2, arg3); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3) => this.InvokeFunc(arg1, arg2, arg3); } @@ -189,7 +187,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallG public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4) => base.InvokeAction(arg1, arg2, arg3, arg4); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4) => this.InvokeFunc(arg1, arg2, arg3, arg4); } @@ -227,7 +225,7 @@ internal class CallGatePubSub : CallGatePubSubBase, IC public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5); } @@ -265,7 +263,7 @@ internal class CallGatePubSub : CallGatePubSubBase public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5, arg6); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5, arg6); } @@ -303,7 +301,7 @@ internal class CallGatePubSub : CallGatePubSub public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5, arg6, arg7); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5, arg6, arg7); } @@ -341,7 +339,7 @@ internal class CallGatePubSub : CallGatePu public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8); } diff --git a/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs b/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs index 40c0c4a59..b6a4e8a61 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs @@ -1,5 +1,3 @@ -using System; - using Dalamud.Plugin.Ipc.Exceptions; namespace Dalamud.Plugin.Ipc.Internal; @@ -13,7 +11,7 @@ internal abstract class CallGatePubSubBase /// Initializes a new instance of the class. /// /// The name of the IPC registration. - public CallGatePubSubBase(string name) + protected CallGatePubSubBase(string name) { this.Channel = Service.Get().GetOrCreateChannel(name); } @@ -54,14 +52,14 @@ internal abstract class CallGatePubSubBase ///
/// Action to subscribe. private protected void Subscribe(Delegate action) - => this.Channel.Subscriptions.Add(action); + => this.Channel.Subscribe(action); /// /// Unsubscribe an expression from this registration. /// /// Action to unsubscribe. private protected void Unsubscribe(Delegate action) - => this.Channel.Subscriptions.Remove(action); + => this.Channel.Unsubscribe(action); /// /// Invoke an action registered for inter-plugin communication. diff --git a/Dalamud/Plugin/Ipc/Internal/DataCache.cs b/Dalamud/Plugin/Ipc/Internal/DataCache.cs index c357f77c2..38cea4866 100644 --- a/Dalamud/Plugin/Ipc/Internal/DataCache.cs +++ b/Dalamud/Plugin/Ipc/Internal/DataCache.cs @@ -1,5 +1,10 @@ -using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; + +using Dalamud.Plugin.Ipc.Exceptions; + +using Serilog; namespace Dalamud.Plugin.Ipc.Internal; @@ -8,10 +13,14 @@ namespace Dalamud.Plugin.Ipc.Internal; /// internal readonly struct DataCache { + /// Name of the data. + internal readonly string Tag; + /// The assembly name of the initial creator. internal readonly string CreatorAssemblyName; /// A not-necessarily distinct list of current users. + /// Also used as a reference count tracker. internal readonly List UserAssemblyNames; /// The type the data was registered as. @@ -23,14 +32,83 @@ internal readonly struct DataCache /// /// Initializes a new instance of the struct. /// + /// Name of the data. /// The assembly name of the initial creator. /// A reference to data. /// The type of the data. - public DataCache(string creatorAssemblyName, object? data, Type type) + public DataCache(string tag, string creatorAssemblyName, object? data, Type type) { + this.Tag = tag; this.CreatorAssemblyName = creatorAssemblyName; - this.UserAssemblyNames = new List { creatorAssemblyName }; + this.UserAssemblyNames = new(); this.Data = data; this.Type = type; } + + /// + /// Creates a new instance of the struct, using the given data generator function. + /// + /// The name for the data cache. + /// The assembly name of the initial creator. + /// The function that generates the data if it does not already exist. + /// The type of the stored data - needs to be a reference type that is shared through Dalamud itself, not loaded by the plugin. + /// The new instance of . + public static DataCache From(string tag, string creatorAssemblyName, Func dataGenerator) + where T : class + { + try + { + var result = new DataCache(tag, creatorAssemblyName, dataGenerator.Invoke(), typeof(T)); + Log.Verbose( + "[{who}] Created new data for [{Tag:l}] for creator {Creator:l}.", + nameof(DataShare), + tag, + creatorAssemblyName); + return result; + } + catch (Exception e) + { + throw ExceptionDispatchInfo.SetCurrentStackTrace( + new DataCacheCreationError(tag, creatorAssemblyName, typeof(T), e)); + } + } + + /// + /// Attempts to fetch the data. + /// + /// The name of the caller assembly. + /// The value, if succeeded. + /// The exception, if failed. + /// Desired type of the data. + /// true on success. + public bool TryGetData( + string callerName, + [NotNullWhen(true)] out T? value, + [NotNullWhen(false)] out Exception? ex) + where T : class + { + switch (this.Data) + { + case null: + value = null; + ex = ExceptionDispatchInfo.SetCurrentStackTrace(new DataCacheValueNullError(this.Tag, this.Type)); + return false; + + case T data: + value = data; + ex = null; + + // Register the access history + lock (this.UserAssemblyNames) + this.UserAssemblyNames.Add(callerName); + + return true; + + default: + value = null; + ex = ExceptionDispatchInfo.SetCurrentStackTrace( + new DataCacheTypeMismatchError(this.Tag, this.CreatorAssemblyName, typeof(T), this.Type)); + return false; + } + } } diff --git a/Dalamud/Plugin/Ipc/Internal/DataShare.cs b/Dalamud/Plugin/Ipc/Internal/DataShare.cs index 5d0faabda..b122f481d 100644 --- a/Dalamud/Plugin/Ipc/Internal/DataShare.cs +++ b/Dalamud/Plugin/Ipc/Internal/DataShare.cs @@ -1,9 +1,7 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Reflection; using Dalamud.Plugin.Ipc.Exceptions; using Serilog; @@ -13,10 +11,14 @@ namespace Dalamud.Plugin.Ipc.Internal; /// /// This class facilitates sharing data-references of standard types between plugins without using more expensive IPC. /// -[ServiceManager.EarlyLoadedService] +[ServiceManager.BlockingEarlyLoadedService] internal class DataShare : IServiceType { - private readonly Dictionary caches = new(); + /// + /// Dictionary of cached values. Note that is being used, as it does its own locking, + /// effectively preventing calling the data generator multiple times concurrently. + /// + private readonly Dictionary> caches = new(); [ServiceManager.ServiceConstructor] private DataShare() @@ -39,38 +41,15 @@ internal class DataShare : IServiceType where T : class { var callerName = GetCallerName(); + + Lazy cacheLazy; lock (this.caches) { - if (this.caches.TryGetValue(tag, out var cache)) - { - if (!cache.Type.IsAssignableTo(typeof(T))) - { - throw new DataCacheTypeMismatchError(tag, cache.CreatorAssemblyName, typeof(T), cache.Type); - } - - cache.UserAssemblyNames.Add(callerName); - return cache.Data as T ?? throw new DataCacheValueNullError(tag, cache.Type); - } - - try - { - var obj = dataGenerator.Invoke(); - if (obj == null) - { - throw new Exception("Returned data was null."); - } - - cache = new DataCache(callerName, obj, typeof(T)); - this.caches[tag] = cache; - - Log.Verbose("[DataShare] Created new data for [{Tag:l}] for creator {Creator:l}.", tag, callerName); - return obj; - } - catch (Exception e) - { - throw new DataCacheCreationError(tag, callerName, typeof(T), e); - } + if (!this.caches.TryGetValue(tag, out cacheLazy)) + this.caches[tag] = cacheLazy = new(() => DataCache.From(tag, callerName, dataGenerator)); } + + return cacheLazy.Value.TryGetData(callerName, out var value, out var ex) ? value : throw ex; } /// @@ -80,34 +59,36 @@ internal class DataShare : IServiceType /// The name for the data cache. public void RelinquishData(string tag) { + DataCache cache; lock (this.caches) { - if (!this.caches.TryGetValue(tag, out var cache)) - { + if (!this.caches.TryGetValue(tag, out var cacheLazy)) return; - } var callerName = GetCallerName(); - lock (this.caches) - { - if (!cache.UserAssemblyNames.Remove(callerName) || cache.UserAssemblyNames.Count > 0) - { - return; - } - if (this.caches.Remove(tag)) - { - if (cache.Data is IDisposable disposable) - { - disposable.Dispose(); - Log.Verbose("[DataShare] Disposed [{Tag:l}] after it was removed from all shares.", tag); - } - else - { - Log.Verbose("[DataShare] Removed [{Tag:l}] from all shares.", tag); - } - } + cache = cacheLazy.Value; + if (!cache.UserAssemblyNames.Remove(callerName) || cache.UserAssemblyNames.Count > 0) + return; + if (!this.caches.Remove(tag)) + return; + } + + if (cache.Data is IDisposable disposable) + { + try + { + disposable.Dispose(); + Log.Verbose("[DataShare] Disposed [{Tag:l}] after it was removed from all shares.", tag); } + catch (Exception e) + { + Log.Error(e, "[DataShare] Failed to dispose [{Tag:l}] after it was removed from all shares.", tag); + } + } + else + { + Log.Verbose("[DataShare] Removed [{Tag:l}] from all shares.", tag); } } @@ -123,23 +104,14 @@ internal class DataShare : IServiceType where T : class { data = null; + Lazy cacheLazy; lock (this.caches) { - if (!this.caches.TryGetValue(tag, out var cache) || !cache.Type.IsAssignableTo(typeof(T))) - { + if (!this.caches.TryGetValue(tag, out cacheLazy)) return false; - } - - var callerName = GetCallerName(); - data = cache.Data as T; - if (data == null) - { - return false; - } - - cache.UserAssemblyNames.Add(callerName); - return true; } + + return cacheLazy.Value.TryGetData(GetCallerName(), out data, out _); } /// @@ -155,27 +127,14 @@ internal class DataShare : IServiceType public T GetData(string tag) where T : class { + Lazy cacheLazy; lock (this.caches) { - if (!this.caches.TryGetValue(tag, out var cache)) - { + if (!this.caches.TryGetValue(tag, out cacheLazy)) throw new KeyNotFoundException($"The data cache [{tag}] is not registered."); - } - - var callerName = Assembly.GetCallingAssembly().GetName().Name ?? string.Empty; - if (!cache.Type.IsAssignableTo(typeof(T))) - { - throw new DataCacheTypeMismatchError(tag, callerName, typeof(T), cache.Type); - } - - if (cache.Data is not T data) - { - throw new DataCacheValueNullError(tag, typeof(T)); - } - - cache.UserAssemblyNames.Add(callerName); - return data; } + + return cacheLazy.Value.TryGetData(GetCallerName(), out var value, out var ex) ? value : throw ex; } /// @@ -186,7 +145,8 @@ internal class DataShare : IServiceType { lock (this.caches) { - return this.caches.Select(kvp => (kvp.Key, kvp.Value.CreatorAssemblyName, kvp.Value.UserAssemblyNames.ToArray())); + return this.caches.Select( + kvp => (kvp.Key, kvp.Value.Value.CreatorAssemblyName, kvp.Value.Value.UserAssemblyNames.ToArray())); } } diff --git a/Dalamud/Plugin/Services/IAddonEventManager.cs b/Dalamud/Plugin/Services/IAddonEventManager.cs new file mode 100644 index 000000000..e696bbaae --- /dev/null +++ b/Dalamud/Plugin/Services/IAddonEventManager.cs @@ -0,0 +1,45 @@ +using Dalamud.Game.Addon; +using Dalamud.Game.Addon.Events; + +namespace Dalamud.Plugin.Services; + +/// +/// Service provider for addon event management. +/// +public interface IAddonEventManager +{ + /// + /// Delegate to be called when an event is received. + /// + /// Event type for this event handler. + /// The parent addon for this event handler. + /// The specific node that will trigger this event handler. + public delegate void AddonEventHandler(AddonEventType atkEventType, nint atkUnitBase, nint atkResNode); + + /// + /// Registers an event handler for the specified addon, node, and type. + /// + /// The parent addon for this event. + /// The node that will trigger this event. + /// The event type for this event. + /// The handler to call when event is triggered. + /// IAddonEventHandle used to remove the event. Null if no event was added. + IAddonEventHandle? AddEvent(nint atkUnitBase, nint atkResNode, AddonEventType eventType, AddonEventHandler eventHandler); + + /// + /// Unregisters an event handler with the specified event id and event type. + /// + /// Unique handle identifying this event. + void RemoveEvent(IAddonEventHandle eventHandle); + + /// + /// Force the game cursor to be the specified cursor. + /// + /// Which cursor to use. + void SetCursor(AddonCursorType cursor); + + /// + /// Un-forces the game cursor. + /// + void ResetCursor(); +} diff --git a/Dalamud/Plugin/Services/IAddonLifecycle.cs b/Dalamud/Plugin/Services/IAddonLifecycle.cs new file mode 100644 index 000000000..6f44349d5 --- /dev/null +++ b/Dalamud/Plugin/Services/IAddonLifecycle.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Runtime.InteropServices; + +using Dalamud.Game.Addon; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +namespace Dalamud.Plugin.Services; + +/// +/// This class provides events for in-game addon lifecycles. +/// +public interface IAddonLifecycle +{ + /// + /// Delegate for receiving addon lifecycle event messages. + /// + /// The event type that triggered the message. + /// Information about what addon triggered the message. + public delegate void AddonEventDelegate(AddonEvent type, AddonArgs args); + + /// + /// Register a listener that will trigger on the specified event and any of the specified addons. + /// + /// Event type to trigger on. + /// Addon names that will trigger the handler to be invoked. + /// The handler to invoke. + void RegisterListener(AddonEvent eventType, IEnumerable addonNames, AddonEventDelegate handler); + + /// + /// Register a listener that will trigger on the specified event only for the specified addon. + /// + /// Event type to trigger on. + /// The addon name that will trigger the handler to be invoked. + /// The handler to invoke. + void RegisterListener(AddonEvent eventType, string addonName, AddonEventDelegate handler); + + /// + /// Register a listener that will trigger on the specified event for any addon. + /// + /// Event type to trigger on. + /// The handler to invoke. + void RegisterListener(AddonEvent eventType, AddonEventDelegate handler); + + /// + /// Unregister listener from specified event type and specified addon names. + /// + /// + /// If a specific handler is not provided, all handlers for the event type and addon names will be unregistered. + /// + /// Event type to deregister. + /// Addon names to deregister. + /// Optional specific handler to remove. + void UnregisterListener(AddonEvent eventType, IEnumerable addonNames, [Optional] AddonEventDelegate handler); + + /// + /// Unregister all listeners for the specified event type and addon name. + /// + /// + /// If a specific handler is not provided, all handlers for the event type and addons will be unregistered. + /// + /// Event type to deregister. + /// Addon name to deregister. + /// Optional specific handler to remove. + void UnregisterListener(AddonEvent eventType, string addonName, [Optional] AddonEventDelegate handler); + + /// + /// Unregister an event type handler.
This will only remove a handler that is added via . + ///
+ /// + /// If a specific handler is not provided, all handlers for the event type and addons will be unregistered. + /// + /// Event type to deregister. + /// Optional specific handler to remove. + void UnregisterListener(AddonEvent eventType, [Optional] AddonEventDelegate handler); + + /// + /// Unregister all events that use the specified handlers. + /// + /// Handlers to remove. + void UnregisterListener(params AddonEventDelegate[] handlers); +} diff --git a/Dalamud/Plugin/Services/IChatGui.cs b/Dalamud/Plugin/Services/IChatGui.cs new file mode 100644 index 000000000..24fd4e830 --- /dev/null +++ b/Dalamud/Plugin/Services/IChatGui.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; + +using Dalamud.Game.Gui; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Plugin.Services; + +/// +/// This class handles interacting with the native chat UI. +/// +public interface IChatGui +{ + /// + /// A delegate type used with the event. + /// + /// The type of chat. + /// The sender ID. + /// The sender name. + /// The message sent. + /// A value indicating whether the message was handled or should be propagated. + public delegate void OnMessageDelegate(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled); + + /// + /// A delegate type used with the event. + /// + /// The type of chat. + /// The sender ID. + /// The sender name. + /// The message sent. + /// A value indicating whether the message was handled or should be propagated. + public delegate void OnCheckMessageHandledDelegate(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled); + + /// + /// A delegate type used with the event. + /// + /// The type of chat. + /// The sender ID. + /// The sender name. + /// The message sent. + public delegate void OnMessageHandledDelegate(XivChatType type, uint senderId, SeString sender, SeString message); + + /// + /// A delegate type used with the event. + /// + /// The type of chat. + /// The sender ID. + /// The sender name. + /// The message sent. + public delegate void OnMessageUnhandledDelegate(XivChatType type, uint senderId, SeString sender, SeString message); + + /// + /// Event that will be fired when a chat message is sent to chat by the game. + /// + public event OnMessageDelegate ChatMessage; + + /// + /// Event that allows you to stop messages from appearing in chat by setting the isHandled parameter to true. + /// + public event OnCheckMessageHandledDelegate CheckMessageHandled; + + /// + /// Event that will be fired when a chat message is handled by Dalamud or a Plugin. + /// + public event OnMessageHandledDelegate ChatMessageHandled; + + /// + /// Event that will be fired when a chat message is not handled by Dalamud or a Plugin. + /// + public event OnMessageUnhandledDelegate ChatMessageUnhandled; + + /// + /// Gets the ID of the last linked item. + /// + public int LastLinkedItemId { get; } + + /// + /// Gets the flags of the last linked item. + /// + public byte LastLinkedItemFlags { get; } + + /// + /// Gets the dictionary of Dalamud Link Handlers. + /// + public IReadOnlyDictionary<(string PluginName, uint CommandId), Action> RegisteredLinkHandlers { get; } + + /// + /// Queue a chat message. Dalamud will send queued messages on the next framework event. + /// + /// A message to send. + public void Print(XivChatEntry chat); + + /// + /// Queue a chat message. Dalamud will send queued messages on the next framework event. + /// + /// A message to send. + /// String to prepend message with "[messageTag] ". + /// Color to display the message tag with. + public void Print(string message, string? messageTag = null, ushort? tagColor = null); + + /// + /// Queue a chat message. Dalamud will send queued messages on the next framework event. + /// + /// A message to send. + /// String to prepend message with "[messageTag] ". + /// Color to display the message tag with. + public void Print(SeString message, string? messageTag = null, ushort? tagColor = null); + + /// + /// Queue a chat message. Dalamud will send queued messages on the next framework event. + /// + /// A message to send. + /// String to prepend message with "[messageTag] ". + /// Color to display the message tag with. + public void PrintError(string message, string? messageTag = null, ushort? tagColor = null); + + /// + /// Queue a chat message. Dalamud will send queued messages on the next framework event. + /// + /// A message to send. + /// String to prepend message with "[messageTag] ". + /// Color to display the message tag with. + public void PrintError(SeString message, string? messageTag = null, ushort? tagColor = null); +} diff --git a/Dalamud/Plugin/Services/IClientState.cs b/Dalamud/Plugin/Services/IClientState.cs index d66db9cc9..652a6c888 100644 --- a/Dalamud/Plugin/Services/IClientState.cs +++ b/Dalamud/Plugin/Services/IClientState.cs @@ -12,17 +12,17 @@ public interface IClientState /// /// Event that gets fired when the current Territory changes. /// - public event EventHandler TerritoryChanged; + public event Action TerritoryChanged; /// /// Event that fires when a character is logging in, and the local character object is available. /// - public event EventHandler Login; + public event Action Login; /// /// Event that fires when a character is logging out. /// - public event EventHandler Logout; + public event Action Logout; /// /// Event that fires when a character is entering PvP. @@ -37,7 +37,7 @@ public interface IClientState /// /// Event that gets fired when a duty is ready. /// - public event EventHandler CfPop; + public event Action CfPop; /// /// Gets the language of the client. @@ -73,4 +73,9 @@ public interface IClientState /// Gets a value indicating whether or not the user is playing PvP, excluding the Wolves' Den. /// public bool IsPvPExcludingDen { get; } + + /// + /// Gets a value indicating whether the client is currently in Group Pose (GPose) mode. + /// + public bool IsGPosing { get; } } diff --git a/Dalamud/Plugin/Services/ICondition.cs b/Dalamud/Plugin/Services/ICondition.cs new file mode 100644 index 000000000..9700cef5a --- /dev/null +++ b/Dalamud/Plugin/Services/ICondition.cs @@ -0,0 +1,54 @@ +using Dalamud.Game.ClientState.Conditions; + +namespace Dalamud.Plugin.Services; + +/// +/// Provides access to conditions (generally player state). You can check whether a player is in combat, mounted, etc. +/// +public interface ICondition +{ + /// + /// A delegate type used with the event. + /// + /// The changed condition. + /// The value the condition is set to. + public delegate void ConditionChangeDelegate(ConditionFlag flag, bool value); + + /// + /// Event that gets fired when a condition is set. + /// Should only get fired for actual changes, so the previous value will always be !value. + /// + public event ConditionChangeDelegate? ConditionChange; + + /// + /// Gets the current max number of conditions. + /// + public int MaxEntries { get; } + + /// + /// Gets the condition array base pointer. + /// + public nint Address { get; } + + /// + /// Check the value of a specific condition/state flag. + /// + /// The condition flag to check. + public bool this[int flag] { get; } + + /// + public bool this[ConditionFlag flag] => this[(int)flag]; + + /// + /// Check if any condition flags are set. + /// + /// Whether any single flag is set. + public bool Any(); + + /// + /// Check if any provided condition flags are set. + /// + /// Whether any single provided flag is set. + /// The condition flags to check. + public bool Any(params ConditionFlag[] flags); +} diff --git a/Dalamud/Plugin/Services/IContextMenu.cs b/Dalamud/Plugin/Services/IContextMenu.cs new file mode 100644 index 000000000..4d792116d --- /dev/null +++ b/Dalamud/Plugin/Services/IContextMenu.cs @@ -0,0 +1,37 @@ +using Dalamud.Game.Gui.ContextMenu; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Plugin.Services; + +/// +/// This class provides methods for interacting with the game's context menu. +/// +public interface IContextMenu +{ + /// + /// A delegate type used for the event. + /// + /// Information about the currently opening menu. + public delegate void OnMenuOpenedDelegate(MenuOpenedArgs args); + + /// + /// Event that gets fired every time the game framework updates. + /// + event OnMenuOpenedDelegate OnMenuOpened; + + /// + /// Adds a menu item to a context menu. + /// + /// The type of context menu to add the item to. + /// The item to add. + void AddMenuItem(ContextMenuType menuType, MenuItem item); + + /// + /// Removes a menu item from a context menu. + /// + /// The type of context menu to remove the item from. + /// The item to add. + /// if the item was removed, if it was not found. + bool RemoveMenuItem(ContextMenuType menuType, MenuItem item); +} diff --git a/Dalamud/Plugin/Services/IDataManager.cs b/Dalamud/Plugin/Services/IDataManager.cs index ff9b40605..e4b249319 100644 --- a/Dalamud/Plugin/Services/IDataManager.cs +++ b/Dalamud/Plugin/Services/IDataManager.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; - -using ImGuiScene; using Lumina; using Lumina.Data; -using Lumina.Data.Files; using Lumina.Excel; namespace Dalamud.Plugin.Services; @@ -20,16 +14,6 @@ public interface IDataManager /// public ClientLanguage Language { get; } - /// - /// Gets the OpCodes sent by the server to the client. - /// - public ReadOnlyDictionary ServerOpCodes { get; } - - /// - /// Gets the OpCodes sent by the client to the server. - /// - public ReadOnlyDictionary ClientOpCodes { get; } - /// /// Gets a object which gives access to any excel/game data. /// @@ -40,11 +24,6 @@ public interface IDataManager ///
public ExcelModule Excel { get; } - /// - /// Gets a value indicating whether Game Data is ready to be read. - /// - public bool IsDataReady { get; } - /// /// Gets a value indicating whether the game data files have been modified by another third-party tool. /// @@ -86,111 +65,4 @@ public interface IDataManager /// The path inside of the game files. /// True if the file exists. public bool FileExists(string path); - - /// - /// Get a containing the icon with the given ID. - /// - /// The icon ID. - /// Return high resolution version. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(uint iconId, bool highResolution = false); - - /// - /// Get a containing the icon with the given ID, of the given language. - /// - /// The requested language. - /// The icon ID. - /// Return high resolution version. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(ClientLanguage iconLanguage, uint iconId, bool highResolution = false); - - /// - /// Get a containing the icon with the given ID, of the given type. - /// - /// The type of the icon (e.g. 'hq' to get the HQ variant of an item icon). - /// The icon ID. - /// Return high resolution version. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(string? type, uint iconId, bool highResolution = false); - - /// - /// Get a containing the icon with the given ID. - /// - /// The icon ID. - /// Return the high resolution version. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(uint iconId, bool highResolution = false); - - /// - /// Get a containing the icon with the given ID, of the given quality. - /// - /// A value indicating whether the icon should be HQ. - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetIcon(bool isHq, uint iconId); - - /// - /// Get a containing the HQ icon with the given ID. - /// - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TexFile? GetHqIcon(uint iconId); - - /// - /// Get the passed as a drawable ImGui TextureWrap. - /// - /// The Lumina . - /// A that can be used to draw the texture. - [Obsolete("Use ITextureProvider instead")] - [return: NotNullIfNotNull(nameof(tex))] - public TextureWrap? GetImGuiTexture(TexFile? tex); - - /// - /// Get the passed texture path as a drawable ImGui TextureWrap. - /// - /// The internal path to the texture. - /// A that can be used to draw the texture. - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTexture(string path); - - /// - /// Get a containing the icon with the given ID, of the given quality. - /// - /// A value indicating whether the icon should be HQ. - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(bool isHq, uint iconId); - - /// - /// Get a containing the icon with the given ID, of the given language. - /// - /// The requested language. - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(ClientLanguage iconLanguage, uint iconId); - - /// - /// Get a containing the icon with the given ID, of the given type. - /// - /// The type of the icon (e.g. 'hq' to get the HQ variant of an item icon). - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureIcon(string type, uint iconId); - - /// - /// Get a containing the HQ icon with the given ID. - /// - /// The icon ID. - /// The containing the icon. - [Obsolete("Use ITextureProvider instead")] - public TextureWrap? GetImGuiTextureHqIcon(uint iconId); } diff --git a/Dalamud/Plugin/Services/IDtrBar.cs b/Dalamud/Plugin/Services/IDtrBar.cs index 6c2b8ad1e..a5a750cf6 100644 --- a/Dalamud/Plugin/Services/IDtrBar.cs +++ b/Dalamud/Plugin/Services/IDtrBar.cs @@ -19,4 +19,10 @@ public interface IDtrBar /// The entry object used to update, hide and remove the entry. /// Thrown when an entry with the specified title exists. public DtrBarEntry Get(string title, SeString? text = null); + + /// + /// Removes a DTR bar entry from the system. + /// + /// Title of the entry to remove. + public void Remove(string title); } diff --git a/Dalamud/Plugin/Services/IDutyState.cs b/Dalamud/Plugin/Services/IDutyState.cs index a2331364c..3d49f68cb 100644 --- a/Dalamud/Plugin/Services/IDutyState.cs +++ b/Dalamud/Plugin/Services/IDutyState.cs @@ -1,6 +1,4 @@ -using System; - -namespace Dalamud.Plugin.Services; +namespace Dalamud.Plugin.Services; /// /// This class represents the state of the currently occupied duty. diff --git a/Dalamud/Plugin/Services/IFlyTextGui.cs b/Dalamud/Plugin/Services/IFlyTextGui.cs new file mode 100644 index 000000000..04fae351d --- /dev/null +++ b/Dalamud/Plugin/Services/IFlyTextGui.cs @@ -0,0 +1,55 @@ +using Dalamud.Game.Gui.FlyText; +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Plugin.Services; + +/// +/// This class facilitates interacting with and creating native in-game "fly text". +/// +public interface IFlyTextGui +{ + /// + /// The delegate defining the type for the FlyText event. + /// + /// The FlyTextKind. See . + /// Value1 passed to the native flytext function. + /// Value2 passed to the native flytext function. Seems unused. + /// Text1 passed to the native flytext function. + /// Text2 passed to the native flytext function. + /// Color passed to the native flytext function. Changes flytext color. + /// Icon ID passed to the native flytext function. Only displays with select FlyTextKind. + /// Damage Type Icon ID passed to the native flytext function. Displayed next to damage values to denote damage type. + /// The vertical offset to place the flytext at. 0 is default. Negative values result + /// in text appearing higher on the screen. This does not change where the element begins to fade. + /// Whether this flytext has been handled. If a subscriber sets this to true, the FlyText will not appear. + public delegate void OnFlyTextCreatedDelegate( + ref FlyTextKind kind, + ref int val1, + ref int val2, + ref SeString text1, + ref SeString text2, + ref uint color, + ref uint icon, + ref uint damageTypeIcon, + ref float yOffset, + ref bool handled); + + /// + /// The FlyText event that can be subscribed to. + /// + public event OnFlyTextCreatedDelegate? FlyTextCreated; + + /// + /// Displays a fly text in-game on the local player. + /// + /// The FlyTextKind. See . + /// The index of the actor to place flytext on. Indexing unknown. 1 places flytext on local player. + /// Value1 passed to the native flytext function. + /// Value2 passed to the native flytext function. Seems unused. + /// Text1 passed to the native flytext function. + /// Text2 passed to the native flytext function. + /// Color passed to the native flytext function. Changes flytext color. + /// Icon ID passed to the native flytext function. Only displays with select FlyTextKind. + /// Damage Type Icon ID passed to the native flytext function. Displayed next to damage values to denote damage type. + public void AddFlyText(FlyTextKind kind, uint actorIndex, uint val1, uint val2, SeString text1, SeString text2, uint color, uint icon, uint damageTypeIcon); +} diff --git a/Dalamud/Plugin/Services/IFramework.cs b/Dalamud/Plugin/Services/IFramework.cs new file mode 100644 index 000000000..f1a4b6906 --- /dev/null +++ b/Dalamud/Plugin/Services/IFramework.cs @@ -0,0 +1,293 @@ +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal.Windows.Data.Widgets; + +namespace Dalamud.Plugin.Services; + +/// +/// This class represents the Framework of the native game client and grants access to various subsystems. +/// +/// +/// Choosing between RunOnFrameworkThread and Run +///
    +///
  • If you do need to do use await and have your task keep executing on the main thread after waiting is +/// done, use Run.
  • +///
  • If you need to call or , use +/// RunOnFrameworkThread. It also skips the task scheduler if invoked already from the framework thread.
  • +///
+/// The game is likely to completely lock up if you call above synchronous function and getter, because starting +/// a new task by default runs on , which would make the task run on the framework +/// thread if invoked via Run. This includes Task.Factory.StartNew and +/// Task.ContinueWith. Use Task.Run if you need to start a new task from the callback specified to +/// Run, as it will force your task to be run in the default thread pool. +/// See to see the difference in behaviors, and how would a misuse of these +/// functions result in a deadlock. +///
+public interface IFramework +{ + /// + /// A delegate type used with the event. + /// + /// The Framework instance. + public delegate void OnUpdateDelegate(IFramework framework); + + /// + /// Event that gets fired every time the game framework updates. + /// + public event OnUpdateDelegate Update; + + /// + /// Gets the last time that the Framework Update event was triggered. + /// + public DateTime LastUpdate { get; } + + /// + /// Gets the last time in UTC that the Framework Update event was triggered. + /// + public DateTime LastUpdateUTC { get; } + + /// + /// Gets the delta between the last Framework Update and the currently executing one. + /// + public TimeSpan UpdateDelta { get; } + + /// + /// Gets a value indicating whether currently executing code is running in the game's framework update thread. + /// + public bool IsInFrameworkUpdateThread { get; } + + /// + /// Gets a value indicating whether game Framework is unloading. + /// + public bool IsFrameworkUnloading { get; } + + /// Gets a that runs tasks during Framework Update event. + /// The task factory. + public TaskFactory GetTaskFactory(); + + /// + /// Returns a task that completes after the given number of ticks. + /// + /// Number of ticks to delay. + /// The cancellation token. + /// A new that gets resolved after specified number of ticks happen. + /// The continuation will run on the framework thread by default. + public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// Function to call. + /// The cancellation token. + /// Task representing the pending or already completed function. + /// + /// Starting new tasks and waiting on them synchronously from this callback will completely lock up + /// the game. Use await if you need to wait on something from an async callback. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task Run(Action action, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// Return type. + /// Function to call. + /// The cancellation token. + /// Task representing the pending or already completed function. + /// + /// Starting new tasks and waiting on them synchronously from this callback will completely lock up + /// the game. Use await if you need to wait on something from an async callback. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task Run(Func action, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// Function to call. + /// The cancellation token. + /// Task representing the pending or already completed function. + /// + /// Starting new tasks and waiting on them synchronously from this callback will completely lock up + /// the game. Use await if you need to wait on something from an async callback. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task Run(Func action, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// Return type. + /// Function to call. + /// The cancellation token. + /// Task representing the pending or already completed function. + /// + /// Starting new tasks and waiting on them synchronously from this callback will completely lock up + /// the game. Use await if you need to wait on something from an async callback. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task Run(Func> action, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + /// Return type. + /// Function to call. + /// Task representing the pending or already completed function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task RunOnFrameworkThread(Func func); + + /// + /// 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. + /// + /// Function to call. + /// Task representing the pending or already completed function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task RunOnFrameworkThread(Action action); + + /// + /// 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. + /// + /// Return type. + /// Function to call. + /// Task representing the pending or already completed function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + [Obsolete($"Use {nameof(RunOnTick)} instead.")] + public Task RunOnFrameworkThread(Func> func); + + /// + /// 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. + /// + /// Function to call. + /// Task representing the pending or already completed function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + [Obsolete($"Use {nameof(RunOnTick)} instead.")] + public Task RunOnFrameworkThread(Func func); + + /// + /// Run given function in upcoming Framework.Tick call. + /// + /// Return type. + /// Function to call. + /// Wait for given timespan before calling this function. + /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. + /// Cancellation token which will prevent the execution of this function if wait conditions are not met. + /// Task representing the pending function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task RunOnTick(Func func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default); + + /// + /// Run given function in upcoming Framework.Tick call. + /// + /// Function to call. + /// Wait for given timespan before calling this function. + /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. + /// Cancellation token which will prevent the execution of this function if wait conditions are not met. + /// Task representing the pending function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task RunOnTick(Action action, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default); + + /// + /// Run given function in upcoming Framework.Tick call. + /// + /// Return type. + /// Function to call. + /// Wait for given timespan before calling this function. + /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. + /// Cancellation token which will prevent the execution of this function if wait conditions are not met. + /// Task representing the pending function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task RunOnTick(Func> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default); + + /// + /// Run given function in upcoming Framework.Tick call. + /// + /// Function to call. + /// Wait for given timespan before calling this function. + /// Count given number of Framework.Tick calls before calling this function. This takes precedence over delay parameter. + /// Cancellation token which will prevent the execution of this function if wait conditions are not met. + /// Task representing the pending function. + /// + /// await, Task.Factory.StartNew or alike will continue off the framework thread. + /// Awaiting on the returned from RunOnFrameworkThread, + /// Run, or RunOnTick right away inside the callback specified to this + /// function has a chance of locking up the game. Do not do await framework.RunOnFrameworkThread(...); + /// directly or indirectly from the delegate passed to this function. + /// See the remarks on if you need to choose which one to use, between + /// Run and RunOnFrameworkThread. Note that RunOnTick is a fancy + /// version of RunOnFrameworkThread. + /// + public Task RunOnTick(Func func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default); +} diff --git a/Dalamud/Plugin/Services/IGameConfig.cs b/Dalamud/Plugin/Services/IGameConfig.cs index 98f6160cc..c69fa906a 100644 --- a/Dalamud/Plugin/Services/IGameConfig.cs +++ b/Dalamud/Plugin/Services/IGameConfig.cs @@ -1,21 +1,45 @@ -using System; -using System.Diagnostics; +using System.Diagnostics; +using System.Threading.Tasks; using Dalamud.Game.Config; -using FFXIVClientStructs.FFXIV.Common.Configuration; +using Dalamud.Plugin.Internal.Types; namespace Dalamud.Plugin.Services; /// /// This class represents the game's configuration. /// +/// +/// Accessing -typed properties such as , directly or indirectly +/// via , +/// , or alike will block, if the game is not done loading.
+/// Therefore, avoid accessing configuration from your plugin constructor, especially if your plugin sets +/// to 2 and to true. +/// If property access from the plugin constructor is desired, do the value retrieval asynchronously via +/// ; do not wait for the result right away. +///
public interface IGameConfig { /// - /// Event which is fired when a game config option is changed. + /// Event which is fired when any game config option is changed. /// public event EventHandler Changed; + /// + /// Event which is fired when a system config option is changed. + /// + public event EventHandler SystemChanged; + + /// + /// Event which is fired when a UiConfig option is changed. + /// + public event EventHandler UiConfigChanged; + + /// + /// Event which is fired when a UiControl config option is changed. + /// + public event EventHandler UiControlChanged; + /// /// Gets the collection of config options that persist between characters. /// @@ -83,7 +107,7 @@ public interface IGameConfig /// Attempts to get the properties of a String option from the System section. ///
/// Option to get the properties of. - /// Details of the option: Default Value + /// Details of the option: Default Value. /// A value representing the success. public bool TryGet(SystemConfigOption option, out StringConfigProperties? properties); @@ -139,7 +163,7 @@ public interface IGameConfig /// Attempts to get the properties of a String option from the UiConfig section. ///
/// Option to get the properties of. - /// Details of the option: Default Value + /// Details of the option: Default Value. /// A value representing the success. public bool TryGet(UiConfigOption option, out StringConfigProperties? properties); @@ -195,7 +219,7 @@ public interface IGameConfig /// Attempts to get the properties of a String option from the UiControl section. ///
/// Option to get the properties of. - /// Details of the option: Default Value + /// Details of the option: Default Value. /// A value representing the success. public bool TryGet(UiControlOption option, out StringConfigProperties? properties); diff --git a/Dalamud/Plugin/Services/IGameInteropProvider.cs b/Dalamud/Plugin/Services/IGameInteropProvider.cs new file mode 100644 index 000000000..217e08445 --- /dev/null +++ b/Dalamud/Plugin/Services/IGameInteropProvider.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; + +using Dalamud.Hooking; +using Dalamud.Utility.Signatures; + +namespace Dalamud.Plugin.Services; + +/// +/// Service responsible for the creation of hooks. +/// +public interface IGameInteropProvider +{ + /// + /// Available hooking backends. + /// + public enum HookBackend + { + /// + /// Choose the best backend automatically. + /// + Automatic, + + /// + /// Use Reloaded hooks. + /// + Reloaded, + + /// + /// Use MinHook. + /// You should never have to use this without talking to us first. + /// + MinHook, + } + + /// + /// Initialize members decorated with the . + /// Initialize any delegate members decorated with the . + /// Fill out any IntPtr members decorated with the with the resolved address. + /// Errors for fallible signatures will be logged. + /// + /// The object to initialize. + public void InitializeFromAttributes(object self); + + /// + /// Creates a hook by replacing the original address with an address pointing to a newly created jump to the detour. + /// + /// A memory address to install a hook. + /// Callback function. Delegate must have a same original function prototype. + /// The hook with the supplied parameters. + /// Delegate of detour. + public Hook HookFromFunctionPointerVariable(IntPtr address, T detour) where T : Delegate; + + /// + /// Creates a hook by rewriting import table address. + /// + /// Module to check for. Current process' main module if null. + /// Name of the DLL, including the extension. + /// Decorated name of the function. + /// Hint or ordinal. 0 to unspecify. + /// Callback function. Delegate must have a same original function prototype. + /// The hook with the supplied parameters. + /// Delegate of detour. + public Hook HookFromImport(ProcessModule? module, string moduleName, string functionName, uint hintOrOrdinal, T detour) where T : Delegate; + + /// + /// Creates a hook. Hooking address is inferred by calling to GetProcAddress() function. + /// The hook is not activated until Enable() method is called. + /// Please do not use MinHook unless you have thoroughly troubleshot why Reloaded does not work. + /// + /// A name of the module currently loaded in the memory. (e.g. ws2_32.dll). + /// A name of the exported function name (e.g. send). + /// Callback function. Delegate must have a same original function prototype. + /// Hooking library to use. + /// The hook with the supplied parameters. + /// Delegate of detour. + Hook HookFromSymbol(string moduleName, string exportName, T detour, HookBackend backend = HookBackend.Automatic) where T : Delegate; + + /// + /// Creates a hook. Hooking address is inferred by calling to GetProcAddress() function. + /// The hook is not activated until Enable() method is called. + /// Please do not use MinHook unless you have thoroughly troubleshot why Reloaded does not work. + /// + /// A memory address to install a hook. + /// Callback function. Delegate must have a same original function prototype. + /// Hooking library to use. + /// The hook with the supplied parameters. + /// Delegate of detour. + Hook HookFromAddress(IntPtr procAddress, T detour, HookBackend backend = HookBackend.Automatic) where T : Delegate; + + /// + /// Creates a hook from a signature into the Dalamud target module. + /// + /// Signature of function to hook. + /// Callback function. Delegate must have a same original function prototype. + /// Hooking library to use. + /// The hook with the supplied parameters. + /// Delegate of detour. + Hook HookFromSignature(string signature, T detour, HookBackend backend = HookBackend.Automatic) where T : Delegate; +} diff --git a/Dalamud/Plugin/Services/IGameInventory.cs b/Dalamud/Plugin/Services/IGameInventory.cs new file mode 100644 index 000000000..a1b1114d7 --- /dev/null +++ b/Dalamud/Plugin/Services/IGameInventory.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; + +using Dalamud.Game.Inventory; +using Dalamud.Game.Inventory.InventoryEventArgTypes; + +namespace Dalamud.Plugin.Services; + +/// +/// This class provides events for the in-game inventory. +/// +public interface IGameInventory +{ + /// + /// Delegate function to be called when inventories have been changed. + /// This delegate sends the entire set of changes recorded. + /// + /// The events. + public delegate void InventoryChangelogDelegate(IReadOnlyCollection events); + + /// + /// Delegate function to be called for each change to inventories. + /// This delegate sends individual events for changes. + /// + /// The event try that triggered this message. + /// Data for the triggered event. + public delegate void InventoryChangedDelegate(GameInventoryEvent type, InventoryEventArgs data); + + /// + /// Delegate function to be called for each change to inventories. + /// This delegate sends individual events for changes. + /// + /// The event arg type. + /// Data for the triggered event. + public delegate void InventoryChangedDelegate(T data) where T : InventoryEventArgs; + + /// + /// Event that is fired when the inventory has been changed.
+ /// Note that some events, such as , , and + /// currently is subject to reinterpretation as , , and + /// .
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangelogDelegate InventoryChanged; + + /// + /// Event that is fired when the inventory has been changed, without trying to interpret two inventory slot changes + /// as a move event as appropriate.
+ /// In other words, , , and + /// currently do not fire in this event. + ///
+ public event InventoryChangelogDelegate InventoryChangedRaw; + + /// + /// Event that is fired when an item is added to an inventory.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangedDelegate ItemAdded; + + /// + /// Event that is fired when an item is removed from an inventory.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangedDelegate ItemRemoved; + + /// + /// Event that is fired when an items properties are changed.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangedDelegate ItemChanged; + + /// + /// Event that is fired when an item is moved from one inventory into another. + /// + public event InventoryChangedDelegate ItemMoved; + + /// + /// Event that is fired when an item is split from one stack into two. + /// + public event InventoryChangedDelegate ItemSplit; + + /// + /// Event that is fired when an item is merged from two stacks into one. + /// + public event InventoryChangedDelegate ItemMerged; + + /// + public event InventoryChangedDelegate ItemAddedExplicit; + + /// + public event InventoryChangedDelegate ItemRemovedExplicit; + + /// + public event InventoryChangedDelegate ItemChangedExplicit; + + /// + public event InventoryChangedDelegate ItemMovedExplicit; + + /// + public event InventoryChangedDelegate ItemSplitExplicit; + + /// + public event InventoryChangedDelegate ItemMergedExplicit; +} diff --git a/Dalamud/Plugin/Services/IGameNetwork.cs b/Dalamud/Plugin/Services/IGameNetwork.cs new file mode 100644 index 000000000..eed79b4af --- /dev/null +++ b/Dalamud/Plugin/Services/IGameNetwork.cs @@ -0,0 +1,26 @@ +using Dalamud.Game.Network; + +namespace Dalamud.Plugin.Services; + +/// +/// This class handles interacting with game network events. +/// +public interface IGameNetwork +{ + // TODO(v9): we shouldn't be passing pointers to the actual data here + + /// + /// The delegate type of a network message event. + /// + /// The pointer to the raw data. + /// The operation ID code. + /// The source actor ID. + /// The taret actor ID. + /// The direction of the packed. + public delegate void OnNetworkMessageDelegate(nint dataPtr, ushort opCode, uint sourceActorId, uint targetActorId, NetworkMessageDirection direction); + + /// + /// Event that is called when a network message is sent/received. + /// + public event OnNetworkMessageDelegate NetworkMessage; +} diff --git a/Dalamud/Plugin/Services/IKeyState.cs b/Dalamud/Plugin/Services/IKeyState.cs new file mode 100644 index 000000000..de78978ca --- /dev/null +++ b/Dalamud/Plugin/Services/IKeyState.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; + +using Dalamud.Game.ClientState.Keys; + +namespace Dalamud.Plugin.Services; + +/// +/// Wrapper around the game keystate buffer, which contains the pressed state for all keyboard keys, indexed by virtual vkCode. +/// +/// +/// The stored key state is actually a combination field, however the below ephemeral states are consumed each frame. Setting +/// the value may be mildly useful, however retrieving the value is largely pointless. In testing, it wasn't possible without +/// setting the statue manually. +/// index & 0 = key pressed. +/// index & 1 = key down (ephemeral). +/// index & 2 = key up (ephemeral). +/// index & 3 = short key press (ephemeral). +/// +public interface IKeyState +{ + /// + /// Get or set the key-pressed state for a given vkCode. + /// + /// The virtual key to change. + /// Whether the specified key is currently pressed. + /// If the vkCode is not valid. Refer to or . + /// If the set value is non-zero. + public bool this[int vkCode] { get; set; } + + /// + public bool this[VirtualKey vkCode] { get; set; } + + /// + /// Gets the value in the index array. + /// + /// The virtual key to change. + /// The raw value stored in the index array. + /// If the vkCode is not valid. Refer to or . + public int GetRawValue(int vkCode); + + /// + public int GetRawValue(VirtualKey vkCode); + + /// + /// Sets the value in the index array. + /// + /// The virtual key to change. + /// The raw value to set in the index array. + /// If the vkCode is not valid. Refer to or . + /// If the set value is non-zero. + public void SetRawValue(int vkCode, int value); + + /// + public void SetRawValue(VirtualKey vkCode, int value); + + /// + /// Gets a value indicating whether the given VirtualKey code is regarded as valid input by the game. + /// + /// Virtual key code. + /// If the code is valid. + public bool IsVirtualKeyValid(int vkCode); + + /// + public bool IsVirtualKeyValid(VirtualKey vkCode); + + /// + /// Gets an array of virtual keys the game considers valid input. + /// + /// An array of valid virtual keys. + public IEnumerable GetValidVirtualKeys(); + + /// + /// Clears the pressed state for all keys. + /// + public void ClearAll(); +} diff --git a/Dalamud/Plugin/Services/INotificationManager.cs b/Dalamud/Plugin/Services/INotificationManager.cs new file mode 100644 index 000000000..7d9ccd0b0 --- /dev/null +++ b/Dalamud/Plugin/Services/INotificationManager.cs @@ -0,0 +1,12 @@ +using Dalamud.Interface.ImGuiNotification; + +namespace Dalamud.Plugin.Services; + +/// Manager for notifications provided by Dalamud using ImGui. +public interface INotificationManager +{ + /// Adds a notification. + /// The new notification. + /// The added notification. + IActiveNotification AddNotification(Notification notification); +} diff --git a/Dalamud/Plugin/Services/IPartyFinderGui.cs b/Dalamud/Plugin/Services/IPartyFinderGui.cs new file mode 100644 index 000000000..f656963db --- /dev/null +++ b/Dalamud/Plugin/Services/IPartyFinderGui.cs @@ -0,0 +1,23 @@ +using Dalamud.Game.Gui.PartyFinder.Types; + +namespace Dalamud.Plugin.Services; + +/// +/// This class handles interacting with the native PartyFinder window. +/// +public interface IPartyFinderGui +{ + /// + /// Event type fired each time the game receives an individual Party Finder listing. + /// Cannot modify listings but can hide them. + /// + /// The listings received. + /// Additional arguments passed by the game. + public delegate void PartyFinderListingEventDelegate(PartyFinderListing listing, PartyFinderListingEventArgs args); + + /// + /// Event fired each time the game receives an individual Party Finder listing. + /// Cannot modify listings but can hide them. + /// + public event PartyFinderListingEventDelegate ReceiveListing; +} diff --git a/Dalamud/Plugin/Services/IPluginLog.cs b/Dalamud/Plugin/Services/IPluginLog.cs new file mode 100644 index 000000000..aac321092 --- /dev/null +++ b/Dalamud/Plugin/Services/IPluginLog.cs @@ -0,0 +1,125 @@ +using Serilog; +using Serilog.Events; + +#pragma warning disable CS1573 // See https://github.com/dotnet/roslyn/issues/40325 + +namespace Dalamud.Plugin.Services; + +/// +/// An opinionated service to handle logging for plugins. +/// +public interface IPluginLog +{ + /// + /// Gets or sets the minimum log level that will be recorded from this plugin to Dalamud's logs. This may be set + /// by either the plugin or by Dalamud itself. + /// + /// + /// Defaults to for downloaded plugins, and + /// for dev plugins. + /// + LogEventLevel MinimumLogLevel { get; set; } + + /// + /// Log a message to the Dalamud log for this plugin. This log level should be + /// used primarily for unrecoverable errors or critical faults in a plugin. + /// + /// Message template describing the event. + /// Objects positionally formatted into the message template. + void Fatal(string messageTemplate, params object[] values); + + /// + /// An (optional) exception that should be recorded alongside this event. + void Fatal(Exception? exception, string messageTemplate, params object[] values); + + /// + /// Log a message to the Dalamud log for this plugin. This log level should be + /// used for recoverable errors or faults that impact plugin functionality. + /// + /// Message template describing the event. + /// Objects positionally formatted into the message template. + void Error(string messageTemplate, params object[] values); + + /// + /// An (optional) exception that should be recorded alongside this event. + void Error(Exception? exception, string messageTemplate, params object[] values); + + /// + /// Log a message to the Dalamud log for this plugin. This log level should be + /// used for user error, potential problems, or high-importance messages that should be logged. + /// + /// Message template describing the event. + /// Objects positionally formatted into the message template. + void Warning(string messageTemplate, params object[] values); + + /// + /// An (optional) exception that should be recorded alongside this event. + void Warning(Exception? exception, string messageTemplate, params object[] values); + + /// + /// Log an message to the Dalamud log for this plugin. This log level + /// should be used for general plugin operations and other relevant information to track a plugin's behavior. + /// + /// Message template describing the event. + /// Objects positionally formatted into the message template. + void Information(string messageTemplate, params object[] values); + + /// + /// An (optional) exception that should be recorded alongside this event. + void Information(Exception? exception, string messageTemplate, params object[] values); + + /// + void Info(string messageTemplate, params object[] values); + + /// + void Info(Exception? exception, string messageTemplate, params object[] values); + + /// + /// Log a message to the Dalamud log for this plugin. This log level should be + /// used for messages or information that aid with debugging or tracing a plugin's operations, but should not be + /// recorded unless requested. + /// + /// + /// By default, this log level is below the default log level of Dalamud. Messages logged at this level will not be + /// recorded unless the global log level is specifically set to Debug or lower. If information should be generally + /// or easily accessible for support purposes without the user taking additional action, consider using the + /// Information level instead. Developers should not use this log level where it can be triggered on a + /// per-frame basis. + /// + /// Message template describing the event. + /// Objects positionally formatted into the message template. + void Debug(string messageTemplate, params object[] values); + + /// + /// An (optional) exception that should be recorded alongside this event. + void Debug(Exception? exception, string messageTemplate, params object[] values); + + /// + /// Log a message to the Dalamud log for this plugin. This log level is + /// intended almost primarily for development purposes and detailed tracing of a plugin's operations. Verbose logs + /// should not be used to expose information useful for support purposes. + /// + /// + /// By default, this log level is below the default log level of Dalamud. Messages logged at this level will not be + /// recorded unless the global log level is specifically set to Verbose. Release plugins must also set the + /// to to use this level, and should only do so + /// upon specific user request (e.g. a "Enable Troubleshooting Logs" button). + /// + /// Message template describing the event. + /// Objects positionally formatted into the message template. + void Verbose(string messageTemplate, params object[] values); + + /// + /// An (optional) exception that should be recorded alongside this event. + void Verbose(Exception? exception, string messageTemplate, params object[] values); + + /// + /// Write a raw log event to the plugin's log. Used for interoperability with other log systems, as well as + /// advanced use cases. + /// + /// The log level for this event. + /// An (optional) exception that should be recorded alongside this event. + /// Message template describing the event. + /// Objects positionally formatted into the message template. + void Write(LogEventLevel level, Exception? exception, string messageTemplate, params object[] values); +} diff --git a/Dalamud/Plugin/Services/ITargetManager.cs b/Dalamud/Plugin/Services/ITargetManager.cs index 108b1ca03..99a9d8dfb 100644 --- a/Dalamud/Plugin/Services/ITargetManager.cs +++ b/Dalamud/Plugin/Services/ITargetManager.cs @@ -41,4 +41,16 @@ public interface ITargetManager /// Set to null to clear the target. ///
public GameObject? SoftTarget { get; set; } + + /// + /// Gets or sets the gpose target. + /// Set to null to clear the target. + /// + public GameObject? GPoseTarget { get; set; } + + /// + /// Gets or sets the mouseover nameplate target. + /// Set to null to clear the target. + /// + public GameObject? MouseOverNameplateTarget { get; set; } } diff --git a/Dalamud/Plugin/Services/ITextureProvider.cs b/Dalamud/Plugin/Services/ITextureProvider.cs index 091b2ed67..f91d4ee8e 100644 --- a/Dalamud/Plugin/Services/ITextureProvider.cs +++ b/Dalamud/Plugin/Services/ITextureProvider.cs @@ -44,7 +44,7 @@ public interface ITextureProvider /// If null, default to the game's current language. /// /// - /// Prevent Dalamud from automatically unloading this icon to save memory. Usually does not need to be set. + /// Not used. This parameter is ignored. /// /// /// Null, if the icon does not exist in the specified configuration, or a texture wrap that can be used @@ -72,7 +72,7 @@ public interface ITextureProvider /// You may only specify paths in the game's VFS. ///
/// The path to the texture in the game's VFS. - /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Not used. This parameter is ignored. /// Null, if the icon does not exist, or a texture wrap that can be used to render the texture. public IDalamudTextureWrap? GetTextureFromGame(string path, bool keepAlive = false); @@ -83,7 +83,7 @@ public interface ITextureProvider /// This API can load .png and .tex files. ///
/// The FileInfo describing the image or texture file. - /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Not used. This parameter is ignored. /// Null, if the file does not exist, or a texture wrap that can be used to render the texture. public IDalamudTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive = false); diff --git a/Dalamud/Plugin/Services/ITitleScreenMenu.cs b/Dalamud/Plugin/Services/ITitleScreenMenu.cs new file mode 100644 index 000000000..b4af06e71 --- /dev/null +++ b/Dalamud/Plugin/Services/ITitleScreenMenu.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +using Dalamud.Interface; +using Dalamud.Interface.Internal; +using ImGuiScene; + +namespace Dalamud.Plugin.Services; + +/// +/// Interface for class responsible for managing elements in the title screen menu. +/// +public interface ITitleScreenMenu +{ + /// + /// Gets the list of entries in the title screen menu. + /// + public IReadOnlyList Entries { get; } + + /// + /// Adds a new entry to the title screen menu. + /// + /// The text to show. + /// The texture to show. + /// The action to execute when the option is selected. + /// A object that can be used to manage the entry. + /// Thrown when the texture provided does not match the required resolution(64x64). + public TitleScreenMenuEntry AddEntry(string text, IDalamudTextureWrap texture, Action onTriggered); + + /// + /// Adds a new entry to the title screen menu. + /// + /// Priority of the entry. + /// The text to show. + /// The texture to show. + /// The action to execute when the option is selected. + /// A object that can be used to manage the entry. + /// Thrown when the texture provided does not match the required resolution(64x64). + public TitleScreenMenuEntry AddEntry(ulong priority, string text, IDalamudTextureWrap texture, Action onTriggered); + + /// + /// Remove an entry from the title screen menu. + /// + /// The entry to remove. + public void RemoveEntry(TitleScreenMenuEntry entry); +} diff --git a/Dalamud/Plugin/Services/IToastGui.cs b/Dalamud/Plugin/Services/IToastGui.cs new file mode 100644 index 000000000..ef83e95ac --- /dev/null +++ b/Dalamud/Plugin/Services/IToastGui.cs @@ -0,0 +1,88 @@ +using Dalamud.Game.Gui.Toast; +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Plugin.Services; + +/// +/// This class facilitates interacting with and creating native toast windows. +/// +public interface IToastGui +{ + /// + /// A delegate type used when a normal toast window appears. + /// + /// The message displayed. + /// Assorted toast options. + /// Whether the toast has been handled or should be propagated. + public delegate void OnNormalToastDelegate(ref SeString message, ref ToastOptions options, ref bool isHandled); + + /// + /// A delegate type used when a quest toast window appears. + /// + /// The message displayed. + /// Assorted toast options. + /// Whether the toast has been handled or should be propagated. + public delegate void OnQuestToastDelegate(ref SeString message, ref QuestToastOptions options, ref bool isHandled); + + /// + /// A delegate type used when an error toast window appears. + /// + /// The message displayed. + /// Whether the toast has been handled or should be propagated. + public delegate void OnErrorToastDelegate(ref SeString message, ref bool isHandled); + + /// + /// Event that will be fired when a toast is sent by the game or a plugin. + /// + public event OnNormalToastDelegate Toast; + + /// + /// Event that will be fired when a quest toast is sent by the game or a plugin. + /// + public event OnQuestToastDelegate QuestToast; + + /// + /// Event that will be fired when an error toast is sent by the game or a plugin. + /// + public event OnErrorToastDelegate ErrorToast; + + /// + /// Show a toast message with the given content. + /// + /// The message to be shown. + /// Options for the toast. + public void ShowNormal(string message, ToastOptions? options = null); + + /// + /// Show a toast message with the given content. + /// + /// The message to be shown. + /// Options for the toast. + public void ShowNormal(SeString message, ToastOptions? options = null); + + /// + /// Show a quest toast message with the given content. + /// + /// The message to be shown. + /// Options for the toast. + public void ShowQuest(string message, QuestToastOptions? options = null); + + /// + /// Show a quest toast message with the given content. + /// + /// The message to be shown. + /// Options for the toast. + public void ShowQuest(SeString message, QuestToastOptions? options = null); + + /// + /// Show an error toast message with the given content. + /// + /// The message to be shown. + public void ShowError(string message); + + /// + /// Show an error toast message with the given content. + /// + /// The message to be shown. + public void ShowError(SeString message); +} diff --git a/Dalamud/ServiceManager.cs b/Dalamud/ServiceManager.cs index d1c1002bd..845a65d6e 100644 --- a/Dalamud/ServiceManager.cs +++ b/Dalamud/ServiceManager.cs @@ -1,9 +1,9 @@ -using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -11,17 +11,19 @@ using Dalamud.Configuration.Internal; using Dalamud.Game; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; +using Dalamud.Storage; +using Dalamud.Utility; using Dalamud.Utility.Timing; using JetBrains.Annotations; namespace Dalamud; // TODO: -// - Unify dependency walking code(load/unload +// - Unify dependency walking code(load/unload) // - Visualize/output .dot or imgui thing /// -/// Class to initialize Service<T>s. +/// Class to initialize . /// internal static class ServiceManager { @@ -30,11 +32,39 @@ internal static class ServiceManager ///
public static readonly ModuleLog Log = new("SVC"); +#if DEBUG + /// + /// Marks which service constructor the current thread's in. For use from only. + /// + internal static readonly ThreadLocal CurrentConstructorServiceType = new(); + + [SuppressMessage("ReSharper", "CollectionNeverQueried.Local", Justification = "Debugging purposes")] + private static readonly List LoadedServices = new(); +#endif + private static readonly TaskCompletionSource BlockingServicesLoadedTaskCompletionSource = new(); - private static readonly List LoadedServices = new(); - private static ManualResetEvent unloadResetEvent = new(false); + + /// + /// Delegate for registering startup blocker task.
+ /// Do not use this delegate outside the constructor. + ///
+ /// The blocker task. + /// The justification for using this feature. + [InjectableType] + public delegate void RegisterStartupBlockerDelegate(Task t, string justification); + + /// + /// Delegate for registering services that should be unloaded before self.
+ /// Intended for use with . If you think you need to use this outside + /// of that, consider having a discussion first.
+ /// Do not use this delegate outside the constructor. + ///
+ /// Services that should be unloaded first. + /// The justification for using this feature. + [InjectableType] + public delegate void RegisterUnloadAfterDelegate(IEnumerable unloadAfter, string justification); /// /// Kinds of services. @@ -48,9 +78,9 @@ internal static class ServiceManager None = 0, /// - /// Regular service. + /// Service that is loaded manually. /// - ManualService = 1 << 0, + ProvidedService = 1 << 0, /// /// Service that is loaded asynchronously while the game starts. @@ -82,42 +112,50 @@ internal static class ServiceManager /// Initializes Provided Services and FFXIVClientStructs. /// /// Instance of . - /// Instance of . + /// Instance of . /// Instance of . - public static void InitializeProvidedServicesAndClientStructs(Dalamud dalamud, DalamudStartInfo startInfo, DalamudConfiguration configuration) + /// Instance of . + public static void InitializeProvidedServices(Dalamud dalamud, ReliableFileStorage fs, DalamudConfiguration configuration, TargetSigScanner scanner) { - // Initialize the process information. - var cacheDir = new DirectoryInfo(Path.Combine(startInfo.WorkingDirectory!, "cachedSigs")); - if (!cacheDir.Exists) - cacheDir.Create(); - +#if DEBUG lock (LoadedServices) { - Service.Provide(dalamud); - LoadedServices.Add(typeof(Dalamud)); - - Service.Provide(startInfo); - LoadedServices.Add(typeof(DalamudStartInfo)); - - Service.Provide(configuration); - LoadedServices.Add(typeof(DalamudConfiguration)); - - Service.Provide(new ServiceContainer()); - LoadedServices.Add(typeof(ServiceContainer)); - - Service.Provide( - new SigScanner( - true, new FileInfo(Path.Combine(cacheDir.FullName, $"{startInfo.GameVersion}.json")))); - LoadedServices.Add(typeof(SigScanner)); + ProvideService(dalamud); + ProvideService(fs); + ProvideService(configuration); + ProvideService(new ServiceContainer()); + ProvideService(scanner); } - using (Timings.Start("CS Resolver Init")) + return; + + void ProvideService(T service) where T : IServiceType { - FFXIVClientStructs.Interop.Resolver.GetInstance.SetupSearchSpace(Service.Get().SearchBase, new FileInfo(Path.Combine(cacheDir.FullName, $"{startInfo.GameVersion}_cs.json"))); - FFXIVClientStructs.Interop.Resolver.GetInstance.Resolve(); + Debug.Assert(typeof(T).GetServiceKind().HasFlag(ServiceKind.ProvidedService), "Provided service must have Service attribute"); + Service.Provide(service); + LoadedServices.Add(typeof(T)); } +#else + ProvideService(dalamud); + ProvideService(fs); + ProvideService(configuration); + ProvideService(new ServiceContainer()); + ProvideService(scanner); + return; + + void ProvideService(T service) where T : IServiceType => Service.Provide(service); +#endif } + /// + /// Gets the concrete types of services, i.e. the non-abstract non-interface types. + /// + /// The enumerable of service types, that may be enumerated only once per call. + public static IEnumerable GetConcreteServiceTypes() => + Assembly.GetExecutingAssembly() + .GetTypes() + .Where(x => x.IsAssignableTo(typeof(IServiceType)) && !x.IsInterface && !x.IsAbstract); + /// /// Kicks off construction of services that can handle early loading. /// @@ -128,42 +166,52 @@ internal static class ServiceManager var earlyLoadingServices = new HashSet(); var blockingEarlyLoadingServices = new HashSet(); + var providedServices = new HashSet(); var dependencyServicesMap = new Dictionary>(); var getAsyncTaskMap = new Dictionary(); var serviceContainer = Service.Get(); - foreach (var serviceType in Assembly.GetExecutingAssembly().GetTypes()) + foreach (var serviceType in GetConcreteServiceTypes()) { var serviceKind = serviceType.GetServiceKind(); - if (serviceKind is ServiceKind.None) - continue; - // Scoped service do not go through Service, so we must let ServiceContainer know what their interfaces map to - if (serviceKind is ServiceKind.ScopedService) - { - serviceContainer.RegisterInterfaces(serviceType); - continue; - } + CheckServiceTypeContracts(serviceType); + + // Let IoC know about the interfaces this service implements + serviceContainer.RegisterInterfaces(serviceType); - Debug.Assert( - !serviceKind.HasFlag(ServiceKind.ManualService) && !serviceKind.HasFlag(ServiceKind.ScopedService), - "Regular and scoped services should never be loaded early"); + // Scoped service do not go through Service and are never early loaded + if (serviceKind.HasFlag(ServiceKind.ScopedService)) + continue; var genericWrappedServiceType = typeof(Service<>).MakeGenericType(serviceType); var getTask = (Task)genericWrappedServiceType .InvokeMember( - "GetAsync", + nameof(Service.GetAsync), BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public, null, null, null); + getAsyncTaskMap[serviceType] = getTask; + + // 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) || + serviceKind.HasFlag(ServiceKind.BlockingEarlyLoadedService), + "At this point, service must be either early loaded or blocking early loaded"); + if (serviceKind.HasFlag(ServiceKind.BlockingEarlyLoadedService)) { - getAsyncTaskMap[serviceType] = getTask; blockingEarlyLoadingServices.Add(serviceType); } else @@ -171,22 +219,25 @@ internal static class ServiceManager earlyLoadingServices.Add(serviceType); } - dependencyServicesMap[serviceType] = - (List)typeof(Service<>) - .MakeGenericType(serviceType) - .InvokeMember( - "GetDependencyServices", - BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public, - null, - null, - null); + var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); + dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT, false) + .Select(x => typeof(Service<>).MakeGenericType(x)) + .ToList(); } + var blockerTasks = new List(); _ = Task.Run(async () => { try { - await Task.WhenAll(blockingEarlyLoadingServices.Select(x => getAsyncTaskMap[x])); + // Wait for all blocking constructors to complete first. + await WaitWithTimeoutConsent(blockingEarlyLoadingServices.Select(x => getAsyncTaskMap[x])); + + // All the BlockingEarlyLoadedService constructors have been run, + // and blockerTasks now will not change. Now wait for them. + // Note that ServiceManager.CallWhenServicesReady does not get to register a blocker. + await WaitWithTimeoutConsent(blockerTasks); + BlockingServicesLoadedTaskCompletionSource.SetResult(); Timings.Event("BlockingServices Initialized"); } @@ -194,6 +245,31 @@ internal static class ServiceManager { BlockingServicesLoadedTaskCompletionSource.SetException(e); } + + return; + + async Task WaitWithTimeoutConsent(IEnumerable tasksEnumerable) + { + var tasks = tasksEnumerable.AsReadOnlyCollection(); + if (tasks.Count == 0) + return; + + var aggregatedTask = Task.WhenAll(tasks); + while (await Task.WhenAny(aggregatedTask, Task.Delay(120000)) != aggregatedTask) + { + if (NativeFunctions.MessageBoxW( + IntPtr.Zero, + "Dalamud is taking a long time to load. Would you like to continue without Dalamud?\n" + + "This can be caused by a faulty plugin, or a bug in Dalamud.", + "Dalamud", + NativeFunctions.MessageBoxType.IconWarning | NativeFunctions.MessageBoxType.YesNo) == 6) + { + throw new TimeoutException( + "Failed to load services in the given time limit, " + + "and the user chose to continue without Dalamud."); + } + } + } }).ConfigureAwait(false); var tasks = new List(); @@ -210,13 +286,13 @@ internal static class ServiceManager var hasDeps = true; foreach (var dependency in dependencyServicesMap[serviceType]) { - var depServiceKind = dependency.GetServiceKind(); - var depResolveTask = getAsyncTaskMap.GetValueOrDefault(dependency); + var depUnderlyingServiceType = dependency.GetGenericArguments().First(); + var depResolveTask = getAsyncTaskMap.GetValueOrDefault(depUnderlyingServiceType); - if (depResolveTask == null && (depServiceKind.HasFlag(ServiceKind.EarlyLoadedService) || depServiceKind.HasFlag(ServiceKind.BlockingEarlyLoadedService))) + if (depResolveTask == null) { - Log.Error("{Type}: {Dependency} has no resolver task, is it early loaded or blocking early loaded?", serviceType.FullName!, dependency.FullName!); - Debug.Assert(false, $"No resolver for dependent service {dependency.FullName}"); + Log.Error("{Type}: {Dependency} has no resolver task", serviceType.FullName!, dependency.FullName!); + Debug.Assert(false, $"No resolver for dependent service {depUnderlyingServiceType.FullName}"); } else if (depResolveTask is { IsCompleted: false }) { @@ -227,16 +303,36 @@ internal static class ServiceManager if (!hasDeps) continue; + // This object will be used in a task. Each task must receive a new object. + var startLoaderArgs = new List(); + if (serviceType.GetCustomAttribute() is not null) + { + startLoaderArgs.Add( + new RegisterStartupBlockerDelegate( + (task, justification) => + { +#if DEBUG + if (CurrentConstructorServiceType.Value != serviceType) + throw new InvalidOperationException("Forbidden."); +#endif + blockerTasks.Add(task); + + // No need to store the justification; the fact that the reason is specified is good enough. + _ = justification; + })); + } + tasks.Add((Task)typeof(Service<>) .MakeGenericType(serviceType) .InvokeMember( - "StartLoader", + nameof(Service.StartLoader), BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.NonPublic, null, null, - null)); + new object[] { startLoaderArgs })); servicesToLoad.Remove(serviceType); +#if DEBUG tasks.Add(tasks.Last().ContinueWith(task => { if (task.IsFaulted) @@ -246,10 +342,20 @@ internal static class ServiceManager LoadedServices.Add(serviceType); } })); +#endif } 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()) { @@ -304,13 +410,13 @@ internal static class ServiceManager unloadResetEvent.Reset(); - var dependencyServicesMap = new Dictionary>(); + var dependencyServicesMap = new Dictionary>(); var allToUnload = new HashSet(); var unloadOrder = new List(); Log.Information("==== COLLECTING SERVICES TO UNLOAD ===="); - foreach (var serviceType in Assembly.GetExecutingAssembly().GetTypes()) + foreach (var serviceType in GetConcreteServiceTypes()) { if (!serviceType.IsAssignableTo(typeof(IServiceType))) continue; @@ -322,16 +428,8 @@ internal static class ServiceManager Log.Verbose("Calling GetDependencyServices for '{ServiceName}'", serviceType.FullName!); - dependencyServicesMap[serviceType] = - ((List)typeof(Service<>) - .MakeGenericType(serviceType) - .InvokeMember( - "GetDependencyServices", - BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public, - null, - null, - null))! - .Select(x => x.GetGenericArguments()[0]).ToList(); + var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); + dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT, true); allToUnload.Add(serviceType); } @@ -373,10 +471,12 @@ internal static class ServiceManager null); } +#if DEBUG lock (LoadedServices) { LoadedServices.Clear(); } +#endif unloadResetEvent.Set(); } @@ -396,7 +496,7 @@ internal static class ServiceManager /// The type of service this type is. public static ServiceKind GetServiceKind(this Type type) { - var attr = type.GetCustomAttribute(true)?.GetType(); + var attr = type.GetCustomAttribute(true)?.GetType(); if (attr == null) return ServiceKind.None; @@ -404,16 +504,54 @@ internal static class ServiceManager type.IsAssignableTo(typeof(IServiceType)), "Service did not inherit from IServiceType"); - if (attr.IsAssignableTo(typeof(BlockingEarlyLoadedService))) + if (attr.IsAssignableTo(typeof(BlockingEarlyLoadedServiceAttribute))) return ServiceKind.BlockingEarlyLoadedService; - if (attr.IsAssignableTo(typeof(EarlyLoadedService))) + if (attr.IsAssignableTo(typeof(EarlyLoadedServiceAttribute))) return ServiceKind.EarlyLoadedService; - if (attr.IsAssignableTo(typeof(ScopedService))) + if (attr.IsAssignableTo(typeof(ScopedServiceAttribute))) return ServiceKind.ScopedService; - return ServiceKind.ManualService; + return ServiceKind.ProvidedService; + } + + /// Validate service type contracts, and throws exceptions accordingly. + /// An instance of that is supposed to be a service type. + /// Does nothing on non-debug builds. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CheckServiceTypeContracts(Type serviceType) + { +#if DEBUG + try + { + if (!serviceType.IsAssignableTo(typeof(IServiceType))) + throw new InvalidOperationException($"Non-{nameof(IServiceType)} passed."); + if (serviceType.GetServiceKind() == ServiceKind.None) + throw new InvalidOperationException("Service type is not specified."); + + var isServiceDisposable = + serviceType.IsAssignableTo(typeof(IInternalDisposableService)); + var isAnyDisposable = + isServiceDisposable + || serviceType.IsAssignableTo(typeof(IDisposable)) + || serviceType.IsAssignableTo(typeof(IAsyncDisposable)); + if (isAnyDisposable && !isServiceDisposable) + { + throw new InvalidOperationException( + $"A service must be an {nameof(IInternalDisposableService)} without specifying " + + $"{nameof(IDisposable)} nor {nameof(IAsyncDisposable)} if it is purely meant to be a service, " + + $"or an {nameof(IPublicDisposableService)} if it also is allowed to be constructed not as a " + + $"service to be used elsewhere and has to offer {nameof(IDisposable)} or " + + $"{nameof(IAsyncDisposable)}. See {nameof(ReliableFileStorage)} for an example of " + + $"{nameof(IPublicDisposableService)}."); + } + } + catch (Exception e) + { + throw new InvalidOperationException($"{serviceType.Name}: {e.Message}"); + } +#endif } /// @@ -437,16 +575,57 @@ internal static class ServiceManager /// Indicates that the class is a service. /// [AttributeUsage(AttributeTargets.Class)] - public class Service : Attribute + public abstract class ServiceAttribute : Attribute { + /// + /// Initializes a new instance of the class. + /// + /// The kind of the service. + protected ServiceAttribute(ServiceKind kind) => this.Kind = kind; + + /// + /// Gets the kind of the service. + /// + public ServiceKind Kind { get; } + } + + /// + /// Indicates that the class is a service, that is provided by some other source. + /// + [AttributeUsage(AttributeTargets.Class)] + public class ProvidedServiceAttribute : ServiceAttribute + { + /// + /// Initializes a new instance of the class. + /// + public ProvidedServiceAttribute() + : base(ServiceKind.ProvidedService) + { + } } /// /// Indicates that the class is a service, and will be instantiated automatically on startup. /// [AttributeUsage(AttributeTargets.Class)] - public class EarlyLoadedService : Service + public class EarlyLoadedServiceAttribute : ServiceAttribute { + /// + /// Initializes a new instance of the class. + /// + public EarlyLoadedServiceAttribute() + : this(ServiceKind.EarlyLoadedService) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The service kind. + protected EarlyLoadedServiceAttribute(ServiceKind kind) + : base(kind) + { + } } /// @@ -454,8 +633,15 @@ internal static class ServiceManager /// blocking game main thread until it completes. /// [AttributeUsage(AttributeTargets.Class)] - public class BlockingEarlyLoadedService : EarlyLoadedService + public class BlockingEarlyLoadedServiceAttribute : EarlyLoadedServiceAttribute { + /// + /// Initializes a new instance of the class. + /// + public BlockingEarlyLoadedServiceAttribute() + : base(ServiceKind.BlockingEarlyLoadedService) + { + } } /// @@ -463,16 +649,47 @@ internal static class ServiceManager /// service scope, and that it cannot be created outside of a scope. /// [AttributeUsage(AttributeTargets.Class)] - public class ScopedService : Service + public class ScopedServiceAttribute : ServiceAttribute { + /// + /// Initializes a new instance of the class. + /// + public ScopedServiceAttribute() + : base(ServiceKind.ScopedService) + { + } } /// - /// Indicates that the method should be called when the services given in the constructor are ready. + /// Indicates that the method should be called when the services given in the marked method's parameters are ready. + /// This will be executed immediately after the constructor has run, if all services specified as its parameters + /// are already ready, or no parameter is given. /// [AttributeUsage(AttributeTargets.Method)] [MeansImplicitUse] public class CallWhenServicesReady : Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// Specify the reason here. + public CallWhenServicesReady(string justification) + { + // No need to store the justification; the fact that the reason is specified is good enough. + _ = justification; + } + } + + /// + /// Indicates that something is a candidate for being considered as an injected parameter for constructors. + /// + [AttributeUsage( + AttributeTargets.Delegate + | AttributeTargets.Class + | AttributeTargets.Struct + | AttributeTargets.Enum + | AttributeTargets.Interface)] + public class InjectableTypeAttribute : Attribute { } } diff --git a/Dalamud/Service{T}.cs b/Dalamud/Service{T}.cs index aa10ead6e..ed03749d5 100644 --- a/Dalamud/Service{T}.cs +++ b/Dalamud/Service{T}.cs @@ -1,12 +1,11 @@ -using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Threading.Tasks; using Dalamud.IoC; using Dalamud.IoC.Internal; -using Dalamud.Plugin.Internal; using Dalamud.Utility.Timing; using JetBrains.Annotations; @@ -19,17 +18,27 @@ namespace Dalamud; /// Only used internally within Dalamud, if plugins need access to things it should be _only_ via DI. /// /// The class you want to store in the service locator. +[SuppressMessage("ReSharper", "StaticMemberInGenericType", Justification = "Service container static type")] internal static class Service where T : IServiceType { + private static readonly ServiceManager.ServiceAttribute ServiceAttribute; private static TaskCompletionSource instanceTcs = new(); + private static List? dependencyServices; + private static List? dependencyServicesForUnload; static Service() { - var exposeToPlugins = typeof(T).GetCustomAttribute() != null; + var type = typeof(T); + ServiceAttribute = + type.GetCustomAttribute(true) + ?? throw new InvalidOperationException( + $"{nameof(T)} is missing {nameof(ServiceManager.ServiceAttribute)} annotations."); + + var exposeToPlugins = type.GetCustomAttribute() != null; if (exposeToPlugins) - ServiceManager.Log.Debug("Service<{0}>: Static ctor called; will be exposed to plugins", typeof(T).Name); + ServiceManager.Log.Debug("Service<{0}>: Static ctor called; will be exposed to plugins", type.Name); else - ServiceManager.Log.Debug("Service<{0}>: Static ctor called", typeof(T).Name); + ServiceManager.Log.Debug("Service<{0}>: Static ctor called", type.Name); if (exposeToPlugins) Service.Get().RegisterSingleton(instanceTcs.Task); @@ -56,14 +65,22 @@ internal static class Service where T : IServiceType None, } + /// Does nothing. + /// Used to invoke the static ctor. + public static void Nop() + { + } + /// /// Sets the type in the service locator to the given object. /// /// Object to set. public static void Provide(T obj) { - instanceTcs.SetResult(obj); ServiceManager.Log.Debug("Service<{0}>: Provided", typeof(T).Name); + if (obj is IPublicDisposableService pds) + pds.MarkDisposeOnlyFromService(); + instanceTcs.SetResult(obj); } /// @@ -82,6 +99,21 @@ internal static class Service where T : IServiceType /// The object. public static T Get() { +#if DEBUG + if (ServiceAttribute.Kind != ServiceManager.ServiceKind.ProvidedService + && ServiceManager.CurrentConstructorServiceType.Value is { } currentServiceType) + { + var deps = ServiceHelpers.GetDependencies(typeof(Service<>).MakeGenericType(currentServiceType), false); + if (!deps.Contains(typeof(T))) + { + throw new InvalidOperationException( + $"Calling {nameof(Service)}<{typeof(T)}>.{nameof(Get)} which is not one of the" + + $" dependency services is forbidden from the service constructor of {currentServiceType}." + + $" This has a high chance of introducing hard-to-debug hangs."); + } + } +#endif + if (!instanceTcs.Task.IsCompleted) instanceTcs.Task.Wait(); return instanceTcs.Task.Result; @@ -91,7 +123,6 @@ internal static class Service where T : IServiceType /// Pull the instance out of the service locator, waiting if necessary. /// /// The object. - [UsedImplicitly] public static Task GetAsync() => instanceTcs.Task; /// @@ -115,77 +146,111 @@ internal static class Service where T : IServiceType } /// - /// Gets an enumerable containing Service<T>s that are required for this Service to initialize without blocking. + /// Gets an enumerable containing s that are required for this Service to initialize + /// without blocking. + /// These are NOT returned as types; raw types will be returned. /// + /// Whether to include the unload dependencies. /// List of dependency services. - [UsedImplicitly] - public static List GetDependencyServices() + public static IReadOnlyCollection GetDependencyServices(bool includeUnloadDependencies) { + if (includeUnloadDependencies && dependencyServicesForUnload is not null) + return dependencyServicesForUnload; + + if (dependencyServices is not null) + return dependencyServices; + var res = new List(); + + ServiceManager.Log.Verbose("Service<{0}>: Getting dependencies", typeof(T).Name); var ctor = GetServiceConstructor(); if (ctor != null) { res.AddRange(ctor .GetParameters() - .Select(x => x.ParameterType)); + .Select(x => x.ParameterType) + .Where(x => x.GetServiceKind() != ServiceManager.ServiceKind.None)); } res.AddRange(typeof(T) .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .Select(x => x.FieldType) - .Where(x => x.GetCustomAttribute(true) != null)); + .Where(x => x.GetCustomAttribute(true) != null) + .Select(x => x.FieldType)); res.AddRange(typeof(T) .GetCustomAttributes() .OfType() .Select(x => x.GetType().GetGenericArguments().First())); - // HACK: PluginManager needs to depend on ALL plugin exposed services - if (typeof(T) == typeof(PluginManager)) + foreach (var type in res) + ServiceManager.Log.Verbose("Service<{0}>: => Dependency: {1}", typeof(T).Name, type.Name); + + var deps = res + .Distinct() + .ToList(); + if (typeof(T).GetCustomAttribute() is not null) { - foreach (var serviceType in Assembly.GetExecutingAssembly().GetTypes()) + var offenders = deps.Where( + x => x.GetCustomAttribute(true)!.Kind + is not ServiceManager.ServiceKind.BlockingEarlyLoadedService + and not ServiceManager.ServiceKind.ProvidedService) + .ToArray(); + if (offenders.Any()) { - if (!serviceType.IsAssignableTo(typeof(IServiceType))) - continue; - - var attr = serviceType.GetCustomAttribute(true); - if (attr == null) - continue; - - // Scoped plugin services lifetime is tied to their scopes. They go away when LocalPlugin goes away. - if (serviceType.GetServiceKind() == ServiceManager.ServiceKind.ScopedService) - continue; - - ServiceManager.Log.Verbose("PluginManager MUST depend on {Type}", serviceType.FullName!); - res.Add(serviceType); + ServiceManager.Log.Error( + "{me} is a {bels}; it can only depend on {bels} and {ps}.\nOffending dependencies:\n{offenders}", + typeof(T), + nameof(ServiceManager.BlockingEarlyLoadedServiceAttribute), + nameof(ServiceManager.BlockingEarlyLoadedServiceAttribute), + nameof(ServiceManager.ProvidedServiceAttribute), + string.Join("\n", offenders.Select(x => $"\t* {x.Name}"))); } } - return res - .Distinct() - .Select(x => typeof(Service<>).MakeGenericType(x)) - .ToList(); + return dependencyServices = deps; } - [UsedImplicitly] - private static Task StartLoader() + /// + /// Starts the service loader. Only to be called from . + /// + /// Additional objects available to constructors. + /// The loader task. + internal static Task StartLoader(IReadOnlyCollection additionalProvidedTypedObjects) { if (instanceTcs.Task.IsCompleted) throw new InvalidOperationException($"{typeof(T).Name} is already loaded or disposed."); - var attr = typeof(T).GetCustomAttribute(true)?.GetType(); - if (attr?.IsAssignableTo(typeof(ServiceManager.EarlyLoadedService)) != true) + var attr = ServiceAttribute.GetType(); + if (attr.IsAssignableTo(typeof(ServiceManager.EarlyLoadedServiceAttribute)) != true) throw new InvalidOperationException($"{typeof(T).Name} is not an EarlyLoadedService"); return Task.Run(Timings.AttachTimingHandle(async () => { + var ctorArgs = new List(additionalProvidedTypedObjects.Count + 1); + ctorArgs.AddRange(additionalProvidedTypedObjects); + ctorArgs.Add( + new ServiceManager.RegisterUnloadAfterDelegate( + (additionalDependencies, justification) => + { +#if DEBUG + if (ServiceManager.CurrentConstructorServiceType.Value != typeof(T)) + throw new InvalidOperationException("Forbidden."); +#endif + dependencyServicesForUnload ??= new(GetDependencyServices(false)); + dependencyServicesForUnload.AddRange(additionalDependencies); + + // No need to store the justification; the fact that the reason is specified is good enough. + _ = justification; + })); + ServiceManager.Log.Debug("Service<{0}>: Begin construction", typeof(T).Name); try { - var instance = await ConstructObject(); + var instance = await ConstructObject(ctorArgs).ConfigureAwait(false); instanceTcs.SetResult(instance); + List? tasks = null; foreach (var method in typeof(T).GetMethods( BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { @@ -193,11 +258,35 @@ internal static class Service where T : IServiceType continue; ServiceManager.Log.Debug("Service<{0}>: Calling {1}", typeof(T).Name, method.Name); - var args = await Task.WhenAll(method.GetParameters().Select( - x => ResolveServiceFromTypeAsync(x.ParameterType))); - method.Invoke(instance, args); + var args = await ResolveInjectedParameters( + method.GetParameters(), + Array.Empty()).ConfigureAwait(false); + if (args.Length == 0) + { + ServiceManager.Log.Warning( + "Service<{0}>: Method {1} does not have any arguments. Consider merging it with the ctor.", + typeof(T).Name, + method.Name); + } + + try + { + if (method.Invoke(instance, args) is Task task) + { + tasks ??= new(); + tasks.Add(task); + } + } + catch (Exception e) + { + tasks ??= new(); + tasks.Add(Task.FromException(e)); + } } + if (tasks is not null) + await Task.WhenAll(tasks); + ServiceManager.Log.Debug("Service<{0}>: Construction complete", typeof(T).Name); return instance; } @@ -216,47 +305,32 @@ internal static class Service where T : IServiceType if (!instanceTcs.Task.IsCompletedSuccessfully) return; - var instance = instanceTcs.Task.Result; - if (instance is IDisposable disposable) + switch (instanceTcs.Task.Result) { - ServiceManager.Log.Debug("Service<{0}>: Disposing", typeof(T).Name); - try - { - disposable.Dispose(); - ServiceManager.Log.Debug("Service<{0}>: Disposed", typeof(T).Name); - } - catch (Exception e) - { - ServiceManager.Log.Warning(e, "Service<{0}>: Dispose failure", typeof(T).Name); - } - } - else - { - ServiceManager.Log.Debug("Service<{0}>: Unset", typeof(T).Name); + case IInternalDisposableService d: + ServiceManager.Log.Debug("Service<{0}>: Disposing", typeof(T).Name); + try + { + d.DisposeService(); + ServiceManager.Log.Debug("Service<{0}>: Disposed", typeof(T).Name); + } + catch (Exception e) + { + ServiceManager.Log.Warning(e, "Service<{0}>: Dispose failure", typeof(T).Name); + } + + break; + + default: + ServiceManager.CheckServiceTypeContracts(typeof(T)); + ServiceManager.Log.Debug("Service<{0}>: Unset", typeof(T).Name); + break; } instanceTcs = new TaskCompletionSource(); instanceTcs.SetException(new UnloadedException()); } - private static async Task ResolveServiceFromTypeAsync(Type type) - { - var task = (Task)typeof(Service<>) - .MakeGenericType(type) - .InvokeMember( - "GetAsync", - BindingFlags.InvokeMethod | - BindingFlags.Static | - BindingFlags.Public, - null, - null, - null)!; - await task; - return typeof(Task<>).MakeGenericType(type) - .GetProperty("Result", BindingFlags.Instance | BindingFlags.Public)! - .GetValue(task); - } - private static ConstructorInfo? GetServiceConstructor() { const BindingFlags ctorBindingFlags = @@ -267,20 +341,69 @@ internal static class Service where T : IServiceType .SingleOrDefault(x => x.GetCustomAttributes(typeof(ServiceManager.ServiceConstructor), true).Any()); } - private static async Task ConstructObject() + private static async Task ConstructObject(IReadOnlyCollection additionalProvidedTypedObjects) { var ctor = GetServiceConstructor(); if (ctor == null) throw new Exception($"Service \"{typeof(T).FullName}\" had no applicable constructor"); - var args = await Task.WhenAll( - ctor.GetParameters().Select(x => ResolveServiceFromTypeAsync(x.ParameterType))); + var args = await ResolveInjectedParameters(ctor.GetParameters(), additionalProvidedTypedObjects) + .ConfigureAwait(false); using (Timings.Start($"{typeof(T).Name} Construct")) { +#if DEBUG + ServiceManager.CurrentConstructorServiceType.Value = typeof(T); + try + { + return (T)ctor.Invoke(args)!; + } + finally + { + ServiceManager.CurrentConstructorServiceType.Value = null; + } +#else return (T)ctor.Invoke(args)!; +#endif } } + private static Task ResolveInjectedParameters( + IReadOnlyList argDefs, + IReadOnlyCollection additionalProvidedTypedObjects) + { + var argTasks = new Task[argDefs.Count]; + for (var i = 0; i < argDefs.Count; i++) + { + var argType = argDefs[i].ParameterType; + ref var argTask = ref argTasks[i]; + + if (argType.GetCustomAttribute() is not null) + { + argTask = Task.FromResult(additionalProvidedTypedObjects.Single(x => x.GetType() == argType)); + continue; + } + + argTask = (Task)typeof(Service<>) + .MakeGenericType(argType) + .InvokeMember( + nameof(GetAsyncAsObject), + BindingFlags.InvokeMethod | + BindingFlags.Static | + BindingFlags.NonPublic, + null, + null, + null)!; + } + + return Task.WhenAll(argTasks); + } + + /// + /// Pull the instance out of the service locator, waiting if necessary. + /// + /// The object. + private static Task GetAsyncAsObject() => instanceTcs.Task.ContinueWith(r => (object)r.Result); + /// /// Exception thrown when service is attempted to be retrieved when it's unloaded. /// @@ -295,3 +418,51 @@ internal static class Service where T : IServiceType } } } + +/// +/// Helper functions for services. +/// +internal static class ServiceHelpers +{ + /// + /// Get a list of dependencies for a service. Only accepts types. + /// These are NOT returned as types; raw types will be returned. + /// + /// The dependencies for this service. + /// Whether to include the unload dependencies. + /// A list of dependencies. + public static IReadOnlyCollection GetDependencies(Type serviceType, bool includeUnloadDependencies) + { +#if DEBUG + if (!serviceType.IsGenericType || serviceType.GetGenericTypeDefinition() != typeof(Service<>)) + { + throw new ArgumentException( + $"Expected an instance of {nameof(Service)}<>", + nameof(serviceType)); + } +#endif + + return (IReadOnlyCollection)serviceType.InvokeMember( + nameof(Service.GetDependencyServices), + BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public, + null, + null, + new object?[] { includeUnloadDependencies }) ?? new List(); + } + + /// + /// Get the type for a given service type. + /// This will throw if the service type is not a valid service. + /// + /// The type to obtain a for. + /// The . + public static Type GetAsService(Type type) + { +#if DEBUG + if (!type.IsAssignableTo(typeof(IServiceType))) + throw new ArgumentException($"Expected an instance of {nameof(IServiceType)}", nameof(type)); +#endif + + return typeof(Service<>).MakeGenericType(type); + } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetAttribute.cs new file mode 100644 index 000000000..a3527cdbc --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetAttribute.cs @@ -0,0 +1,36 @@ +namespace Dalamud.Storage.Assets; + +/// +/// Stores the basic information of a Dalamud asset. +/// +[AttributeUsage(AttributeTargets.Field)] +internal class DalamudAssetAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The purpose. + /// The data. + /// Whether the asset is required. + public DalamudAssetAttribute(DalamudAssetPurpose purpose, byte[]? data = null, bool required = true) + { + this.Purpose = purpose; + this.Data = data; + this.Required = required; + } + + /// + /// Gets the purpose of the asset. + /// + public DalamudAssetPurpose Purpose { get; } + + /// + /// Gets the data, if available. + /// + public byte[]? Data { get; } + + /// + /// Gets a value indicating whether the asset is required. + /// + public bool Required { get; } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetExtensions.cs b/Dalamud/Storage/Assets/DalamudAssetExtensions.cs new file mode 100644 index 000000000..9181f1a5d --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetExtensions.cs @@ -0,0 +1,17 @@ +using Dalamud.Utility; + +namespace Dalamud.Storage.Assets; + +/// +/// Extension methods for . +/// +public static class DalamudAssetExtensions +{ + /// + /// Gets the purpose. + /// + /// The asset. + /// The purpose. + public static DalamudAssetPurpose GetPurpose(this DalamudAsset asset) => + asset.GetAttribute()?.Purpose ?? DalamudAssetPurpose.Empty; +} diff --git a/Dalamud/Storage/Assets/DalamudAssetManager.cs b/Dalamud/Storage/Assets/DalamudAssetManager.cs new file mode 100644 index 000000000..4f53460fb --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetManager.cs @@ -0,0 +1,374 @@ +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Networking.Http; +using Dalamud.Utility; +using Dalamud.Utility.Timing; + +using JetBrains.Annotations; + +using Serilog; + +namespace Dalamud.Storage.Assets; + +/// +/// A concrete class for . +/// +[PluginInterface] +[ServiceManager.BlockingEarlyLoadedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal sealed class DalamudAssetManager : IInternalDisposableService, IDalamudAssetManager +{ + private const int DownloadAttemptCount = 10; + private const int RenameAttemptCount = 10; + + private readonly object syncRoot = new(); + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly Dictionary?> fileStreams; + private readonly Dictionary?> textureWraps; + private readonly Dalamud dalamud; + private readonly HappyHttpClient httpClient; + private readonly string localSourceDirectory; + private readonly CancellationTokenSource cancellationTokenSource; + + private bool isDisposed; + + [ServiceManager.ServiceConstructor] + private DalamudAssetManager( + Dalamud dalamud, + HappyHttpClient httpClient, + ServiceManager.RegisterStartupBlockerDelegate registerStartupBlocker) + { + this.dalamud = dalamud; + this.httpClient = httpClient; + this.localSourceDirectory = Path.Combine(this.dalamud.AssetDirectory.FullName, "..", "local"); + Directory.CreateDirectory(this.localSourceDirectory); + this.scopedFinalizer.Add(this.cancellationTokenSource = new()); + + this.fileStreams = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); + this.textureWraps = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); + + // Block until all the required assets to be ready. + var loadTimings = Timings.Start("DAM LoadAll"); + registerStartupBlocker( + Task.WhenAll( + Enum.GetValues() + .Where(x => x is not DalamudAsset.Empty4X4) + .Where(x => x.GetAttribute()?.Required is true) + .Select(this.CreateStreamAsync) + .Select(x => x.ToContentDisposedTask())) + .ContinueWith( + r => + { + loadTimings.Dispose(); + return r; + }) + .Unwrap(), + "Prevent Dalamud from loading more stuff, until we've ensured that all required assets are available."); + + Task.WhenAll( + Enum.GetValues() + .Where(x => x is not DalamudAsset.Empty4X4) + .Where(x => x.GetAttribute()?.Required is false) + .Select(this.CreateStreamAsync) + .Select(x => x.ToContentDisposedTask(true))) + .ContinueWith(r => Log.Verbose($"Optional assets load state: {r}")); + } + + /// + public IDalamudTextureWrap Empty4X4 => this.GetDalamudTextureWrap(DalamudAsset.Empty4X4); + + /// + void IInternalDisposableService.DisposeService() + { + lock (this.syncRoot) + { + if (this.isDisposed) + return; + + this.isDisposed = true; + } + + this.cancellationTokenSource.Cancel(); + Task.WaitAll( + Array.Empty() + .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(); + } + + /// + [Pure] + public bool IsStreamImmediatelyAvailable(DalamudAsset asset) => + asset.GetAttribute()?.Data is not null + || this.fileStreams[asset]?.IsCompletedSuccessfully is true; + + /// + [Pure] + public Stream CreateStream(DalamudAsset asset) + { + var s = this.CreateStreamAsync(asset); + s.Wait(); + if (s.IsCompletedSuccessfully) + return s.Result; + if (s.Exception is not null) + throw new AggregateException(s.Exception.InnerExceptions); + throw new OperationCanceledException(); + } + + /// + [Pure] + public Task CreateStreamAsync(DalamudAsset asset) + { + if (asset.GetAttribute() is { Data: { } rawData }) + return Task.FromResult(new MemoryStream(rawData, false)); + + Task task; + lock (this.syncRoot) + { + if (this.isDisposed) + throw new ObjectDisposedException(nameof(DalamudAssetManager)); + + task = this.fileStreams[asset] ??= CreateInnerAsync(); + } + + return this.TransformImmediate( + task, + x => (Stream)new FileStream( + x.Name, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan)); + + async Task CreateInnerAsync() + { + string path; + List exceptions = null; + foreach (var name in asset.GetAttributes().Select(x => x.FileName)) + { + if (!File.Exists(path = Path.Combine(this.dalamud.AssetDirectory.FullName, name))) + continue; + + try + { + return File.OpenRead(path); + } + catch (Exception e) when (e is not OperationCanceledException) + { + exceptions ??= new(); + exceptions.Add(e); + } + } + + if (File.Exists(path = Path.Combine(this.localSourceDirectory, asset.ToString()))) + { + try + { + return File.OpenRead(path); + } + catch (Exception e) when (e is not OperationCanceledException) + { + exceptions ??= new(); + exceptions.Add(e); + } + } + + var tempPath = $"{path}.{Environment.ProcessId:x}.{Environment.CurrentManagedThreadId:x}"; + try + { + for (var i = 0; i < DownloadAttemptCount; i++) + { + var attemptedAny = false; + foreach (var url in asset.GetAttributes()) + { + Log.Information("[{who}] {asset}: Trying {url}", nameof(DalamudAssetManager), asset, url); + attemptedAny = true; + + try + { + await using (var tempPathStream = File.Open(tempPath, FileMode.Create, FileAccess.Write)) + { + await url.DownloadAsync( + this.httpClient.SharedHttpClient, + tempPathStream, + this.cancellationTokenSource.Token); + } + + for (var j = RenameAttemptCount; ; j--) + { + try + { + File.Move(tempPath, path); + } + catch (IOException ioe) + { + if (j == 0) + throw; + Log.Warning( + ioe, + "[{who}] {asset}: Renaming failed; trying again {n} more times", + nameof(DalamudAssetManager), + asset, + j); + await Task.Delay(1000, this.cancellationTokenSource.Token); + continue; + } + + return File.OpenRead(path); + } + } + catch (Exception e) when (e is not OperationCanceledException) + { + Log.Error(e, "[{who}] {asset}: Failed {url}", nameof(DalamudAssetManager), asset, url); + } + } + + if (!attemptedAny) + throw new FileNotFoundException($"Failed to find the asset {asset}.", asset.ToString()); + + // Wait up to 5 minutes + var delay = Math.Min(300, (1 << i) * 1000); + Log.Error( + "[{who}] {asset}: Failed to download. Trying again in {sec} seconds...", + nameof(DalamudAssetManager), + asset, + delay); + await Task.Delay(delay * 1000, this.cancellationTokenSource.Token); + } + + throw new FileNotFoundException($"Failed to load the asset {asset}.", asset.ToString()); + } + catch (Exception e) when (e is not OperationCanceledException) + { + exceptions ??= new(); + exceptions.Add(e); + try + { + File.Delete(tempPath); + } + catch + { + // don't care + } + } + + throw new AggregateException(exceptions); + } + } + + /// + [Pure] + public IDalamudTextureWrap GetDalamudTextureWrap(DalamudAsset asset) => + this.GetDalamudTextureWrapAsync(asset).Result; + + /// + [Pure] + [return: NotNullIfNotNull(nameof(defaultWrap))] + public IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap) + { + var task = this.GetDalamudTextureWrapAsync(asset); + return task.IsCompletedSuccessfully ? task.Result : defaultWrap; + } + + /// + [Pure] + public Task GetDalamudTextureWrapAsync(DalamudAsset asset) + { + var purpose = asset.GetPurpose(); + if (purpose is not DalamudAssetPurpose.TextureFromPng and not DalamudAssetPurpose.TextureFromRaw) + throw new ArgumentOutOfRangeException(nameof(asset), asset, "The asset cannot be taken as a Texture2D."); + + Task task; + lock (this.syncRoot) + { + if (this.isDisposed) + throw new ObjectDisposedException(nameof(DalamudAssetManager)); + + task = this.textureWraps[asset] ??= CreateInnerAsync(); + } + + return task; + + async Task CreateInnerAsync() + { + var buf = Array.Empty(); + try + { + var im = (await Service.GetAsync()).Manager; + await using var stream = await this.CreateStreamAsync(asset); + var length = checked((int)stream.Length); + buf = ArrayPool.Shared.Rent(length); + stream.ReadExactly(buf, 0, length); + var image = purpose switch + { + DalamudAssetPurpose.TextureFromPng => im.LoadImage(buf), + DalamudAssetPurpose.TextureFromRaw => + asset.GetAttribute() is { } raw + ? im.LoadImageFromDxgiFormat(buf, raw.Pitch, raw.Width, raw.Height, raw.Format) + : throw new InvalidOperationException( + "TextureFromRaw must accompany a DalamudAssetRawTextureAttribute."), + _ => null, + }; + var disposeDeferred = + this.scopedFinalizer.Add(image) + ?? throw new InvalidOperationException("Something went wrong very badly"); + return new DisposeSuppressingDalamudTextureWrap(disposeDeferred); + } + catch (Exception e) + { + Log.Error(e, "[{name}] Failed to load {asset}.", nameof(DalamudAssetManager), asset); + throw; + } + finally + { + ArrayPool.Shared.Return(buf); + } + } + } + + private Task TransformImmediate(Task task, Func transformer) + { + if (task.IsCompletedSuccessfully) + return Task.FromResult(transformer(task.Result)); + if (task.Exception is { } exc) + return Task.FromException(exc); + return task.ContinueWith(_ => this.TransformImmediate(task, transformer)).Unwrap(); + } + + private class DisposeSuppressingDalamudTextureWrap : IDalamudTextureWrap + { + private readonly IDalamudTextureWrap innerWrap; + + public DisposeSuppressingDalamudTextureWrap(IDalamudTextureWrap wrap) => this.innerWrap = wrap; + + /// + public IntPtr ImGuiHandle => this.innerWrap.ImGuiHandle; + + /// + public int Width => this.innerWrap.Width; + + /// + public int Height => this.innerWrap.Height; + + /// + public void Dispose() + { + // suppressed + } + } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetOnlineSourceAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetOnlineSourceAttribute.cs new file mode 100644 index 000000000..25ed995d7 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetOnlineSourceAttribute.cs @@ -0,0 +1,48 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Dalamud.Storage.Assets; + +/// +/// Marks that an asset can be download from online. +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] +internal class DalamudAssetOnlineSourceAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The URL. + public DalamudAssetOnlineSourceAttribute(string url) + { + this.Url = url; + } + + /// + /// Gets the source URL of the file. + /// + public string Url { get; } + + /// + /// Downloads to the given stream. + /// + /// The client. + /// The stream. + /// The cancellation token. + /// The task. + public async Task DownloadAsync(HttpClient client, Stream stream, CancellationToken cancellationToken) + { + using var resp = await client.GetAsync(this.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + resp.EnsureSuccessStatusCode(); + if (resp.StatusCode != HttpStatusCode.OK) + throw new NotSupportedException($"Only 200 OK is supported; got {resp.StatusCode}"); + + await using var readStream = await resp.Content.ReadAsStreamAsync(cancellationToken); + await readStream.CopyToAsync(stream, cancellationToken); + if (resp.Content.Headers.ContentLength is { } length && stream.Length != length) + throw new IOException($"Expected {length} bytes; got {stream.Length} bytes."); + } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetPathAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetPathAttribute.cs new file mode 100644 index 000000000..1df52aa39 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetPathAttribute.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace Dalamud.Storage.Assets; + +/// +/// File names to look up in Dalamud assets. +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] +internal class DalamudAssetPathAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The path components. + public DalamudAssetPathAttribute(params string[] pathComponents) => this.FileName = Path.Join(pathComponents); + + /// + /// Gets the file name. + /// + public string FileName { get; } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetPurpose.cs b/Dalamud/Storage/Assets/DalamudAssetPurpose.cs new file mode 100644 index 000000000..b059cb3d6 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetPurpose.cs @@ -0,0 +1,27 @@ +namespace Dalamud.Storage.Assets; + +/// +/// Purposes of a Dalamud asset. +/// +public enum DalamudAssetPurpose +{ + /// + /// The asset has no purpose. + /// + Empty = 0, + + /// + /// The asset is a .png file, and can be purposed as a . + /// + TextureFromPng = 10, + + /// + /// The asset is a raw texture, and can be purposed as a . + /// + TextureFromRaw = 1001, + + /// + /// The asset is a font file. + /// + Font = 2000, +} diff --git a/Dalamud/Storage/Assets/DalamudAssetRawTextureAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetRawTextureAttribute.cs new file mode 100644 index 000000000..b79abb7d7 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetRawTextureAttribute.cs @@ -0,0 +1,45 @@ +using SharpDX.DXGI; + +namespace Dalamud.Storage.Assets; + +/// +/// Provide raw texture data directly. +/// +[AttributeUsage(AttributeTargets.Field)] +internal class DalamudAssetRawTextureAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The width. + /// The pitch. + /// The height. + /// The format. + public DalamudAssetRawTextureAttribute(int width, int pitch, int height, Format format) + { + this.Width = width; + this.Pitch = pitch; + this.Height = height; + this.Format = format; + } + + /// + /// Gets the width. + /// + public int Width { get; } + + /// + /// Gets the pitch. + /// + public int Pitch { get; } + + /// + /// Gets the height. + /// + public int Height { get; } + + /// + /// Gets the format. + /// + public Format Format { get; } +} diff --git a/Dalamud/Storage/Assets/IDalamudAssetManager.cs b/Dalamud/Storage/Assets/IDalamudAssetManager.cs new file mode 100644 index 000000000..643eef18c --- /dev/null +++ b/Dalamud/Storage/Assets/IDalamudAssetManager.cs @@ -0,0 +1,81 @@ +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; +using System.IO; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal; + +namespace Dalamud.Storage.Assets; + +/// +/// Holds Dalamud Assets' handles hostage, so that they do not get closed while Dalamud is running.
+/// Also, attempts to load optional assets.
+///
+/// Note on
+/// It will help you get notified if you discard the result of functions, mostly likely because of a mistake. +/// Think of C++ [[nodiscard]]. Also, like the intended meaning of the attribute, such methods will not have +/// externally visible state changes. +///
+public interface IDalamudAssetManager +{ + /// + /// Gets the shared texture wrap for . + /// + IDalamudTextureWrap Empty4X4 { get; } + + /// + /// Gets whether the stream for the asset is instantly available. + /// + /// The asset. + /// Whether the stream of an asset is immediately available. + [Pure] + bool IsStreamImmediatelyAvailable(DalamudAsset asset); + + /// + /// Creates a stream backed by the specified asset, waiting as necessary.
+ /// Call after use. + ///
+ /// The asset. + /// The stream. + [Pure] + Stream CreateStream(DalamudAsset asset); + + /// + /// Creates a stream backed by the specified asset.
+ /// Call after use. + ///
+ /// The asset. + /// The stream, wrapped inside a . + [Pure] + Task CreateStreamAsync(DalamudAsset asset); + + /// + /// Gets a shared instance of , after waiting as necessary.
+ /// Calls to is unnecessary; they will be ignored. + ///
+ /// The texture asset. + /// The texture wrap. + [Pure] + IDalamudTextureWrap GetDalamudTextureWrap(DalamudAsset asset); + + /// + /// Gets a shared instance of if it is available instantly; + /// if it is not ready, returns .
+ /// Calls to is unnecessary; they will be ignored. + ///
+ /// The texture asset. + /// The default return value, if the asset is not ready for whatever reason. + /// The texture wrap. Can be null only if is null. + [Pure] + [return: NotNullIfNotNull(nameof(defaultWrap))] + IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap); + + /// + /// Gets a shared instance of in a .
+ /// Calls to is unnecessary; they will be ignored. + ///
+ /// The texture asset. + /// The new texture wrap, wrapped inside a . + [Pure] + Task GetDalamudTextureWrapAsync(DalamudAsset asset); +} diff --git a/Dalamud/Storage/FileReadException.cs b/Dalamud/Storage/FileReadException.cs new file mode 100644 index 000000000..09f7ff4fb --- /dev/null +++ b/Dalamud/Storage/FileReadException.cs @@ -0,0 +1,16 @@ +namespace Dalamud.Storage; + +/// +/// Thrown if all read operations fail. +/// +public class FileReadException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// Inner error that caused this exception. + internal FileReadException(Exception inner) + : base("Failed to read file", inner) + { + } +} diff --git a/Dalamud/Storage/ReliableFileStorage.cs b/Dalamud/Storage/ReliableFileStorage.cs new file mode 100644 index 000000000..eab93269e --- /dev/null +++ b/Dalamud/Storage/ReliableFileStorage.cs @@ -0,0 +1,345 @@ +using System.IO; +using System.Text; + +using Dalamud.Logging.Internal; +using Dalamud.Utility; +using SQLite; + +namespace Dalamud.Storage; + +/* + * TODO: A file that is read frequently, but written very rarely, might not have offline changes by users persisted + * into the backup database, since it is only written to the backup database when it is written to the filesystem. + */ + +/// +/// A service that provides a reliable file storage. +/// Implements a VFS that writes files to the disk, and additionally keeps files in a SQLite database +/// for journaling/backup purposes. +/// Consumers can choose to receive a backup if they think that the file is corrupt. +/// +/// +/// This is not an early-loaded service, as it is needed before they are initialized. +/// +[ServiceManager.ProvidedService] +[Api10ToDo("Make internal and IInternalDisposableService, and remove #pragma guard from the caller.")] +public class ReliableFileStorage : IPublicDisposableService +{ + private static readonly ModuleLog Log = new("VFS"); + + private readonly object syncRoot = new(); + + private SQLiteConnection? db; + private bool isService; + + /// + /// Initializes a new instance of the class. + /// + /// Path to the VFS. + [Obsolete("Dalamud internal use only.", false)] + [Api10ToDo("Make internal, and remove #pragma guard from the caller.")] + public ReliableFileStorage(string vfsDbPath) + { + var databasePath = Path.Combine(vfsDbPath, "dalamudVfs.db"); + + Log.Verbose("Initializing VFS database at {Path}", databasePath); + + try + { + this.SetupDb(databasePath); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load VFS database, starting fresh"); + + try + { + if (File.Exists(databasePath)) + File.Delete(databasePath); + + this.SetupDb(databasePath); + } + catch (Exception) + { + // ignored, we can run without one + } + } + } + + /// + /// Check if a file exists. + /// This will return true if the file does not exist on the filesystem, but in the transparent backup. + /// You must then use this instance to read the file to ensure consistency. + /// + /// The path to check. + /// The container to check in. + /// True if the file exists. + public bool Exists(string path, Guid containerId = default) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + if (File.Exists(path)) + return true; + + if (this.db == null) + return false; + + // If the file doesn't actually exist on the FS, but it does in the DB, we can say YES and read operations will read from the DB instead + var normalizedPath = NormalizePath(path); + var file = this.db.Table().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId); + return file != null; + } + + /// + /// Write all text to a file. + /// + /// Path to write to. + /// The contents of the file. + /// Container to write to. + public void WriteAllText(string path, string? contents, Guid containerId = default) + => this.WriteAllText(path, contents, Encoding.UTF8, containerId); + + /// + /// Write all text to a file. + /// + /// Path to write to. + /// The contents of the file. + /// The encoding to write with. + /// Container to write to. + public void WriteAllText(string path, string? contents, Encoding encoding, Guid containerId = default) + { + var bytes = encoding.GetBytes(contents ?? string.Empty); + this.WriteAllBytes(path, bytes, containerId); + } + + /// + /// Write all bytes to a file. + /// + /// Path to write to. + /// The contents of the file. + /// Container to write to. + public void WriteAllBytes(string path, byte[] bytes, Guid containerId = default) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + lock (this.syncRoot) + { + if (this.db == null) + { + Util.WriteAllBytesSafe(path, bytes); + return; + } + + this.db.RunInTransaction(() => + { + var normalizedPath = NormalizePath(path); + var file = this.db.Table().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId); + if (file == null) + { + file = new DbFile + { + ContainerId = containerId, + Path = normalizedPath, + Data = bytes, + }; + this.db.Insert(file); + } + else + { + file.Data = bytes; + this.db.Update(file); + } + + Util.WriteAllBytesSafe(path, bytes); + }); + } + } + + /// + /// Read all text from a file. + /// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not + /// automatically written back to disk, however. + /// + /// The path to read from. + /// Whether or not the backup of the file should take priority. + /// The container to read from. + /// All text stored in this file. + /// Thrown if the file does not exist on the filesystem or in the backup. + public string ReadAllText(string path, bool forceBackup = false, Guid containerId = default) + => this.ReadAllText(path, Encoding.UTF8, forceBackup, containerId); + + /// + /// Read all text from a file. + /// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not + /// automatically written back to disk, however. + /// + /// The path to read from. + /// The encoding to read with. + /// Whether or not the backup of the file should take priority. + /// The container to read from. + /// All text stored in this file. + /// Thrown if the file does not exist on the filesystem or in the backup. + public string ReadAllText(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default) + { + var bytes = this.ReadAllBytes(path, forceBackup, containerId); + return encoding.GetString(bytes); + } + + /// + /// Read all text from a file, and automatically try again with the backup if the file does not exist or + /// the function throws an exception. If the backup read also throws an exception, + /// or the file does not exist in the backup, a is thrown. + /// + /// The path to read from. + /// Lambda that reads the file. Throw here to automatically attempt a read from the backup. + /// The container to read from. + /// Thrown if the file does not exist on the filesystem or in the backup. + /// Thrown here if the file and the backup fail their read. + public void ReadAllText(string path, Action reader, Guid containerId = default) + => this.ReadAllText(path, Encoding.UTF8, reader, containerId); + + /// + /// Read all text from a file, and automatically try again with the backup if the file does not exist or + /// the function throws an exception. If the backup read also throws an exception, + /// or the file does not exist in the backup, a is thrown. + /// + /// The path to read from. + /// The encoding to read with. + /// Lambda that reads the file. Throw here to automatically attempt a read from the backup. + /// The container to read from. + /// Thrown if the file does not exist on the filesystem or in the backup. + /// Thrown here if the file and the backup fail their read. + public void ReadAllText(string path, Encoding encoding, Action reader, Guid containerId = default) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + // TODO: We are technically reading one time too many here, if the file does not exist on the FS, ReadAllText + // fails over to the backup, and then the backup fails to read in the lambda. We should do something about that, + // but it's not a big deal. Would be nice if ReadAllText could indicate if it did fail over. + + // 1.) Try without using the backup + try + { + var text = this.ReadAllText(path, encoding, false, containerId); + reader(text); + return; + } + catch (FileNotFoundException) + { + // We can't do anything about this. + throw; + } + catch (Exception ex) + { + Log.Verbose(ex, "First chance read from {Path} failed, trying backup", path); + } + + // 2.) Try using the backup + try + { + var text = this.ReadAllText(path, encoding, true, containerId); + reader(text); + } + catch (Exception ex) + { + Log.Error(ex, "Second chance read from {Path} failed, giving up", path); + throw new FileReadException(ex); + } + } + + /// + /// Read all bytes from a file. + /// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not + /// automatically written back to disk, however. + /// + /// The path to read from. + /// Whether or not the backup of the file should take priority. + /// The container to read from. + /// All bytes stored in this file. + /// Thrown if the file does not exist on the filesystem or in the backup. + public byte[] ReadAllBytes(string path, bool forceBackup = false, Guid containerId = default) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + if (forceBackup) + { + // If the db failed to load, act as if the file does not exist + if (this.db == null) + throw new FileNotFoundException("Backup database was not available"); + + var normalizedPath = NormalizePath(path); + var file = this.db.Table().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId); + if (file == null) + throw new FileNotFoundException(); + + return file.Data; + } + + // If the file doesn't exist, immediately check the backup db + if (!File.Exists(path)) + return this.ReadAllBytes(path, true, containerId); + + try + { + return File.ReadAllBytes(path); + } + catch (Exception e) + { + Log.Error(e, "Failed to read file from disk, falling back to database"); + return this.ReadAllBytes(path, true, containerId); + } + } + + /// + public void Dispose() + { + if (!this.isService) + this.DisposeCore(); + } + + /// + void IInternalDisposableService.DisposeService() + { + if (this.isService) + this.DisposeCore(); + } + + /// + void IPublicDisposableService.MarkDisposeOnlyFromService() => this.isService = true; + + /// + /// Replace possible non-portable parts of a path with portable versions. + /// + /// The path to normalize. + /// The normalized path. + private static string NormalizePath(string path) + { + // Replace users folder + var usersFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + path = path.Replace(usersFolder, "%USERPROFILE%"); + + return path; + } + + private void SetupDb(string path) + { + this.db = new SQLiteConnection(path, + SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex); + this.db.CreateTable(); + } + + private void DisposeCore() => this.db?.Dispose(); + + private class DbFile + { + [PrimaryKey] + [AutoIncrement] + public int Id { get; set; } + + public Guid ContainerId { get; set; } + + public string Path { get; set; } = null!; + + public byte[] Data { get; set; } = null!; + } +} diff --git a/Dalamud/Support/Troubleshooting.cs b/Dalamud/Support/Troubleshooting.cs index 9893451f4..59ebcaa16 100644 --- a/Dalamud/Support/Troubleshooting.cs +++ b/Dalamud/Support/Troubleshooting.cs @@ -58,7 +58,7 @@ public static class Troubleshooting /// internal static void LogTroubleshooting() { - var startInfo = Service.Get(); + var startInfo = Service.Get().StartInfo; var configuration = Service.Get(); var interfaceManager = Service.GetNullable(); var pluginManager = Service.GetNullable(); @@ -72,7 +72,7 @@ public static class Troubleshooting EverStartedLoadingPlugins = pluginManager?.InstalledPlugins.Where(x => x.HasEverStartedLoad).Select(x => x.InternalName).ToList(), DalamudVersion = Util.AssemblyVersion, DalamudGitHash = Util.GetGitHash(), - GameVersion = startInfo.GameVersion.ToString(), + GameVersion = startInfo.GameVersion?.ToString() ?? "Unknown", Language = startInfo.Language.ToString(), BetaKey = configuration.DalamudBetaKey, DoPluginTest = configuration.DoPluginTest, diff --git a/Dalamud/Utility/Api10ToDoAttribute.cs b/Dalamud/Utility/Api10ToDoAttribute.cs new file mode 100644 index 000000000..a13aaead5 --- /dev/null +++ b/Dalamud/Utility/Api10ToDoAttribute.cs @@ -0,0 +1,29 @@ +namespace Dalamud.Utility; + +/// +/// Utility class for marking something to be changed for API 10, for ease of lookup. +/// +[AttributeUsage(AttributeTargets.All, Inherited = false)] +internal sealed class Api10ToDoAttribute : Attribute +{ + /// + /// Marks that this exists purely for making API 9 plugins work. + /// + public const string DeleteCompatBehavior = "Delete. This is for making API 9 plugins work."; + + /// + /// Marks that this should be moved to an another namespace. + /// + public const string MoveNamespace = "Move to another namespace."; + + /// + /// Initializes a new instance of the class. + /// + /// The explanation. + /// The explanation 2. + public Api10ToDoAttribute(string what, string what2 = "") + { + _ = what; + _ = what2; + } +} diff --git a/Dalamud/Utility/ArrayExtensions.cs b/Dalamud/Utility/ArrayExtensions.cs new file mode 100644 index 000000000..5b6ce2332 --- /dev/null +++ b/Dalamud/Utility/ArrayExtensions.cs @@ -0,0 +1,172 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Dalamud.Utility; + +[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1618:Generic type parameters should be documented", Justification = "Reviewed,")] +[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Reviewed,")] +[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented", Justification = "Reviewed,")] +[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented", Justification = "Reviewed,")] +internal static class ArrayExtensions +{ + /// Iterate over enumerables with additional index. + public static IEnumerable<(T Value, int Index)> WithIndex(this IEnumerable list) + => list.Select((x, i) => (x, i)); + + /// Remove an added index from an indexed enumerable. + public static IEnumerable WithoutIndex(this IEnumerable<(T Value, int Index)> list) + => list.Select(x => x.Value); + + /// Remove the value and only keep the index from an indexed enumerable. + public static IEnumerable WithoutValue(this IEnumerable<(T Value, int Index)> list) + => list.Select(x => x.Index); + + // Find the index of the first object fulfilling predicate's criteria in the given list. + // Returns -1 if no such object is found. + public static int IndexOf(this IEnumerable array, Predicate predicate) + { + var i = 0; + foreach (var obj in array) + { + if (predicate(obj)) + return i; + + ++i; + } + + return -1; + } + + // Find the index of the first occurrence of needle in the given list. + // Returns -1 if needle is not contained in the list. + public static int IndexOf(this IEnumerable array, T needle) where T : notnull + { + var i = 0; + foreach (var obj in array) + { + if (needle.Equals(obj)) + return i; + + ++i; + } + + return -1; + } + + // Find the first object fulfilling predicate's criteria in the given list, if one exists. + // Returns true if an object is found, false otherwise. + public static bool FindFirst(this IEnumerable array, Predicate predicate, [NotNullWhen(true)] out T? result) + { + foreach (var obj in array) + { + if (predicate(obj)) + { + result = obj!; + return true; + } + } + + result = default; + return false; + } + + // Find the first occurrence of needle in the given list and return the value contained in the list in result. + // Returns true if an object is found, false otherwise. + public static bool FindFirst(this IEnumerable array, T needle, [NotNullWhen(true)] out T? result) where T : notnull + { + foreach (var obj in array) + { + if (obj.Equals(needle)) + { + result = obj; + return true; + } + } + + result = default; + return false; + } + + /// + /// Interprets the given array as an , so that you can enumerate it multiple + /// times, and know the number of elements within. + /// + /// The enumerable. + /// The element type. + /// casted as a if it is one; otherwise the result of . + public static IReadOnlyCollection AsReadOnlyCollection(this IEnumerable array) => + array as IReadOnlyCollection ?? array.ToArray(); + + /// + public static int FindIndex(this IReadOnlyList list, Predicate match) + => list.FindIndex(0, list.Count, match); + + /// + public static int FindIndex(this IReadOnlyList list, int startIndex, Predicate match) + => list.FindIndex(startIndex, list.Count - startIndex, match); + + /// + public static int FindIndex(this IReadOnlyList list, int startIndex, int count, Predicate match) + { + if ((uint)startIndex > (uint)list.Count) + throw new ArgumentOutOfRangeException(nameof(startIndex), startIndex, null); + + if (count < 0 || startIndex > list.Count - count) + throw new ArgumentOutOfRangeException(nameof(count), count, null); + + if (match == null) + throw new ArgumentNullException(nameof(match)); + + var endIndex = startIndex + count; + for (var i = startIndex; i < endIndex; i++) + { + if (match(list[i])) return i; + } + + return -1; + } + + /// + public static int FindLastIndex(this IReadOnlyList list, Predicate match) + => list.FindLastIndex(list.Count - 1, list.Count, match); + + /// + public static int FindLastIndex(this IReadOnlyList list, int startIndex, Predicate match) + => list.FindLastIndex(startIndex, startIndex + 1, match); + + /// + public static int FindLastIndex(this IReadOnlyList list, int startIndex, int count, Predicate match) + { + if (match == null) + throw new ArgumentNullException(nameof(match)); + + if (list.Count == 0) + { + // Special case for 0 length List + if (startIndex != -1) + throw new ArgumentOutOfRangeException(nameof(startIndex), startIndex, null); + } + else + { + // Make sure we're not out of range + if ((uint)startIndex >= (uint)list.Count) + throw new ArgumentOutOfRangeException(nameof(startIndex), startIndex, null); + } + + // 2nd have of this also catches when startIndex == MAXINT, so MAXINT - 0 + 1 == -1, which is < 0. + if (count < 0 || startIndex - count + 1 < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, null); + + var endIndex = startIndex - count; + for (var i = startIndex; i > endIndex; i--) + { + if (match(list[i])) + { + return i; + } + } + + return -1; + } +} diff --git a/Dalamud/Utility/DateTimeSpanExtensions.cs b/Dalamud/Utility/DateTimeSpanExtensions.cs new file mode 100644 index 000000000..8422a4a26 --- /dev/null +++ b/Dalamud/Utility/DateTimeSpanExtensions.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; + +using CheapLoc; + +using Dalamud.Logging.Internal; + +namespace Dalamud.Utility; + +/// +/// Utility functions for and . +/// +public static class DateTimeSpanExtensions +{ + private static readonly ModuleLog Log = new(nameof(DateTimeSpanExtensions)); + + private static ParsedRelativeFormatStrings? relativeFormatStringLong; + + private static ParsedRelativeFormatStrings? relativeFormatStringShort; + + /// Formats an instance of as a localized absolute time. + /// When. + /// The formatted string. + /// The string will be formatted according to Square Enix Account region settings, if Dalamud default + /// language is English. + public static unsafe string LocAbsolute(this DateTime when) + { + var culture = Service.GetNullable()?.DalamudLanguageCultureInfo ?? CultureInfo.InvariantCulture; + if (!Equals(culture, CultureInfo.InvariantCulture)) + return when.ToString("G", culture); + + var framework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.Instance(); + var region = 0; + if (framework is not null) + region = framework->Region; + return region switch + { + 1 => when.ToString("MM/dd/yyyy HH:mm:ss"), // na + 2 => when.ToString("dd-mm-yyyy HH:mm:ss"), // eu + _ => when.ToString("yyyy-MM-dd HH:mm:ss"), // jp(0), cn(3), kr(4), and other possible errorneous cases + }; + } + + /// Formats an instance of as a localized relative time. + /// When. + /// The formatted string. + public static string LocRelativePastLong(this DateTime when) + { + var loc = Loc.Localize( + "DateTimeSpanExtensions.RelativeFormatStringsLong", + "172800,{0:%d} days ago\n86400,yesterday\n7200,{0:%h} hours ago\n3600,an hour ago\n120,{0:%m} minutes ago\n60,a minute ago\n2,{0:%s} seconds ago\n1,a second ago\n-Infinity,just now"); + Debug.Assert(loc != null, "loc != null"); + + if (relativeFormatStringLong?.FormatStringLoc != loc) + relativeFormatStringLong ??= new(loc); + + return relativeFormatStringLong.Format(DateTime.Now - when); + } + + /// Formats an instance of as a localized relative time. + /// When. + /// The formatted string. + public static string LocRelativePastShort(this DateTime when) + { + var loc = Loc.Localize( + "DateTimeSpanExtensions.RelativeFormatStringsShort", + "86400,{0:%d}d\n3600,{0:%h}h\n60,{0:%m}m\n1,{0:%s}s\n-Infinity,now"); + Debug.Assert(loc != null, "loc != null"); + + if (relativeFormatStringShort?.FormatStringLoc != loc) + relativeFormatStringShort = new(loc); + + return relativeFormatStringShort.Format(DateTime.Now - when); + } + + private sealed class ParsedRelativeFormatStrings + { + private readonly List<(float MinSeconds, string FormatString)> formatStrings = new(); + + public ParsedRelativeFormatStrings(string value) + { + this.FormatStringLoc = value; + foreach (var line in value.Split("\n")) + { + var sep = line.IndexOf(','); + if (sep < 0) + { + Log.Error("A line without comma has been found: {line}", line); + continue; + } + + if (!float.TryParse( + line.AsSpan(0, sep), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var seconds)) + { + Log.Error("Could not parse the duration: {line}", line); + continue; + } + + this.formatStrings.Add((seconds, line[(sep + 1)..])); + } + + this.formatStrings.Sort((a, b) => b.MinSeconds.CompareTo(a.MinSeconds)); + } + + public string FormatStringLoc { get; } + + /// Formats an instance of as a localized string. + /// The duration. + /// The formatted string. + public string Format(TimeSpan ts) + { + foreach (var (minSeconds, formatString) in this.formatStrings) + { + if (ts.TotalSeconds >= minSeconds) + return string.Format(formatString, ts); + } + + return this.formatStrings[^1].FormatString.Format(ts); + } + } +} diff --git a/Dalamud/Utility/DisposeSafety.cs b/Dalamud/Utility/DisposeSafety.cs new file mode 100644 index 000000000..64d31048f --- /dev/null +++ b/Dalamud/Utility/DisposeSafety.cs @@ -0,0 +1,450 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reactive.Disposables; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace Dalamud.Utility; + +/// +/// Utilities for disposing stuff. +/// +public static class DisposeSafety +{ + /// + /// Interface that marks a disposable that it can call back on dispose. + /// + public interface IDisposeCallback : IDisposable + { + /// + /// Event to be fired before object dispose. First parameter is the object iself. + /// + event Action? BeforeDispose; + + /// + /// Event to be fired after object dispose. First parameter is the object iself. + /// + event Action? AfterDispose; + } + + /// + /// Returns a proxy that on dispose will dispose the result of the given + /// .
+ /// If any exception has occurred, it will be ignored. + ///
+ /// The task. + /// A disposable type. + /// The proxy . + public static IDisposable ToDisposableIgnoreExceptions(this Task task) + where T : IDisposable + { + return Disposable.Create( + () => task.ContinueWith( + r => + { + _ = r.Exception; + if (r.IsCompleted) + { + try + { + r.Dispose(); + } + catch + { + // ignore + } + } + })); + } + + /// + /// Transforms into a , disposing the content as necessary. + /// + /// The task. + /// Ignore all exceptions. + /// A disposable type. + /// A wrapper for the task. + public static Task ToContentDisposedTask(this Task task, bool ignoreAllExceptions = false) + where T : IDisposable => task.ContinueWith( + r => + { + if (!r.IsCompletedSuccessfully) + { + if (ignoreAllExceptions) + { + _ = r.Exception; + return Task.CompletedTask; + } + + return r; + } + + try + { + r.Result.Dispose(); + } + catch (Exception e) + { + if (!ignoreAllExceptions) + { + return Task.FromException( + new AggregateException( + new[] { e }.Concat( + (IEnumerable)r.Exception?.InnerExceptions + ?? new[] { new OperationCanceledException() }))); + } + } + + return Task.CompletedTask; + }).Unwrap(); + + /// + /// Returns a proxy that on dispose will dispose all the elements of the given + /// of s. + /// + /// The disposables. + /// The disposable types. + /// The proxy . + /// Error. + public static IDisposable AggregateToDisposable(this IEnumerable? disposables) + where T : IDisposable + { + if (disposables is not T[] array) + array = disposables?.ToArray() ?? Array.Empty(); + + return Disposable.Create( + () => + { + List exceptions = null; + foreach (var d in array) + { + try + { + d?.Dispose(); + } + catch (Exception de) + { + exceptions ??= new(); + exceptions.Add(de); + } + } + + if (exceptions is not null) + throw new AggregateException(exceptions); + }); + } + + /// + /// Utility class for managing finalizing stuff. + /// + public class ScopedFinalizer : IDisposeCallback, IAsyncDisposable + { + private readonly List objects = new(); + + /// + public event Action? BeforeDispose; + + /// + public event Action? AfterDispose; + + /// + public void EnsureCapacity(int capacity) + { + lock (this.objects) + this.objects.EnsureCapacity(capacity); + } + + /// + /// The parameter. + [return: NotNullIfNotNull(nameof(d))] + public T? Add(T? d) where T : IDisposable + { + if (d is not null) + { + lock (this.objects) + this.objects.Add(this.CheckAdd(d)); + } + + return d; + } + + /// + [return: NotNullIfNotNull(nameof(d))] + public Action? Add(Action? d) + { + if (d is not null) + { + lock (this.objects) + this.objects.Add(this.CheckAdd(d)); + } + + return d; + } + + /// + [return: NotNullIfNotNull(nameof(d))] + public Func? Add(Func? d) + { + if (d is not null) + { + lock (this.objects) + this.objects.Add(this.CheckAdd(d)); + } + + return d; + } + + /// + public GCHandle Add(GCHandle d) + { + if (d != default) + { + lock (this.objects) + this.objects.Add(this.CheckAdd(d)); + } + + return d; + } + + /// + /// Queue all the given to be disposed later. + /// + /// Disposables. + public void AddRange(IEnumerable ds) + { + lock (this.objects) + this.objects.AddRange(ds.Where(d => d is not null).Select(d => (object)this.CheckAdd(d))); + } + + /// + /// Queue all the given to be run later. + /// + /// Actions. + public void AddRange(IEnumerable ds) + { + lock (this.objects) + this.objects.AddRange(ds.Where(d => d is not null).Select(d => (object)this.CheckAdd(d))); + } + + /// + /// Queue all the given returning to be run later. + /// + /// Func{Task}s. + public void AddRange(IEnumerable?> ds) + { + lock (this.objects) + this.objects.AddRange(ds.Where(d => d is not null).Select(d => (object)this.CheckAdd(d))); + } + + /// + /// Queue all the given to be disposed later. + /// + /// GCHandles. + public void AddRange(IEnumerable ds) + { + lock (this.objects) + this.objects.AddRange(ds.Select(d => (object)this.CheckAdd(d))); + } + + /// + /// Cancel all pending disposals. + /// + /// Use this after successful initialization of multiple disposables. + public void Cancel() + { + lock (this.objects) + { + foreach (var o in this.objects) + this.CheckRemove(o); + this.objects.Clear(); + } + } + + /// + /// This for method chaining. + public ScopedFinalizer WithEnsureCapacity(int capacity) + { + this.EnsureCapacity(capacity); + return this; + } + + /// + /// This for method chaining. + public ScopedFinalizer With(IDisposable d) + { + this.Add(d); + return this; + } + + /// + /// This for method chaining. + public ScopedFinalizer With(Action d) + { + this.Add(d); + return this; + } + + /// + /// This for method chaining. + public ScopedFinalizer With(Func d) + { + this.Add(d); + return this; + } + + /// + /// This for method chaining. + public ScopedFinalizer With(GCHandle d) + { + this.Add(d); + return this; + } + + /// + public void Dispose() + { + this.BeforeDispose?.InvokeSafely(this); + + List? exceptions = null; + while (true) + { + object obj; + lock (this.objects) + { + if (this.objects.Count == 0) + break; + obj = this.objects[^1]; + this.objects.RemoveAt(this.objects.Count - 1); + } + + try + { + switch (obj) + { + case IDisposable x: + x.Dispose(); + break; + case Action a: + a.Invoke(); + break; + case Func a: + a.Invoke().Wait(); + break; + case GCHandle a: + a.Free(); + break; + } + } + catch (Exception ex) + { + exceptions ??= new(); + exceptions.Add(ex); + } + } + + lock (this.objects) + this.objects.TrimExcess(); + + if (exceptions is not null) + { + var exs = exceptions.Count == 1 ? exceptions[0] : new AggregateException(exceptions); + try + { + this.AfterDispose?.Invoke(this, exs); + } + catch + { + // whatever + } + + throw exs; + } + } + + /// + public async ValueTask DisposeAsync() + { + this.BeforeDispose?.InvokeSafely(this); + + List? exceptions = null; + while (true) + { + object obj; + lock (this.objects) + { + if (this.objects.Count == 0) + break; + obj = this.objects[^1]; + this.objects.RemoveAt(this.objects.Count - 1); + } + + try + { + switch (obj) + { + case IAsyncDisposable x: + await x.DisposeAsync(); + break; + case IDisposable x: + x.Dispose(); + break; + case Func a: + await a.Invoke(); + break; + case Action a: + a.Invoke(); + break; + case GCHandle a: + a.Free(); + break; + } + } + catch (Exception ex) + { + exceptions ??= new(); + exceptions.Add(ex); + } + } + + lock (this.objects) + this.objects.TrimExcess(); + + if (exceptions is not null) + { + var exs = exceptions.Count == 1 ? exceptions[0] : new AggregateException(exceptions); + try + { + this.AfterDispose?.Invoke(this, exs); + } + catch + { + // whatever + } + + throw exs; + } + } + + private T CheckAdd(T item) + { + if (item is IDisposeCallback dc) + dc.BeforeDispose += this.OnItemDisposed; + + return item; + } + + private void CheckRemove(object item) + { + if (item is IDisposeCallback dc) + dc.BeforeDispose -= this.OnItemDisposed; + } + + private void OnItemDisposed(IDisposeCallback obj) + { + obj.BeforeDispose -= this.OnItemDisposed; + lock (this.objects) + this.objects.Remove(obj); + } + } +} diff --git a/Dalamud/Utility/EnumExtensions.cs b/Dalamud/Utility/EnumExtensions.cs index 0bb60962e..493e6be1f 100644 --- a/Dalamud/Utility/EnumExtensions.cs +++ b/Dalamud/Utility/EnumExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System.Collections.Generic; using System.Linq; namespace Dalamud.Utility; @@ -8,6 +8,26 @@ namespace Dalamud.Utility; /// public static class EnumExtensions { + /// + /// Gets attributes on an enum. + /// + /// The type of attribute to get. + /// The enum value that has an attached attribute. + /// The enumerable of the attached attributes. + public static IEnumerable GetAttributes(this Enum value) + where TAttribute : Attribute + { + var type = value.GetType(); + var name = Enum.GetName(type, value); + if (name.IsNullOrEmpty()) + return Array.Empty(); + + return type.GetField(name)? + .GetCustomAttributes(false) + .OfType() + ?? Array.Empty(); + } + /// /// Gets an attribute on an enum. /// @@ -15,18 +35,8 @@ public static class EnumExtensions /// The enum value that has an attached attribute. /// The attached attribute, if any. public static TAttribute? GetAttribute(this Enum value) - where TAttribute : Attribute - { - var type = value.GetType(); - var name = Enum.GetName(type, value); - if (name.IsNullOrEmpty()) - return null; - - return type.GetField(name)? - .GetCustomAttributes(false) - .OfType() - .SingleOrDefault(); - } + where TAttribute : Attribute => + value.GetAttributes().SingleOrDefault(); /// /// Gets an indicator if enum has been flagged as obsolete (deprecated). diff --git a/Dalamud/Utility/EventHandlerExtensions.cs b/Dalamud/Utility/EventHandlerExtensions.cs index bce815a7b..9bb35a8f1 100644 --- a/Dalamud/Utility/EventHandlerExtensions.cs +++ b/Dalamud/Utility/EventHandlerExtensions.cs @@ -1,11 +1,10 @@ -using System; using System.Linq; using Dalamud.Game; +using Dalamud.Game.Gui.ContextMenu; +using Dalamud.Plugin.Services; using Serilog; -using static Dalamud.Game.Framework; - namespace Dalamud.Utility; /// @@ -20,7 +19,7 @@ internal static class EventHandlerExtensions /// The EventHandler in question. /// Default sender for Invoke equivalent. /// Default EventArgs for Invoke equivalent. - public static void InvokeSafely(this EventHandler eh, object sender, EventArgs e) + public static void InvokeSafely(this EventHandler? eh, object sender, EventArgs e) { if (eh == null) return; @@ -39,7 +38,7 @@ internal static class EventHandlerExtensions /// Default sender for Invoke equivalent. /// Default EventArgs for Invoke equivalent. /// Type of EventArgs. - public static void InvokeSafely(this EventHandler eh, object sender, T e) + public static void InvokeSafely(this EventHandler? eh, object sender, T e) { if (eh == null) return; @@ -55,7 +54,7 @@ internal static class EventHandlerExtensions /// of a thrown Exception inside of an invocation. /// /// The Action in question. - public static void InvokeSafely(this Action act) + public static void InvokeSafely(this Action? act) { if (act == null) return; @@ -66,23 +65,58 @@ internal static class EventHandlerExtensions } } + /// + /// Replacement for Invoke() on event Actions to catch exceptions that stop event propagation in case + /// of a thrown Exception inside of an invocation. + /// + /// The Action in question. + /// Templated argument for Action. + /// Type of Action args. + public static void InvokeSafely(this Action? act, T argument) + { + if (act == null) + return; + + foreach (var action in act.GetInvocationList().Cast>()) + { + HandleInvoke(action, argument); + } + } + /// /// Replacement for Invoke() on OnUpdateDelegate to catch exceptions that stop event propagation in case /// of a thrown Exception inside of an invocation. /// /// The OnUpdateDelegate in question. /// Framework to be passed on to OnUpdateDelegate. - public static void InvokeSafely(this OnUpdateDelegate updateDelegate, Framework framework) + public static void InvokeSafely(this IFramework.OnUpdateDelegate? updateDelegate, Framework framework) { if (updateDelegate == null) return; - foreach (var action in updateDelegate.GetInvocationList().Cast()) + foreach (var action in updateDelegate.GetInvocationList().Cast()) { HandleInvoke(() => action(framework)); } } + /// + /// Replacement for Invoke() on OnMenuOpenedDelegate to catch exceptions that stop event propagation in case + /// of a thrown Exception inside of an invocation. + /// + /// The OnMenuOpenedDelegate in question. + /// Templated argument for Action. + public static void InvokeSafely(this IContextMenu.OnMenuOpenedDelegate? openedDelegate, MenuOpenedArgs argument) + { + if (openedDelegate == null) + return; + + foreach (var action in openedDelegate.GetInvocationList().Cast()) + { + HandleInvoke(() => action(argument)); + } + } + private static void HandleInvoke(Action act) { try @@ -94,4 +128,16 @@ internal static class EventHandlerExtensions Log.Error(ex, "Exception during raise of {handler}", act.Method); } } + + private static void HandleInvoke(Action act, T argument) + { + try + { + act(argument); + } + catch (Exception ex) + { + Log.Error(ex, "Exception during raise of {handler}", act.Method); + } + } } diff --git a/Dalamud/Utility/FuzzyMatcher.cs b/Dalamud/Utility/FuzzyMatcher.cs index 647c9586d..9ac71d8bb 100644 --- a/Dalamud/Utility/FuzzyMatcher.cs +++ b/Dalamud/Utility/FuzzyMatcher.cs @@ -6,6 +6,9 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +#pragma warning disable SA1600 +#pragma warning disable SA1602 + internal readonly ref struct FuzzyMatcher { private static readonly (int, int)[] EmptySegArray = Array.Empty<(int, int)>(); @@ -13,31 +16,31 @@ internal readonly ref struct FuzzyMatcher private readonly string needleString = string.Empty; private readonly ReadOnlySpan needleSpan = ReadOnlySpan.Empty; private readonly int needleFinalPosition = -1; - private readonly (int start, int end)[] needleSegments = EmptySegArray; + private readonly (int Start, int End)[] needleSegments = EmptySegArray; private readonly MatchMode mode = MatchMode.Simple; public FuzzyMatcher(string term, MatchMode matchMode) { - needleString = term; - needleSpan = needleString.AsSpan(); - needleFinalPosition = needleSpan.Length - 1; - mode = matchMode; + this.needleString = term; + this.needleSpan = this.needleString.AsSpan(); + this.needleFinalPosition = this.needleSpan.Length - 1; + this.mode = matchMode; switch (matchMode) { case MatchMode.FuzzyParts: - needleSegments = FindNeedleSegments(needleSpan); + this.needleSegments = FindNeedleSegments(this.needleSpan); break; case MatchMode.Fuzzy: case MatchMode.Simple: - needleSegments = EmptySegArray; + this.needleSegments = EmptySegArray; break; default: throw new ArgumentOutOfRangeException(nameof(matchMode), matchMode, null); } } - private static (int start, int end)[] FindNeedleSegments(ReadOnlySpan span) + private static (int Start, int End)[] FindNeedleSegments(ReadOnlySpan span) { var segments = new List<(int, int)>(); var wordStart = -1; @@ -66,37 +69,39 @@ internal readonly ref struct FuzzyMatcher return segments.ToArray(); } +#pragma warning disable SA1202 public int Matches(string value) +#pragma warning restore SA1202 { - if (needleFinalPosition < 0) + if (this.needleFinalPosition < 0) { return 0; } - if (mode == MatchMode.Simple) + if (this.mode == MatchMode.Simple) { - return value.Contains(needleString) ? 1 : 0; + return value.Contains(this.needleString) ? 1 : 0; } var haystack = value.AsSpan(); - if (mode == MatchMode.Fuzzy) + if (this.mode == MatchMode.Fuzzy) { - return GetRawScore(haystack, 0, needleFinalPosition); + return this.GetRawScore(haystack, 0, this.needleFinalPosition); } - if (mode == MatchMode.FuzzyParts) + if (this.mode == MatchMode.FuzzyParts) { - if (needleSegments.Length < 2) + if (this.needleSegments.Length < 2) { - return GetRawScore(haystack, 0, needleFinalPosition); + return this.GetRawScore(haystack, 0, this.needleFinalPosition); } var total = 0; - for (var i = 0; i < needleSegments.Length; i++) + for (var i = 0; i < this.needleSegments.Length; i++) { - var (start, end) = needleSegments[i]; - var cur = GetRawScore(haystack, start, end); + var (start, end) = this.needleSegments[i]; + var cur = this.GetRawScore(haystack, start, end); if (cur == 0) { return 0; @@ -116,7 +121,7 @@ internal readonly ref struct FuzzyMatcher var max = 0; for (var i = 0; i < values.Length; i++) { - var cur = Matches(values[i]); + var cur = this.Matches(values[i]); if (cur > max) { max = cur; @@ -128,7 +133,7 @@ internal readonly ref struct FuzzyMatcher private int GetRawScore(ReadOnlySpan haystack, int needleStart, int needleEnd) { - var (startPos, gaps, consecutive, borderMatches, endPos) = FindForward(haystack, needleStart, needleEnd); + var (startPos, gaps, consecutive, borderMatches, endPos) = this.FindForward(haystack, needleStart, needleEnd); if (startPos < 0) { return 0; @@ -140,7 +145,7 @@ internal readonly ref struct FuzzyMatcher // PluginLog.Debug( // $"['{needleString.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] fwd: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={score}"); - (startPos, gaps, consecutive, borderMatches) = FindReverse(haystack, endPos, needleStart, needleEnd); + (startPos, gaps, consecutive, borderMatches) = this.FindReverse(haystack, endPos, needleStart, needleEnd); var revScore = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); // PluginLog.Debug( // $"['{needleString.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] rev: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={revScore}"); @@ -149,7 +154,9 @@ internal readonly ref struct FuzzyMatcher } [MethodImpl(MethodImplOptions.AggressiveInlining)] +#pragma warning disable SA1204 private static int CalculateRawScore(int needleSize, int startPos, int gaps, int consecutive, int borderMatches) +#pragma warning restore SA1204 { var score = 100 + needleSize * 3 @@ -162,7 +169,7 @@ internal readonly ref struct FuzzyMatcher return score < 1 ? 1 : score; } - private (int startPos, int gaps, int consecutive, int borderMatches, int haystackIndex) FindForward( + private (int StartPos, int Gaps, int Consecutive, int BorderMatches, int HaystackIndex) FindForward( ReadOnlySpan haystack, int needleStart, int needleEnd) { var needleIndex = needleStart; @@ -175,7 +182,7 @@ internal readonly ref struct FuzzyMatcher for (var haystackIndex = 0; haystackIndex < haystack.Length; haystackIndex++) { - if (haystack[haystackIndex] == needleSpan[needleIndex]) + if (haystack[haystackIndex] == this.needleSpan[needleIndex]) { #if BORDER_MATCHING if (haystackIndex > 0) @@ -217,8 +224,8 @@ internal readonly ref struct FuzzyMatcher return (-1, 0, 0, 0, 0); } - private (int startPos, int gaps, int consecutive, int borderMatches) FindReverse(ReadOnlySpan haystack, - int haystackLastMatchIndex, int needleStart, int needleEnd) + private (int StartPos, int Gaps, int Consecutive, int BorderMatches) FindReverse( + ReadOnlySpan haystack, int haystackLastMatchIndex, int needleStart, int needleEnd) { var needleIndex = needleEnd; var revLastMatchIndex = haystack.Length + 10; @@ -229,7 +236,7 @@ internal readonly ref struct FuzzyMatcher for (var haystackIndex = haystackLastMatchIndex; haystackIndex >= 0; haystackIndex--) { - if (haystack[haystackIndex] == needleSpan[needleIndex]) + if (haystack[haystackIndex] == this.needleSpan[needleIndex]) { #if BORDER_MATCHING if (haystackIndex > 0) @@ -265,9 +272,12 @@ internal readonly ref struct FuzzyMatcher } } -public enum MatchMode +internal enum MatchMode { Simple, Fuzzy, - FuzzyParts + FuzzyParts, } + +#pragma warning restore SA1600 +#pragma warning restore SA1602 diff --git a/Dalamud/Utility/IDeferredDisposable.cs b/Dalamud/Utility/IDeferredDisposable.cs new file mode 100644 index 000000000..41a7dd8d3 --- /dev/null +++ b/Dalamud/Utility/IDeferredDisposable.cs @@ -0,0 +1,12 @@ +namespace Dalamud.Utility; + +/// +/// An extension of which makes queue +/// to be called at a later time. +/// +internal interface IDeferredDisposable : IDisposable +{ + /// Actually dispose the object. + /// Not to be called from the code that uses the end object. + void RealDispose(); +} diff --git a/Dalamud/Utility/IRefCountable.cs b/Dalamud/Utility/IRefCountable.cs new file mode 100644 index 000000000..76d1059d1 --- /dev/null +++ b/Dalamud/Utility/IRefCountable.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using System.Threading; + +namespace Dalamud.Utility; + +/// +/// Interface for reference counting. +/// +internal interface IRefCountable : IDisposable +{ + /// + /// Result for . + /// + public enum RefCountResult + { + /// + /// The object still has remaining references. No futher action should be done. + /// + StillAlive = 1, + + /// + /// The last reference to the object has been released. The object should be fully released. + /// + FinalRelease = 2, + + /// + /// The object already has been disposed. may be thrown. + /// + AlreadyDisposed = 3, + } + + /// + /// Adds a reference to this reference counted object. + /// + /// The new number of references. + int AddRef(); + + /// + /// Releases a reference from this reference counted object.
+ /// When all references are released, the object will be fully disposed. + ///
+ /// The new number of references. + int Release(); + + /// + /// Alias for . + /// + void IDisposable.Dispose() => this.Release(); + + /// + /// Alters by . + /// + /// The delta to the reference count. + /// The reference to the reference count. + /// The new reference count. + /// The followup action that should be done. + public static RefCountResult AlterRefCount(int delta, ref int refCount, out int newRefCount) + { + Debug.Assert(delta is 1 or -1, "delta must be 1 or -1"); + + while (true) + { + var refCountCopy = refCount; + if (refCountCopy <= 0) + { + newRefCount = refCountCopy; + return RefCountResult.AlreadyDisposed; + } + + newRefCount = refCountCopy + delta; + if (refCountCopy != Interlocked.CompareExchange(ref refCount, newRefCount, refCountCopy)) + continue; + + return newRefCount == 0 ? RefCountResult.FinalRelease : RefCountResult.StillAlive; + } + } +} diff --git a/Dalamud/Utility/RollingList.cs b/Dalamud/Utility/RollingList.cs new file mode 100644 index 000000000..9ca012be4 --- /dev/null +++ b/Dalamud/Utility/RollingList.cs @@ -0,0 +1,234 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Dalamud.Utility +{ + /// + /// A list with limited capacity holding items of type . + /// Adding further items will result in the list rolling over. + /// + /// Item type. + /// + /// Implemented as a circular list using a internally. + /// Insertions and Removals are not supported. + /// Not thread-safe. + /// + internal class RollingList : IList + { + private List items; + private int size; + private int firstIndex; + + /// Initializes a new instance of the class. + /// size. + /// Internal initial capacity. + public RollingList(int size, int capacity) + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(size), size, 0); + capacity = Math.Min(capacity, size); + this.size = size; + this.items = new List(capacity); + } + + /// Initializes a new instance of the class. + /// size. + public RollingList(int size) + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(size), size, 0); + this.size = size; + this.items = new(); + } + + /// Initializes a new instance of the class. + /// Collection where elements are copied from. + /// size. + public RollingList(IEnumerable items, int size) + { + if (!items.TryGetNonEnumeratedCount(out var capacity)) capacity = 4; + capacity = Math.Min(capacity, size); + this.size = size; + this.items = new List(capacity); + this.AddRange(items); + } + + /// Initializes a new instance of the class. + /// Collection where elements are copied from. + /// size. + /// Internal initial capacity. + public RollingList(IEnumerable items, int size, int capacity) + { + if (items.TryGetNonEnumeratedCount(out var count) && count > capacity) capacity = count; + capacity = Math.Min(capacity, size); + this.size = size; + this.items = new List(capacity); + this.AddRange(items); + } + + /// Gets item count. + public int Count => this.items.Count; + + /// Gets or sets the internal list capacity. + public int Capacity + { + get => this.items.Capacity; + set => this.items.Capacity = Math.Min(value, this.size); + } + + /// Gets or sets rolling list size. + public int Size + { + get => this.size; + set + { + if (value == this.size) return; + if (value > this.size) + { + if (this.firstIndex > 0) + { + this.items = new List(this); + this.firstIndex = 0; + } + } + else // value < this._size + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(value), value, 0); + if (value < this.Count) + { + this.items = new List(this.TakeLast(value)); + this.firstIndex = 0; + } + } + + this.size = value; + } + } + + /// Gets a value indicating whether the item is read only. + public bool IsReadOnly => false; + + /// Gets or sets an item by index. + /// Item index. + /// Item at specified index. + public T this[int index] + { + get + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfGreaterThanOrEqual(nameof(index), index, this.Count); + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(index), index, 0); + return this.items[this.GetRealIndex(index)]; + } + + set + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfGreaterThanOrEqual(nameof(index), index, this.Count); + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(index), index, 0); + this.items[this.GetRealIndex(index)] = value; + } + } + + /// Adds an item to this . + /// Item to add. + public void Add(T item) + { + if (this.size == 0) return; + if (this.items.Count >= this.size) + { + this.items[this.firstIndex] = item; + this.firstIndex = (this.firstIndex + 1) % this.size; + } + else + { + if (this.items.Count == this.items.Capacity) + { + // Manual list capacity resize + var newCapacity = Math.Max(Math.Min(this.size, this.items.Capacity * 2), this.items.Capacity); + this.items.Capacity = newCapacity; + } + + this.items.Add(item); + } + + Debug.Assert(this.items.Count <= this.size, "Item count should be less than Size"); + } + + /// Add items to this . + /// Items to add. + public void AddRange(IEnumerable items) + { + if (this.size == 0) return; + foreach (var item in items) this.Add(item); + } + + /// Removes all elements from the + public void Clear() + { + this.items.Clear(); + this.firstIndex = 0; + } + + /// Find the index of a specific item. + /// item to find. + /// Index where is found. -1 if not found. + public int IndexOf(T item) + { + var index = this.items.IndexOf(item); + if (index == -1) return -1; + return this.GetVirtualIndex(index); + } + + /// Not supported. + [SuppressMessage("Documentation Rules", "SA1611", Justification = "Not supported")] + void IList.Insert(int index, T item) => throw new NotSupportedException(); + + /// Not supported. + [SuppressMessage("Documentation Rules", "SA1611", Justification = "Not supported")] + void IList.RemoveAt(int index) => throw new NotSupportedException(); + + /// Find wether an item exists. + /// item to find. + /// Wether is found. + public bool Contains(T item) => this.items.Contains(item); + + /// Copies the content of this list into an array. + /// Array to copy into. + /// index to start coping into. + public void CopyTo(T[] array, int arrayIndex) + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(arrayIndex), arrayIndex, 0); + if (array.Length - arrayIndex < this.Count) ThrowHelper.ThrowArgumentException("Not enough space"); + for (var index = 0; index < this.Count; index++) + { + array[arrayIndex++] = this[index]; + } + } + + /// Not supported. + [SuppressMessage("Documentation Rules", "SA1611", Justification = "Not supported")] + [SuppressMessage("Documentation Rules", "SA1615", Justification = "Not supported")] + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + /// Gets an enumerator for this . + /// enumerator. + public IEnumerator GetEnumerator() + { + for (var index = 0; index < this.items.Count; index++) + { + yield return this.items[this.GetRealIndex(index)]; + } + } + + /// Gets an enumerator for this . + /// enumerator. + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int GetRealIndex(int index) => this.size > 0 ? (index + this.firstIndex) % this.size : 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int GetVirtualIndex(int index) => this.size > 0 ? (this.size + index - this.firstIndex) % this.size : 0; + } +} diff --git a/Dalamud/Utility/Signatures/SignatureHelper.cs b/Dalamud/Utility/Signatures/SignatureHelper.cs index bd99b8515..51f59bba2 100755 --- a/Dalamud/Utility/Signatures/SignatureHelper.cs +++ b/Dalamud/Utility/Signatures/SignatureHelper.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; @@ -6,6 +7,7 @@ using System.Runtime.InteropServices; using Dalamud.Game; using Dalamud.Hooking; using Dalamud.Logging; +using Dalamud.Plugin.Services; using Dalamud.Utility.Signatures.Wrappers; using Serilog; @@ -14,25 +16,28 @@ namespace Dalamud.Utility.Signatures; /// /// A utility class to help reduce signature boilerplate code. /// -public static class SignatureHelper +internal static class SignatureHelper { private const BindingFlags Flags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; /// - /// Initialises an object's fields and properties that are annotated with a + /// Initializes an object's fields and properties that are annotated with a /// . /// - /// The object to initialise. + /// The object to initialize. /// If warnings should be logged using . - public static void Initialise(object self, bool log = true) + /// Collection of created IDalamudHooks. + internal static IEnumerable Initialize(object self, bool log = true) { - var scanner = Service.Get(); + var scanner = Service.Get(); var selfType = self.GetType(); var fields = selfType.GetFields(Flags).Select(field => (IFieldOrPropertyInfo)new FieldInfoWrapper(field)) .Concat(selfType.GetProperties(Flags).Select(prop => new PropertyInfoWrapper(prop))) .Select(field => (field, field.GetCustomAttribute())) .Where(field => field.Item2 != null); + var createdHooks = new List(); + foreach (var (info, sig) in fields) { var wasWrapped = false; @@ -61,7 +66,7 @@ public static class SignatureHelper : message; if (fallible) { - PluginLog.Warning(errorMsg); + Log.Warning(errorMsg); } else { @@ -149,15 +154,16 @@ public static class SignatureHelper detour = del; } - var ctor = actualType.GetConstructor(new[] { typeof(IntPtr), hookDelegateType }); - if (ctor == null) + var creator = actualType.GetMethod("FromAddress", BindingFlags.Static | BindingFlags.NonPublic); + if (creator == null) { - Log.Error("Error in SignatureHelper: could not find Hook constructor"); + Log.Error("Error in SignatureHelper: could not find Hook creator"); continue; } - var hook = ctor.Invoke(new object?[] { ptr, detour }); + var hook = creator.Invoke(null, new object?[] { ptr, detour, false }) as IDalamudHook; info.SetValue(self, hook); + createdHooks.Add(hook); break; } @@ -182,5 +188,7 @@ public static class SignatureHelper } } } + + return createdHooks; } } diff --git a/Dalamud.Interface/StableInsertionSortExtension.cs b/Dalamud/Utility/StableInsertionSortExtension.cs similarity index 57% rename from Dalamud.Interface/StableInsertionSortExtension.cs rename to Dalamud/Utility/StableInsertionSortExtension.cs index d2884f838..f7c9b43be 100644 --- a/Dalamud.Interface/StableInsertionSortExtension.cs +++ b/Dalamud/Utility/StableInsertionSortExtension.cs @@ -1,9 +1,21 @@ +using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; -namespace Dalamud.Interface; +namespace Dalamud.Utility; +/// +/// Extensions methods providing stable insertion sorts for IList. +/// internal static class StableInsertionSortExtension { + /// + /// Perform a stable sort on a list. + /// + /// The list to sort. + /// Selector to order by. + /// Element type. + /// Selected type. [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public static void StableSort(this IList list, Func selector) { @@ -13,6 +25,12 @@ internal static class StableInsertionSortExtension list[i] = tmpList[i]; } + /// + /// Perform a stable sort on a list. + /// + /// The list to sort. + /// Comparer to use when comparing items. + /// Element type. [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public static void StableSort(this IList list, Comparison comparer) { diff --git a/Dalamud/Utility/ThreadBoundTaskScheduler.cs b/Dalamud/Utility/ThreadBoundTaskScheduler.cs new file mode 100644 index 000000000..4b6de29ff --- /dev/null +++ b/Dalamud/Utility/ThreadBoundTaskScheduler.cs @@ -0,0 +1,90 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Dalamud.Utility; + +/// +/// A task scheduler that runs tasks on a specific thread. +/// +internal class ThreadBoundTaskScheduler : TaskScheduler +{ + private const byte Scheduled = 0; + private const byte Running = 1; + + private readonly ConcurrentDictionary scheduledTasks = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The thread to bind this task scheduelr to. + public ThreadBoundTaskScheduler(Thread? boundThread = null) + { + this.BoundThread = boundThread; + } + + /// + /// Gets or sets the thread this task scheduler is bound to. + /// + public Thread? BoundThread { get; set; } + + /// + /// Gets a value indicating whether we're on the bound thread. + /// + public bool IsOnBoundThread => Thread.CurrentThread == this.BoundThread; + + /// + /// Runs queued tasks. + /// + public void Run() + { + foreach (var task in this.scheduledTasks.Keys) + { + if (!this.scheduledTasks.TryUpdate(task, Running, Scheduled)) + continue; + + _ = this.TryExecuteTask(task); + } + } + + /// + protected override IEnumerable GetScheduledTasks() + { + return this.scheduledTasks.Keys; + } + + /// + protected override void QueueTask(Task task) + { + this.scheduledTasks[task] = Scheduled; + } + + /// + protected override bool TryDequeue(Task task) + { + if (!this.scheduledTasks.TryRemove(task, out _)) + return false; + return true; + } + + /// + 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; + } +} diff --git a/Dalamud/Utility/ThreadSafety.cs b/Dalamud/Utility/ThreadSafety.cs index 7c4b0dfcb..ce3ddc602 100644 --- a/Dalamud/Utility/ThreadSafety.cs +++ b/Dalamud/Utility/ThreadSafety.cs @@ -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. ///
/// Thrown when the current thread is not the main thread. + [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. /// /// Thrown when the current thread is the main thread. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void AssertNotMainThread() { if (threadStaticIsMainThread) @@ -39,6 +42,15 @@ public static class ThreadSafety } } + /// , but only on debug compilation mode. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void DebugAssertMainThread() + { +#if DEBUG + AssertMainThread(); +#endif + } + /// /// Marks a thread as the main thread. /// diff --git a/Dalamud/Utility/ThrowHelper.cs b/Dalamud/Utility/ThrowHelper.cs new file mode 100644 index 000000000..647aa92c0 --- /dev/null +++ b/Dalamud/Utility/ThrowHelper.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Dalamud.Utility +{ + /// Helper methods for throwing exceptions. + internal static class ThrowHelper + { + /// Throws a with a specified . + /// Message for the exception. + /// Thrown by this method. + [DoesNotReturn] + public static void ThrowArgumentException(string message) => throw new ArgumentException(message); + + /// Throws a with a specified for a specified . + /// Parameter name. + /// Message for the exception. + /// Thrown by this method. + [DoesNotReturn] + public static void ThrowArgumentOutOfRangeException(string paramName, string message) => throw new ArgumentOutOfRangeException(paramName, message); + + /// Throws a if the specified is less than . + /// value type. + /// Parameter name. + /// Value to compare from. + /// Value to compare with. + /// Thrown by this method if is less than . + public static void ThrowArgumentOutOfRangeExceptionIfLessThan(string paramName, T value, T comparand) where T : IComparable + { +#if NET8_0_OR_GREATER + ArgumentOutOfRangeException.ThrowIfLessThan(value, comparand); +#else + if (Comparer.Default.Compare(value, comparand) <= -1) ThrowArgumentOutOfRangeException(paramName, $"{paramName} must be greater than or equal {comparand}"); +#endif + } + + /// Throws a if the specified is greater than or equal to . + /// value type. + /// Parameter name. + /// Value to compare from. + /// Value to compare with. + /// Thrown by this method if is greater than or equal to. + public static void ThrowArgumentOutOfRangeExceptionIfGreaterThanOrEqual(string paramName, T value, T comparand) where T : IComparable + { +#if NET8_0_OR_GREATER + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(value, comparand); +#else + if (Comparer.Default.Compare(value, comparand) >= 0) ThrowArgumentOutOfRangeException(paramName, $"{paramName} must be less than {comparand}"); +#endif + } + } +} diff --git a/Dalamud/Utility/Util.cs b/Dalamud/Utility/Util.cs index b3e0e5d7f..89146c769 100644 --- a/Dalamud/Utility/Util.cs +++ b/Dalamud/Utility/Util.cs @@ -1,29 +1,32 @@ -using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; -using System.Net.Http; using System.Numerics; using System.Reflection; +using System.Reflection.Emit; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; +using System.Threading.Tasks; using Dalamud.Configuration.Internal; using Dalamud.Data; using Dalamud.Game; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; -using Dalamud.Interface; using Dalamud.Interface.Colors; -using Dalamud.Logging.Internal; -using Dalamud.Networking.Http; +using Dalamud.Interface.Utility; using ImGuiNET; using Lumina.Excel.GeneratedSheets; -using Microsoft.Win32; using Serilog; +using TerraFX.Interop.Windows; + +using Windows.Win32.Storage.FileSystem; + namespace Dalamud.Utility; /// @@ -31,24 +34,19 @@ namespace Dalamud.Utility; /// public static class Util { + private static readonly Type GenericSpanType = typeof(Span<>); private static string? gitHashInternal; private static int? gitCommitCountInternal; private static string? gitHashClientStructsInternal; private static ulong moduleStartAddr; private static ulong moduleEndAddr; - - /// - /// Gets an httpclient for usage. - /// Do NOT await this. - /// - [Obsolete($"Use Service<{nameof(HappyHttpClient)}> instead.")] - public static HttpClient HttpClient { get; } = Service.Get().SharedHttpClient; /// /// Gets the assembly version of Dalamud. /// - public static string AssemblyVersion { get; } = Assembly.GetAssembly(typeof(ChatHandlers)).GetName().Version.ToString(); + public static string AssemblyVersion { get; } = + Assembly.GetAssembly(typeof(ChatHandlers)).GetName().Version.ToString(); /// /// Check two byte arrays for equality. @@ -250,80 +248,7 @@ public static class Util /// Whether or not this structure should start out expanded. /// The already followed path. public static void ShowStruct(object obj, ulong addr, bool autoExpand = false, IEnumerable? path = null) - { - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(3, 2)); - path ??= new List(); - - if (moduleEndAddr == 0 && moduleStartAddr == 0) - { - try - { - var processModule = Process.GetCurrentProcess().MainModule; - if (processModule != null) - { - moduleStartAddr = (ulong)processModule.BaseAddress.ToInt64(); - moduleEndAddr = moduleStartAddr + (ulong)processModule.ModuleMemorySize; - } - else - { - moduleEndAddr = 1; - } - } - catch - { - moduleEndAddr = 1; - } - } - - ImGui.PushStyleColor(ImGuiCol.Text, 0xFF00FFFF); - if (autoExpand) - { - ImGui.SetNextItemOpen(true, ImGuiCond.Appearing); - } - - if (ImGui.TreeNode($"{obj}##print-obj-{addr:X}-{string.Join("-", path)}")) - { - ImGui.PopStyleColor(); - foreach (var f in obj.GetType().GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.Instance)) - { - var fixedBuffer = (FixedBufferAttribute)f.GetCustomAttribute(typeof(FixedBufferAttribute)); - if (fixedBuffer != null) - { - ImGui.Text($"fixed"); - ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.9f, 1), $"{fixedBuffer.ElementType.Name}[0x{fixedBuffer.Length:X}]"); - } - else - { - ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.9f, 1), $"{f.FieldType.Name}"); - } - - ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.4f, 1), $"{f.Name}: "); - ImGui.SameLine(); - - ShowValue(addr, new List(path) { f.Name }, f.FieldType, f.GetValue(obj)); - } - - foreach (var p in obj.GetType().GetProperties().Where(p => p.GetGetMethod()?.GetParameters().Length == 0)) - { - ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.9f, 1), $"{p.PropertyType.Name}"); - ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.2f, 0.6f, 0.4f, 1), $"{p.Name}: "); - ImGui.SameLine(); - - ShowValue(addr, new List(path) { p.Name }, p.PropertyType, p.GetValue(obj)); - } - - ImGui.TreePop(); - } - else - { - ImGui.PopStyleColor(); - } - - ImGui.PopStyleVar(); - } + => ShowStructInternal(obj, addr, autoExpand, path); /// /// Show a structure in an ImGui context. @@ -373,14 +298,21 @@ public static class Util ImGui.Indent(); - foreach (var propertyInfo in type.GetProperties().Where(p => p.GetGetMethod()?.GetParameters().Length == 0)) + foreach (var p in type.GetProperties().Where(p => p.GetGetMethod()?.GetParameters().Length == 0)) { - var value = propertyInfo.GetValue(obj); - var valueType = value?.GetType(); - if (valueType == typeof(IntPtr)) - ImGui.TextColored(ImGuiColors.DalamudOrange, $" {propertyInfo.Name}: 0x{value:X}"); + if (p.PropertyType.IsGenericType && (p.PropertyType.IsByRef || p.PropertyType.IsByRefLike)) + { + ImGui.TextColored(ImGuiColors.DalamudOrange, $" {p.Name}: (ref typed property)"); + } else - ImGui.TextColored(ImGuiColors.DalamudOrange, $" {propertyInfo.Name}: {value}"); + { + var value = p.GetValue(obj); + var valueType = value?.GetType(); + if (valueType == typeof(IntPtr)) + ImGui.TextColored(ImGuiColors.DalamudOrange, $" {p.Name}: 0x{value:X}"); + else + ImGui.TextColored(ImGuiColors.DalamudOrange, $" {p.Name}: {value}"); + } } ImGui.Unindent(); @@ -407,7 +339,8 @@ public static class Util /// Specify whether to exit immediately. public static void Fatal(string message, string caption, bool exit = true) { - var flags = NativeFunctions.MessageBoxType.Ok | NativeFunctions.MessageBoxType.IconError | NativeFunctions.MessageBoxType.Topmost; + var flags = NativeFunctions.MessageBoxType.Ok | NativeFunctions.MessageBoxType.IconError | + NativeFunctions.MessageBoxType.Topmost; _ = NativeFunctions.MessageBoxW(Process.GetCurrentProcess().MainWindowHandle, message, caption, flags); if (exit) @@ -497,47 +430,57 @@ public static class Util } /// - /// Copy one stream to another. + /// Determine if Dalamud is currently running within a Wine context (e.g. either on macOS or Linux). This method + /// will not return information about the host operating system. /// - /// The source stream. - /// The destination stream. - /// The maximum length to copy. - [Obsolete("Use Stream.CopyTo() instead", true)] - public static void CopyTo(Stream src, Stream dest, int len = 4069) + /// Returns true if Wine is detected, false otherwise. + public static bool IsWine() { - var bytes = new byte[len]; - int cnt; + if (EnvironmentConfiguration.XlWineOnLinux) return true; + if (Environment.GetEnvironmentVariable("XL_PLATFORM") is not null and not "Windows") return true; - while ((cnt = src.Read(bytes, 0, bytes.Length)) != 0) dest.Write(bytes, 0, cnt); + var ntdll = NativeFunctions.GetModuleHandleW("ntdll.dll"); + + // Test to see if any Wine specific exports exist. If they do, then we are running on Wine. + // The exports "wine_get_version", "wine_get_build_id", and "wine_get_host_version" will tend to be hidden + // by most Linux users (else FFXIV will want a macOS license), so we will additionally check some lesser-known + // exports as well. + return AnyProcExists( + ntdll, + "wine_get_version", + "wine_get_build_id", + "wine_get_host_version", + "wine_server_call", + "wine_unix_to_nt_file_name"); + + bool AnyProcExists(nint handle, params string[] procs) => + procs.Any(p => NativeFunctions.GetProcAddress(handle, p) != nint.Zero); } /// - /// Heuristically determine if Dalamud is running on Linux/WINE. + /// Gets the best guess for the current host's platform based on the XL_PLATFORM environment variable or + /// heuristics. /// - /// Whether or not Dalamud is running on Linux/WINE. - public static bool IsLinux() + /// + /// macOS users running without XL_PLATFORM being set will be reported as Linux users. Due to the way our + /// Wines work, there isn't a great (consistent) way to split the two apart if we're not told. + /// + /// Returns the that Dalamud is currently running on. + public static OSPlatform GetHostPlatform() { - bool Check1() + switch (Environment.GetEnvironmentVariable("XL_PLATFORM")) { - return EnvironmentConfiguration.XlWineOnLinux; + case "Windows": return OSPlatform.Windows; + case "MacOS": return OSPlatform.OSX; + case "Linux": return OSPlatform.Linux; } - - bool Check2() - { - var hModule = NativeFunctions.GetModuleHandleW("ntdll.dll"); - var proc1 = NativeFunctions.GetProcAddress(hModule, "wine_get_version"); - var proc2 = NativeFunctions.GetProcAddress(hModule, "wine_get_build_id"); - - return proc1 != IntPtr.Zero || proc2 != IntPtr.Zero; - } - - bool Check3() - { - return Registry.CurrentUser.OpenSubKey(@"Software\Wine") != null || - Registry.LocalMachine.OpenSubKey(@"Software\Wine") != null; - } - - return Check1() || Check2() || Check3(); + + // n.b. we had some fancy code here to check if the Wine host version returned "Darwin" but apparently + // *all* our Wines report Darwin if exports aren't hidden. As such, it is effectively impossible (without some + // (very cursed and inaccurate heuristics) to determine if we're on macOS or Linux unless we're explicitly told + // by our launcher. See commit a7aacb15e4603a367e2f980578271a9a639d8852 for the old check. + + return IsWine() ? OSPlatform.Linux : OSPlatform.Windows; } /// @@ -637,7 +580,7 @@ public static class Util } } } - } + } finally { foreach (var enumerator in enumerators) @@ -648,38 +591,24 @@ public static class Util } /// - /// Dispose this object. + /// Request that Windows flash the game window to grab the user's attention. /// - /// The object to dispose. - /// The type of object to dispose. - internal static void ExplicitDispose(this T obj) where T : IDisposable + /// Attempt to flash even if the game is currently focused. + public static void FlashWindow(bool flashIfOpen = false) { - obj.Dispose(); - } + if (NativeFunctions.ApplicationIsActivated() && !flashIfOpen) + return; - /// - /// Dispose this object. - /// - /// The object to dispose. - /// Log message to print, if specified and an error occurs. - /// Module logger, if any. - /// The type of object to dispose. - internal static void ExplicitDisposeIgnoreExceptions(this T obj, string? logMessage = null, ModuleLog? moduleLog = null) where T : IDisposable - { - try + var flashInfo = new NativeFunctions.FlashWindowInfo { - obj.Dispose(); - } - catch (Exception e) - { - if (logMessage == null) - return; + Size = (uint)Marshal.SizeOf(), + Count = uint.MaxValue, + Timeout = 0, + Flags = NativeFunctions.FlashWindow.All | NativeFunctions.FlashWindow.TimerNoFG, + Hwnd = Process.GetCurrentProcess().MainWindowHandle, + }; - if (moduleLog != null) - moduleLog.Error(e, logMessage); - else - Log.Error(e, logMessage); - } + NativeFunctions.FlashWindowEx(ref flashInfo); } /// @@ -688,14 +617,63 @@ public static class Util /// /// The path of the file to write to. /// The text to write. - internal static void WriteAllTextSafe(string path, string text) + public static void WriteAllTextSafe(string path, string text) { - var tmpPath = path + ".tmp"; - if (File.Exists(tmpPath)) - File.Delete(tmpPath); + WriteAllTextSafe(path, text, Encoding.UTF8); + } + + /// + /// Overwrite text in a file by first writing it to a temporary file, and then + /// moving that file to the path specified. + /// + /// The path of the file to write to. + /// The text to write. + /// Encoding to use. + public static void WriteAllTextSafe(string path, string text, Encoding encoding) + { + WriteAllBytesSafe(path, encoding.GetBytes(text)); + } + + /// + /// Overwrite data in a file by first writing it to a temporary file, and then + /// moving that file to the path specified. + /// + /// The path of the file to write to. + /// The data to write. + public static unsafe void WriteAllBytesSafe(string path, byte[] bytes) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + // Open the temp file + var tempPath = path + ".tmp"; - File.WriteAllText(tmpPath, text); - File.Move(tmpPath, path, true); + using var tempFile = Windows.Win32.PInvoke.CreateFile( + tempPath, + (uint)(FILE_ACCESS_RIGHTS.FILE_GENERIC_READ | FILE_ACCESS_RIGHTS.FILE_GENERIC_WRITE), + FILE_SHARE_MODE.FILE_SHARE_NONE, + null, + FILE_CREATION_DISPOSITION.CREATE_ALWAYS, + FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_NORMAL, + null); + + if (tempFile.IsInvalid) + throw new Win32Exception(); + + // Write the data + uint bytesWritten = 0; + if (!Windows.Win32.PInvoke.WriteFile(tempFile, new ReadOnlySpan(bytes), &bytesWritten, null)) + throw new Win32Exception(); + + if (bytesWritten != bytes.Length) + throw new Exception($"Could not write all bytes to temp file ({bytesWritten} of {bytes.Length})"); + + if (!Windows.Win32.PInvoke.FlushFileBuffers(tempFile)) + throw new Win32Exception(); + + tempFile.Close(); + + if (!Windows.Win32.PInvoke.MoveFileEx(tempPath, path, MOVE_FILE_FLAGS.MOVEFILE_REPLACE_EXISTING | MOVE_FILE_FLAGS.MOVEFILE_WRITE_THROUGH)) + throw new Win32Exception(); } /// @@ -711,6 +689,55 @@ public static class Util return names.ElementAt(rng.Next(0, names.Count() - 1)).Singular.RawString; } + /// + /// Throws a corresponding exception if is true. + /// + /// The result value. + internal static void ThrowOnError(this HRESULT hr) + { + if (hr.FAILED) + Marshal.ThrowExceptionForHR(hr.Value); + } + + /// + /// Calls if the task is incomplete. + /// + /// The task. + /// The exception to set. + internal static void SetExceptionIfIncomplete(this TaskCompletionSource t, Exception ex) + { + if (t.Task.IsCompleted) + return; + try + { + t.SetException(ex); + } + catch + { + // ignore + } + } + + /// + /// Calls if the task is incomplete. + /// + /// The type of the result. + /// The task. + /// The exception to set. + internal static void SetExceptionIfIncomplete(this TaskCompletionSource t, Exception ex) + { + if (t.Task.IsCompleted) + return; + try + { + t.SetException(ex); + } + catch + { + // ignore + } + } + /// /// Print formatted GameObject Information to ImGui. /// @@ -744,8 +771,121 @@ public static class Util ImGui.SetClipboardText(actor.Address.ToInt64().ToString("X")); } } - - private static unsafe void ShowValue(ulong addr, IEnumerable path, Type type, object value) + + private static void ShowSpanProperty(ulong addr, IList path, PropertyInfo p, object obj) + { + var objType = obj.GetType(); + var propType = p.PropertyType; + if (p.GetGetMethod() is not { } getMethod) + { + ImGui.Text("(No getter available)"); + return; + } + + var dm = new DynamicMethod( + "-", + MethodAttributes.Public | MethodAttributes.Static, + CallingConventions.Standard, + null, + new[] { typeof(object), typeof(IList), typeof(ulong) }, + obj.GetType(), + true); + + var ilg = dm.GetILGenerator(); + var objLocalIndex = unchecked((byte)ilg.DeclareLocal(objType, true).LocalIndex); + var propLocalIndex = unchecked((byte)ilg.DeclareLocal(propType, true).LocalIndex); + ilg.Emit(OpCodes.Ldarg_0); + if (objType.IsValueType) + { + ilg.Emit(OpCodes.Unbox_Any, objType); + ilg.Emit(OpCodes.Stloc_S, objLocalIndex); + ilg.Emit(OpCodes.Ldloca_S, objLocalIndex); + } + + ilg.Emit(OpCodes.Call, getMethod); + var mm = typeof(Util).GetMethod(nameof(ShowSpanPrivate), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(p.PropertyType.GetGenericArguments()); + ilg.Emit(OpCodes.Stloc_S, propLocalIndex); + ilg.Emit(OpCodes.Ldarg_2); // addr = arg2 + ilg.Emit(OpCodes.Ldarg_1); // path = arg1 + ilg.Emit(OpCodes.Ldc_I4_0); // offset = 0 + ilg.Emit(OpCodes.Ldc_I4_1); // isTop = true + ilg.Emit(OpCodes.Ldloca_S, propLocalIndex); // spanobj + ilg.Emit(OpCodes.Call, mm); + ilg.Emit(OpCodes.Ret); + + dm.Invoke(null, new[] { obj, path, addr }); + } + + private static unsafe void ShowSpanPrivate(ulong addr, IList path, int offset, bool isTop, in Span spanobj) + { +#pragma warning disable CS8500 // This takes the address of, gets the size of, or declares a pointer to a managed type + if (isTop) + { + fixed (void* p = spanobj) + { + if (!ImGui.TreeNode( + $"Span<{typeof(T).Name}> of length {spanobj.Length:n0} (0x{spanobj.Length:X})" + + $"##print-obj-{addr:X}-{string.Join("-", path)}-head")) + { + return; + } + } + } + + try + { + const int batchSize = 20; + if (spanobj.Length > batchSize) + { + var skip = batchSize; + while ((spanobj.Length + skip - 1) / skip > batchSize) + skip *= batchSize; + for (var i = 0; i < spanobj.Length; i += skip) + { + var next = Math.Min(i + skip, spanobj.Length); + path.Add($"{offset + i:X}_{skip}"); + if (ImGui.TreeNode( + $"{offset + i:n0} ~ {offset + next - 1:n0} (0x{offset + i:X} ~ 0x{offset + next - 1:X})" + + $"##print-obj-{addr:X}-{string.Join("-", path)}")) + { + try + { + ShowSpanPrivate(addr, path, offset + i, false, spanobj[i..next]); + } + finally + { + ImGui.TreePop(); + } + } + + path.RemoveAt(path.Count - 1); + } + } + else + { + fixed (T* p = spanobj) + { + var pointerType = typeof(T*); + for (var i = 0; i < spanobj.Length; i++) + { + ImGui.TextUnformatted($"[{offset + i:n0} (0x{offset + i:X})] "); + ImGui.SameLine(); + path.Add($"{offset + i}"); + ShowValue(addr, path, pointerType, Pointer.Box(p + i, pointerType), true); + } + } + } + } + finally + { + if (isTop) + ImGui.TreePop(); + } +#pragma warning restore CS8500 // This takes the address of, gets the size of, or declares a pointer to a managed type + } + + private static unsafe void ShowValue(ulong addr, IList path, Type type, object value, bool hideAddress) { if (type.IsPointer) { @@ -753,28 +893,32 @@ public static class Util var unboxed = Pointer.Unbox(val); if (unboxed != null) { - var unboxedAddr = (ulong)unboxed; - ImGuiHelpers.ClickToCopyText($"{(ulong)unboxed:X}"); - if (moduleStartAddr > 0 && unboxedAddr >= moduleStartAddr && unboxedAddr <= moduleEndAddr) + if (!hideAddress) { + var unboxedAddr = (ulong)unboxed; + ImGuiHelpers.ClickToCopyText($"{(ulong)unboxed:X}"); + if (moduleStartAddr > 0 && unboxedAddr >= moduleStartAddr && unboxedAddr <= moduleEndAddr) + { + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, 0xffcbc0ff); + ImGuiHelpers.ClickToCopyText($"ffxiv_dx11.exe+{unboxedAddr - moduleStartAddr:X}"); + ImGui.PopStyleColor(); + } + ImGui.SameLine(); - ImGui.PushStyleColor(ImGuiCol.Text, 0xffcbc0ff); - ImGuiHelpers.ClickToCopyText($"ffxiv_dx11.exe+{unboxedAddr - moduleStartAddr:X}"); - ImGui.PopStyleColor(); } try { var eType = type.GetElementType(); var ptrObj = SafeMemory.PtrToStructure(new IntPtr(unboxed), eType); - ImGui.SameLine(); if (ptrObj == null) { ImGui.Text("null or invalid"); } else { - ShowStruct(ptrObj, (ulong)unboxed, path: new List(path)); + ShowStructInternal(ptrObj, addr, path: path, hideAddress: hideAddress); } } catch @@ -791,7 +935,7 @@ public static class Util { if (!type.IsPrimitive) { - ShowStruct(value, addr, path: new List(path)); + ShowStructInternal(value, addr, path: path, hideAddress: hideAddress); } else { @@ -799,4 +943,127 @@ public static class Util } } } + + /// + /// Show a structure in an ImGui context. + /// + /// The structure to show. + /// The address to the structure. + /// Whether or not this structure should start out expanded. + /// The already followed path. + /// Do not print addresses. Use when displaying a copied value. + private static void ShowStructInternal(object obj, ulong addr, bool autoExpand = false, IEnumerable? path = null, bool hideAddress = false) + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(3, 2)); + path ??= new List(); + var pathList = path is List ? (List)path : path.ToList(); + + if (moduleEndAddr == 0 && moduleStartAddr == 0) + { + try + { + var processModule = Process.GetCurrentProcess().MainModule; + if (processModule != null) + { + moduleStartAddr = (ulong)processModule.BaseAddress.ToInt64(); + moduleEndAddr = moduleStartAddr + (ulong)processModule.ModuleMemorySize; + } + else + { + moduleEndAddr = 1; + } + } + catch + { + moduleEndAddr = 1; + } + } + + ImGui.PushStyleColor(ImGuiCol.Text, 0xFF00FFFF); + if (autoExpand) + { + ImGui.SetNextItemOpen(true, ImGuiCond.Appearing); + } + + if (ImGui.TreeNode($"{obj}##print-obj-{addr:X}-{string.Join("-", pathList)}")) + { + ImGui.PopStyleColor(); + foreach (var f in obj.GetType() + .GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.Instance)) + { + var fixedBuffer = (FixedBufferAttribute)f.GetCustomAttribute(typeof(FixedBufferAttribute)); + if (fixedBuffer != null) + { + ImGui.Text($"fixed"); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.9f, 1), + $"{fixedBuffer.ElementType.Name}[0x{fixedBuffer.Length:X}]"); + } + else + { + ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.9f, 1), $"{f.FieldType.Name}"); + } + + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.4f, 1), $"{f.Name}: "); + ImGui.SameLine(); + + pathList.Add(f.Name); + try + { + if (f.FieldType.IsGenericType && (f.FieldType.IsByRef || f.FieldType.IsByRefLike)) + ImGui.Text("Cannot preview ref typed fields."); // object never contains ref struct + else + ShowValue(addr, pathList, f.FieldType, f.GetValue(obj), hideAddress); + } + catch (Exception ex) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.4f, 0.4f, 1f)); + ImGui.TextUnformatted($"Error: {ex.GetType().Name}: {ex.Message}"); + ImGui.PopStyleColor(); + } + finally + { + pathList.RemoveAt(pathList.Count - 1); + } + } + + foreach (var p in obj.GetType().GetProperties().Where(p => p.GetGetMethod()?.GetParameters().Length == 0)) + { + ImGui.TextColored(new Vector4(0.2f, 0.9f, 0.9f, 1), $"{p.PropertyType.Name}"); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.2f, 0.6f, 0.4f, 1), $"{p.Name}: "); + ImGui.SameLine(); + + pathList.Add(p.Name); + try + { + if (p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == GenericSpanType) + ShowSpanProperty(addr, pathList, p, obj); + else if (p.PropertyType.IsGenericType && (p.PropertyType.IsByRef || p.PropertyType.IsByRefLike)) + ImGui.Text("Cannot preview ref typed properties."); + else + ShowValue(addr, pathList, p.PropertyType, p.GetValue(obj), hideAddress); + } + catch (Exception ex) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.4f, 0.4f, 1f)); + ImGui.TextUnformatted($"Error: {ex.GetType().Name}: {ex.Message}"); + ImGui.PopStyleColor(); + } + finally + { + pathList.RemoveAt(pathList.Count - 1); + } + } + + ImGui.TreePop(); + } + else + { + ImGui.PopStyleColor(); + } + + ImGui.PopStyleVar(); + } } diff --git a/DalamudCrashHandler/DalamudCrashHandler.cpp b/DalamudCrashHandler/DalamudCrashHandler.cpp index 18f7f0791..d4e9f0a1c 100644 --- a/DalamudCrashHandler/DalamudCrashHandler.cpp +++ b/DalamudCrashHandler/DalamudCrashHandler.cpp @@ -25,7 +25,7 @@ #include #include #include -#include +#include #pragma comment(lib, "comctl32.lib") #pragma comment(linker, "/manifestdependency:\"type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"") @@ -58,7 +58,7 @@ std::wstring u8_to_ws(const std::string& s) { } std::wstring get_window_string(HWND hWnd) { - std::wstring buf(GetWindowTextLengthW(hWnd), L'\0'); + std::wstring buf(GetWindowTextLengthW(hWnd) + 1, L'\0'); GetWindowTextW(hWnd, &buf[0], static_cast(buf.size())); return buf; } @@ -152,7 +152,7 @@ std::wstring describe_module(const std::filesystem::path& path) { WORD wLanguage; WORD wCodePage; }; - const auto langs = std::span(reinterpret_cast(lpBuffer), size / sizeof(LANGANDCODEPAGE)); + const auto langs = std::span(static_cast(lpBuffer), size / sizeof(LANGANDCODEPAGE)); for (const auto& lang : langs) { if (!VerQueryValueW(block.data(), std::format(L"\\StringFileInfo\\{:04x}{:04x}\\FileDescription", lang.wLanguage, lang.wCodePage).c_str(), &lpBuffer, &size)) continue; @@ -441,6 +441,26 @@ std::wstring escape_shell_arg(const std::wstring& arg) { return res; } +void open_folder_and_select_items(HWND hwndOpener, const std::wstring& path) { + const auto piid = ILCreateFromPathW(path.c_str()); + if (!piid + || FAILED(SHOpenFolderAndSelectItems(piid, 0, nullptr, 0))) { + const auto args = std::format(L"/select,{}", escape_shell_arg(path)); + SHELLEXECUTEINFOW seiw{ + .cbSize = sizeof seiw, + .hwnd = hwndOpener, + .lpFile = L"explorer.exe", + .lpParameters = args.c_str(), + .nShow = SW_SHOW, + }; + if (!ShellExecuteExW(&seiw)) + throw_last_error("ShellExecuteExW"); + } + + if (piid) + ILFree(piid); +} + void export_tspack(HWND hWndParent, const std::filesystem::path& logDir, const std::string& crashLog, const std::string& troubleshootingPackData) { static const char* SourceLogFiles[] = { "output.log", @@ -456,8 +476,9 @@ void export_tspack(HWND hWndParent, const std::filesystem::path& logDir, const s { L"All files (*.*)", L"*" }, }}; - IShellItemPtr pItem; + std::optional filePath; try { + IShellItemPtr pItem; SYSTEMTIME st; GetLocalTime(&st); IFileSaveDialogPtr pDialog; @@ -474,33 +495,39 @@ void export_tspack(HWND hWndParent, const std::filesystem::path& logDir, const s } throw_if_failed(pDialog->GetResult(&pItem), {}, "pDialog->GetResult"); - - IBindCtxPtr pBindCtx; - throw_if_failed(CreateBindCtx(0, &pBindCtx), {}, "CreateBindCtx"); - auto options = BIND_OPTS{.cbStruct = sizeof(BIND_OPTS), .grfMode = STGM_READWRITE | STGM_SHARE_EXCLUSIVE | STGM_CREATE}; - throw_if_failed(pBindCtx->SetBindOptions(&options), {}, "pBindCtx->SetBindOptions"); + PWSTR pFilePath = nullptr; + throw_if_failed(pItem->GetDisplayName(SIGDN_FILESYSPATH, &pFilePath), {}, "pItem->GetDisplayName"); + pItem.Release(); + filePath.emplace(pFilePath); - IStreamPtr pStream; - throw_if_failed(pItem->BindToHandler(pBindCtx, BHID_Stream, IID_PPV_ARGS(&pStream)), {}, "pItem->BindToHandler"); - - throw_if_failed(pStream->SetSize({}), {}, "pStream->SetSize"); + std::fstream fileStream(*filePath, std::ios::binary | std::ios::in | std::ios::out | std::ios::trunc); mz_zip_archive zipa{}; - zipa.m_pIO_opaque = &*pStream; + zipa.m_pIO_opaque = &fileStream; zipa.m_pRead = [](void* pOpaque, mz_uint64 file_ofs, void* pBuf, size_t n) -> size_t { - const auto pStream = static_cast(pOpaque); - throw_if_failed(pStream->Seek({ .QuadPart = static_cast(file_ofs) }, STREAM_SEEK_SET, nullptr), {}, "pStream->Seek"); - ULONG read; - throw_if_failed(pStream->Read(pBuf, static_cast(n), &read), {}, "pStream->Read"); - return read; + const auto pStream = static_cast(pOpaque); + if (!pStream || !pStream->is_open()) + throw std::runtime_error("Read operation failed: Stream is not open"); + pStream->seekg(file_ofs, std::ios::beg); + if (pStream->fail()) + throw std::runtime_error("Read operation failed: Error seeking in stream"); + pStream->read(static_cast(pBuf), n); + if (pStream->fail()) + throw std::runtime_error("Read operation failed: Error reading from stream"); + return pStream->gcount(); }; zipa.m_pWrite = [](void* pOpaque, mz_uint64 file_ofs, const void* pBuf, size_t n) -> size_t { - const auto pStream = static_cast(pOpaque); - throw_if_failed(pStream->Seek({ .QuadPart = static_cast(file_ofs) }, STREAM_SEEK_SET, nullptr), {}, "pStream->Seek"); - ULONG written; - throw_if_failed(pStream->Write(pBuf, static_cast(n), &written), {}, "pStream->Write"); - return written; + const auto pStream = static_cast(pOpaque); + if (!pStream || !pStream->is_open()) + throw std::runtime_error("Write operation failed: Stream is not open"); + pStream->seekp(file_ofs, std::ios::beg); + if (pStream->fail()) + throw std::runtime_error("Write operation failed: Error seeking in stream"); + pStream->write(static_cast(pBuf), n); + if (pStream->fail()) + throw std::runtime_error("Write operation failed: Error writing to stream"); + return n; }; const auto mz_throw_if_failed = [&zipa](mz_bool res, const std::string& clue) { if (!res) @@ -510,13 +537,14 @@ void export_tspack(HWND hWndParent, const std::filesystem::path& logDir, const s mz_throw_if_failed(mz_zip_writer_init_v2(&zipa, 0, 0), "mz_zip_writer_init_v2"); mz_throw_if_failed(mz_zip_writer_add_mem(&zipa, "trouble.json", troubleshootingPackData.data(), troubleshootingPackData.size(), MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE | MZ_BEST_COMPRESSION), "mz_zip_writer_add_mem: trouble.json"); mz_throw_if_failed(mz_zip_writer_add_mem(&zipa, "crash.log", crashLog.data(), crashLog.size(), MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE | MZ_BEST_COMPRESSION), "mz_zip_writer_add_mem: crash.log"); + std::string logExportLog; struct HandleAndBaseOffset { HANDLE h; int64_t off; }; const auto fnHandleReader = [](void* pOpaque, mz_uint64 file_ofs, void* pBuf, size_t n) -> size_t { - const auto& info = *reinterpret_cast(pOpaque); + const auto& info = *static_cast(pOpaque); if (!SetFilePointerEx(info.h, { .QuadPart = static_cast(info.off + file_ofs) }, nullptr, SEEK_SET)) throw_last_error("fnHandleReader: SetFilePointerEx"); if (DWORD read; !ReadFile(info.h, pBuf, static_cast(n), &read, nullptr)) @@ -526,8 +554,12 @@ void export_tspack(HWND hWndParent, const std::filesystem::path& logDir, const s }; for (const auto& pcszLogFileName : SourceLogFiles) { const auto logFilePath = logDir / pcszLogFileName; - if (!exists(logFilePath)) + if (!exists(logFilePath)) { + logExportLog += std::format("File does not exist: {}\n", ws_to_u8(logFilePath.wstring())); continue; + } else { + logExportLog += std::format("Including: {}\n", ws_to_u8(logFilePath.wstring())); + } const auto hLogFile = CreateFileW(logFilePath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_FLAG_SEQUENTIAL_SCAN, nullptr); if (hLogFile == INVALID_HANDLE_VALUE) @@ -545,7 +577,14 @@ void export_tspack(HWND hWndParent, const std::filesystem::path& logDir, const s } auto handleInfo = HandleAndBaseOffset{.h = hLogFile, .off = baseOffset.QuadPart}; - const auto modt = std::chrono::system_clock::to_time_t(std::chrono::clock_cast(last_write_time(logFilePath))); + WIN32_FILE_ATTRIBUTE_DATA fileInfo = { 0 }; + time_t modt = time(nullptr); + if (GetFileAttributesExW(logFilePath.c_str(), GetFileExInfoStandard, &fileInfo)) { + ULARGE_INTEGER ull = { 0 }; + ull.LowPart = fileInfo.ftLastWriteTime.dwLowDateTime; + ull.HighPart = fileInfo.ftLastWriteTime.dwHighDateTime; + modt = ull.QuadPart / 10000000ULL - 11644473600ULL; + } mz_throw_if_failed(mz_zip_writer_add_read_buf_callback( &zipa, pcszLogFileName, @@ -559,39 +598,26 @@ void export_tspack(HWND hWndParent, const std::filesystem::path& logDir, const s ), std::format("mz_zip_writer_add_read_buf_callback({})", ws_to_u8(logFilePath.wstring()))); } + mz_throw_if_failed(mz_zip_writer_add_mem(&zipa, "logexport.log", logExportLog.data(), logExportLog.size(), MZ_ZIP_FLAG_WRITE_HEADER_SET_SIZE | MZ_BEST_COMPRESSION), "mz_zip_writer_add_mem: logexport.log"); mz_throw_if_failed(mz_zip_writer_finalize_archive(&zipa), "mz_zip_writer_finalize_archive"); mz_throw_if_failed(mz_zip_writer_end(&zipa), "mz_zip_writer_end"); } catch (const std::exception& e) { MessageBoxW(hWndParent, std::format(L"Failed to save file: {}", u8_to_ws(e.what())).c_str(), get_window_string(hWndParent).c_str(), MB_OK | MB_ICONERROR); - - if (pItem) { + if (filePath) { try { - IFileOperationPtr pFileOps; - throw_if_failed(pFileOps.CreateInstance(__uuidof(FileOperation), nullptr, CLSCTX_ALL)); - throw_if_failed(pFileOps->SetOperationFlags(FOF_NO_UI)); - throw_if_failed(pFileOps->DeleteItem(pItem, nullptr)); - throw_if_failed(pFileOps->PerformOperations()); - } catch (const std::exception& e2) { + std::filesystem::remove(*filePath); + } catch (const std::filesystem::filesystem_error& e2) { std::wcerr << std::format(L"Failed to remove temporary file: {}", u8_to_ws(e2.what())) << std::endl; } - pItem.Release(); } + return; } - if (pItem) { - PWSTR pwszFileName; - if (FAILED(pItem->GetDisplayName(SIGDN_FILESYSPATH, &pwszFileName))) { - if (FAILED(pItem->GetDisplayName(SIGDN_DESKTOPABSOLUTEEDITING, &pwszFileName))) { - MessageBoxW(hWndParent, L"The file has been saved to the specified path.", get_window_string(hWndParent).c_str(), MB_OK | MB_ICONINFORMATION); - } else { - std::unique_ptr::type, decltype(CoTaskMemFree)*> pszFileNamePtr(pwszFileName, CoTaskMemFree); - MessageBoxW(hWndParent, std::format(L"The file has been saved to: {}", pwszFileName).c_str(), get_window_string(hWndParent).c_str(), MB_OK | MB_ICONINFORMATION); - } - } else { - std::unique_ptr::type, decltype(CoTaskMemFree)*> pszFileNamePtr(pwszFileName, CoTaskMemFree); - ShellExecuteW(hWndParent, nullptr, L"explorer.exe", escape_shell_arg(std::format(L"/select,{}", pwszFileName)).c_str(), nullptr, SW_SHOW); - } + if (filePath) { + // Not sure why, but without the wait, the selected file momentarily disappears and reappears + Sleep(1000); + open_folder_and_select_items(hWndParent, *filePath); } } @@ -602,6 +628,7 @@ enum { IdRadioRestartWithoutDalamud, IdButtonRestart = 201, + IdButtonSaveTsPack = 202, IdButtonHelp = IDHELP, IdButtonExit = IDCANCEL, }; @@ -612,7 +639,8 @@ void restart_game_using_injector(int nRadioButton, const std::vector args; - args.emplace_back((std::filesystem::path(pathStr).parent_path() / L"Dalamud.Injector.exe").wstring()); + std::wstring injectorPath = (std::filesystem::path(pathStr).parent_path() / L"Dalamud.Injector.exe").wstring(); + args.emplace_back(L'\"' + injectorPath + L'\"'); args.emplace_back(L"launch"); switch (nRadioButton) { case IdRadioRestartWithout3pPlugins: @@ -625,12 +653,11 @@ void restart_game_using_injector(int nRadioButton, const std::vector> launcherArgs; auto fullDump = false; + + // IFileSaveDialog only works on STA + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); std::vector args; if (int argc = 0; const auto argv = CommandLineToArgvW(GetCommandLineW(), &argc)) { @@ -709,6 +739,42 @@ int main() { return InvalidParameter; } + if (logDir.filename().wstring().ends_with(L".log")) { + std::wcout << L"logDir seems to be pointing to a file; stripping the last path component.\n" << std::endl; + std::wcout << L"Previous: " << logDir.wstring() << std::endl; + logDir = logDir.parent_path(); + std::wcout << L"Stripped: " << logDir.wstring() << std::endl; + } + + // Only keep the last 3 minidumps + if (!logDir.empty()) + { + std::vector> minidumps; + for (const auto& entry : std::filesystem::directory_iterator(logDir)) { + if (entry.path().filename().wstring().ends_with(L".dmp")) { + minidumps.emplace_back(entry.path(), std::filesystem::last_write_time(entry)); + } + } + + if (minidumps.size() > 3) + { + std::sort(minidumps.begin(), minidumps.end(), [](const auto& a, const auto& b) { return a.second < b.second; }); + for (size_t i = 0; i < minidumps.size() - 3; i++) { + if (std::filesystem::exists(minidumps[i].first)) + { + std::wcout << std::format(L"Removing old minidump: {}", minidumps[i].first.wstring()) << std::endl; + std::filesystem::remove(minidumps[i].first); + } + + // Also remove corresponding .log, if it exists + if (const auto logPath = minidumps[i].first.replace_extension(L".log"); std::filesystem::exists(logPath)) { + std::wcout << std::format(L"Removing corresponding log: {}", logPath.wstring()) << std::endl; + std::filesystem::remove(logPath); + } + } + } + } + while (true) { std::cout << "Waiting for crash...\n"; @@ -739,6 +805,36 @@ int main() { std::cout << "Crash triggered" << std::endl; + std::cout << "Creating progress window" << std::endl; + IProgressDialog* pProgressDialog = NULL; + if (SUCCEEDED(CoCreateInstance(CLSID_ProgressDialog, NULL, CLSCTX_ALL, IID_IProgressDialog, (void**)&pProgressDialog)) && pProgressDialog) { + pProgressDialog->SetTitle(L"Dalamud Crash Handler"); + pProgressDialog->SetLine(1, L"The game has crashed!", FALSE, NULL); + pProgressDialog->SetLine(2, L"Dalamud is collecting further information...", FALSE, NULL); + pProgressDialog->SetLine(3, L"Refreshing Game Module List", FALSE, NULL); + pProgressDialog->StartProgressDialog(NULL, NULL, PROGDLG_MARQUEEPROGRESS | PROGDLG_NOCANCEL | PROGDLG_NOMINIMIZE, NULL); + IOleWindow* pOleWindow; + HRESULT hr = pProgressDialog->QueryInterface(IID_IOleWindow, (LPVOID*)&pOleWindow); + if (SUCCEEDED(hr)) + { + HWND hwndProgressDialog = NULL; + hr = pOleWindow->GetWindow(&hwndProgressDialog); + if (SUCCEEDED(hr)) + { + SetWindowPos(hwndProgressDialog, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW); + SetForegroundWindow(hwndProgressDialog); + } + + pOleWindow->Release(); + } + + } + else { + std::cerr << "Failed to create progress window" << std::endl; + pProgressDialog = NULL; + } + auto shutup_mutex = CreateMutex(NULL, false, L"DALAMUD_CRASHES_NO_MORE"); bool shutup = false; if (shutup_mutex == NULL && GetLastError() == ERROR_ALREADY_EXISTS) @@ -777,6 +873,9 @@ int main() { std::wcerr << std::format(L"SymInitialize error: 0x{:x}", GetLastError()) << std::endl; } + if (pProgressDialog) + pProgressDialog->SetLine(3, L"Reading troubleshooting data", FALSE, NULL); + std::wstring stackTrace(exinfo.dwStackTraceLength, L'\0'); if (exinfo.dwStackTraceLength) { if (DWORD read; !ReadFile(hPipeRead, &stackTrace[0], 2 * exinfo.dwStackTraceLength, &read, nullptr)) { @@ -791,10 +890,12 @@ int main() { } } + if (pProgressDialog) + pProgressDialog->SetLine(3, fullDump ? L"Creating full dump" : L"Creating minidump", FALSE, NULL); + SYSTEMTIME st; GetLocalTime(&st); const auto dalamudLogPath = logDir.empty() ? std::filesystem::path() : logDir / L"Dalamud.log"; - const auto dalamudBootLogPath = logDir.empty() ? std::filesystem::path() : logDir / L"Dalamud.boot.log"; const auto dumpPath = logDir.empty() ? std::filesystem::path() : logDir / std::format(L"dalamud_appcrash_{:04}{:02}{:02}_{:02}{:02}{:02}_{:03}_{}.dmp", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, dwProcessId); const auto logPath = logDir.empty() ? std::filesystem::path() : logDir / std::format(L"dalamud_appcrash_{:04}{:02}{:02}_{:02}{:02}{:02}_{:03}_{}.log", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, dwProcessId); std::wstring dumpError; @@ -840,73 +941,36 @@ int main() { log << std::format(L"Dump at: {}", dumpPath.wstring()) << std::endl; else log << std::format(L"Dump error: {}", dumpError) << std::endl; - log << L"Time: " << std::chrono::zoned_time{ std::chrono::current_zone(), std::chrono::system_clock::now() } << std::endl; + log << L"System Time: " << std::chrono::system_clock::now() << std::endl; log << L"\n" << stackTrace << std::endl; + if (pProgressDialog) + pProgressDialog->SetLine(3, L"Refreshing Module List", FALSE, NULL); + SymRefreshModuleList(GetCurrentProcess()); print_exception_info(exinfo.hThreadHandle, exinfo.ExceptionPointers, exinfo.ContextRecord, log); const auto window_log_str = log.str(); print_exception_info_extended(exinfo.ExceptionPointers, exinfo.ContextRecord, log); std::wofstream(logPath) << log.str(); - std::thread submitThread; - if (!getenv("DALAMUD_NO_METRIC")) { - auto url = std::format(L"/Dalamud/Metric/ReportCrash?lt={}&code={:x}", exinfo.nLifetime, exinfo.ExceptionRecord.ExceptionCode); - - submitThread = std::thread([url = std::move(url)] { - const auto hInternet = WinHttpOpen(L"Dalamud Crash Handler/1.0", - WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, - WINHTTP_NO_PROXY_NAME, - WINHTTP_NO_PROXY_BYPASS, 0); - const auto hConnect = !hInternet ? nullptr : WinHttpConnect(hInternet, L"kamori.goats.dev", INTERNET_DEFAULT_HTTP_PORT, 0); - const auto hRequest = !hConnect ? nullptr : WinHttpOpenRequest(hConnect, L"GET", url.c_str(), NULL, WINHTTP_NO_REFERER, - WINHTTP_DEFAULT_ACCEPT_TYPES, - 0); - if (hRequest) WinHttpAddRequestHeaders(hRequest, L"Host: kamori.goats.dev", (ULONG)-1L, WINHTTP_ADDREQ_FLAG_ADD); - const auto bSent = !hRequest ? false : WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, - 0, WINHTTP_NO_REQUEST_DATA, 0, - 0, 0); - - if (!bSent) - std::cerr << std::format("Failed to send metric: 0x{:x}", GetLastError()) << std::endl; - - if (WinHttpReceiveResponse(hRequest, nullptr)) - { - DWORD dwStatusCode = 0; - DWORD dwStatusCodeSize = sizeof(DWORD); - - WinHttpQueryHeaders(hRequest, - WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, - WINHTTP_HEADER_NAME_BY_INDEX, - &dwStatusCode, &dwStatusCodeSize, WINHTTP_NO_HEADER_INDEX); - - if (dwStatusCode != 200) - std::cerr << std::format("Failed to send metric: {}", dwStatusCode) << std::endl; - } - - if (hRequest) WinHttpCloseHandle(hRequest); - if (hConnect) WinHttpCloseHandle(hConnect); - if (hInternet) WinHttpCloseHandle(hInternet); - }); - } - TASKDIALOGCONFIG config = { 0 }; const TASKDIALOG_BUTTON radios[]{ - {IdRadioRestartNormal, L"Restart"}, - {IdRadioRestartWithout3pPlugins, L"Restart without 3rd party plugins"}, + {IdRadioRestartNormal, L"Restart normally"}, + {IdRadioRestartWithout3pPlugins, L"Restart without custom repository plugins"}, {IdRadioRestartWithoutPlugins, L"Restart without any plugins"}, {IdRadioRestartWithoutDalamud, L"Restart without Dalamud"}, }; const TASKDIALOG_BUTTON buttons[]{ - {IdButtonRestart, L"Restart\nRestart the game, optionally without plugins or Dalamud."}, + {IdButtonRestart, L"Restart\nRestart the game with the above-selected option."}, + {IdButtonSaveTsPack, L"Save Troubleshooting Info\nSave a .tspack file containing information about this crash for analysis."}, {IdButtonExit, L"Exit\nExit the game."}, }; config.cbSize = sizeof(config); config.hInstance = GetModuleHandleW(nullptr); - config.dwFlags = TDF_ENABLE_HYPERLINKS | TDF_CAN_BE_MINIMIZED | TDF_ALLOW_DIALOG_CANCELLATION | TDF_USE_COMMAND_LINKS; + config.dwFlags = TDF_ENABLE_HYPERLINKS | TDF_CAN_BE_MINIMIZED | TDF_ALLOW_DIALOG_CANCELLATION | TDF_USE_COMMAND_LINKS | TDF_NO_DEFAULT_RADIO_BUTTON; config.pszMainIcon = MAKEINTRESOURCE(IDI_ICON1); config.pszMainInstruction = L"An error in the game occurred"; config.pszContent = (L"" @@ -914,7 +978,7 @@ int main() { "\n" R"aa(Try running a game repair in XIVLauncher by right clicking the login button, and disabling plugins you don't need. Please also check your antivirus, see our help site for more information.)aa" "\n" "\n" - R"aa(Upload this file (click here) if you want to ask for help in our Discord server.)aa" "\n" + R"aa(For further assistance, please upload a troubleshooting pack to our Discord server.)aa" "\n" ); config.pButtons = buttons; @@ -923,10 +987,9 @@ int main() { config.pszExpandedControlText = L"Hide stack trace"; config.pszCollapsedControlText = L"Stack trace for plugin developers"; config.pszExpandedInformation = window_log_str.c_str(); - config.pszWindowTitle = L"Dalamud Error"; + config.pszWindowTitle = L"Dalamud Crash Handler"; config.pRadioButtons = radios; config.cRadioButtons = ARRAYSIZE(radios); - config.nDefaultRadioButton = IdRadioRestartNormal; config.cxWidth = 300; #if _DEBUG @@ -948,6 +1011,7 @@ int main() { case TDN_CREATED: { SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW); + SendMessage(hwnd, TDM_ENABLE_BUTTON, IdButtonRestart, 0); return S_OK; } case TDN_HYPERLINK_CLICKED: @@ -956,7 +1020,7 @@ int main() { if (link == L"help") { ShellExecuteW(hwnd, nullptr, L"https://goatcorp.github.io/faq?utm_source=vectored", nullptr, nullptr, SW_SHOW); } else if (link == L"logdir") { - ShellExecuteW(hwnd, nullptr, L"explorer.exe", escape_shell_arg(std::format(L"/select,{}", logPath.wstring())).c_str(), nullptr, SW_SHOW); + open_folder_and_select_items(hwnd, logPath.wstring()); } else if (link == L"logfile") { ShellExecuteW(hwnd, nullptr, logPath.c_str(), nullptr, nullptr, SW_SHOW); } else if (link == L"exporttspack") { @@ -969,6 +1033,18 @@ int main() { } return S_OK; } + case TDN_RADIO_BUTTON_CLICKED: + SendMessage(hwnd, TDM_ENABLE_BUTTON, IdButtonRestart, 1); + return S_OK; + case TDN_BUTTON_CLICKED: + const auto button = static_cast(wParam); + if (button == IdButtonSaveTsPack) + { + export_tspack(hwnd, logDir, ws_to_u8(log.str()), troubleshootingPackData); + return S_FALSE; // keep the dialog open + } + + return S_OK; } return S_OK; @@ -978,10 +1054,11 @@ int main() { return (*reinterpret_cast(dwRefData))(hwnd, uNotification, wParam, lParam); }; config.lpCallbackData = reinterpret_cast(&callback); - - if (submitThread.joinable()) { - submitThread.join(); - submitThread = {}; + + if (pProgressDialog) { + pProgressDialog->StopProgressDialog(); + pProgressDialog->Release(); + pProgressDialog = NULL; } if (shutup) { @@ -991,7 +1068,7 @@ int main() { int nButtonPressed = 0, nRadioButton = 0; if (FAILED(TaskDialogIndirect(&config, &nButtonPressed, &nRadioButton, nullptr))) { - ResumeThread(exinfo.hThreadHandle); + SetEvent(exinfo.hEventHandle); } else { switch (nButtonPressed) { case IdButtonRestart: @@ -1002,7 +1079,7 @@ int main() { } default: if (attemptResume) - ResumeThread(exinfo.hThreadHandle); + SetEvent(exinfo.hEventHandle); else TerminateProcess(g_hProcess, exinfo.ExceptionRecord.ExceptionCode); } diff --git a/LICENSE b/LICENSE index 946b95d8d..0ad25db4b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,84 +1,661 @@ -AFFERO GENERAL PUBLIC LICENSE -Version 1, March 2002

Copyright © 2002 Affero Inc.
510 Third Street - Suite 225, San Francisco, CA 94107, USA -This license is a modified version of the GNU General Public License copyright (C) 1989, 1991 Free Software Foundation, Inc. made with their permission. Section 2(d) has been added to cover use of software over a computer network. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Preamble + Preamble -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the Affero General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This Public License applies to most of Affero's software and to any other program whose authors commit to using it. (Some other Affero software is covered by the GNU Library General Public License instead.) You can apply it to your programs, too. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. -When we speak of free software, we are referring to freedom, not price. This General Public License is designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. -To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. -For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. -We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. -Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. -Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. -The precise terms and conditions for copying, distribution and modification follow. + The precise terms and conditions for copying, distribution and +modification follow. -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + TERMS AND CONDITIONS -0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this Affero General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". + 0. Definitions. -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. + "This License" refers to version 3 of the GNU Affero General Public License. -1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. -2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
 -a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
 -b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
 -c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
 -d) If the Program as you received it is intended to interact with users through a computer network and if, in the version you received, any user interacting with the Program was given the opportunity to request transmission to that user of the Program's complete source code, you must not remove that facility from your modified version of the Program or work based on the Program, and must offer an equivalent opportunity for all users interacting with your Program through a computer network to request immediate transmission by HTTP of the complete source code of your modified version or other derivative work. + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + A "covered work" means either the unmodified Program or a work based +on the Program. -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. -In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. -3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
 -a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
 -b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
 -c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. -The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + 1. Source Code. -If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. -4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. -5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. -6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. -7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. + The Corresponding Source for a work in source code form is that +same work. -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + 2. Basic Permissions. -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. -8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. -9. Affero Inc. may publish revised and/or new versions of the Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. -Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by Affero, Inc. If the Program does not specify a version number of this License, you may choose any version ever published by Affero, Inc. + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. -You may also choose to redistribute modified versions of this program under any version of the Free Software Foundation's GNU General Public License version 3 or higher, so long as that version of the GNU GPL includes terms and conditions substantially equivalent to those of this license. + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. -10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by Affero, Inc., write to us; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. -NO WARRANTY + 4. Conveying Verbatim Copies. -11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. -12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index a4c346c80..5aa50b1c1 --- a/build.sh +++ b/build.sh @@ -59,4 +59,4 @@ fi echo "Microsoft (R) .NET Core SDK version $("$DOTNET_EXE" --version)" "$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false /p:EnableWindowsTargeting=true -nologo -clp:NoSummary --verbosity quiet -"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- /p:EnableWindowsTargeting=true "$@" +"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" diff --git a/global.json b/global.json index 3d9090158..133f31ec2 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "version": "7.0.0", - "rollForward": "latestMinor", + "rollForward": "latestMajor", "allowPrerelease": true } -} \ No newline at end of file +} diff --git a/lib/CoreCLR/boot.cpp b/lib/CoreCLR/boot.cpp index e3db99c4f..54276aad1 100644 --- a/lib/CoreCLR/boot.cpp +++ b/lib/CoreCLR/boot.cpp @@ -27,7 +27,7 @@ void ConsoleTeardown() std::optional g_clr; -int InitializeClrAndGetEntryPoint( +HRESULT InitializeClrAndGetEntryPoint( void* calling_module, bool enable_etw, std::wstring runtimeconfig_path, @@ -76,7 +76,7 @@ int InitializeClrAndGetEntryPoint( if (result != 0) { logging::E("Unable to get RoamingAppData path (err={})", result); - return result; + return HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND); } std::filesystem::path fs_app_data(_appdata); @@ -92,7 +92,7 @@ int InitializeClrAndGetEntryPoint( if (!std::filesystem::exists(dotnet_path)) { logging::E("Error: Unable to find .NET runtime path"); - return 1; + return HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND); } get_hostfxr_parameters init_parameters @@ -137,12 +137,12 @@ int InitializeClrAndGetEntryPoint( entrypoint_delegate_type_name.c_str(), nullptr, entrypoint_fn)) != 0) { - logging::E("Failed to load module (err={})", result); + logging::E("Failed to load module (err=0x{:X})", static_cast(result)); return result; } logging::I("Done!"); // =========================================================================== // - return 0; + return S_OK; } diff --git a/lib/CoreCLR/boot.h b/lib/CoreCLR/boot.h index f75077edd..33bc58bbf 100644 --- a/lib/CoreCLR/boot.h +++ b/lib/CoreCLR/boot.h @@ -1,7 +1,7 @@ void ConsoleSetup(const std::wstring console_name); void ConsoleTeardown(); -int InitializeClrAndGetEntryPoint( +HRESULT InitializeClrAndGetEntryPoint( void* calling_module, bool enable_etw, std::wstring runtimeconfig_path, diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index 9b2eab0f2..ac2ced26f 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit 9b2eab0f212030c062427b307b96118881d36b99 +Subproject commit ac2ced26fc98153c65f5b8f0eaf0f464258ff683 diff --git a/targets/Dalamud.Plugin.Bootstrap.targets b/targets/Dalamud.Plugin.Bootstrap.targets index c30a5acba..db4bf6cd7 100644 --- a/targets/Dalamud.Plugin.Bootstrap.targets +++ b/targets/Dalamud.Plugin.Bootstrap.targets @@ -1,11 +1,10 @@ - $(appdata)\XIVLauncher\addon\Hooks\dev\ - - - - $(DALAMUD_HOME)/ + $(appdata)\XIVLauncher\addon\Hooks\dev\ + $(HOME)/.xlcore/dalamud/Hooks/dev/ + $(HOME)/Library/Application Support/XIV on Mac/dalamud/Hooks/dev/ + $(DALAMUD_HOME)/ diff --git a/targets/Dalamud.Plugin.targets b/targets/Dalamud.Plugin.targets index 4a5f9e97e..37c0940d7 100644 --- a/targets/Dalamud.Plugin.targets +++ b/targets/Dalamud.Plugin.targets @@ -14,11 +14,10 @@ - + -