some hooking struggles. still has random stackoverflow crashes.
This commit is contained in:
parent
1cd8378078
commit
f29f966846
8 changed files with 180 additions and 1014 deletions
|
|
@ -69,114 +69,43 @@ static IDXGISwapChain* g_pSwapChain = nullptr;
|
|||
static ID3D11RenderTargetView* g_mainRenderTargetView = nullptr;
|
||||
static WNDPROC g_oOriginalWndProc = nullptr;
|
||||
|
||||
// =======================
|
||||
// MINHOOK
|
||||
// =======================
|
||||
static bool g_MinHookInitialized = false;
|
||||
static bool g_Hooked = false;
|
||||
|
||||
/// GVARS END
|
||||
|
||||
#define STATIC_FIELDS_OFFSET 0xB8
|
||||
|
||||
// il2cpp patch
|
||||
// Helper: pattern scan with mask ('x' == match, '?' == wildcard)
|
||||
static uint8_t* find_pattern(uint8_t* base, SIZE_T size, const unsigned char* pattern, const char* mask, SIZE_T pattern_len)
|
||||
/// RNG START
|
||||
auto getRandomSeed()
|
||||
-> std::seed_seq
|
||||
{
|
||||
if (!base || !pattern || !mask || pattern_len == 0) return nullptr;
|
||||
SIZE_T max_scan = (size > pattern_len) ? (size - pattern_len) : 0;
|
||||
for (SIZE_T i = 0; i <= max_scan; ++i)
|
||||
{
|
||||
bool matched = true;
|
||||
for (SIZE_T j = 0; j < pattern_len; ++j)
|
||||
{
|
||||
if (mask[j] == '?') continue;
|
||||
if (base[i + j] != pattern[j])
|
||||
{
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched)
|
||||
return base + i;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
// This gets a source of actual, honest-to-god randomness
|
||||
std::random_device source;
|
||||
|
||||
// Find the real il2cpp_class_from_name implementation by scanning GameAssembly for an IDA-provided signature.
|
||||
// Returns the function pointer if found, otherwise nullptr.
|
||||
static il2cpp_class_from_name_prot find_il2cpp_class_from_name(HMODULE handle)
|
||||
{
|
||||
if (!handle) return nullptr;
|
||||
|
||||
// Get module size via PE headers
|
||||
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)handle;
|
||||
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return nullptr;
|
||||
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((uint8_t*)handle + dos->e_lfanew);
|
||||
if (nt->Signature != IMAGE_NT_SIGNATURE) return nullptr;
|
||||
SIZE_T module_size = nt->OptionalHeader.SizeOfImage;
|
||||
uint8_t* base = (uint8_t*)handle;
|
||||
|
||||
// IDA signature:
|
||||
// 4C 89 44 24 ? 48 89 54 24 ? 53 55 56 57 41 54 41 55 41 56 41 57 48 81 EC ?? ?? ?? ??
|
||||
// Build pattern and mask (use 0x00 as placeholder for wildcard bytes)
|
||||
const unsigned char pattern[] = {
|
||||
0x4C, 0x89, 0x44, 0x24, 0x00, // 4C 89 44 24 ?
|
||||
0x48, 0x89, 0x54, 0x24, 0x00, // 48 89 54 24 ?
|
||||
0x53, 0x55, 0x56, 0x57,
|
||||
0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57,
|
||||
0x48, 0x81, 0xEC, 0x00, 0x00, 0x00, 0x00 // 48 81 EC ?? ?? ?? ??
|
||||
};
|
||||
// Mask: 'x' for fixed bytes, '?' for wildcard
|
||||
const char mask[] = "xxxx?xxxx?xxxxxxxxxxxx????";
|
||||
const SIZE_T pattern_len = sizeof(pattern);
|
||||
|
||||
uint8_t* found = find_pattern(base, module_size, pattern, mask, pattern_len);
|
||||
if (found)
|
||||
{
|
||||
// The found address should point to the real function entry. Return as function pointer.
|
||||
return (il2cpp_class_from_name_prot)found;
|
||||
// Here, we fill an array of random data from the source
|
||||
unsigned int random_data[10];
|
||||
for (auto& elem : random_data) {
|
||||
elem = source();
|
||||
}
|
||||
|
||||
// If not found with this signature, attempt a looser fallback:
|
||||
// Search for the common prologue bytes "53 55 56 57 41 54 41 55 41 56 41 57" (function push regs)
|
||||
const unsigned char fallback_pattern[] = {
|
||||
0x53, 0x55, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57
|
||||
};
|
||||
const char fallback_mask[] = "xxxxxxxxxxxx";
|
||||
uint8_t* fallback_found = find_pattern(base, module_size, fallback_pattern, fallback_mask, sizeof(fallback_pattern));
|
||||
if (fallback_found)
|
||||
{
|
||||
// Walk backwards up to a small range to try to find the full expected prologue start (heuristic).
|
||||
// Limit to 32 bytes back to avoid scanning too far.
|
||||
for (int back = 0; back < 32; ++back)
|
||||
{
|
||||
uint8_t* candidate = fallback_found - back;
|
||||
// Check the bytes before candidate for the 0x4C 0x89 sequence which often starts the prologue we expect.
|
||||
if (candidate >= base && candidate[0] == 0x4C && candidate[1] == 0x89)
|
||||
{
|
||||
return (il2cpp_class_from_name_prot)candidate;
|
||||
}
|
||||
}
|
||||
// Otherwise return the fallback_found as best-effort.
|
||||
return (il2cpp_class_from_name_prot)fallback_found;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
// this creates the random seed sequence out of the random data
|
||||
return std::seed_seq(random_data + 0, random_data + 10);
|
||||
}
|
||||
|
||||
// Find the real il2cpp_class_get_methods.
|
||||
// Returns the function pointer if found, otherwise nullptr.
|
||||
static il2cpp_class_get_methods_prot find_il2cpp_class_get_methods(HMODULE handle)
|
||||
{
|
||||
double randomnumber() {
|
||||
// Making rng static ensures that it stays the same
|
||||
// Between different invocations of the function
|
||||
static auto seed = getRandomSeed();
|
||||
static std::default_random_engine rng(seed);
|
||||
|
||||
if (!handle) return nullptr;
|
||||
return (il2cpp_class_get_methods_prot)GetProcAddress(handle, "mono_class_get_methods");
|
||||
std::uniform_real_distribution<double> dist(0.0, 1.0);
|
||||
return dist(rng);
|
||||
}
|
||||
|
||||
static il2cpp_method_get_name_prot find_il2cpp_method_get_name(HMODULE handle)
|
||||
{
|
||||
|
||||
if (!handle) return nullptr;
|
||||
return (il2cpp_method_get_name_prot)GetProcAddress(handle, "mono_property_get_set_method");
|
||||
}
|
||||
|
||||
// il2cpp patch end
|
||||
|
||||
/// RNG END
|
||||
|
||||
/// IMGUI START
|
||||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
|
|
@ -208,45 +137,47 @@ static PresentFn oPresent = nullptr;
|
|||
|
||||
HRESULT __stdcall hkPresent(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags)
|
||||
{
|
||||
if (!g_pd3dDevice)
|
||||
static bool initialized = false;
|
||||
|
||||
if (!initialized)
|
||||
{
|
||||
if (!pSwapChain)
|
||||
return S_OK;
|
||||
|
||||
g_pSwapChain = pSwapChain;
|
||||
|
||||
// D3D11 Device
|
||||
if (FAILED(pSwapChain->GetDevice(__uuidof(ID3D11Device), (void**)&g_pd3dDevice)))
|
||||
return oPresent(pSwapChain, SyncInterval, Flags);
|
||||
return S_OK;
|
||||
|
||||
g_pd3dDevice->GetImmediateContext(&g_pd3dDeviceContext);
|
||||
|
||||
// hwnd
|
||||
DXGI_SWAP_CHAIN_DESC sd{};
|
||||
pSwapChain->GetDesc(&sd);
|
||||
HWND hWnd = sd.OutputWindow;
|
||||
|
||||
// WndProc for Insert key listener
|
||||
g_oOriginalWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)WndProc);
|
||||
|
||||
// ImGui
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImGuiIO& io = ImGui::GetIO(); (void)io;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
|
||||
ImGui::StyleColorsDark();
|
||||
|
||||
ImGui_ImplWin32_Init(hWnd);
|
||||
ImGui_ImplDX11_Init(g_pd3dDevice, g_pd3dDeviceContext);
|
||||
if (!ImGui_ImplWin32_Init(hWnd) || !ImGui_ImplDX11_Init(g_pd3dDevice, g_pd3dDeviceContext))
|
||||
return S_OK;
|
||||
|
||||
// create RenderTargetView
|
||||
ID3D11Texture2D* pBackBuffer = nullptr;
|
||||
if (SUCCEEDED(pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pBackBuffer)))
|
||||
if (FAILED(pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&pBackBuffer)))
|
||||
return S_OK;
|
||||
|
||||
if (FAILED(g_pd3dDevice->CreateRenderTargetView(pBackBuffer, nullptr, &g_mainRenderTargetView)))
|
||||
{
|
||||
g_pd3dDevice->CreateRenderTargetView(pBackBuffer, nullptr, &g_mainRenderTargetView);
|
||||
pBackBuffer->Release();
|
||||
return S_OK;
|
||||
}
|
||||
else
|
||||
{
|
||||
return oPresent(pSwapChain, SyncInterval, Flags);
|
||||
}
|
||||
pBackBuffer->Release();
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// render
|
||||
|
|
@ -258,14 +189,12 @@ HRESULT __stdcall hkPresent(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT
|
|||
{
|
||||
ImGui::SetNextWindowSize(ImVec2(500, 400), ImGuiCond_FirstUseEver);
|
||||
ImGui::Begin("Operator PvE for v1.0 - build 0.0.1", &g_ShowMenu);
|
||||
|
||||
ImGui::TextColored(ImVec4(0, 1, 0, 1), "第一阶段彻底成功!");
|
||||
ImGui::Separator();
|
||||
ImGui::Text("FPS: %.1f", ImGui::GetIO().Framerate);
|
||||
ImGui::Text("按 INSERT 键切换菜单");
|
||||
ImGui::Text("所有初始化已完成,无 goto 漏洞");
|
||||
ImGui::Text("所有初始化已完成,无递归漏洞");
|
||||
ImGui::TextColored(ImVec4(1, 1, 0, 1), "你可以开始第二阶段了!");
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
|
|
@ -276,59 +205,77 @@ HRESULT __stdcall hkPresent(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT
|
|||
return oPresent(pSwapChain, SyncInterval, Flags);
|
||||
}
|
||||
|
||||
// VTable Hook
|
||||
void log_info(const char* fmt, ...);
|
||||
void InitMinHook()
|
||||
{
|
||||
if (g_MinHookInitialized) return;
|
||||
|
||||
if (MH_Initialize() == MH_OK)
|
||||
{
|
||||
g_MinHookInitialized = true;
|
||||
log_info("[+] MinHook initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
log_info("[-] MinHook not initialized");
|
||||
}
|
||||
|
||||
void HookPresent()
|
||||
{
|
||||
while (true)
|
||||
if (g_Hooked) return;
|
||||
|
||||
HWND gameWindow = FindWindowA("UnityWndClass", nullptr);
|
||||
if (!gameWindow)
|
||||
{
|
||||
// 1. 等待游戏窗口创建(EFT 的窗口类名是 "UnityWndClass")
|
||||
HWND gameWindow = FindWindowA("UnityWndClass", nullptr);
|
||||
if (!gameWindow)
|
||||
{
|
||||
Sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 获取游戏真正的 SwapChain
|
||||
IDXGISwapChain* pSwapChain = nullptr;
|
||||
ID3D11Device* pDevice = nullptr;
|
||||
|
||||
// 用游戏窗口创建一个“伪”描述,但实际会返回游戏正在用的 SwapChain
|
||||
DXGI_SWAP_CHAIN_DESC sd{};
|
||||
sd.BufferCount = 1;
|
||||
sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
|
||||
sd.OutputWindow = gameWindow;
|
||||
sd.SampleDesc.Count = 1;
|
||||
sd.Windowed = TRUE;
|
||||
sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
|
||||
|
||||
HRESULT hr = D3D11CreateDeviceAndSwapChain(
|
||||
nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0,
|
||||
D3D11_SDK_VERSION, &sd, &pSwapChain, &pDevice, nullptr, nullptr);
|
||||
if (SUCCEEDED(hr) && pSwapChain && pDevice)
|
||||
{
|
||||
void** vtable = *(void***)pSwapChain;
|
||||
oPresent = (PresentFn)vtable[8];
|
||||
|
||||
// 关键:用原子操作写内存,防止崩溃
|
||||
DWORD oldProtect;
|
||||
VirtualProtect(&vtable[8], 8, PAGE_EXECUTE_READWRITE, &oldProtect);
|
||||
vtable[8] = (void*)hkPresent;
|
||||
VirtualProtect(&vtable[8], 8, oldProtect, &oldProtect);
|
||||
|
||||
printf("[+] We're hooked @ %p\n", &hkPresent);
|
||||
|
||||
pSwapChain->Release();
|
||||
pDevice->Release();
|
||||
break;
|
||||
}
|
||||
|
||||
if (pSwapChain) pSwapChain->Release();
|
||||
if (pDevice) pDevice->Release();
|
||||
std::cout << "Hit 4." << std::endl;
|
||||
Sleep(1000);
|
||||
return;
|
||||
}
|
||||
|
||||
InitMinHook();
|
||||
|
||||
IDXGISwapChain* pSwapChain = nullptr;
|
||||
ID3D11Device* pDevice = nullptr;
|
||||
|
||||
DXGI_SWAP_CHAIN_DESC sd{};
|
||||
sd.BufferCount = 1;
|
||||
sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
|
||||
sd.OutputWindow = gameWindow;
|
||||
sd.SampleDesc.Count = 1;
|
||||
sd.Windowed = TRUE;
|
||||
sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
|
||||
|
||||
HRESULT hr = D3D11CreateDeviceAndSwapChain(
|
||||
nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0,
|
||||
D3D11_SDK_VERSION, &sd, &pSwapChain, &pDevice, nullptr, nullptr);
|
||||
|
||||
if (FAILED(hr) || !pSwapChain || !pDevice)
|
||||
{
|
||||
if (pSwapChain) pSwapChain->Release();
|
||||
if (pDevice) pDevice->Release();
|
||||
return;
|
||||
}
|
||||
|
||||
void** vtable = *(void***)pSwapChain;
|
||||
void* presentAddr = vtable[8];
|
||||
|
||||
if (MH_CreateHook(presentAddr, hkPresent, (LPVOID*)&oPresent) != MH_OK)
|
||||
{
|
||||
log_info("[-] MinHook creation failed");
|
||||
goto post;
|
||||
}
|
||||
|
||||
if (MH_EnableHook(presentAddr) != MH_OK)
|
||||
{
|
||||
log_info("[-] MinHook enable failed");
|
||||
goto post;
|
||||
}
|
||||
|
||||
log_info("[+] MinHook Present hooked @ %p -> %p", presentAddr, hkPresent);
|
||||
g_Hooked = true;
|
||||
|
||||
post:
|
||||
pSwapChain->Release();
|
||||
pDevice->Release();
|
||||
}
|
||||
|
||||
/// IMGUI END
|
||||
|
|
@ -515,14 +462,11 @@ const Il2CppMethod* find_battleye_init(Il2CppClass* main_application)
|
|||
if ((unsigned(name[0]) & 0xFF) == 0xEE &&
|
||||
(unsigned(name[1]) & 0xFF) == 0x80 &&
|
||||
(unsigned(name[2]) & 0xFF) == 0x81)
|
||||
{
|
||||
return method;
|
||||
}
|
||||
|
||||
// In other versions, it might be ValidateAnticheat
|
||||
if (strcmp(name, "ValidateAnticheat") == 0)
|
||||
{
|
||||
return method;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
|
@ -643,15 +587,7 @@ void start()
|
|||
FILE* conout = nullptr;
|
||||
if (freopen_s(&conout, "CONOUT$", "w", stdout) != 0)
|
||||
{
|
||||
printf("freopen_s failed\n");
|
||||
}
|
||||
if (freopen_s(&conout, "CONOUT$", "w", stderr) != 0)
|
||||
{
|
||||
printf("freopen_s failed\n");
|
||||
}
|
||||
if (freopen_s(&conout, "CONIN$", "r", stdin) != 0)
|
||||
{
|
||||
printf("freopen_s failed\n");
|
||||
log_info("freopen_s failed");
|
||||
}
|
||||
|
||||
// 1. Wait for GameAssembly.dll to load and get IL2CPP API addresses
|
||||
|
|
@ -670,29 +606,29 @@ void start()
|
|||
il2cpp_class_from_name = find_il2cpp_class_from_name(il2cpp);
|
||||
if (! il2cpp_class_from_name)
|
||||
{
|
||||
printf("[-] il2cpp_class_from_name was not found.");
|
||||
log_info("[-] il2cpp_class_from_name was not found.");
|
||||
Sleep(10000);
|
||||
exit(-1);
|
||||
}
|
||||
printf("[+] il2cpp_class_from_name loaded.");
|
||||
log_info("[+] il2cpp_class_from_name loaded.");
|
||||
|
||||
il2cpp_class_get_methods = find_il2cpp_class_get_methods(il2cpp);
|
||||
if (!il2cpp_class_get_methods)
|
||||
{
|
||||
printf("[-] il2cpp_class_get_methods was not found.");
|
||||
log_info("[-] il2cpp_class_get_methods was not found.");
|
||||
Sleep(10000);
|
||||
exit(-1);
|
||||
}
|
||||
printf("[+] il2cpp_class_get_methods loaded.");
|
||||
log_info("[+] il2cpp_class_get_methods loaded.");
|
||||
|
||||
il2cpp_method_get_name = find_il2cpp_method_get_name(il2cpp);
|
||||
if (!il2cpp_method_get_name)
|
||||
{
|
||||
printf("[-] il2cpp_method_get_name was not found.");
|
||||
log_info("[-] il2cpp_method_get_name was not found.");
|
||||
Sleep(10000);
|
||||
exit(-1);
|
||||
}
|
||||
printf("[+] il2cpp_method_get_name loaded.");
|
||||
log_info("[+] il2cpp_method_get_name loaded.");
|
||||
|
||||
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_prot)GetProcAddress(il2cpp, "il2cpp_assembly_get_image");
|
||||
il2cpp_image_get_name = (il2cpp_image_get_name_prot)GetProcAddress(il2cpp, "il2cpp_image_get_name");
|
||||
|
|
@ -706,7 +642,7 @@ void start()
|
|||
printf(".");
|
||||
Sleep(100);
|
||||
}
|
||||
printf("\n");
|
||||
log_info("");
|
||||
|
||||
Il2CppDomain* domain = il2cpp_get_root_domain();
|
||||
if (!domain) {
|
||||
|
|
@ -891,13 +827,29 @@ void start()
|
|||
// 8. Idle Loop and CameraManager Tracking (Core Merge)
|
||||
// =================================================================
|
||||
|
||||
log_info("\n[+] All patches applied. Entering idle tracking loop. Hooking menu.");
|
||||
|
||||
CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)HookPresent, nullptr, 0, nullptr);
|
||||
log_info("\n[+] All patches applied. Entering idle tracking loop.");
|
||||
log_info("[!] This cheat is free of charge and available on unknowncheats.me .");
|
||||
log_info("[!] If you payed for this cheat, you've been scammed.");
|
||||
log_info("[!] 此作弊模块在 unknowncheats.me 上免费公开共享。");
|
||||
log_info("[!] 如果你花钱了,你被骗了,请举报卖家。");
|
||||
|
||||
// Keep the thread alive to prevent it from exiting the IL2CPP domain, avoiding fatal GC errors.
|
||||
while (true)
|
||||
{
|
||||
// Keep the thread active, using 3-second sleep to minimize CPU usage and set the tracking interval
|
||||
Sleep(3000);
|
||||
|
||||
HookPresent(); // yolo
|
||||
|
||||
// Signature repetition
|
||||
if ((int)(randomnumber() * 10) < 2)
|
||||
{
|
||||
log_info("[!] This cheat is free of charge and available on unknowncheats.me .");
|
||||
log_info("[!] If you payed for this cheat, you've been scammed.");
|
||||
log_info("[!] 此作弊模块在 unknowncheats.me 上免费公开共享。");
|
||||
log_info("[!] 如果你花钱了,你被骗了,请举报卖家。");
|
||||
}
|
||||
|
||||
// TarkovApplication instance tracking
|
||||
if (!g_TarkovApplicationInstance)
|
||||
{
|
||||
|
|
@ -908,23 +860,20 @@ void start()
|
|||
|
||||
// CameraManager tracking logic
|
||||
void* cm = get_camera_manager_instance(image);
|
||||
if (cm)
|
||||
{
|
||||
// Track CameraManager instance changes - print on initial detection OR when instance changes
|
||||
if (cm != g_CameraManagerInstance)
|
||||
{
|
||||
if (!g_CameraManagerInstance) {
|
||||
log_info("[+] CameraManager initial detection: 0x%llX", (uintptr_t)cm);
|
||||
}
|
||||
else {
|
||||
log_info("[+] CameraManager instance updated: 0x%llX", (uintptr_t)cm);
|
||||
}
|
||||
g_CameraManagerInstance = cm;
|
||||
}
|
||||
if (!cm)
|
||||
continue;
|
||||
// Track CameraManager instance changes - print on initial detection OR when instance changes
|
||||
if (cm == g_CameraManagerInstance)
|
||||
continue;
|
||||
|
||||
if (!g_CameraManagerInstance) {
|
||||
log_info("[+] CameraManager initial detection: 0x%llX", (uintptr_t)cm);
|
||||
}
|
||||
else {
|
||||
log_info("[+] CameraManager instance updated: 0x%llX", (uintptr_t)cm);
|
||||
}
|
||||
|
||||
// Keep the thread active, using 3-second sleep to minimize CPU usage and set the tracking interval
|
||||
Sleep(3000);
|
||||
g_CameraManagerInstance = cm;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue