Merge branch 'apiX' into feature/itextureprovider-updates

This commit is contained in:
Soreepeong 2024-05-12 22:17:32 +09:00
commit 8c7771bf7d
2213 changed files with 10372 additions and 1088868 deletions

View file

@ -16,6 +16,9 @@ jobs:
fetch-depth: 0
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v1.0.2
- uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.100'
- name: Define VERSION
run: |
$env:COMMIT = $env:GITHUB_SHA.Substring(0, 7)

View file

@ -11,8 +11,7 @@ jobs:
strategy:
matrix:
branches:
- net8
#- new_im_hooks # Unmergeable
- new_im_hooks
defaults:
run:

2
.gitmodules vendored
View file

@ -3,7 +3,7 @@
url = https://github.com/goatcorp/ImGuiScene
[submodule "lib/FFXIVClientStructs"]
path = lib/FFXIVClientStructs
url = https://github.com/aers/FFXIVClientStructs.git
url = https://github.com/aers/FFXIVClientStructs
[submodule "lib/Nomade040-nmd"]
path = lib/Nomade040-nmd
url = https://github.com/Nomade040/nmd.git

View file

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

View file

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

View file

@ -82,6 +82,21 @@ void from_json(const nlohmann::json& json, DalamudStartInfo::LoadMethod& value)
}
}
void from_json(const nlohmann::json& json, DalamudStartInfo::UnhandledExceptionHandlingMode& value) {
if (json.is_number_integer()) {
value = static_cast<DalamudStartInfo::UnhandledExceptionHandlingMode>(json.get<int>());
} else if (json.is_string()) {
const auto langstr = unicode::convert<std::string>(json.get<std::string>(), &unicode::lower);
if (langstr == "default")
value = DalamudStartInfo::UnhandledExceptionHandlingMode::Default;
else if (langstr == "stalldebug")
value = DalamudStartInfo::UnhandledExceptionHandlingMode::StallDebug;
else if (langstr == "none")
value = DalamudStartInfo::UnhandledExceptionHandlingMode::None;
}
}
void from_json(const nlohmann::json& json, DalamudStartInfo& config) {
if (!json.is_object())
return;
@ -121,7 +136,7 @@ void from_json(const nlohmann::json& json, DalamudStartInfo& config) {
}
config.CrashHandlerShow = json.value("CrashHandlerShow", config.CrashHandlerShow);
config.NoExceptionHandlers = json.value("NoExceptionHandlers", config.NoExceptionHandlers);
config.UnhandledException = json.value("UnhandledException", config.UnhandledException);
}
void DalamudStartInfo::from_envvars() {

View file

@ -32,6 +32,13 @@ struct DalamudStartInfo {
};
friend void from_json(const nlohmann::json&, LoadMethod&);
enum class UnhandledExceptionHandlingMode : int {
Default,
StallDebug,
None,
};
friend void from_json(const nlohmann::json&, UnhandledExceptionHandlingMode&);
LoadMethod DalamudLoadMethod = LoadMethod::Entrypoint;
std::string WorkingDirectory;
std::string ConfigurationPath;
@ -59,7 +66,7 @@ struct DalamudStartInfo {
std::set<std::string> BootUnhookDlls{};
bool CrashHandlerShow = false;
bool NoExceptionHandlers = false;
UnhandledExceptionHandlingMode UnhandledException = UnhandledExceptionHandlingMode::Default;
friend void from_json(const nlohmann::json&, DalamudStartInfo&);
void from_envvars();

View file

@ -133,7 +133,7 @@ HRESULT WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) {
// ============================== VEH ======================================== //
logging::I("Initializing VEH...");
if (g_startInfo.NoExceptionHandlers) {
if (g_startInfo.UnhandledException == DalamudStartInfo::UnhandledExceptionHandlingMode::None) {
logging::W("=> Exception handlers are disabled from DalamudStartInfo.");
} else if (g_startInfo.BootVehEnabled) {
if (veh::add_handler(g_startInfo.BootVehFull, g_startInfo.WorkingDirectory))

View file

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

View file

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

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

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

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

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

View file

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

View file

@ -136,6 +136,17 @@ static void append_injector_launch_args(std::vector<std::wstring>& args)
args.emplace_back(L"--msgbox2");
if ((g_startInfo.BootWaitMessageBox & DalamudStartInfo::WaitMessageboxFlags::BeforeDalamudConstruct) != DalamudStartInfo::WaitMessageboxFlags::None)
args.emplace_back(L"--msgbox3");
switch (g_startInfo.UnhandledException) {
case DalamudStartInfo::UnhandledExceptionHandlingMode::Default:
args.emplace_back(L"--unhandled-exception=default");
break;
case DalamudStartInfo::UnhandledExceptionHandlingMode::StallDebug:
args.emplace_back(L"--unhandled-exception=stalldebug");
break;
case DalamudStartInfo::UnhandledExceptionHandlingMode::None:
args.emplace_back(L"--unhandled-exception=none");
break;
}
args.emplace_back(L"--");
@ -148,6 +159,13 @@ static void append_injector_launch_args(std::vector<std::wstring>& args)
LONG exception_handler(EXCEPTION_POINTERS* ex)
{
if (g_startInfo.UnhandledException == DalamudStartInfo::UnhandledExceptionHandlingMode::StallDebug) {
while (!IsDebuggerPresent())
Sleep(100);
return EXCEPTION_CONTINUE_SEARCH;
}
// block any other exceptions hitting the handler while the messagebox is open
const auto lock = std::lock_guard(g_exception_handler_mutex);

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

View file

@ -145,7 +145,7 @@ public record DalamudStartInfo
public bool CrashHandlerShow { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to disable all kinds of global exception handlers.
/// Gets or sets a value indicating how to deal with unhandled exceptions.
/// </summary>
public bool NoExceptionHandlers { get; set; }
public UnhandledExceptionHandlingMode UnhandledException { get; set; }
}

View file

@ -23,7 +23,6 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// Initializes a new instance of the <see cref="GameVersion"/> class.
/// </summary>
/// <param name="version">Version string to parse.</param>
[JsonConstructor]
public GameVersion(string version)
{
var ver = Parse(version);
@ -42,20 +41,9 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <param name="day">The day.</param>
/// <param name="major">The major version.</param>
/// <param name="minor">The minor version.</param>
public GameVersion(int year, int month, int day, int major, int minor)
[JsonConstructor]
public GameVersion(int year, int month, int day, int major, int minor) : this(year, month, day, major)
{
if ((this.Year = year) < 0)
throw new ArgumentOutOfRangeException(nameof(year));
if ((this.Month = month) < 0)
throw new ArgumentOutOfRangeException(nameof(month));
if ((this.Day = day) < 0)
throw new ArgumentOutOfRangeException(nameof(day));
if ((this.Major = major) < 0)
throw new ArgumentOutOfRangeException(nameof(major));
if ((this.Minor = minor) < 0)
throw new ArgumentOutOfRangeException(nameof(minor));
}
@ -67,17 +55,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <param name="month">The month.</param>
/// <param name="day">The day.</param>
/// <param name="major">The major version.</param>
public GameVersion(int year, int month, int day, int major)
public GameVersion(int year, int month, int day, int major) : this(year, month, day)
{
if ((this.Year = year) < 0)
throw new ArgumentOutOfRangeException(nameof(year));
if ((this.Month = month) < 0)
throw new ArgumentOutOfRangeException(nameof(month));
if ((this.Day = day) < 0)
throw new ArgumentOutOfRangeException(nameof(day));
if ((this.Major = major) < 0)
throw new ArgumentOutOfRangeException(nameof(major));
}
@ -88,14 +67,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <param name="year">The year.</param>
/// <param name="month">The month.</param>
/// <param name="day">The day.</param>
public GameVersion(int year, int month, int day)
public GameVersion(int year, int month, int day) : this(year, month)
{
if ((this.Year = year) < 0)
throw new ArgumentOutOfRangeException(nameof(year));
if ((this.Month = month) < 0)
throw new ArgumentOutOfRangeException(nameof(month));
if ((this.Day = day) < 0)
throw new ArgumentOutOfRangeException(nameof(day));
}
@ -105,11 +78,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// </summary>
/// <param name="year">The year.</param>
/// <param name="month">The month.</param>
public GameVersion(int year, int month)
public GameVersion(int year, int month) : this(year)
{
if ((this.Year = year) < 0)
throw new ArgumentOutOfRangeException(nameof(year));
if ((this.Month = month) < 0)
throw new ArgumentOutOfRangeException(nameof(month));
}
@ -139,26 +109,31 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <summary>
/// Gets the year component.
/// </summary>
[JsonRequired]
public int Year { get; } = -1;
/// <summary>
/// Gets the month component.
/// </summary>
[JsonRequired]
public int Month { get; } = -1;
/// <summary>
/// Gets the day component.
/// </summary>
[JsonRequired]
public int Day { get; } = -1;
/// <summary>
/// Gets the major version component.
/// </summary>
[JsonRequired]
public int Major { get; } = -1;
/// <summary>
/// Gets the minor version component.
/// </summary>
[JsonRequired]
public int Minor { get; } = -1;
public static implicit operator GameVersion(string ver)
@ -183,17 +158,13 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
public static bool operator <(GameVersion v1, GameVersion v2)
{
if (v1 is null)
throw new ArgumentNullException(nameof(v1));
ArgumentNullException.ThrowIfNull(v1);
return v1.CompareTo(v2) < 0;
}
public static bool operator <=(GameVersion v1, GameVersion v2)
{
if (v1 is null)
throw new ArgumentNullException(nameof(v1));
ArgumentNullException.ThrowIfNull(v1);
return v1.CompareTo(v2) <= 0;
}
@ -209,8 +180,7 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
public static GameVersion operator +(GameVersion v1, TimeSpan v2)
{
if (v1 == null)
throw new ArgumentNullException(nameof(v1));
ArgumentNullException.ThrowIfNull(v1);
if (v1.Year == -1 || v1.Month == -1 || v1.Day == -1)
return v1;
@ -222,8 +192,7 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
public static GameVersion operator -(GameVersion v1, TimeSpan v2)
{
if (v1 == null)
throw new ArgumentNullException(nameof(v1));
ArgumentNullException.ThrowIfNull(v1);
if (v1.Year == -1 || v1.Month == -1 || v1.Day == -1)
return v1;
@ -240,18 +209,18 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <returns>GameVersion object.</returns>
public static GameVersion Parse(string input)
{
if (input == null)
throw new ArgumentNullException(nameof(input));
ArgumentNullException.ThrowIfNull(input);
if (input.ToLower(CultureInfo.InvariantCulture) == "any")
return new GameVersion();
return Any;
var parts = input.Split('.');
var tplParts = parts.Select(p =>
{
var result = int.TryParse(p, out var value);
return (result, value);
}).ToArray();
var tplParts = parts.Select(
p =>
{
var result = int.TryParse(p, out var value);
return (result, value);
}).ToArray();
if (tplParts.Any(t => !t.result))
throw new FormatException("Bad formatting");
@ -259,18 +228,15 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
var intParts = tplParts.Select(t => t.value).ToArray();
var len = intParts.Length;
if (len == 1)
return new GameVersion(intParts[0]);
else if (len == 2)
return new GameVersion(intParts[0], intParts[1]);
else if (len == 3)
return new GameVersion(intParts[0], intParts[1], intParts[2]);
else if (len == 4)
return new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3]);
else if (len == 5)
return new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3], intParts[4]);
else
throw new ArgumentException("Too many parts");
return len switch
{
1 => new GameVersion(intParts[0]),
2 => new GameVersion(intParts[0], intParts[1]),
3 => new GameVersion(intParts[0], intParts[1], intParts[2]),
4 => new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3]),
5 => new GameVersion(intParts[0], intParts[1], intParts[2], intParts[3], intParts[4]),
_ => throw new ArgumentException("Too many parts"),
};
}
/// <summary>
@ -299,17 +265,12 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <inheritdoc/>
public int CompareTo(object? obj)
{
if (obj == null)
return 1;
if (obj is GameVersion value)
return obj switch
{
return this.CompareTo(value);
}
else
{
throw new ArgumentException("Argument must be a GameVersion");
}
null => 1,
GameVersion value => this.CompareTo(value),
_ => throw new ArgumentException("Argument must be a GameVersion", nameof(obj)),
};
}
/// <inheritdoc/>
@ -342,16 +303,14 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
if (this.Minor != value.Minor)
return this.Minor > value.Minor ? 1 : -1;
// This should never happen
return 0;
}
/// <inheritdoc/>
public override bool Equals(object? obj)
{
if (obj is not GameVersion value)
return false;
return this.Equals(value);
return obj is GameVersion value && this.Equals(value);
}
/// <inheritdoc/>
@ -373,16 +332,8 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
/// <inheritdoc/>
public override int GetHashCode()
{
var accumulator = 0;
// This might be horribly wrong, but it isn't used heavily.
accumulator |= this.Year.GetHashCode();
accumulator |= this.Month.GetHashCode();
accumulator |= this.Day.GetHashCode();
accumulator |= this.Major.GetHashCode();
accumulator |= this.Minor.GetHashCode();
return accumulator;
// https://learn.microsoft.com/en-us/dotnet/api/system.object.gethashcode?view=net-8.0#notes-to-inheritors
return HashCode.Combine(this.Year, this.Month, this.Day, this.Major, this.Minor);
}
/// <inheritdoc/>
@ -396,11 +347,11 @@ public sealed class GameVersion : ICloneable, IComparable, IComparable<GameVersi
return "any";
return new StringBuilder()
.Append(string.Format("{0:D4}.", this.Year == -1 ? 0 : this.Year))
.Append(string.Format("{0:D2}.", this.Month == -1 ? 0 : this.Month))
.Append(string.Format("{0:D2}.", this.Day == -1 ? 0 : this.Day))
.Append(string.Format("{0:D4}.", this.Major == -1 ? 0 : this.Major))
.Append(string.Format("{0:D4}", this.Minor == -1 ? 0 : this.Minor))
.Append($"{(this.Year == -1 ? 0 : this.Year):D4}.")
.Append($"{(this.Month == -1 ? 0 : this.Month):D2}.")
.Append($"{(this.Day == -1 ? 0 : this.Day):D2}.")
.Append($"{(this.Major == -1 ? 0 : this.Major):D4}.")
.Append($"{(this.Minor == -1 ? 0 : this.Minor):D4}")
.ToString();
}
}

View file

@ -15,17 +15,16 @@ public sealed class GameVersionConverter : JsonConverter
/// <param name="serializer">The calling serializer.</param>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
switch (value)
{
writer.WriteNull();
}
else if (value is GameVersion)
{
writer.WriteValue(value.ToString());
}
else
{
throw new JsonSerializationException("Expected GameVersion object value");
case null:
writer.WriteNull();
break;
case GameVersion:
writer.WriteValue(value.ToString());
break;
default:
throw new JsonSerializationException("Expected GameVersion object value");
}
}
@ -43,24 +42,20 @@ public sealed class GameVersionConverter : JsonConverter
{
return null;
}
else
if (reader.TokenType == JsonToken.String)
{
if (reader.TokenType == JsonToken.String)
try
{
try
{
return new GameVersion((string)reader.Value!);
}
catch (Exception ex)
{
throw new JsonSerializationException($"Error parsing GameVersion string: {reader.Value}", ex);
}
return new GameVersion((string)reader.Value!);
}
else
catch (Exception ex)
{
throw new JsonSerializationException($"Unexpected token or value when parsing GameVersion. Token: {reader.TokenType}, Value: {reader.Value}");
throw new JsonSerializationException($"Error parsing GameVersion string: {reader.Value}", ex);
}
}
throw new JsonSerializationException($"Unexpected token or value when parsing GameVersion. Token: {reader.TokenType}, Value: {reader.Value}");
}
/// <summary>

View file

@ -0,0 +1,16 @@
namespace Dalamud.Common;
/// <summary>Enum describing what to do on unhandled exceptions.</summary>
public enum UnhandledExceptionHandlingMode
{
/// <summary>Always show Dalamud Crash Handler on crash, except for some exceptions.</summary>
/// <remarks>See `vectored_exception_handler` in `veh.cpp`.</remarks>
Default,
/// <summary>Waits for debugger if none is attached, and pass the exception to the next handler.</summary>
/// <remarks>See `exception_handler` in `veh.cpp`.</remarks>
StallDebug,
/// <summary>Do not register an exception handler.</summary>
None,
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Dalamud.CorePlugin</AssemblyName>
<TargetFramework>net7.0-windows</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<Platforms>x64</Platforms>
<LangVersion>10.0</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

View file

@ -97,8 +97,6 @@ namespace Dalamud.CorePlugin
this.Interface.UiBuilder.Draw -= this.OnDraw;
this.windowSystem.RemoveAllWindows();
this.Interface.ExplicitDispose();
}
/// <summary>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Target">
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64;AnyCPU</Platforms>

View file

@ -99,7 +99,6 @@ namespace Dalamud.Injector
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)
@ -277,6 +276,7 @@ namespace Dalamud.Injector
var logName = startInfo.LogName;
var logPath = startInfo.LogPath;
var languageStr = startInfo.Language.ToString().ToLowerInvariant();
var unhandledExceptionStr = startInfo.UnhandledException.ToString().ToLowerInvariant();
var troubleshootingData = "{\"empty\": true, \"description\": \"No troubleshooting data supplied.\"}";
for (var i = 2; i < args.Count; i++)
@ -317,6 +317,10 @@ namespace Dalamud.Injector
{
logPath = args[i][key.Length..];
}
else if (args[i].StartsWith(key = "--unhandled-exception="))
{
unhandledExceptionStr = args[i][key.Length..];
}
else
{
continue;
@ -395,9 +399,15 @@ namespace Dalamud.Injector
startInfo.BootShowConsole = args.Contains("--console");
startInfo.BootEnableEtw = args.Contains("--etw");
startInfo.BootLogPath = GetLogPath(startInfo.LogPath, "dalamud.boot", startInfo.LogName);
startInfo.BootEnabledGameFixes = new List<string> {
"prevent_devicechange_crashes", "disable_game_openprocess_access_check",
"redirect_openprocess", "backup_userdata_save", "prevent_icmphandle_crashes",
startInfo.BootEnabledGameFixes = new()
{
// See: xivfixes.h, xivfixes.cpp
"prevent_devicechange_crashes",
"disable_game_openprocess_access_check",
"redirect_openprocess",
"backup_userdata_save",
"prevent_icmphandle_crashes",
"symbol_load_patches",
};
startInfo.BootDotnetOpenProcessHookMode = 0;
startInfo.BootWaitMessageBox |= args.Contains("--msgbox1") ? 1 : 0;
@ -410,7 +420,14 @@ namespace Dalamud.Injector
startInfo.NoLoadThirdPartyPlugins = args.Contains("--no-3rd-plugin");
// startInfo.BootUnhookDlls = new List<string>() { "kernel32.dll", "ntdll.dll", "user32.dll" };
startInfo.CrashHandlerShow = args.Contains("--crash-handler-console");
startInfo.NoExceptionHandlers = args.Contains("--no-exception-handlers");
startInfo.UnhandledException =
Enum.TryParse<UnhandledExceptionHandlingMode>(
unhandledExceptionStr,
true,
out var parsedUnhandledException)
? parsedUnhandledException
: throw new CommandLineException(
$"\"{unhandledExceptionStr}\" is not a valid unhandled exception handling mode.");
return startInfo;
}
@ -452,7 +469,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], [--no-exception-handlers]");
Console.WriteLine("Enable VEH:\t[--veh], [--veh-full], [--unhandled-exception=default|stalldebug|none]");
Console.WriteLine("Show messagebox:\t[--msgbox1], [--msgbox2], [--msgbox3]");
Console.WriteLine("No plugins:\t[--no-plugin] [--no-3rd-plugin]");
Console.WriteLine("Logging:\t[--logname=<logfile suffix>] [--logpath=<log base directory>]");

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Target">
<TargetFramework>net7.0-windows</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64;AnyCPU</Platforms>
<LangVersion>9.0</LangVersion>
<LangVersion>11.0</LangVersion>
</PropertyGroup>
<PropertyGroup Label="Feature">

View file

@ -0,0 +1,138 @@
using Dalamud.Common.Game;
using JetBrains.Annotations;
using Newtonsoft.Json;
using Xunit;
namespace Dalamud.Test.Game;
public class GameVersionConverterTests
{
[Fact]
public void ReadJson_ConvertsFromString()
{
var serialized = """
{
"Version": "2020.06.15.0000.0000"
}
""";
var deserialized = JsonConvert.DeserializeObject<TestSerializationClass>(serialized);
Assert.NotNull(deserialized);
Assert.Equal(GameVersion.Parse("2020.06.15.0000.0000"), deserialized.Version);
}
[Fact]
public void ReadJson_ConvertsFromNull()
{
var serialized = """
{
"Version": null
}
""";
var deserialized = JsonConvert.DeserializeObject<TestSerializationClass>(serialized);
Assert.NotNull(deserialized);
Assert.Null(deserialized.Version);
}
[Fact]
public void ReadJson_WhenInvalidType_Throws()
{
var serialized = """
{
"Version": 2
}
""";
Assert.Throws<JsonSerializationException>(
() => JsonConvert.DeserializeObject<TestSerializationClass>(serialized));
}
[Fact]
public void ReadJson_WhenInvalidVersion_Throws()
{
var serialized = """
{
"Version": "junk"
}
""";
Assert.Throws<JsonSerializationException>(
() => JsonConvert.DeserializeObject<TestSerializationClass>(serialized));
}
[Fact]
public void WriteJson_ConvertsToString()
{
var deserialized = new TestSerializationClass
{
Version = GameVersion.Parse("2020.06.15.0000.0000"),
};
var serialized = JsonConvert.SerializeObject(deserialized);
Assert.Equal("""{"Version":"2020.06.15.0000.0000"}""", RemoveWhitespace(serialized));
}
[Fact]
public void WriteJson_ConvertsToNull()
{
var deserialized = new TestSerializationClass
{
Version = null,
};
var serialized = JsonConvert.SerializeObject(deserialized);
Assert.Equal("""{"Version":null}""", RemoveWhitespace(serialized));
}
[Fact]
public void WriteJson_WhenInvalidVersion_Throws()
{
var deserialized = new TestWrongTypeSerializationClass
{
Version = 42,
};
Assert.Throws<JsonSerializationException>(() => JsonConvert.SerializeObject(deserialized));
}
[Fact]
public void CanConvert_WhenGameVersion_ReturnsTrue()
{
var converter = new GameVersionConverter();
Assert.True(converter.CanConvert(typeof(GameVersion)));
}
[Fact]
public void CanConvert_WhenNotGameVersion_ReturnsFalse()
{
var converter = new GameVersionConverter();
Assert.False(converter.CanConvert(typeof(int)));
}
[Fact]
public void CanConvert_WhenNull_ReturnsFalse()
{
var converter = new GameVersionConverter();
Assert.False(converter.CanConvert(null!));
}
private static string RemoveWhitespace(string input)
{
return input.Replace(" ", "").Replace("\r", "").Replace("\n", "");
}
private class TestSerializationClass
{
[JsonConverter(typeof(GameVersionConverter))]
[CanBeNull]
public GameVersion Version { get; init; }
}
private class TestWrongTypeSerializationClass
{
[JsonConverter(typeof(GameVersionConverter))]
public int Version { get; init; }
}
}

View file

@ -1,10 +1,71 @@
using System;
using Dalamud.Common.Game;
using Newtonsoft.Json;
using Xunit;
namespace Dalamud.Test.Game
{
public class GameVersionTests
{
[Fact]
public void VersionComparisons()
{
var v1 = GameVersion.Parse("2021.01.01.0000.0000");
var v2 = GameVersion.Parse("2021.01.01.0000.0000");
Assert.True(v1 == v2);
Assert.False(v1 != v2);
Assert.False(v1 < v2);
Assert.True(v1 <= v2);
Assert.False(v1 > v2);
Assert.True(v1 >= v2);
}
[Fact]
public void VersionAddition()
{
var v1 = GameVersion.Parse("2021.01.01.0000.0000");
var v2 = GameVersion.Parse("2021.01.05.0000.0000");
Assert.Equal(v2, v1 + TimeSpan.FromDays(4));
}
[Fact]
public void VersionAdditionAny()
{
Assert.Equal(GameVersion.Any, GameVersion.Any + TimeSpan.FromDays(4));
}
[Fact]
public void VersionSubtraction()
{
var v1 = GameVersion.Parse("2021.01.05.0000.0000");
var v2 = GameVersion.Parse("2021.01.01.0000.0000");
Assert.Equal(v2, v1 - TimeSpan.FromDays(4));
}
[Fact]
public void VersionSubtractionAny()
{
Assert.Equal(GameVersion.Any, GameVersion.Any - TimeSpan.FromDays(4));
}
[Fact]
public void VersionClone()
{
var v1 = GameVersion.Parse("2021.01.01.0000.0000");
var v2 = v1.Clone();
Assert.NotSame(v1, v2);
}
[Fact]
public void VersionCast()
{
var v = GameVersion.Parse("2021.01.01.0000.0000");
Assert.Equal("2021.01.01.0000.0000", v);
}
[Theory]
[InlineData("any", "any")]
[InlineData("2021.01.01.0000.0000", "2021.01.01.0000.0000")]
@ -14,6 +75,18 @@ namespace Dalamud.Test.Game
var v2 = GameVersion.Parse(ver2);
Assert.Equal(v1, v2);
Assert.Equal(0, v1.CompareTo(v2));
Assert.Equal(v1.GetHashCode(), v2.GetHashCode());
}
[Fact]
public void VersionNullEquality()
{
// Tests `Equals(GameVersion? value)`
Assert.False(GameVersion.Parse("2021.01.01.0000.0000").Equals(null));
// Tests `Equals(object? value)`
Assert.False(GameVersion.Parse("2021.01.01.0000.0000").Equals((object)null));
}
[Theory]
@ -31,6 +104,67 @@ namespace Dalamud.Test.Game
Assert.True(v1.CompareTo(v2) < 0);
}
[Theory]
[InlineData("any", "2020.06.15.0000.0000")]
public void VersionComparisonInverse(string ver1, string ver2)
{
var v1 = GameVersion.Parse(ver1);
var v2 = GameVersion.Parse(ver2);
Assert.True(v1.CompareTo(v2) > 0);
}
[Fact]
public void VersionComparisonNull()
{
var v = GameVersion.Parse("2020.06.15.0000.0000");
// Tests `CompareTo(GameVersion? value)`
Assert.True(v.CompareTo(null) > 0);
// Tests `CompareTo(object? value)`
Assert.True(v.CompareTo((object)null) > 0);
}
[Fact]
public void VersionComparisonBoxed()
{
var v1 = GameVersion.Parse("2020.06.15.0000.0000");
var v2 = GameVersion.Parse("2020.06.15.0000.0000");
Assert.Equal(0, v1.CompareTo((object)v2));
}
[Fact]
public void VersionComparisonBoxedInvalid()
{
var v = GameVersion.Parse("2020.06.15.0000.0000");
Assert.Throws<ArgumentException>(() => v.CompareTo(42));
}
[Theory]
[InlineData("2020.06.15.0000.0000")]
[InlineData("2021.01.01.0000")]
[InlineData("2021.01.01")]
[InlineData("2021.01")]
[InlineData("2021")]
public void VersionParse(string ver)
{
var v = GameVersion.Parse(ver);
Assert.NotNull(v);
}
[Theory]
[InlineData("2020.06.15.0000.0000")]
[InlineData("2021.01.01.0000")]
[InlineData("2021.01.01")]
[InlineData("2021.01")]
[InlineData("2021")]
public void VersionTryParse(string ver)
{
Assert.True(GameVersion.TryParse(ver, out var v));
Assert.NotNull(v);
}
[Theory]
[InlineData("2020.06.15.0000.0000")]
[InlineData("2021.01.01.0000")]
@ -39,9 +173,8 @@ namespace Dalamud.Test.Game
[InlineData("2021")]
public void VersionConstructor(string ver)
{
var v = GameVersion.Parse(ver);
Assert.True(v != null);
var v = new GameVersion(ver);
Assert.NotNull(v);
}
[Theory]
@ -54,5 +187,89 @@ namespace Dalamud.Test.Game
Assert.False(result);
Assert.Null(v);
}
[Theory]
[InlineData("any", "any")]
[InlineData("2020.06.15.0000.0000", "2020.06.15.0000.0000")]
[InlineData("2021.01.01.0000", "2021.01.01.0000.0000")]
[InlineData("2021.01.01", "2021.01.01.0000.0000")]
[InlineData("2021.01", "2021.01.00.0000.0000")]
[InlineData("2021", "2021.00.00.0000.0000")]
public void VersionToString(string ver1, string ver2)
{
var v1 = GameVersion.Parse(ver1);
Assert.Equal(ver2, v1.ToString());
}
[Fact]
public void VersionIsSerializationSafe()
{
var v = GameVersion.Parse("2020.06.15.0000.0000");
var serialized = JsonConvert.SerializeObject(v);
var deserialized = JsonConvert.DeserializeObject<GameVersion>(serialized);
Assert.Equal(v, deserialized);
}
[Fact]
public void VersionInvalidDeserialization()
{
var serialized = """
{
"Year": -1,
"Month": -1,
"Day": -1,
"Major": -1,
"Minor": -1,
}
""";
Assert.Throws<ArgumentOutOfRangeException>(() => JsonConvert.DeserializeObject<GameVersion>(serialized));
}
[Fact]
public void VersionInvalidTypeDeserialization()
{
var serialized = """
{
"Value": "Hello"
}
""";
Assert.Throws<JsonSerializationException>(() => JsonConvert.DeserializeObject<GameVersion>(serialized));
}
[Fact]
public void VersionConstructorNegativeYear()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(-2024));
}
[Fact]
public void VersionConstructorNegativeMonth()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, -3));
}
[Fact]
public void VersionConstructorNegativeDay()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, 3, -13));
}
[Fact]
public void VersionConstructorNegativeMajor()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, 3, 13, -1));
}
[Fact]
public void VersionConstructorNegativeMinor()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GameVersion(2024, 3, 13, 0, -1));
}
[Fact]
public void VersionParseNull()
{
Assert.Throws<ArgumentNullException>(() => GameVersion.Parse(null!));
}
}
}

View file

@ -0,0 +1,395 @@
using System;
using System.IO;
using System.Linq;
using System.Reactive.Disposables;
using System.Threading.Tasks;
using Dalamud.Storage;
using Xunit;
namespace Dalamud.Test.Storage;
public class ReliableFileStorageTests
{
private const string DbFileName = "dalamudVfs.db";
private const string TestFileName = "file.txt";
private const string TestFileContent1 = "hello from señor dalamundo";
private const string TestFileContent2 = "rewritten";
[Fact]
public async Task IsConcurrencySafe()
{
var dbDir = CreateTempDir();
var rfs = new DisposableReliableFileStorage(dbDir);
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
// Do reads/writes/deletes on the same file on many threads at once and
// see if anything throws
await Task.WhenAll(
Enumerable.Range(1, 6)
.Select(
i => Parallel.ForEachAsync(
Enumerable.Range(1, 100),
(j, _) =>
{
if (i % 2 == 0)
{
// ReSharper disable once AccessToDisposedClosure
rfs.Instance.WriteAllText(tempFile, j.ToString());
}
else if (i % 3 == 0)
{
try
{
// ReSharper disable once AccessToDisposedClosure
rfs.Instance.ReadAllText(tempFile);
}
catch (FileNotFoundException)
{
// this is fine
}
}
else
{
File.Delete(tempFile);
}
return ValueTask.CompletedTask;
})));
}
[Fact]
public void Constructor_Dispose_Works()
{
var dbDir = CreateTempDir();
var dbPath = Path.Combine(dbDir, DbFileName);
using var rfs = new DisposableReliableFileStorage(dbDir);
Assert.True(File.Exists(dbPath));
}
[Fact]
public void Exists_ThrowsIfPathIsEmpty()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentException>(() => rfs.Instance.Exists(""));
}
[Fact]
public void Exists_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => rfs.Instance.Exists(null!));
}
[Fact]
public void Exists_WhenFileMissing_ReturnsFalse()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
Assert.False(rfs.Instance.Exists(tempFile));
}
[Fact]
public void Exists_WhenFileMissing_WhenDbFailed_ReturnsFalse()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateFailedRfs();
Assert.False(rfs.Instance.Exists(tempFile));
}
[Fact]
public async Task Exists_WhenFileOnDisk_ReturnsTrue()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
await File.WriteAllTextAsync(tempFile, TestFileContent1);
using var rfs = CreateRfs();
Assert.True(rfs.Instance.Exists(tempFile));
}
[Fact]
public void Exists_WhenFileInBackup_ReturnsTrue()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.True(rfs.Instance.Exists(tempFile));
}
[Fact]
public void Exists_WhenFileInBackup_WithDifferentContainerId_ReturnsFalse()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.False(rfs.Instance.Exists(tempFile, Guid.NewGuid()));
}
[Fact]
public void WriteAllText_ThrowsIfPathIsEmpty()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentException>(() => rfs.Instance.WriteAllText("", TestFileContent1));
}
[Fact]
public void WriteAllText_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => rfs.Instance.WriteAllText(null!, TestFileContent1));
}
[Fact]
public async Task WriteAllText_WritesToDbAndDisk()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
Assert.True(File.Exists(tempFile));
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile, forceBackup: true));
Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile));
}
[Fact]
public void WriteAllText_SeparatesContainers()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
var containerId = Guid.NewGuid();
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
rfs.Instance.WriteAllText(tempFile, TestFileContent2, containerId);
File.Delete(tempFile);
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile, forceBackup: true));
Assert.Equal(TestFileContent2, rfs.Instance.ReadAllText(tempFile, forceBackup: true, containerId));
}
[Fact]
public async Task WriteAllText_WhenDbFailed_WritesToDisk()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateFailedRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
Assert.True(File.Exists(tempFile));
Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile));
}
[Fact]
public async Task WriteAllText_CanUpdateExistingFile()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
rfs.Instance.WriteAllText(tempFile, TestFileContent2);
Assert.True(File.Exists(tempFile));
Assert.Equal(TestFileContent2, rfs.Instance.ReadAllText(tempFile, forceBackup: true));
Assert.Equal(TestFileContent2, await File.ReadAllTextAsync(tempFile));
}
[Fact]
public void WriteAllText_SupportsNullContent()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, null);
Assert.True(File.Exists(tempFile));
Assert.Equal("", rfs.Instance.ReadAllText(tempFile));
}
[Fact]
public void ReadAllText_ThrowsIfPathIsEmpty()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentException>(() => rfs.Instance.ReadAllText(""));
}
[Fact]
public void ReadAllText_ThrowsIfPathIsNull()
{
using var rfs = CreateRfs();
Assert.Throws<ArgumentNullException>(() => rfs.Instance.ReadAllText(null!));
}
[Fact]
public async Task ReadAllText_WhenFileOnDisk_ReturnsContent()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
await File.WriteAllTextAsync(tempFile, TestFileContent1);
using var rfs = CreateRfs();
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile));
}
[Fact]
public void ReadAllText_WhenFileMissingWithBackup_ReturnsContent()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile));
}
[Fact]
public void ReadAllText_WhenFileMissingWithBackup_ThrowsWithDifferentContainerId()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
var containerId = Guid.NewGuid();
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
File.Delete(tempFile);
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile, containerId: containerId));
}
[Fact]
public void ReadAllText_WhenFileMissing_ThrowsIfDbFailed()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateFailedRfs();
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile));
}
[Fact]
public async Task ReadAllText_WithReader_WhenFileOnDisk_ReadsContent()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
await File.WriteAllTextAsync(tempFile, TestFileContent1);
using var rfs = CreateRfs();
rfs.Instance.ReadAllText(tempFile, text => Assert.Equal(TestFileContent1, text));
}
[Fact]
public async Task ReadAllText_WithReader_WhenReaderThrows_ThrowsIfBackupMissing()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
await File.WriteAllTextAsync(tempFile, TestFileContent1);
var readerCalledOnce = false;
using var rfs = CreateRfs();
Assert.Throws<FileReadException>(() => rfs.Instance.ReadAllText(tempFile, Reader));
return;
void Reader(string text)
{
var wasReaderCalledOnce = readerCalledOnce;
readerCalledOnce = true;
if (!wasReaderCalledOnce) throw new Exception();
}
}
[Fact]
public void ReadAllText_WithReader_WhenReaderThrows_ReadsContentFromBackup()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
var readerCalledOnce = false;
var assertionCalled = false;
using var rfs = CreateRfs();
rfs.Instance.WriteAllText(tempFile, TestFileContent1);
File.Delete(tempFile);
rfs.Instance.ReadAllText(tempFile, Reader);
Assert.True(assertionCalled);
return;
void Reader(string text)
{
var wasReaderCalledOnce = readerCalledOnce;
readerCalledOnce = true;
if (!wasReaderCalledOnce) throw new Exception();
Assert.Equal(TestFileContent1, text);
assertionCalled = true;
}
}
[Fact]
public async Task ReadAllText_WithReader_RethrowsFileNotFoundException()
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
await File.WriteAllTextAsync(tempFile, TestFileContent1);
using var rfs = CreateRfs();
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile, _ => throw new FileNotFoundException()));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ReadAllText_WhenFileDoesNotExist_Throws(bool forceBackup)
{
var tempFile = Path.Combine(CreateTempDir(), TestFileName);
using var rfs = CreateRfs();
Assert.Throws<FileNotFoundException>(() => rfs.Instance.ReadAllText(tempFile, forceBackup));
}
private static DisposableReliableFileStorage CreateRfs()
{
var dbDir = CreateTempDir();
return new(dbDir);
}
private static DisposableReliableFileStorage CreateFailedRfs()
{
var dbDir = CreateTempDir();
var dbPath = Path.Combine(dbDir, DbFileName);
// Create a corrupt DB deliberately, and hold its handle until
// the end of the scope
using var f = File.Open(dbPath, FileMode.CreateNew);
f.Write("broken"u8);
// Throws an SQLiteException initially, and then throws an
// IOException when attempting to delete the file because
// there's already an active handle associated with it
return new(dbDir);
}
private static string CreateTempDir()
{
string tempDir;
do
{
// Generate temp directories until we get a new one (usually happens on the first try)
tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
}
while (File.Exists(tempDir));
Directory.CreateDirectory(tempDir);
return tempDir;
}
private sealed class DisposableReliableFileStorage : IDisposable
{
public DisposableReliableFileStorage(string rfsDbPath) => this.Instance = new(rfsDbPath);
public ReliableFileStorage Instance { get; }
public void Dispose() => ((IInternalDisposableService)this.Instance).DisposeService();
}
}

View file

@ -40,6 +40,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "DalamudCrashHandler", "Dala
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dalamud.Common", "Dalamud.Common\Dalamud.Common.csproj", "{F21B13D2-D7D0-4456-B70F-3F8D695064E2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.LocExporter", "tools\Dalamud.LocExporter\Dalamud.LocExporter.csproj", "{A568929D-6FF6-4DFA-9D14-5D7DC08FA5E0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -102,6 +104,10 @@ Global
{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
{A568929D-6FF6-4DFA-9D14-5D7DC08FA5E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A568929D-6FF6-4DFA-9D14-5D7DC08FA5E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A568929D-6FF6-4DFA-9D14-5D7DC08FA5E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A568929D-6FF6-4DFA-9D14-5D7DC08FA5E0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View file

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Dalamud.Game.Text;
using Dalamud.Interface.FontIdentifier;
@ -26,7 +28,7 @@ namespace Dalamud.Configuration.Internal;
#pragma warning disable SA1015
[InherentDependency<ReliableFileStorage>] // We must still have this when unloading
#pragma warning restore SA1015
internal sealed class DalamudConfiguration : IServiceType, IDisposable
internal sealed class DalamudConfiguration : IInternalDisposableService
{
private static readonly JsonSerializerSettings SerializerSettings = new()
{
@ -367,6 +369,11 @@ internal sealed class DalamudConfiguration : IServiceType, IDisposable
/// </summary>
public bool ShowTsm { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to reduce motions (animations).
/// </summary>
public bool? ReduceMotions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not market board data should be uploaded.
/// </summary>
@ -484,6 +491,15 @@ internal sealed class DalamudConfiguration : IServiceType, IDisposable
deserialized ??= new DalamudConfiguration();
deserialized.configPath = path;
try
{
deserialized.SetDefaults();
}
catch (Exception e)
{
Log.Error(e, "Failed to set defaults for DalamudConfiguration");
}
return deserialized;
}
@ -505,7 +521,7 @@ internal sealed class DalamudConfiguration : IServiceType, IDisposable
}
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
// Make sure that we save, if a save is queued while we are shutting down
this.Update();
@ -525,6 +541,31 @@ internal sealed class DalamudConfiguration : IServiceType, IDisposable
}
}
private void SetDefaults()
{
// "Reduced motion"
if (!this.ReduceMotions.HasValue)
{
// https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/animation/animation_win.cc;l=29?q=ReducedMotion&ss=chromium
var winAnimEnabled = 0;
var success = NativeFunctions.SystemParametersInfo(
(uint)NativeFunctions.AccessibilityParameter.SPI_GETCLIENTAREAANIMATION,
0,
ref winAnimEnabled,
0);
if (!success)
{
Log.Warning("Failed to get Windows animation setting, assuming reduced motion is off (GetLastError: {GetLastError:X})", Marshal.GetLastPInvokeError());
this.ReduceMotions = false;
}
else
{
this.ReduceMotions = winAnimEnabled == 0;
}
}
}
private void Save()
{
ThreadSafety.AssertMainThread();

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
namespace Dalamud.Configuration.Internal;
@ -21,4 +22,9 @@ internal sealed class DevPluginSettings
/// Gets or sets an ID uniquely identifying this specific instance of a devPlugin.
/// </summary>
public Guid WorkingPluginId { get; set; } = Guid.Empty;
/// <summary>
/// Gets or sets a list of validation problems that have been dismissed by the user.
/// </summary>
public List<string> DismissedValidationProblems { get; set; } = new();
}

View file

@ -9,7 +9,6 @@ using System.Threading.Tasks;
using Dalamud.Common;
using Dalamud.Configuration.Internal;
using Dalamud.Game;
using Dalamud.Interface.Internal;
using Dalamud.Plugin.Internal;
using Dalamud.Storage;
using Dalamud.Utility;
@ -187,27 +186,6 @@ internal sealed class Dalamud : IServiceType
this.unloadSignal.WaitOne();
}
/// <summary>
/// Dispose subsystems related to plugin handling.
/// </summary>
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<DalamudIme>.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<InterfaceManager>.GetNullable()?.Dispose();
Service<DalamudInterface>.GetNullable()?.Dispose();
Service<PluginManager>.GetNullable()?.Dispose();
}
/// <summary>
/// Replace the current exception handler with the default one.
/// </summary>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Target">
<TargetFramework>net7.0-windows</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64;AnyCPU</Platforms>
<LangVersion>11.0</LangVersion>
<LangVersion>12.0</LangVersion>
</PropertyGroup>
<PropertyGroup Label="Feature">
<DalamudVersion>9.0.0.21</DalamudVersion>
<DalamudVersion>9.1.0.5</DalamudVersion>
<Description>XIV Launcher addon framework</Description>
<AssemblyVersion>$(DalamudVersion)</AssemblyVersion>
<Version>$(DalamudVersion)</Version>
@ -76,7 +76,6 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MinSharp" Version="1.0.4" />
<PackageReference Include="MonoModReorg.RuntimeDetour" Version="23.1.2-prerelease.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
@ -113,10 +112,6 @@
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="Game\Addon\" />
</ItemGroup>
<Target Name="AddRuntimeDependenciesToContent" BeforeTargets="GetCopyToOutputDirectoryItems" DependsOnTargets="GenerateBuildDependencyFile;GenerateBuildRuntimeConfigurationFiles">
<ItemGroup>
<ContentWithTargetPath Include="$(ProjectDepsFilePath)" CopyToOutputDirectory="PreserveNewest" TargetPath="$(ProjectDepsFileName)" />
@ -124,14 +119,6 @@
</ItemGroup>
</Target>
<Target Name="ChangeAliasesOfNugetRefs" BeforeTargets="FindReferenceAssembliesForReferences;ResolveReferences">
<ItemGroup>
<ReferencePath Condition="'%(FileName)' == 'MonoMod.Iced'">
<Aliases>monomod</Aliases>
</ReferencePath>
</ItemGroup>
</Target>
<PropertyGroup>
<!-- Needed temporarily for CI -->
<TempVerFile>$(OutputPath)TEMP_gitver.txt</TempVerFile>

View file

@ -1,5 +1,3 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Threading;
@ -9,7 +7,6 @@ using Dalamud.IoC.Internal;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Dalamud.Utility.Timing;
using JetBrains.Annotations;
using Lumina;
using Lumina.Data;
using Lumina.Excel;
@ -23,11 +20,11 @@ namespace Dalamud.Data;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IDataManager>]
#pragma warning restore SA1015
internal sealed class DataManager : IDisposable, IServiceType, IDataManager
internal sealed class DataManager : IInternalDisposableService, IDataManager
{
private readonly Thread luminaResourceThread;
private readonly CancellationTokenSource luminaCancellationTokenSource;
@ -76,6 +73,9 @@ internal sealed class DataManager : IDisposable, IServiceType, IDataManager
dalamud.StartInfo.TroubleshootingPackData);
this.HasModifiedGameDataFiles =
tsInfo?.IndexIntegrity is LauncherTroubleshootingInfo.IndexIntegrityResult.Failed or LauncherTroubleshootingInfo.IndexIntegrityResult.Exception;
if (this.HasModifiedGameDataFiles)
Log.Verbose("Game data integrity check failed!\n{TsData}", dalamud.StartInfo.TroubleshootingPackData);
}
catch
{
@ -158,7 +158,7 @@ internal sealed class DataManager : IDisposable, IServiceType, IDataManager
#endregion
/// <inheritdoc/>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.luminaCancellationTokenSource.Cancel();
}
@ -175,6 +175,6 @@ internal sealed class DataManager : IDisposable, IServiceType, IDataManager
Success,
}
public IndexIntegrityResult IndexIntegrity { get; set; }
public IndexIntegrityResult? IndexIntegrity { get; set; }
}
}

View file

@ -147,8 +147,16 @@ public sealed class EntryPoint
LogLevelSwitch.MinimumLevel = configuration.LogLevel;
// Log any unhandled exception.
if (!info.NoExceptionHandlers)
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
switch (info.UnhandledException)
{
case UnhandledExceptionHandlingMode.Default:
AppDomain.CurrentDomain.UnhandledException += OnUnhandledExceptionDefault;
break;
case UnhandledExceptionHandlingMode.StallDebug:
AppDomain.CurrentDomain.UnhandledException += OnUnhandledExceptionStallDebug;
break;
}
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
var unloadFailed = false;
@ -197,8 +205,15 @@ public sealed class EntryPoint
finally
{
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
if (!info.NoExceptionHandlers)
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
switch (info.UnhandledException)
{
case UnhandledExceptionHandlingMode.Default:
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledExceptionDefault;
break;
case UnhandledExceptionHandlingMode.StallDebug:
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledExceptionStallDebug;
break;
}
Log.Information("Session has ended.");
Log.CloseAndFlush();
@ -246,7 +261,7 @@ public sealed class EntryPoint
}
}
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
private static void OnUnhandledExceptionDefault(object sender, UnhandledExceptionEventArgs args)
{
switch (args.ExceptionObject)
{
@ -306,6 +321,12 @@ public sealed class EntryPoint
}
}
private static void OnUnhandledExceptionStallDebug(object sender, UnhandledExceptionEventArgs args)
{
while (!Debugger.IsAttached)
Thread.Sleep(100);
}
private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs args)
{
if (!args.Observed)

View file

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

View file

@ -18,8 +18,8 @@ namespace Dalamud.Game.Addon.Events;
/// Service provider for addon event management.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal unsafe class AddonEventManager : IDisposable, IServiceType
[ServiceManager.EarlyLoadedService]
internal unsafe class AddonEventManager : IInternalDisposableService
{
/// <summary>
/// PluginName for Dalamud Internal use.
@ -62,7 +62,7 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType
private delegate nint UpdateCursorDelegate(RaptureAtkModule* module);
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.onUpdateCursor.Dispose();
@ -204,7 +204,7 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType
#pragma warning disable SA1015
[ResolveVia<IAddonEventManager>]
#pragma warning restore SA1015
internal class AddonEventManagerPluginScoped : IDisposable, IServiceType, IAddonEventManager
internal class AddonEventManagerPluginScoped : IInternalDisposableService, IAddonEventManager
{
[ServiceManager.ServiceDependency]
private readonly AddonEventManager eventManagerService = Service<AddonEventManager>.Get();
@ -225,7 +225,7 @@ internal class AddonEventManagerPluginScoped : IDisposable, IServiceType, IAddon
}
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
// if multiple plugins force cursors and dispose without un-forcing them then all forces will be cleared.
if (this.isForcingCursor)

View file

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

View file

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

View file

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

View file

@ -7,12 +7,14 @@ using System.Threading.Tasks;
using CheapLoc;
using Dalamud.Configuration.Internal;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.Gui;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Internal;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Internal.Windows;
using Dalamud.Interface.Internal.Windows.PluginInstaller;
using Dalamud.Logging.Internal;
@ -24,7 +26,7 @@ namespace Dalamud.Game;
/// <summary>
/// Chat events and public helper functions.
/// </summary>
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
internal class ChatHandlers : IServiceType
{
// private static readonly Dictionary<string, string> UnicodeToDiscordEmojiDict = new()
@ -289,13 +291,20 @@ internal class ChatHandlers : IServiceType
var chatGui = Service<ChatGui>.GetNullable();
var pluginManager = Service<PluginManager>.GetNullable();
var notifications = Service<NotificationManager>.GetNullable();
var condition = Service<Condition>.GetNullable();
if (chatGui == null || pluginManager == null || notifications == null)
if (chatGui == null || pluginManager == null || notifications == null || condition == null)
{
Log.Warning("Aborting auto-update because a required service was not loaded.");
return false;
}
if (condition.Any(ConditionFlag.BoundByDuty, ConditionFlag.BoundByDuty56, ConditionFlag.BoundByDuty95))
{
Log.Warning("Aborting auto-update because the player is in a duty.");
return false;
}
if (!pluginManager.ReposReady || !pluginManager.InstalledPlugins.Any() || !pluginManager.AvailablePlugins.Any())
{
// Plugins aren't ready yet.

View file

@ -14,7 +14,7 @@ namespace Dalamud.Game.ClientState.Aetherytes;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IAetheryteList>]
#pragma warning restore SA1015

View file

@ -16,7 +16,7 @@ namespace Dalamud.Game.ClientState.Buddy;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IBuddyList>]
#pragma warning restore SA1015

View file

@ -12,6 +12,8 @@ using Dalamud.Logging.Internal;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Lumina.Excel.GeneratedSheets;
using Action = System.Action;
@ -22,8 +24,8 @@ namespace Dalamud.Game.ClientState;
/// This class represents the state of the game client at the time of access.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class ClientState : IDisposable, IServiceType, IClientState
[ServiceManager.EarlyLoadedService]
internal sealed class ClientState : IInternalDisposableService, IClientState
{
private static readonly ModuleLog Log = new("ClientState");
@ -89,6 +91,16 @@ internal sealed class ClientState : IDisposable, IServiceType, IClientState
/// <inheritdoc/>
public ushort TerritoryType { get; private set; }
/// <inheritdoc/>
public unsafe uint MapId
{
get
{
var agentMap = AgentMap.Instance();
return agentMap != null ? AgentMap.Instance()->CurrentMapId : 0;
}
}
/// <inheritdoc/>
public PlayerCharacter? LocalPlayer => Service<ObjectTable>.GetNullable()?[0] as PlayerCharacter;
@ -115,7 +127,7 @@ internal sealed class ClientState : IDisposable, IServiceType, IClientState
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.setupTerritoryTypeHook.Dispose();
this.framework.Update -= this.FrameworkOnOnUpdateEvent;
@ -196,7 +208,7 @@ internal sealed class ClientState : IDisposable, IServiceType, IClientState
#pragma warning disable SA1015
[ResolveVia<IClientState>]
#pragma warning restore SA1015
internal class ClientStatePluginScoped : IDisposable, IServiceType, IClientState
internal class ClientStatePluginScoped : IInternalDisposableService, IClientState
{
[ServiceManager.ServiceDependency]
private readonly ClientState clientStateService = Service<ClientState>.Get();
@ -237,6 +249,9 @@ internal class ClientStatePluginScoped : IDisposable, IServiceType, IClientState
/// <inheritdoc/>
public ushort TerritoryType => this.clientStateService.TerritoryType;
/// <inheritdoc/>
public uint MapId => this.clientStateService.MapId;
/// <inheritdoc/>
public PlayerCharacter? LocalPlayer => this.clientStateService.LocalPlayer;
@ -257,7 +272,7 @@ internal class ClientStatePluginScoped : IDisposable, IServiceType, IClientState
public bool IsGPosing => this.clientStateService.IsGPosing;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.clientStateService.TerritoryChanged -= this.TerritoryChangedForward;
this.clientStateService.Login -= this.LoginForward;

View file

@ -9,8 +9,8 @@ namespace Dalamud.Game.ClientState.Conditions;
/// Provides access to conditions (generally player state). You can check whether a player is in combat, mounted, etc.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed partial class Condition : IServiceType, ICondition
[ServiceManager.EarlyLoadedService]
internal sealed class Condition : IInternalDisposableService, ICondition
{
/// <summary>
/// Gets the current max number of conditions. You can get this just by looking at the condition sheet and how many rows it has.
@ -22,6 +22,8 @@ internal sealed partial class Condition : IServiceType, ICondition
private readonly bool[] cache = new bool[MaxConditionEntries];
private bool isDisposed;
[ServiceManager.ServiceConstructor]
private Condition(ClientState clientState)
{
@ -35,6 +37,9 @@ internal sealed partial class Condition : IServiceType, ICondition
this.framework.Update += this.FrameworkUpdate;
}
/// <summary>Finalizes an instance of the <see cref="Condition" /> class.</summary>
~Condition() => this.Dispose(false);
/// <inheritdoc/>
public event ICondition.ConditionChangeDelegate? ConditionChange;
@ -60,6 +65,9 @@ internal sealed partial class Condition : IServiceType, ICondition
public bool this[ConditionFlag flag]
=> this[(int)flag];
/// <inheritdoc/>
void IInternalDisposableService.DisposeService() => this.Dispose(true);
/// <inheritdoc/>
public bool Any()
{
@ -89,6 +97,19 @@ internal sealed partial class Condition : IServiceType, ICondition
return false;
}
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++)
@ -112,44 +133,6 @@ internal sealed partial class Condition : IServiceType, ICondition
}
}
/// <summary>
/// Provides access to conditions (generally player state). You can check whether a player is in combat, mounted, etc.
/// </summary>
internal sealed partial class Condition : IDisposable
{
private bool isDisposed;
/// <summary>
/// Finalizes an instance of the <see cref="Condition" /> class.
/// </summary>
~Condition()
{
this.Dispose(false);
}
/// <summary>
/// Disposes this instance, alongside its hooks.
/// </summary>
void IDisposable.Dispose()
{
GC.SuppressFinalize(this);
this.Dispose(true);
}
private void Dispose(bool disposing)
{
if (this.isDisposed)
return;
if (disposing)
{
this.framework.Update -= this.FrameworkUpdate;
}
this.isDisposed = true;
}
}
/// <summary>
/// Plugin-scoped version of a Condition service.
/// </summary>
@ -159,7 +142,7 @@ internal sealed partial class Condition : IDisposable
#pragma warning disable SA1015
[ResolveVia<ICondition>]
#pragma warning restore SA1015
internal class ConditionPluginScoped : IDisposable, IServiceType, ICondition
internal class ConditionPluginScoped : IInternalDisposableService, ICondition
{
[ServiceManager.ServiceDependency]
private readonly Condition conditionService = Service<Condition>.Get();
@ -185,7 +168,7 @@ internal class ConditionPluginScoped : IDisposable, IServiceType, ICondition
public bool this[int flag] => this.conditionService[flag];
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.conditionService.ConditionChange -= this.ConditionChangedForward;

View file

@ -428,7 +428,14 @@ public enum ConditionFlag
/// <summary>
/// Unable to execute command while bound by duty.
/// </summary>
[Obsolete("Use InDutyQueue")]
BoundToDuty97 = 91,
/// <summary>
/// Unable to execute command while bound by duty.
/// Specifically triggered when you are in a queue for a duty but not inside a duty.
/// </summary>
InDutyQueue = 91,
/// <summary>
/// Unable to execute command while readying to visit another World.

View file

@ -14,7 +14,7 @@ namespace Dalamud.Game.ClientState.Fates;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IFateTable>]
#pragma warning restore SA1015

View file

@ -17,11 +17,11 @@ namespace Dalamud.Game.ClientState.GamePad;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IGamepadState>]
#pragma warning restore SA1015
internal unsafe class GamepadState : IDisposable, IServiceType, IGamepadState
internal unsafe class GamepadState : IInternalDisposableService, IGamepadState
{
private readonly Hook<ControllerPoll>? gamepadPoll;
@ -109,7 +109,7 @@ internal unsafe class GamepadState : IDisposable, IServiceType, IGamepadState
/// <summary>
/// Disposes this instance, alongside its hooks.
/// </summary>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.Dispose(true);
GC.SuppressFinalize(this);

View file

@ -15,7 +15,7 @@ namespace Dalamud.Game.ClientState.JobGauge;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IJobGauges>]
#pragma warning restore SA1015

View file

@ -24,7 +24,7 @@ namespace Dalamud.Game.ClientState.Keys;
/// </remarks>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IKeyState>]
#pragma warning restore SA1015

View file

@ -1,15 +1,22 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Microsoft.Extensions.ObjectPool;
using Serilog;
using CSGameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
namespace Dalamud.Game.ClientState.Objects;
/// <summary>
@ -17,7 +24,7 @@ namespace Dalamud.Game.ClientState.Objects;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IObjectTable>]
#pragma warning restore SA1015
@ -25,18 +32,41 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable
{
private const int ObjectTableLength = 599;
private readonly ClientStateAddressResolver address;
private readonly ClientState clientState;
private readonly CachedEntry[] cachedObjectTable = new CachedEntry[ObjectTableLength];
private readonly ObjectPool<Enumerator> multiThreadedEnumerators =
new DefaultObjectPoolProvider().Create<Enumerator>();
private readonly Enumerator?[] frameworkThreadEnumerators = new Enumerator?[4];
private long nextMultithreadedUsageWarnTime;
[ServiceManager.ServiceConstructor]
private ObjectTable(ClientState clientState)
private unsafe ObjectTable(ClientState clientState)
{
this.address = clientState.AddressResolver;
this.clientState = clientState;
Log.Verbose($"Object table address 0x{this.address.ObjectTable.ToInt64():X}");
var nativeObjectTableAddress = (CSGameObject**)this.clientState.AddressResolver.ObjectTable;
for (var i = 0; i < this.cachedObjectTable.Length; i++)
this.cachedObjectTable[i] = new(nativeObjectTableAddress, i);
for (var i = 0; i < this.frameworkThreadEnumerators.Length; i++)
this.frameworkThreadEnumerators[i] = new(this, i);
Log.Verbose($"Object table address 0x{this.clientState.AddressResolver.ObjectTable.ToInt64():X}");
}
/// <inheritdoc/>
public IntPtr Address => this.address.ObjectTable;
public nint Address
{
get
{
_ = this.WarnMultithreadedUsage();
return this.clientState.AddressResolver.ObjectTable;
}
}
/// <inheritdoc/>
public int Length => ObjectTableLength;
@ -46,50 +76,49 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable
{
get
{
var address = this.GetObjectAddress(index);
return this.CreateObjectReference(address);
_ = this.WarnMultithreadedUsage();
return index is >= ObjectTableLength or < 0 ? null : this.cachedObjectTable[index].Update();
}
}
/// <inheritdoc/>
public GameObject? SearchById(ulong objectId)
{
_ = this.WarnMultithreadedUsage();
if (objectId is GameObject.InvalidGameObjectId or 0)
return null;
foreach (var obj in this)
foreach (var e in this.cachedObjectTable)
{
if (obj == null)
continue;
if (obj.ObjectId == objectId)
return obj;
if (e.Update() is { } o && o.ObjectId == objectId)
return o;
}
return null;
}
/// <inheritdoc/>
public unsafe IntPtr GetObjectAddress(int index)
public unsafe nint GetObjectAddress(int index)
{
if (index < 0 || index >= ObjectTableLength)
return IntPtr.Zero;
_ = this.WarnMultithreadedUsage();
return *(IntPtr*)(this.address.ObjectTable + (8 * index));
return index is < 0 or >= ObjectTableLength ? nint.Zero : (nint)this.cachedObjectTable[index].Address;
}
/// <inheritdoc/>
public unsafe GameObject? CreateObjectReference(IntPtr address)
public unsafe GameObject? CreateObjectReference(nint address)
{
var clientState = Service<ClientState>.GetNullable();
_ = this.WarnMultithreadedUsage();
if (clientState == null || clientState.LocalContentId == 0)
if (this.clientState.LocalContentId == 0)
return null;
if (address == IntPtr.Zero)
if (address == nint.Zero)
return null;
var obj = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)address;
var obj = (CSGameObject*)address;
var objKind = (ObjectKind)obj->ObjectKind;
return objKind switch
{
@ -104,6 +133,82 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable
_ => new GameObject(address),
};
}
[Api10ToDo("Use ThreadSafety.AssertMainThread() instead of this.")]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool WarnMultithreadedUsage()
{
if (ThreadSafety.IsMainThread)
return false;
var n = Environment.TickCount64;
if (this.nextMultithreadedUsageWarnTime < n)
{
this.nextMultithreadedUsageWarnTime = n + 30000;
Log.Warning(
"{plugin} is accessing {objectTable} outside the main thread. This is deprecated.",
Service<PluginManager>.Get().FindCallingPlugin()?.Name ?? "<unknown plugin>",
nameof(ObjectTable));
}
return true;
}
/// <summary>Stores an object table entry, with preallocated concrete types.</summary>
internal readonly unsafe struct CachedEntry
{
private readonly CSGameObject** gameObjectPtrPtr;
private readonly PlayerCharacter playerCharacter;
private readonly BattleNpc battleNpc;
private readonly Npc npc;
private readonly EventObj eventObj;
private readonly GameObject gameObject;
/// <summary>Initializes a new instance of the <see cref="CachedEntry"/> struct.</summary>
/// <param name="ownerTable">The object table that this entry should be pointing to.</param>
/// <param name="slot">The slot index inside the table.</param>
public CachedEntry(CSGameObject** ownerTable, int slot)
{
this.gameObjectPtrPtr = ownerTable + slot;
this.playerCharacter = new(nint.Zero);
this.battleNpc = new(nint.Zero);
this.npc = new(nint.Zero);
this.eventObj = new(nint.Zero);
this.gameObject = new(nint.Zero);
}
/// <summary>Gets the address of the underlying native object. May be null.</summary>
public CSGameObject* Address
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => *this.gameObjectPtrPtr;
}
/// <summary>Updates and gets the wrapped game object pointed by this struct.</summary>
/// <returns>The pointed object, or <c>null</c> if no object exists at that slot.</returns>
public GameObject? Update()
{
var address = this.Address;
if (address is null)
return null;
var activeObject = (ObjectKind)address->ObjectKind switch
{
ObjectKind.Player => this.playerCharacter,
ObjectKind.BattleNpc => this.battleNpc,
ObjectKind.EventNpc => this.npc,
ObjectKind.Retainer => this.npc,
ObjectKind.EventObj => this.eventObj,
ObjectKind.Companion => this.npc,
ObjectKind.MountType => this.npc,
ObjectKind.Ornament => this.npc,
_ => this.gameObject,
};
activeObject.Address = (nint)address;
return activeObject;
}
}
}
/// <summary>
@ -111,23 +216,93 @@ internal sealed partial class ObjectTable : IServiceType, IObjectTable
/// </summary>
internal sealed partial class ObjectTable
{
/// <inheritdoc/>
int IReadOnlyCollection<GameObject>.Count => this.Length;
/// <inheritdoc/>
public IEnumerator<GameObject> GetEnumerator()
{
for (var i = 0; i < ObjectTableLength; i++)
// If something's trying to enumerate outside the framework thread, we use the ObjectPool.
if (this.WarnMultithreadedUsage())
{
var obj = this[i];
if (obj == null)
continue;
yield return obj;
// let's not
var e = this.multiThreadedEnumerators.Get();
e.InitializeForPooledObjects(this);
return e;
}
// If we're on the framework thread, see if there's an already allocated enumerator available for use.
foreach (ref var x in this.frameworkThreadEnumerators.AsSpan())
{
if (x is not null)
{
var t = x;
x = null;
t.Reset();
return t;
}
}
// No reusable enumerator is available; allocate a new temporary one.
return new Enumerator(this, -1);
}
/// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
private sealed class Enumerator : IEnumerator<GameObject>, IResettable
{
private readonly int slotId;
private ObjectTable? owner;
private int index = -1;
public Enumerator() => this.slotId = -1;
public Enumerator(ObjectTable owner, int slotId)
{
this.owner = owner;
this.slotId = slotId;
}
public GameObject Current { get; private set; } = null!;
object IEnumerator.Current => this.Current;
public bool MoveNext()
{
if (this.index == ObjectTableLength)
return false;
var cache = this.owner!.cachedObjectTable.AsSpan();
for (this.index++; this.index < ObjectTableLength; this.index++)
{
if (cache[this.index].Update() is { } ao)
{
this.Current = ao;
return true;
}
}
return false;
}
public void InitializeForPooledObjects(ObjectTable ot) => this.owner = ot;
public void Reset() => this.index = -1;
public void Dispose()
{
if (this.owner is not { } o)
return;
if (this.slotId == -1)
o.multiThreadedEnumerators.Return(this);
else
o.frameworkThreadEnumerators[this.slotId] = this;
}
public bool TryReset()
{
this.Reset();
return true;
}
}
}

View file

@ -31,5 +31,5 @@ public unsafe class PlayerCharacter : BattleChara
/// <summary>
/// Gets the target actor ID of the PlayerCharacter.
/// </summary>
public override ulong TargetObjectId => this.Struct->Character.LookTargetId;
public override ulong TargetObjectId => this.Struct->Character.Gaze.Controller.GazesSpan[0].TargetInfo.TargetId;
}

View file

@ -12,7 +12,7 @@ namespace Dalamud.Game.ClientState.Objects;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<ITargetManager>]
#pragma warning restore SA1015

View file

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

View file

@ -29,7 +29,7 @@ public unsafe partial class GameObject : IEquatable<GameObject>
/// <summary>
/// Gets the address of the game object in memory.
/// </summary>
public IntPtr Address { get; }
public IntPtr Address { get; internal set; }
/// <summary>
/// Gets the Dalamud instance.

View file

@ -15,7 +15,7 @@ namespace Dalamud.Game.ClientState.Party;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IPartyList>]
#pragma warning restore SA1015

View file

@ -18,8 +18,8 @@ namespace Dalamud.Game.Command;
/// This class manages registered in-game slash commands.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class CommandManager : IServiceType, IDisposable, ICommandManager
[ServiceManager.EarlyLoadedService]
internal sealed class CommandManager : IInternalDisposableService, ICommandManager
{
private static readonly ModuleLog Log = new("Command");
@ -130,7 +130,7 @@ internal sealed class CommandManager : IServiceType, IDisposable, ICommandManage
}
/// <inheritdoc/>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.chatGui.CheckMessageHandled -= this.OnCheckMessageHandled;
}
@ -170,7 +170,7 @@ internal sealed class CommandManager : IServiceType, IDisposable, ICommandManage
#pragma warning disable SA1015
[ResolveVia<ICommandManager>]
#pragma warning restore SA1015
internal class CommandManagerPluginScoped : IDisposable, IServiceType, ICommandManager
internal class CommandManagerPluginScoped : IInternalDisposableService, ICommandManager
{
private static readonly ModuleLog Log = new("Command");
@ -193,7 +193,7 @@ internal class CommandManagerPluginScoped : IDisposable, IServiceType, ICommandM
public ReadOnlyDictionary<string, CommandInfo> Commands => this.commandManagerService.Commands;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
foreach (var command in this.pluginRegisteredCommands)
{

View file

@ -14,8 +14,8 @@ namespace Dalamud.Game.Config;
/// This class represents the game's configuration.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class GameConfig : IServiceType, IGameConfig, IDisposable
[ServiceManager.EarlyLoadedService]
internal sealed class GameConfig : IInternalDisposableService, IGameConfig
{
private readonly TaskCompletionSource tcsInitialization = new();
private readonly TaskCompletionSource<GameConfigSection> tcsSystem = new();
@ -195,7 +195,7 @@ internal sealed class GameConfig : IServiceType, IGameConfig, IDisposable
public void Set(UiControlOption option, string value) => this.UiControl.Set(option.GetName(), value);
/// <inheritdoc/>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
var ode = new ObjectDisposedException(nameof(GameConfig));
this.tcsInitialization.SetExceptionIfIncomplete(ode);
@ -248,7 +248,7 @@ internal sealed class GameConfig : IServiceType, IGameConfig, IDisposable
#pragma warning disable SA1015
[ResolveVia<IGameConfig>]
#pragma warning restore SA1015
internal class GameConfigPluginScoped : IDisposable, IServiceType, IGameConfig
internal class GameConfigPluginScoped : IInternalDisposableService, IGameConfig
{
[ServiceManager.ServiceDependency]
private readonly GameConfig gameConfigService = Service<GameConfig>.Get();
@ -295,7 +295,7 @@ internal class GameConfigPluginScoped : IDisposable, IServiceType, IGameConfig
public GameConfigSection UiControl => this.gameConfigService.UiControl;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.gameConfigService.Changed -= this.ConfigChangedForward;
this.initializationTask.ContinueWith(

View file

@ -12,8 +12,8 @@ namespace Dalamud.Game.DutyState;
/// This class represents the state of the currently occupied duty.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal unsafe class DutyState : IDisposable, IServiceType, IDutyState
[ServiceManager.EarlyLoadedService]
internal unsafe class DutyState : IInternalDisposableService, IDutyState
{
private readonly DutyStateAddressResolver address;
private readonly Hook<SetupContentDirectNetworkMessageDelegate> contentDirectorNetworkMessageHook;
@ -62,7 +62,7 @@ internal unsafe class DutyState : IDisposable, IServiceType, IDutyState
private bool CompletedThisTerritory { get; set; }
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.contentDirectorNetworkMessageHook.Dispose();
this.framework.Update -= this.FrameworkOnUpdateEvent;
@ -168,7 +168,7 @@ internal unsafe class DutyState : IDisposable, IServiceType, IDutyState
#pragma warning disable SA1015
[ResolveVia<IDutyState>]
#pragma warning restore SA1015
internal class DutyStatePluginScoped : IDisposable, IServiceType, IDutyState
internal class DutyStatePluginScoped : IInternalDisposableService, IDutyState
{
[ServiceManager.ServiceDependency]
private readonly DutyState dutyStateService = Service<DutyState>.Get();
@ -200,7 +200,7 @@ internal class DutyStatePluginScoped : IDisposable, IServiceType, IDutyState
public bool IsDutyStarted => this.dutyStateService.IsDutyStarted;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.dutyStateService.DutyStarted -= this.DutyStartedForward;
this.dutyStateService.DutyWiped -= this.DutyWipedForward;

View file

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@ -21,14 +22,12 @@ namespace Dalamud.Game;
/// This class represents the Framework of the native game client and grants access to various subsystems.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class Framework : IDisposable, IServiceType, IFramework
[ServiceManager.EarlyLoadedService]
internal sealed class Framework : IInternalDisposableService, IFramework
{
private static readonly ModuleLog Log = new("Framework");
private static readonly Stopwatch StatsStopwatch = new();
private readonly GameLifecycle lifecycle;
private readonly Stopwatch updateStopwatch = new();
private readonly HitchDetector hitchDetector;
@ -37,25 +36,37 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
private readonly Hook<OnRealDestroyDelegate> destroyHook;
private readonly FrameworkAddressResolver addressResolver;
[ServiceManager.ServiceDependency]
private readonly GameLifecycle lifecycle = Service<GameLifecycle>.Get();
[ServiceManager.ServiceDependency]
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
private readonly object runOnNextTickTaskListSync = new();
private List<RunOnNextTickTaskBase> runOnNextTickTaskList = new();
private List<RunOnNextTickTaskBase> runOnNextTickTaskList2 = new();
private readonly CancellationTokenSource frameworkDestroy;
private readonly ThreadBoundTaskScheduler frameworkThreadTaskScheduler;
private Thread? frameworkUpdateThread;
private readonly ConcurrentDictionary<TaskCompletionSource, (ulong Expire, CancellationToken CancellationToken)>
tickDelayedTaskCompletionSources = new();
private ulong tickCounter;
[ServiceManager.ServiceConstructor]
private Framework(TargetSigScanner sigScanner, GameLifecycle lifecycle)
private Framework(TargetSigScanner sigScanner)
{
this.lifecycle = lifecycle;
this.hitchDetector = new HitchDetector("FrameworkUpdate", this.configuration.FrameworkUpdateHitch);
this.addressResolver = new FrameworkAddressResolver();
this.addressResolver.Setup(sigScanner);
this.frameworkDestroy = new();
this.frameworkThreadTaskScheduler = new();
this.FrameworkThreadTaskFactory = new(
this.frameworkDestroy.Token,
TaskCreationOptions.None,
TaskContinuationOptions.None,
this.frameworkThreadTaskScheduler);
this.updateHook = Hook<OnUpdateDetour>.FromAddress(this.addressResolver.TickAddress, this.HandleFrameworkUpdate);
this.destroyHook = Hook<OnRealDestroyDelegate>.FromAddress(this.addressResolver.DestroyAddress, this.HandleFrameworkDestroy);
@ -76,6 +87,11 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
/// <inheritdoc/>
public event IFramework.OnUpdateDelegate? Update;
/// <summary>
/// Executes during FrameworkUpdate before all <see cref="Update"/> delegates.
/// </summary>
internal event IFramework.OnUpdateDelegate? BeforeUpdate;
/// <summary>
/// Gets or sets a value indicating whether the collection of stats is enabled.
/// </summary>
@ -96,10 +112,10 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
public TimeSpan UpdateDelta { get; private set; } = TimeSpan.Zero;
/// <inheritdoc/>
public bool IsInFrameworkUpdateThread => Thread.CurrentThread == this.frameworkUpdateThread;
public bool IsInFrameworkUpdateThread => this.frameworkThreadTaskScheduler.IsOnBoundThread;
/// <inheritdoc/>
public bool IsFrameworkUnloading { get; internal set; }
public bool IsFrameworkUnloading => this.frameworkDestroy.IsCancellationRequested;
/// <summary>
/// Gets the list of update sub-delegates that didn't get updated this frame.
@ -111,6 +127,56 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
/// </summary>
internal bool DispatchUpdateEvents { get; set; } = true;
private TaskFactory FrameworkThreadTaskFactory { get; }
/// <inheritdoc/>
public TaskFactory GetTaskFactory() => this.FrameworkThreadTaskFactory;
/// <inheritdoc/>
public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default)
{
if (this.frameworkDestroy.IsCancellationRequested)
return Task.FromCanceled(this.frameworkDestroy.Token);
if (numTicks <= 0)
return Task.CompletedTask;
var tcs = new TaskCompletionSource();
this.tickDelayedTaskCompletionSources[tcs] = (this.tickCounter + (ulong)numTicks, cancellationToken);
return tcs.Task;
}
/// <inheritdoc/>
public Task Run(Action action, CancellationToken cancellationToken = default)
{
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken);
}
/// <inheritdoc/>
public Task<T> Run<T>(Func<T> action, CancellationToken cancellationToken = default)
{
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken);
}
/// <inheritdoc/>
public Task Run(Func<Task> action, CancellationToken cancellationToken = default)
{
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken).Unwrap();
}
/// <inheritdoc/>
public Task<T> Run<T>(Func<Task<T>> action, CancellationToken cancellationToken = default)
{
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.StartNew(action, cancellationToken).Unwrap();
}
/// <inheritdoc/>
public Task<T> RunOnFrameworkThread<T>(Func<T> func) =>
this.IsInFrameworkUpdateThread || this.IsFrameworkUnloading ? Task.FromResult(func()) : this.RunOnTick(func);
@ -157,20 +223,18 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled<T>(cts.Token);
}
var tcs = new TaskCompletionSource<T>();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc<T>()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Func = func,
});
}
return tcs.Task;
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => func(),
cancellationToken,
TaskContinuationOptions.HideScheduler,
this.frameworkThreadTaskScheduler);
}
/// <inheritdoc/>
@ -186,20 +250,18 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled(cts.Token);
}
var tcs = new TaskCompletionSource();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskAction()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Action = action,
});
}
return tcs.Task;
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => action(),
cancellationToken,
TaskContinuationOptions.HideScheduler,
this.frameworkThreadTaskScheduler);
}
/// <inheritdoc/>
@ -215,20 +277,18 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled<T>(cts.Token);
}
var tcs = new TaskCompletionSource<Task<T>>();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc<Task<T>>()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Func = func,
});
}
return tcs.Task.ContinueWith(x => x.Result, cancellationToken).Unwrap();
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => func(),
cancellationToken,
TaskContinuationOptions.HideScheduler,
this.frameworkThreadTaskScheduler).Unwrap();
}
/// <inheritdoc/>
@ -244,26 +304,24 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
return Task.FromCanceled(cts.Token);
}
var tcs = new TaskCompletionSource<Task>();
lock (this.runOnNextTickTaskListSync)
{
this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc<Task>()
if (cancellationToken == default)
cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken;
return this.FrameworkThreadTaskFactory.ContinueWhenAll(
new[]
{
RemainingTicks = delayTicks,
RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds),
CancellationToken = cancellationToken,
TaskCompletionSource = tcs,
Func = func,
});
}
return tcs.Task.ContinueWith(x => x.Result, cancellationToken).Unwrap();
Task.Delay(delay, cancellationToken),
this.DelayTicks(delayTicks, cancellationToken),
},
_ => func(),
cancellationToken,
TaskContinuationOptions.HideScheduler,
this.frameworkThreadTaskScheduler).Unwrap();
}
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.RunOnFrameworkThread(() =>
{
@ -280,7 +338,7 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
this.updateStopwatch.Reset();
StatsStopwatch.Reset();
}
/// <summary>
/// Adds a update time to the stats history.
/// </summary>
@ -307,7 +365,7 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
internal void ProfileAndInvoke(IFramework.OnUpdateDelegate? eventDelegate, IFramework frameworkInstance)
{
if (eventDelegate is null) return;
var invokeList = eventDelegate.GetInvocationList();
// Individually invoke OnUpdate handlers and time them.
@ -333,26 +391,14 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
}
}
private void RunPendingTickTasks()
{
if (this.runOnNextTickTaskList.Count == 0 && this.runOnNextTickTaskList2.Count == 0)
return;
for (var i = 0; i < 2; i++)
{
lock (this.runOnNextTickTaskListSync)
(this.runOnNextTickTaskList, this.runOnNextTickTaskList2) = (this.runOnNextTickTaskList2, this.runOnNextTickTaskList);
this.runOnNextTickTaskList2.RemoveAll(x => x.Run());
}
}
private bool HandleFrameworkUpdate(IntPtr framework)
{
this.frameworkUpdateThread ??= Thread.CurrentThread;
this.frameworkThreadTaskScheduler.BoundThread ??= Thread.CurrentThread;
ThreadSafety.MarkMainThread();
this.BeforeUpdate?.InvokeSafely(this);
this.hitchDetector.Start();
try
@ -381,18 +427,30 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
this.LastUpdate = DateTime.Now;
this.LastUpdateUTC = DateTime.UtcNow;
this.tickCounter++;
foreach (var (k, (expiry, ct)) in this.tickDelayedTaskCompletionSources)
{
if (ct.IsCancellationRequested)
k.SetCanceled(ct);
else if (expiry <= this.tickCounter)
k.SetResult();
else
continue;
this.tickDelayedTaskCompletionSources.Remove(k, out _);
}
if (StatsEnabled)
{
StatsStopwatch.Restart();
this.RunPendingTickTasks();
this.frameworkThreadTaskScheduler.Run();
StatsStopwatch.Stop();
AddToStats(nameof(this.RunPendingTickTasks), StatsStopwatch.Elapsed.TotalMilliseconds);
AddToStats(nameof(this.frameworkThreadTaskScheduler), StatsStopwatch.Elapsed.TotalMilliseconds);
}
else
{
this.RunPendingTickTasks();
this.frameworkThreadTaskScheduler.Run();
}
if (StatsEnabled && this.Update != null)
@ -404,7 +462,7 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
// Cleanup handlers that are no longer being called
foreach (var key in this.NonUpdatedSubDelegates)
{
if (key == nameof(this.RunPendingTickTasks))
if (key == nameof(this.FrameworkThreadTaskFactory))
continue;
if (StatsHistory[key].Count > 0)
@ -425,14 +483,17 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
this.hitchDetector.Stop();
original:
original:
return this.updateHook.OriginalDisposeSafe(framework);
}
private bool HandleFrameworkDestroy(IntPtr framework)
{
this.IsFrameworkUnloading = true;
this.frameworkDestroy.Cancel();
this.DispatchUpdateEvents = false;
foreach (var k in this.tickDelayedTaskCompletionSources.Keys)
k.SetCanceled(this.frameworkDestroy.Token);
this.tickDelayedTaskCompletionSources.Clear();
// All the same, for now...
this.lifecycle.SetShuttingDown();
@ -440,95 +501,12 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
Log.Information("Framework::Destroy!");
Service<Dalamud>.Get().Unload();
this.RunPendingTickTasks();
this.frameworkThreadTaskScheduler.Run();
ServiceManager.WaitForServiceUnload();
Log.Information("Framework::Destroy OK!");
return this.destroyHook.OriginalDisposeSafe(framework);
}
private abstract class RunOnNextTickTaskBase
{
internal int RemainingTicks { get; set; }
internal long RunAfterTickCount { get; init; }
internal CancellationToken CancellationToken { get; init; }
internal bool Run()
{
if (this.CancellationToken.IsCancellationRequested)
{
this.CancelImpl();
return true;
}
if (this.RemainingTicks > 0)
this.RemainingTicks -= 1;
if (this.RemainingTicks > 0)
return false;
if (this.RunAfterTickCount > Environment.TickCount64)
return false;
this.RunImpl();
return true;
}
protected abstract void RunImpl();
protected abstract void CancelImpl();
}
private class RunOnNextTickTaskFunc<T> : RunOnNextTickTaskBase
{
internal TaskCompletionSource<T> TaskCompletionSource { get; init; }
internal Func<T> Func { get; init; }
protected override void RunImpl()
{
try
{
this.TaskCompletionSource.SetResult(this.Func());
}
catch (Exception ex)
{
this.TaskCompletionSource.SetException(ex);
}
}
protected override void CancelImpl()
{
this.TaskCompletionSource.SetCanceled();
}
}
private class RunOnNextTickTaskAction : RunOnNextTickTaskBase
{
internal TaskCompletionSource TaskCompletionSource { get; init; }
internal Action Action { get; init; }
protected override void RunImpl()
{
try
{
this.Action();
this.TaskCompletionSource.SetResult();
}
catch (Exception ex)
{
this.TaskCompletionSource.SetException(ex);
}
}
protected override void CancelImpl()
{
this.TaskCompletionSource.SetCanceled();
}
}
}
/// <summary>
@ -540,7 +518,7 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework
#pragma warning disable SA1015
[ResolveVia<IFramework>]
#pragma warning restore SA1015
internal class FrameworkPluginScoped : IDisposable, IServiceType, IFramework
internal class FrameworkPluginScoped : IInternalDisposableService, IFramework
{
[ServiceManager.ServiceDependency]
private readonly Framework frameworkService = Service<Framework>.Get();
@ -558,51 +536,74 @@ internal class FrameworkPluginScoped : IDisposable, IServiceType, IFramework
/// <inheritdoc/>
public DateTime LastUpdate => this.frameworkService.LastUpdate;
/// <inheritdoc/>
public DateTime LastUpdateUTC => this.frameworkService.LastUpdateUTC;
/// <inheritdoc/>
public TimeSpan UpdateDelta => this.frameworkService.UpdateDelta;
/// <inheritdoc/>
public bool IsInFrameworkUpdateThread => this.frameworkService.IsInFrameworkUpdateThread;
/// <inheritdoc/>
public bool IsFrameworkUnloading => this.frameworkService.IsFrameworkUnloading;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.frameworkService.Update -= this.OnUpdateForward;
this.Update = null;
}
/// <inheritdoc/>
public TaskFactory GetTaskFactory() => this.frameworkService.GetTaskFactory();
/// <inheritdoc/>
public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default) =>
this.frameworkService.DelayTicks(numTicks, cancellationToken);
/// <inheritdoc/>
public Task Run(Action action, CancellationToken cancellationToken = default) =>
this.frameworkService.Run(action, cancellationToken);
/// <inheritdoc/>
public Task<T> Run<T>(Func<T> action, CancellationToken cancellationToken = default) =>
this.frameworkService.Run(action, cancellationToken);
/// <inheritdoc/>
public Task Run(Func<Task> action, CancellationToken cancellationToken = default) =>
this.frameworkService.Run(action, cancellationToken);
/// <inheritdoc/>
public Task<T> Run<T>(Func<Task<T>> action, CancellationToken cancellationToken = default) =>
this.frameworkService.Run(action, cancellationToken);
/// <inheritdoc/>
public Task<T> RunOnFrameworkThread<T>(Func<T> func)
=> this.frameworkService.RunOnFrameworkThread(func);
/// <inheritdoc/>
public Task RunOnFrameworkThread(Action action)
=> this.frameworkService.RunOnFrameworkThread(action);
/// <inheritdoc/>
public Task<T> RunOnFrameworkThread<T>(Func<Task<T>> func)
=> this.frameworkService.RunOnFrameworkThread(func);
/// <inheritdoc/>
public Task RunOnFrameworkThread(Func<Task> func)
=> this.frameworkService.RunOnFrameworkThread(func);
/// <inheritdoc/>
public Task<T> RunOnTick<T>(Func<T> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default)
=> this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken);
/// <inheritdoc/>
public Task RunOnTick(Action action, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default)
=> this.frameworkService.RunOnTick(action, delay, delayTicks, cancellationToken);
/// <inheritdoc/>
public Task<T> RunOnTick<T>(Func<Task<T>> func, TimeSpan delay = default, int delayTicks = default, CancellationToken cancellationToken = default)
=> this.frameworkService.RunOnTick(func, delay, delayTicks, cancellationToken);

View file

@ -11,7 +11,7 @@ namespace Dalamud.Game;
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IGameLifecycle>]
#pragma warning restore SA1015

View file

@ -12,6 +12,7 @@ using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Memory;
using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.System.String;
@ -28,8 +29,8 @@ namespace Dalamud.Game.Gui;
/// This class handles interacting with the native chat UI.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
[ServiceManager.EarlyLoadedService]
internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui
{
private static readonly ModuleLog Log = new("ChatGui");
@ -61,7 +62,7 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
this.populateItemLinkHook.Enable();
this.interactableLinkClickedHook.Enable();
}
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate uint PrintMessageDelegate(RaptureLogModule* manager, XivChatType chatType, Utf8String* sender, Utf8String* message, int timestamp, byte silent);
@ -109,7 +110,7 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.printMessageHook.Dispose();
this.populateItemLinkHook.Dispose();
@ -121,31 +122,31 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
{
this.chatQueue.Enqueue(chat);
}
/// <inheritdoc/>
public void Print(string message, string? messageTag = null, ushort? tagColor = null)
{
this.PrintTagged(message, this.configuration.GeneralChatType, messageTag, tagColor);
}
/// <inheritdoc/>
public void Print(SeString message, string? messageTag = null, ushort? tagColor = null)
{
this.PrintTagged(message, this.configuration.GeneralChatType, messageTag, tagColor);
}
/// <inheritdoc/>
public void PrintError(string message, string? messageTag = null, ushort? tagColor = null)
{
this.PrintTagged(message, XivChatType.Urgent, messageTag, tagColor);
}
/// <inheritdoc/>
public void PrintError(SeString message, string? messageTag = null, ushort? tagColor = null)
{
this.PrintTagged(message, XivChatType.Urgent, messageTag, tagColor);
}
/// <summary>
/// Process a chat queue.
/// </summary>
@ -154,9 +155,29 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
while (this.chatQueue.Count > 0)
{
var chat = this.chatQueue.Dequeue();
var replacedMessage = new SeStringBuilder();
// Normalize Unicode NBSP to the built-in one, as the former won't renderl
foreach (var payload in chat.Message.Payloads)
{
if (payload is TextPayload { Text: not null } textPayload)
{
var split = textPayload.Text.Split("\u202f"); // NARROW NO-BREAK SPACE
for (var i = 0; i < split.Length; i++)
{
replacedMessage.AddText(split[i]);
if (i + 1 < split.Length)
replacedMessage.Add(new RawPayload([0x02, (byte)Lumina.Text.Payloads.PayloadType.Indent, 0x01, 0x03]));
}
}
else
{
replacedMessage.Add(payload);
}
}
var sender = Utf8String.FromSequence(chat.Name.Encode());
var message = Utf8String.FromSequence(chat.Message.Encode());
var message = Utf8String.FromSequence(replacedMessage.BuiltString.Encode());
this.HandlePrintMessageDetour(RaptureLogModule.Instance(), chat.Type, sender, message, (int)chat.SenderId, (byte)(chat.Parameters != 0 ? 1 : 0));
@ -193,7 +214,7 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
lock (this.dalamudLinkHandlers)
{
var changed = false;
foreach (var handler in this.RegisteredLinkHandlers.Keys.Where(k => k.PluginName == pluginName))
changed |= this.dalamudLinkHandlers.Remove(handler);
if (changed)
@ -230,18 +251,18 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
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)
@ -253,7 +274,7 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
builder.AddText($"[{tag}] ");
}
}
this.Print(new XivChatEntry
{
Message = builder.Build().Append(message),
@ -409,7 +430,7 @@ internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui
#pragma warning disable SA1015
[ResolveVia<IChatGui>]
#pragma warning restore SA1015
internal class ChatGuiPluginScoped : IDisposable, IServiceType, IChatGui
internal class ChatGuiPluginScoped : IInternalDisposableService, IChatGui
{
[ServiceManager.ServiceDependency]
private readonly ChatGui chatGuiService = Service<ChatGui>.Get();
@ -424,16 +445,16 @@ internal class ChatGuiPluginScoped : IDisposable, IServiceType, IChatGui
this.chatGuiService.ChatMessageHandled += this.OnMessageHandledForward;
this.chatGuiService.ChatMessageUnhandled += this.OnMessageUnhandledForward;
}
/// <inheritdoc/>
public event IChatGui.OnMessageDelegate? ChatMessage;
/// <inheritdoc/>
public event IChatGui.OnCheckMessageHandledDelegate? CheckMessageHandled;
/// <inheritdoc/>
public event IChatGui.OnMessageHandledDelegate? ChatMessageHandled;
/// <inheritdoc/>
public event IChatGui.OnMessageUnhandledDelegate? ChatMessageUnhandled;
@ -447,7 +468,7 @@ internal class ChatGuiPluginScoped : IDisposable, IServiceType, IChatGui
public IReadOnlyDictionary<(string PluginName, uint CommandId), Action<uint, SeString>> RegisteredLinkHandlers => this.chatGuiService.RegisteredLinkHandlers;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.chatGuiService.ChatMessage -= this.OnMessageForward;
this.chatGuiService.CheckMessageHandled -= this.OnCheckMessageForward;
@ -459,23 +480,23 @@ internal class ChatGuiPluginScoped : IDisposable, IServiceType, IChatGui
this.ChatMessageHandled = null;
this.ChatMessageUnhandled = null;
}
/// <inheritdoc/>
public void Print(XivChatEntry chat)
=> this.chatGuiService.Print(chat);
/// <inheritdoc/>
public void Print(string message, string? messageTag = null, ushort? tagColor = null)
public void Print(string message, string? messageTag = null, ushort? tagColor = null)
=> this.chatGuiService.Print(message, messageTag, tagColor);
/// <inheritdoc/>
public void Print(SeString message, string? messageTag = null, ushort? tagColor = null)
=> this.chatGuiService.Print(message, messageTag, tagColor);
/// <inheritdoc/>
public void PrintError(string message, string? messageTag = null, ushort? tagColor = null)
=> this.chatGuiService.PrintError(message, messageTag, tagColor);
/// <inheritdoc/>
public void PrintError(SeString message, string? messageTag = null, ushort? tagColor = null)
=> this.chatGuiService.PrintError(message, messageTag, tagColor);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,8 +21,8 @@ namespace Dalamud.Game.Gui.Dtr;
/// Class used to interface with the server info bar.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar
[ServiceManager.EarlyLoadedService]
internal sealed unsafe class DtrBar : IInternalDisposableService, IDtrBar
{
private const uint BaseNodeId = 1000;
@ -101,7 +101,7 @@ internal sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar
}
/// <inheritdoc/>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.addonLifecycle.UnregisterListener(this.dtrPostDrawListener);
this.addonLifecycle.UnregisterListener(this.dtrPostRequestedUpdateListener);
@ -493,7 +493,7 @@ internal sealed unsafe class DtrBar : IDisposable, IServiceType, IDtrBar
#pragma warning disable SA1015
[ResolveVia<IDtrBar>]
#pragma warning restore SA1015
internal class DtrBarPluginScoped : IDisposable, IServiceType, IDtrBar
internal class DtrBarPluginScoped : IInternalDisposableService, IDtrBar
{
[ServiceManager.ServiceDependency]
private readonly DtrBar dtrBarService = Service<DtrBar>.Get();
@ -501,7 +501,7 @@ internal class DtrBarPluginScoped : IDisposable, IServiceType, IDtrBar
private readonly Dictionary<string, DtrBarEntry> pluginEntries = new();
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
foreach (var entry in this.pluginEntries)
{

View file

@ -15,8 +15,8 @@ namespace Dalamud.Game.Gui.FlyText;
/// This class facilitates interacting with and creating native in-game "fly text".
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class FlyTextGui : IDisposable, IServiceType, IFlyTextGui
[ServiceManager.EarlyLoadedService]
internal sealed class FlyTextGui : IInternalDisposableService, IFlyTextGui
{
/// <summary>
/// The native function responsible for adding fly text to the UI. See <see cref="FlyTextGuiAddressResolver.AddFlyText"/>.
@ -78,7 +78,7 @@ internal sealed class FlyTextGui : IDisposable, IServiceType, IFlyTextGui
/// <summary>
/// Disposes of managed and unmanaged resources.
/// </summary>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.createFlyTextHook.Dispose();
}
@ -277,7 +277,7 @@ internal sealed class FlyTextGui : IDisposable, IServiceType, IFlyTextGui
#pragma warning disable SA1015
[ResolveVia<IFlyTextGui>]
#pragma warning restore SA1015
internal class FlyTextGuiPluginScoped : IDisposable, IServiceType, IFlyTextGui
internal class FlyTextGuiPluginScoped : IInternalDisposableService, IFlyTextGui
{
[ServiceManager.ServiceDependency]
private readonly FlyTextGui flyTextGuiService = Service<FlyTextGui>.Get();
@ -294,7 +294,7 @@ internal class FlyTextGuiPluginScoped : IDisposable, IServiceType, IFlyTextGui
public event IFlyTextGui.OnFlyTextCreatedDelegate? FlyTextCreated;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.flyTextGuiService.FlyTextCreated -= this.FlyTextCreatedForward;

View file

@ -26,8 +26,8 @@ namespace Dalamud.Game.Gui;
/// A class handling many aspects of the in-game UI.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui
[ServiceManager.EarlyLoadedService]
internal sealed unsafe class GameGui : IInternalDisposableService, IGameGui
{
private static readonly ModuleLog Log = new("GameGui");
@ -344,7 +344,7 @@ internal sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui
/// <summary>
/// Disables the hooks and submodules of this module.
/// </summary>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.setGlobalBgmHook.Dispose();
this.handleItemHoverHook.Dispose();
@ -520,7 +520,7 @@ internal sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui
#pragma warning disable SA1015
[ResolveVia<IGameGui>]
#pragma warning restore SA1015
internal class GameGuiPluginScoped : IDisposable, IServiceType, IGameGui
internal class GameGuiPluginScoped : IInternalDisposableService, IGameGui
{
[ServiceManager.ServiceDependency]
private readonly GameGui gameGuiService = Service<GameGui>.Get();
@ -558,7 +558,7 @@ internal class GameGuiPluginScoped : IDisposable, IServiceType, IGameGui
public HoveredAction HoveredAction => this.gameGuiService.HoveredAction;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.gameGuiService.UiHideToggled -= this.UiHideToggledForward;
this.gameGuiService.HoveredItemChanged -= this.HoveredItemForward;

View file

@ -14,8 +14,8 @@ namespace Dalamud.Game.Gui.PartyFinder;
/// This class handles interacting with the native PartyFinder window.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class PartyFinderGui : IDisposable, IServiceType, IPartyFinderGui
[ServiceManager.EarlyLoadedService]
internal sealed class PartyFinderGui : IInternalDisposableService, IPartyFinderGui
{
private readonly PartyFinderAddressResolver address;
private readonly IntPtr memory;
@ -47,7 +47,7 @@ internal sealed class PartyFinderGui : IDisposable, IServiceType, IPartyFinderGu
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.receiveListingHook.Dispose();
@ -131,7 +131,7 @@ internal sealed class PartyFinderGui : IDisposable, IServiceType, IPartyFinderGu
#pragma warning disable SA1015
[ResolveVia<IPartyFinderGui>]
#pragma warning restore SA1015
internal class PartyFinderGuiPluginScoped : IDisposable, IServiceType, IPartyFinderGui
internal class PartyFinderGuiPluginScoped : IInternalDisposableService, IPartyFinderGui
{
[ServiceManager.ServiceDependency]
private readonly PartyFinderGui partyFinderGuiService = Service<PartyFinderGui>.Get();
@ -148,7 +148,7 @@ internal class PartyFinderGuiPluginScoped : IDisposable, IServiceType, IPartyFin
public event IPartyFinderGui.PartyFinderListingEventDelegate? ReceiveListing;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.partyFinderGuiService.ReceiveListing -= this.ReceiveListingForward;

View file

@ -13,8 +13,8 @@ namespace Dalamud.Game.Gui.Toast;
/// This class facilitates interacting with and creating native toast windows.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed partial class ToastGui : IDisposable, IServiceType, IToastGui
[ServiceManager.EarlyLoadedService]
internal sealed partial class ToastGui : IInternalDisposableService, IToastGui
{
private const uint QuestToastCheckmarkMagic = 60081;
@ -73,7 +73,7 @@ internal sealed partial class ToastGui : IDisposable, IServiceType, IToastGui
/// <summary>
/// Disposes of managed and unmanaged resources.
/// </summary>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.showNormalToastHook.Dispose();
this.showQuestToastHook.Dispose();
@ -383,7 +383,7 @@ internal sealed partial class ToastGui
#pragma warning disable SA1015
[ResolveVia<IToastGui>]
#pragma warning restore SA1015
internal class ToastGuiPluginScoped : IDisposable, IServiceType, IToastGui
internal class ToastGuiPluginScoped : IInternalDisposableService, IToastGui
{
[ServiceManager.ServiceDependency]
private readonly ToastGui toastGuiService = Service<ToastGui>.Get();
@ -408,7 +408,7 @@ internal class ToastGuiPluginScoped : IDisposable, IServiceType, IToastGui
public event IToastGui.OnErrorToastDelegate? ErrorToast;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.toastGuiService.Toast -= this.ToastForward;
this.toastGuiService.QuestToast -= this.QuestToastForward;

View file

@ -12,7 +12,7 @@ namespace Dalamud.Game.Internal;
/// This class disables anti-debug functionality in the game client.
/// </summary>
[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;
@ -43,16 +43,25 @@ internal sealed partial class AntiDebug : IServiceType
}
}
/// <summary>Finalizes an instance of the <see cref="AntiDebug"/> class.</summary>
~AntiDebug() => this.Disable();
/// <summary>
/// Gets a value indicating whether the anti-debugging is enabled.
/// </summary>
public bool IsEnabled { get; private set; } = false;
/// <inheritdoc />
void IInternalDisposableService.DisposeService() => this.Disable();
/// <summary>
/// Enables the anti-debugging by overwriting code in memory.
/// </summary>
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
/// </summary>
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;
}
}
/// <summary>
/// Implementing IDisposable.
/// </summary>
internal sealed partial class AntiDebug : IDisposable
{
private bool disposed = false;
/// <summary>
/// Finalizes an instance of the <see cref="AntiDebug"/> class.
/// </summary>
~AntiDebug() => this.Dispose(false);
/// <summary>
/// Disposes of managed and unmanaged resources.
/// </summary>
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes of managed and unmanaged resources.
/// </summary>
/// <param name="disposing">If this was disposed through calling Dispose() or from being finalized.</param>
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;
}
}

View file

@ -20,7 +20,7 @@ namespace Dalamud.Game.Internal;
/// This class implements in-game Dalamud options in the in-game System menu.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal sealed unsafe partial class DalamudAtkTweaks : IServiceType
internal sealed unsafe class DalamudAtkTweaks : IInternalDisposableService
{
private readonly AtkValueChangeType atkValueChangeType;
private readonly AtkValueSetString atkValueSetString;
@ -40,6 +40,8 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType
private readonly string locDalamudPlugins;
private readonly string locDalamudSettings;
private bool disposed = false;
[ServiceManager.ServiceConstructor]
private DalamudAtkTweaks(TargetSigScanner sigScanner)
{
@ -69,6 +71,9 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType
this.hookAtkUnitBaseReceiveGlobalEvent.Enable();
}
/// <summary>Finalizes an instance of the <see cref="DalamudAtkTweaks"/> class.</summary>
~DalamudAtkTweaks() => this.Dispose(false);
private delegate void AgentHudOpenSystemMenuPrototype(void* thisPtr, AtkValue* atkValueArgs, uint menuSize);
private delegate void AtkValueChangeType(AtkValue* thisPtr, ValueType type);
@ -79,6 +84,26 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType
private delegate IntPtr AtkUnitBaseReceiveGlobalEvent(AtkUnitBase* thisPtr, ushort cmd, uint a3, IntPtr a4, uint* a5);
/// <inheritdoc/>
void IInternalDisposableService.DisposeService() => this.Dispose(true);
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;
}
/*
private void ContextMenuOnContextMenuOpened(ContextMenuOpenedArgs args)
{
@ -229,45 +254,3 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType
}
}
}
/// <summary>
/// Implements IDisposable.
/// </summary>
internal sealed partial class DalamudAtkTweaks : IDisposable
{
private bool disposed = false;
/// <summary>
/// Finalizes an instance of the <see cref="DalamudAtkTweaks"/> class.
/// </summary>
~DalamudAtkTweaks() => this.Dispose(false);
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
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;
}
}

View file

@ -18,8 +18,8 @@ namespace Dalamud.Game.Inventory;
/// This class provides events for the players in-game inventory.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal class GameInventory : IDisposable, IServiceType
[ServiceManager.EarlyLoadedService]
internal class GameInventory : IInternalDisposableService
{
private readonly List<GameInventoryPluginScoped> subscribersPendingChange = new();
private readonly List<GameInventoryPluginScoped> subscribers = new();
@ -61,7 +61,7 @@ internal class GameInventory : IDisposable, IServiceType
private unsafe delegate void RaptureAtkModuleUpdateDelegate(RaptureAtkModule* ram, float f1);
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
lock (this.subscribersPendingChange)
{
@ -351,7 +351,7 @@ internal class GameInventory : IDisposable, IServiceType
#pragma warning disable SA1015
[ResolveVia<IGameInventory>]
#pragma warning restore SA1015
internal class GameInventoryPluginScoped : IDisposable, IServiceType, IGameInventory
internal class GameInventoryPluginScoped : IInternalDisposableService, IGameInventory
{
private static readonly ModuleLog Log = new(nameof(GameInventoryPluginScoped));
@ -406,7 +406,7 @@ internal class GameInventoryPluginScoped : IDisposable, IServiceType, IGameInven
public event IGameInventory.InventoryChangedDelegate<InventoryItemMergedArgs>? ItemMergedExplicit;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.gameInventoryService.Unsubscribe(this);

View file

@ -1,7 +1,10 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace Dalamud.Game.Inventory;
@ -103,7 +106,7 @@ public unsafe struct GameInventoryItem : IEquatable<GameInventoryItem>
/// <summary>
/// Gets the array of materia grades.
/// </summary>
public ReadOnlySpan<ushort> MateriaGrade =>
public ReadOnlySpan<byte> MateriaGrade =>
new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5);
/// <summary>

View file

@ -1,72 +0,0 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Services;
namespace Dalamud.Game.Libc;
/// <summary>
/// This class handles creating cstrings utilizing native game methods.
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<ILibcFunction>]
#pragma warning restore SA1015
internal sealed class LibcFunction : IServiceType, ILibcFunction
{
private readonly LibcFunctionAddressResolver address;
private readonly StdStringFromCStringDelegate stdStringCtorCString;
private readonly StdStringDeallocateDelegate stdStringDeallocate;
[ServiceManager.ServiceConstructor]
private LibcFunction(TargetSigScanner sigScanner)
{
this.address = new LibcFunctionAddressResolver();
this.address.Setup(sigScanner);
this.stdStringCtorCString = Marshal.GetDelegateForFunctionPointer<StdStringFromCStringDelegate>(this.address.StdStringFromCstring);
this.stdStringDeallocate = Marshal.GetDelegateForFunctionPointer<StdStringDeallocateDelegate>(this.address.StdStringDeallocate);
}
// TODO: prolly callconv is not okay in x86
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate IntPtr StdStringFromCStringDelegate(IntPtr pStdString, [MarshalAs(UnmanagedType.LPArray)] byte[] content, IntPtr size);
// TODO: prolly callconv is not okay in x86
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate IntPtr StdStringDeallocateDelegate(IntPtr address);
/// <inheritdoc/>
public OwnedStdString NewString(byte[] content)
{
// While 0x70 bytes in the memory should be enough in DX11 version,
// I don't trust my analysis so we're just going to allocate almost two times more than that.
var pString = Marshal.AllocHGlobal(256);
// Initialize a string
var size = new IntPtr(content.Length);
var pReallocString = this.stdStringCtorCString(pString, content, size);
// Log.Verbose("Prev: {Prev} Now: {Now}", pString, pReallocString);
return new OwnedStdString(pReallocString, this.DeallocateStdString);
}
/// <inheritdoc/>
public OwnedStdString NewString(string content, Encoding? encoding = null)
{
encoding ??= Encoding.UTF8;
return this.NewString(encoding.GetBytes(content));
}
private void DeallocateStdString(IntPtr address)
{
this.stdStringDeallocate(address);
}
}

View file

@ -1,28 +0,0 @@
using System;
namespace Dalamud.Game.Libc;
/// <summary>
/// The address resolver for the <see cref="LibcFunction"/> class.
/// </summary>
internal sealed class LibcFunctionAddressResolver : BaseAddressResolver
{
private delegate IntPtr StringFromCString();
/// <summary>
/// Gets the address of the native StdStringFromCstring method.
/// </summary>
public IntPtr StdStringFromCstring { get; private set; }
/// <summary>
/// Gets the address of the native StdStringDeallocate method.
/// </summary>
public IntPtr StdStringDeallocate { get; private set; }
/// <inheritdoc/>
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");
}
}

View file

@ -1,100 +0,0 @@
using System;
using System.Runtime.InteropServices;
namespace Dalamud.Game.Libc;
/// <summary>
/// An address wrapper around the <see cref="StdString"/> class.
/// </summary>
public sealed partial class OwnedStdString
{
private readonly DeallocatorDelegate dealloc;
/// <summary>
/// Initializes a new instance of the <see cref="OwnedStdString"/> class.
/// Construct a wrapper around std::string.
/// </summary>
/// <remarks>
/// Violating any of these might cause an undefined hehaviour.
/// 1. This function takes the ownership of the address.
/// 2. A memory pointed by address argument is assumed to be allocated by Marshal.AllocHGlobal thus will try to call Marshal.FreeHGlobal on the address.
/// 3. std::string object pointed by address must be initialized before calling this function.
/// </remarks>
/// <param name="address">The address of the owned std string.</param>
/// <param name="dealloc">A deallocator function.</param>
internal OwnedStdString(IntPtr address, DeallocatorDelegate dealloc)
{
this.Address = address;
this.dealloc = dealloc;
}
/// <summary>
/// The delegate type that deallocates a std string.
/// </summary>
/// <param name="address">Address to deallocate.</param>
internal delegate void DeallocatorDelegate(IntPtr address);
/// <summary>
/// Gets the address of the std string.
/// </summary>
public IntPtr Address { get; private set; }
/// <summary>
/// Read the wrapped StdString.
/// </summary>
/// <returns>The StdString.</returns>
public StdString Read() => StdString.ReadFromPointer(this.Address);
}
/// <summary>
/// Implements IDisposable.
/// </summary>
public sealed partial class OwnedStdString : IDisposable
{
private bool isDisposed;
/// <summary>
/// Finalizes an instance of the <see cref="OwnedStdString"/> class.
/// </summary>
~OwnedStdString() => this.Dispose(false);
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
public void Dispose()
{
GC.SuppressFinalize(this);
this.Dispose(true);
}
/// <summary>
/// Dispose of managed and unmanaged resources.
/// </summary>
/// <param name="disposing">A value indicating whether this was called via Dispose or finalized.</param>
public void Dispose(bool disposing)
{
if (this.isDisposed)
return;
this.isDisposed = true;
if (disposing)
{
}
if (this.Address == IntPtr.Zero)
{
// Something got seriously fucked.
throw new AccessViolationException();
}
// Deallocate inner string first
this.dealloc(this.Address);
// Free the heap
Marshal.FreeHGlobal(this.Address);
// Better safe (running on a nullptr) than sorry. (running on a dangling pointer)
this.Address = IntPtr.Zero;
}
}

View file

@ -1,68 +0,0 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace Dalamud.Game.Libc;
/// <summary>
/// Interation with std::string.
/// </summary>
public class StdString
{
/// <summary>
/// Initializes a new instance of the <see cref="StdString"/> class.
/// </summary>
private StdString()
{
}
/// <summary>
/// Gets the value of the cstring.
/// </summary>
public string Value { get; private set; }
/// <summary>
/// Gets or sets the raw byte representation of the cstring.
/// </summary>
public byte[] RawData { get; set; }
/// <summary>
/// Marshal a null terminated cstring from memory to a UTF-8 encoded string.
/// </summary>
/// <param name="cstring">Address of the cstring.</param>
/// <returns>A UTF-8 encoded string.</returns>
public static StdString ReadFromPointer(IntPtr cstring)
{
unsafe
{
if (cstring == IntPtr.Zero)
{
throw new ArgumentNullException(nameof(cstring));
}
var innerAddress = Marshal.ReadIntPtr(cstring);
if (innerAddress == IntPtr.Zero)
{
throw new NullReferenceException("Inner reference to the cstring is null.");
}
// Count the number of chars. String is assumed to be zero-terminated.
var count = 0;
while (Marshal.ReadByte(innerAddress, count) != 0)
{
count += 1;
}
// raw copy, as UTF8 string conversion is lossy
var rawData = new byte[count];
Marshal.Copy(innerAddress, rawData, 0, count);
return new StdString
{
RawData = rawData,
Value = Encoding.UTF8.GetString(rawData),
};
}
}
}

View file

@ -14,8 +14,8 @@ namespace Dalamud.Game.Network;
/// This class handles interacting with game network events.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class GameNetwork : IDisposable, IServiceType, IGameNetwork
[ServiceManager.EarlyLoadedService]
internal sealed class GameNetwork : IInternalDisposableService, IGameNetwork
{
private readonly GameNetworkAddressResolver address;
private readonly Hook<ProcessZonePacketDownDelegate> processZonePacketDownHook;
@ -59,7 +59,7 @@ internal sealed class GameNetwork : IDisposable, IServiceType, IGameNetwork
public event IGameNetwork.OnNetworkMessageDelegate? NetworkMessage;
/// <inheritdoc/>
void IDisposable.Dispose()
void IInternalDisposableService.DisposeService()
{
this.processZonePacketDownHook.Dispose();
this.processZonePacketUpHook.Dispose();
@ -145,7 +145,7 @@ internal sealed class GameNetwork : IDisposable, IServiceType, IGameNetwork
#pragma warning disable SA1015
[ResolveVia<IGameNetwork>]
#pragma warning restore SA1015
internal class GameNetworkPluginScoped : IDisposable, IServiceType, IGameNetwork
internal class GameNetworkPluginScoped : IInternalDisposableService, IGameNetwork
{
[ServiceManager.ServiceDependency]
private readonly GameNetwork gameNetworkService = Service<GameNetwork>.Get();
@ -162,7 +162,7 @@ internal class GameNetworkPluginScoped : IDisposable, IServiceType, IGameNetwork
public event IGameNetwork.OnNetworkMessageDelegate? NetworkMessage;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.gameNetworkService.NetworkMessage -= this.NetworkMessageForward;

View file

@ -12,6 +12,7 @@ using Dalamud.Game.Gui;
using Dalamud.Game.Network.Internal.MarketBoardUploaders;
using Dalamud.Game.Network.Internal.MarketBoardUploaders.Universalis;
using Dalamud.Game.Network.Structures;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Networking.Http;
using Dalamud.Utility;
@ -24,8 +25,8 @@ namespace Dalamud.Game.Network.Internal;
/// <summary>
/// This class handles network notifications and uploading market board data.
/// </summary>
[ServiceManager.BlockingEarlyLoadedService]
internal unsafe class NetworkHandlers : IDisposable, IServiceType
[ServiceManager.EarlyLoadedService]
internal unsafe class NetworkHandlers : IInternalDisposableService
{
private readonly IMarketBoardUploader uploader;
@ -212,7 +213,7 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType
/// <summary>
/// Disposes of managed and unmanaged resources.
/// </summary>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.disposing = true;
this.Dispose(this.disposing);
@ -268,8 +269,8 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType
return result;
}
var cfcName = cfCondition.Name.ToString();
if (cfcName.IsNullOrEmpty())
var cfcName = cfCondition.Name.ToDalamudString();
if (cfcName.Payloads.Count == 0)
{
cfcName = "Duty Roulette";
cfCondition.Image = 112324;
@ -279,7 +280,10 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType
{
if (this.configuration.DutyFinderChatMessage)
{
Service<ChatGui>.GetNullable()?.Print($"Duty pop: {cfcName}");
var b = new SeStringBuilder();
b.Append("Duty pop: ");
b.Append(cfcName);
Service<ChatGui>.GetNullable()?.Print(b.Build());
}
this.CfPop.InvokeSafely(cfCondition);

View file

@ -10,7 +10,7 @@ namespace Dalamud.Game.Network.Internal;
/// This class enables TCP optimizations in the game socket for better performance.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal sealed class WinSockHandlers : IDisposable, IServiceType
internal sealed class WinSockHandlers : IInternalDisposableService
{
private Hook<SocketDelegate> ws2SocketHook;
@ -27,7 +27,7 @@ internal sealed class WinSockHandlers : IDisposable, IServiceType
/// <summary>
/// Disposes of managed and unmanaged resources.
/// </summary>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
this.ws2SocketHook?.Dispose();
}

View file

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

View file

@ -104,6 +104,10 @@ public class SigScanner : IDisposable, ISigScanner
/// <inheritdoc/>
public ProcessModule Module { get; }
/// <summary>Gets or sets a value indicating whether this instance of <see cref="SigScanner"/> is meant to be a
/// Dalamud service.</summary>
private protected bool IsService { get; set; }
private IntPtr TextSectionTop => this.TextSectionBase + this.TextSectionSize;
/// <summary>
@ -309,13 +313,11 @@ public class SigScanner : IDisposable, ISigScanner
}
}
/// <summary>
/// Free the memory of the copied module search area on object disposal, if applicable.
/// </summary>
/// <inheritdoc/>
public void Dispose()
{
this.Save();
Marshal.FreeHGlobal(this.moduleCopyPtr);
if (!this.IsService)
this.DisposeCore();
}
/// <summary>
@ -337,6 +339,15 @@ public class SigScanner : IDisposable, ISigScanner
}
}
/// <summary>
/// Free the memory of the copied module search area on object disposal, if applicable.
/// </summary>
private protected void DisposeCore()
{
this.Save();
Marshal.FreeHGlobal(this.moduleCopyPtr);
}
/// <summary>
/// Helper for ScanText to get the correct address for IDA sigs that mark the first JMP or CALL location.
/// </summary>

View file

@ -15,7 +15,7 @@ namespace Dalamud.Game;
#pragma warning disable SA1015
[ResolveVia<ISigScanner>]
#pragma warning restore SA1015
internal class TargetSigScanner : SigScanner, IServiceType
internal class TargetSigScanner : SigScanner, IPublicDisposableService
{
/// <summary>
/// Initializes a new instance of the <see cref="TargetSigScanner"/> class.
@ -26,4 +26,14 @@ internal class TargetSigScanner : SigScanner, IServiceType
: base(Process.GetCurrentProcess().MainModule!, doCopy, cacheFile)
{
}
/// <inheritdoc/>
void IInternalDisposableService.DisposeService()
{
if (this.IsService)
this.DisposeCore();
}
/// <inheritdoc/>
void IPublicDisposableService.MarkDisposeOnlyFromService() => this.IsService = true;
}

View file

@ -381,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);

View file

@ -21,7 +21,7 @@ namespace Dalamud.Hooking.Internal;
#pragma warning disable SA1015
[ResolveVia<IGameInteropProvider>]
#pragma warning restore SA1015
internal class GameInteropProviderPluginScoped : IGameInteropProvider, IServiceType, IDisposable
internal class GameInteropProviderPluginScoped : IGameInteropProvider, IInternalDisposableService
{
private readonly LocalPlugin plugin;
private readonly SigScanner scanner;
@ -83,7 +83,7 @@ internal class GameInteropProviderPluginScoped : IGameInteropProvider, IServiceT
=> this.HookFromAddress(this.scanner.ScanText(signature), detour, backend);
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
var notDisposed = this.trackedHooks.Where(x => !x.IsDisposed).ToArray();
if (notDisposed.Length != 0)

View file

@ -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.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal class HookManager : IDisposable, IServiceType
internal class HookManager : IInternalDisposableService
{
/// <summary>
/// Logger shared with <see cref="Unhooker"/>.
@ -74,7 +74,7 @@ internal class HookManager : IDisposable, IServiceType
}
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
RevertHooks();
TrackedHooks.Clear();

View file

@ -14,8 +14,8 @@ namespace Dalamud.Hooking.WndProcHook;
/// <summary>
/// Manages WndProc hooks for game main window and extra ImGui viewport windows.
/// </summary>
[ServiceManager.BlockingEarlyLoadedService]
internal sealed class WndProcHookManager : IServiceType, IDisposable
[ServiceManager.EarlyLoadedService]
internal sealed class WndProcHookManager : IInternalDisposableService
{
private static readonly ModuleLog Log = new(nameof(WndProcHookManager));
@ -56,7 +56,7 @@ internal sealed class WndProcHookManager : IServiceType, IDisposable
public event WndProcEventDelegate? PostWndProc;
/// <inheritdoc/>
public void Dispose()
void IInternalDisposableService.DisposeService()
{
if (this.dispatchMessageWHook.IsDisposed)
return;

View file

@ -6,3 +6,20 @@
public interface IServiceType
{
}
/// <summary><see cref="IDisposable"/>, but for <see cref="IServiceType"/>.</summary>
/// <remarks>Use this to prevent services from accidentally being disposed by plugins or <c>using</c> clauses.</remarks>
internal interface IInternalDisposableService : IServiceType
{
/// <summary>Disposes the service.</summary>
void DisposeService();
}
/// <summary>An <see cref="IInternalDisposableService"/> which happens to be public and needs to expose
/// <see cref="IDisposable.Dispose"/>.</summary>
internal interface IPublicDisposableService : IInternalDisposableService, IDisposable
{
/// <summary>Marks that only <see cref="IInternalDisposableService.DisposeService"/> should respond,
/// while suppressing <see cref="IDisposable.Dispose"/>.</summary>
void MarkDisposeOnlyFromService();
}

View file

@ -11,11 +11,18 @@ public static partial class ImGuiComponents
/// HelpMarker component to add a help icon with text on hover.
/// </summary>
/// <param name="helpText">The text to display on hover.</param>
public static void HelpMarker(string helpText)
public static void HelpMarker(string helpText) => HelpMarker(helpText, FontAwesomeIcon.InfoCircle);
/// <summary>
/// HelpMarker component to add a custom icon with text on hover.
/// </summary>
/// <param name="helpText">The text to display on hover.</param>
/// <param name="icon">The icon to use.</param>
public static void HelpMarker(string helpText, FontAwesomeIcon icon)
{
ImGui.SameLine();
ImGui.PushFont(UiBuilder.IconFont);
ImGui.TextDisabled(FontAwesomeIcon.InfoCircle.ToIconString());
ImGui.TextDisabled(icon.ToIconString());
ImGui.PopFont();
if (!ImGui.IsItemHovered()) return;
ImGui.BeginTooltip();

View file

@ -15,11 +15,11 @@ namespace Dalamud.Interface.DragDrop;
/// and can be used to create ImGui drag and drop sources and targets for those external events.
/// </summary>
[PluginInterface]
[ServiceManager.BlockingEarlyLoadedService]
[ServiceManager.EarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IDragDropManager>]
#pragma warning restore SA1015
internal partial class DragDropManager : IDisposable, IDragDropManager, IServiceType
internal partial class DragDropManager : IInternalDisposableService, IDragDropManager
{
private nint windowHandlePtr = nint.Zero;
@ -56,6 +56,9 @@ internal partial class DragDropManager : IDisposable, IDragDropManager, IService
/// <summary> Gets the list of directory paths currently being dragged from an external application over any FFXIV-related viewport or stored from the last drop. </summary>
public IReadOnlyList<string> Directories { get; private set; } = Array.Empty<string>();
/// <inheritdoc/>
void IInternalDisposableService.DisposeService() => this.Disable();
/// <summary> Enable external drag and drop. </summary>
public void Enable()
{
@ -99,10 +102,6 @@ internal partial class DragDropManager : IDisposable, IDragDropManager, IService
this.ServiceAvailable = false;
}
/// <inheritdoc cref="Disable"/>
public void Dispose()
=> this.Disable();
/// <inheritdoc cref="IDragDropManager.CreateImGuiSource(string, Func{IDragDropManager, bool}, Func{IDragDropManager, bool})"/>
public void CreateImGuiSource(string label, Func<IDragDropManager, bool> validityCheck, Func<IDragDropManager, bool> tooltipBuilder)
{

View file

@ -1,143 +0,0 @@
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;
/// <summary>
/// ABI-compatible wrapper for <see cref="IFontHandle"/>.
/// </summary>
[Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)]
public sealed class GameFontHandle : IFontHandle
{
private readonly GamePrebakedFontHandle fontHandle;
private readonly FontAtlasFactory fontAtlasFactory;
/// <summary>
/// Initializes a new instance of the <see cref="GameFontHandle"/> class.<br />
/// Ownership of <paramref name="fontHandle"/> is transferred.
/// </summary>
/// <param name="fontHandle">The wrapped <see cref="GamePrebakedFontHandle"/>.</param>
/// <param name="fontAtlasFactory">An instance of <see cref="FontAtlasFactory"/>.</param>
internal GameFontHandle(GamePrebakedFontHandle fontHandle, FontAtlasFactory fontAtlasFactory)
{
this.fontHandle = fontHandle;
this.fontAtlasFactory = fontAtlasFactory;
}
/// <inheritdoc />
public event IFontHandle.ImFontChangedDelegate ImFontChanged
{
add => this.fontHandle.ImFontChanged += value;
remove => this.fontHandle.ImFontChanged -= value;
}
/// <inheritdoc />
public Exception? LoadException => this.fontHandle.LoadException;
/// <inheritdoc />
public bool Available => this.fontHandle.Available;
/// <summary>
/// Gets the font.<br />
/// Use of this properly is safe only from the UI thread.<br />
/// Use <see cref="IFontHandle.Push"/> if the intended purpose of this property is <see cref="ImGui.PushFont"/>.<br />
/// Futures changes may make simple <see cref="ImGui.PushFont"/> not enough.<br />
/// If you need to access a font outside the UI thread, use <see cref="IFontHandle.Lock"/>.
/// </summary>
[Obsolete($"Use {nameof(Push)}-{nameof(ImGui.GetFont)} or {nameof(Lock)} instead.", false)]
public ImFontPtr ImFont => this.fontHandle.LockUntilPostFrame();
/// <summary>
/// Gets the font style. Only applicable for <see cref="GameFontHandle"/>.
/// </summary>
[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;
/// <summary>
/// Gets the relevant <see cref="FdtReader"/>.<br />
/// <br />
/// Only applicable for game fonts. Otherwise it will throw.
/// </summary>
[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)!;
/// <inheritdoc />
public void Dispose() => this.fontHandle.Dispose();
/// <inheritdoc />
public ILockedImFont Lock() => this.fontHandle.Lock();
/// <inheritdoc />
public IDisposable Push() => this.fontHandle.Push();
/// <inheritdoc />
public void Pop() => this.fontHandle.Pop();
/// <inheritdoc />
public Task<IFontHandle> WaitAsync() => this.fontHandle.WaitAsync();
/// <summary>
/// Creates a new <see cref="GameFontLayoutPlan.Builder"/>.<br />
/// <br />
/// Only applicable for game fonts. Otherwise it will throw.
/// </summary>
/// <param name="text">Text.</param>
/// <returns>A new builder for GameFontLayoutPlan.</returns>
[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);
/// <summary>
/// Draws text.
/// </summary>
/// <param name="text">Text to draw.</param>
[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)
{
ImGui.TextUnformatted(text);
}
else
{
var pos = ImGui.GetWindowPos() + ImGui.GetCursorPos();
pos.X -= ImGui.GetScrollX();
pos.Y -= ImGui.GetScrollY();
var layout = this.LayoutBuilder(text).Build();
layout.Draw(ImGui.GetWindowDrawList(), pos, ImGui.GetColorU32(ImGuiCol.Text));
ImGui.Dummy(new Vector2(layout.Width, layout.Height));
}
}
/// <summary>
/// Draws text in given color.
/// </summary>
/// <param name="col">Color.</param>
/// <param name="text">Text to draw.</param>
[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);
this.Text(text);
ImGui.PopStyleColor();
}
/// <summary>
/// Draws disabled text.
/// </summary>
/// <param name="text">Text to draw.</param>
[Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)]
public void TextDisabled(string text)
{
unsafe
{
this.TextColored(*ImGui.GetStyleColorVec4(ImGuiCol.TextDisabled), text);
}
}
}

View file

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

Some files were not shown because too many files have changed in this diff Show more