From 4c77a3f1487404c1d8b25f30efe87537058697b4 Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:49:07 -0700 Subject: [PATCH] sdk: add get_module_info, get_plugin_directory, d3d9 callbacks (#908) ## Link to GitHub Issue or related Pull Request, if one exists n/a ## Description of change To make plugin development easier, add `get_module_info` and `get_plugin_directory` to deal with common tasks for hooking DLLs and reading INI files. Also, add the ability to register for d3d9 present callbacks via `register_d3d9`, which makes it significantly easier for plugins to draw things on screen. ## Testing See sample code. --- src/spice2x/CMakeLists.txt | 12 +- .../graphics/backends/d3d9/d3d9_backend.cpp | 3 + .../graphics/backends/d3d9/d3d9_device.cpp | 19 +- .../graphics/backends/d3d9/d3d9_gfdm.cpp | 4 + .../graphics/backends/d3d9/d3d9_swapchain.cpp | 4 + src/spice2x/hooks/graphics/graphics.cpp | 8 +- src/spice2x/launcher/launcher.cpp | 2 +- src/spice2x/sdk/d3d9.cpp | 403 ++++++++++++++++++ src/spice2x/sdk/d3d9.h | 17 + src/spice2x/sdk/include/spicesdk.h | 95 +++++ src/spice2x/sdk/modules.cpp | 161 +++++++ src/spice2x/sdk/modules.h | 24 ++ src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp | 63 ++- .../sdk/sample/v0/cpp/v0_cpp_imgui.cpp | 218 ++++++++++ src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.h | 10 + src/spice2x/sdk/sdk.cpp | 65 ++- src/spice2x/sdk/sdk.h | 2 +- 17 files changed, 1095 insertions(+), 15 deletions(-) create mode 100644 src/spice2x/sdk/d3d9.cpp create mode 100644 src/spice2x/sdk/d3d9.h create mode 100644 src/spice2x/sdk/modules.cpp create mode 100644 src/spice2x/sdk/modules.h create mode 100644 src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.cpp create mode 100644 src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index b495b535..41265897 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -719,6 +719,8 @@ set(SOURCE_FILES ${SOURCE_FILES} reader/crypt.cpp # sdk + sdk/d3d9.cpp + sdk/modules.cpp sdk/sdk.cpp # stubs @@ -1047,8 +1049,16 @@ if(NOT MSVC) endif() # sdk_sample_v0_cpp.dll (64 bit) -set(SOURCE_FILES sdk/sample/v0/cpp/v0_cpp.cpp) +set(SOURCE_FILES + sdk/sample/v0/cpp/v0_cpp.cpp + sdk/sample/v0/cpp/v0_cpp_imgui.cpp + external/imgui/imgui.cpp + external/imgui/imgui_draw.cpp + external/imgui/imgui_tables.cpp + external/imgui/imgui_widgets.cpp + external/imgui/backends/imgui_impl_dx9.cpp) add_library(spicetools_sdk_sample_v0_cpp_64 SHARED ${SOURCE_FILES} ${RESOURCE_FILES} sdk/sample/v0/cpp/v0_cpp.def) +target_link_libraries(spicetools_sdk_sample_v0_cpp_64 PRIVATE imm32) set_target_properties(spicetools_sdk_sample_v0_cpp_64 PROPERTIES PREFIX "") set_target_properties(spicetools_sdk_sample_v0_cpp_64 PROPERTIES OUTPUT_NAME "sdk_sample_v0_cpp") diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp index 719f5614..fb545c14 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp @@ -22,6 +22,7 @@ #include "launcher/shutdown.h" #include "misc/wintouchemu.h" #include "overlay/overlay.h" +#include "sdk/d3d9.h" #include "util/detour.h" #include "util/deferlog.h" #include "util/flags_helper.h" @@ -1511,6 +1512,8 @@ void graphics_d3d9_on_present( device->EndScene(); } + sdk::d3d9::draw(hFocusWindow, device); + // after the overlay render so the screenshot includes toasts / menus if (GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) { graphics_d3d9_process_screenshot(device, wrapped_device); diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp index d03b9c8b..ac436012 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp @@ -11,6 +11,7 @@ #include "games/gitadora/gitadora.h" #include "hooks/graphics/graphics.h" #include "overlay/overlay.h" +#include "sdk/d3d9.h" #include "util/flags_helper.h" #include "util/utils.h" #include "cfg/screen_resize.h" @@ -136,6 +137,7 @@ ULONG STDMETHODCALLTYPE WrappedIDirect3DDevice9::Release() { // release owned objects if there are no more references if (local_refs == 0) { + sdk::d3d9::destroy(this->pReal); if (this->main_swapchain) { this->main_swapchain->Release(); this->main_swapchain = nullptr; @@ -632,6 +634,8 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::Reset( } } + sdk::d3d9::invalidate(pReal); + // reset overlay if (overlay::OVERLAY && overlay::OVERLAY->uses_device(pReal)) { overlay::OVERLAY->reset_invalidate(); @@ -643,6 +647,7 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::Reset( d3d9_readback::discard_snapshot_targets(pReal); HRESULT res = pReal->Reset(pPresentationParameters); + sdk::d3d9::reset_complete(pReal, SUCCEEDED(res)); // recreate overlay if (overlay::OVERLAY && overlay::OVERLAY->uses_device(pReal) && SUCCEEDED(res)) { @@ -673,7 +678,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::Present( gfdm_small_head.compose(this); } - CHECK_RESULT(pReal->Present(pSourceRect, pDestRect, hDestWindowOverride, pDirtyRegion)); + const HRESULT result = pReal->Present(pSourceRect, pDestRect, hDestWindowOverride, pDirtyRegion); + sdk::d3d9::present_complete(pReal, result); + CHECK_RESULT(result); } HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::GetBackBuffer( @@ -2088,8 +2095,10 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::PresentEx( graphics_d3d9_on_present(hFocusWindow, pReal, this); - CHECK_RESULT(static_cast(pReal)->PresentEx( - pSourceRect, pDestRect, hDestWindowOverride, pDirtyRegion, dwFlags)); + const HRESULT result = static_cast(pReal)->PresentEx( + pSourceRect, pDestRect, hDestWindowOverride, pDirtyRegion, dwFlags); + sdk::d3d9::present_complete(pReal, result); + CHECK_RESULT(result); } HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::GetGPUThreadPriority( @@ -2336,6 +2345,8 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::ResetEx( } } + sdk::d3d9::invalidate(pReal); + // reset overlay if (overlay::OVERLAY && overlay::OVERLAY->uses_device(pReal)) { overlay::OVERLAY->reset_invalidate(); @@ -2350,6 +2361,8 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::ResetEx( gfdm_parameters.presentation_parameters, gfdm_parameters.fullscreen_display_modes); + sdk::d3d9::reset_complete(pReal, SUCCEEDED(res)); + if (is_gfdm_two_head_exclusive() && SUCCEEDED(res) && gfdm_parameters.recovery_candidate) diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp index c00246d9..e27c6e1c 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp @@ -7,6 +7,7 @@ #include "games/gitadora/gitadora.h" #include "hooks/graphics/graphics.h" +#include "sdk/d3d9.h" #include "util/logging.h" #include "d3d9_device.h" @@ -558,6 +559,7 @@ HRESULT graphics_d3d9_gfdm_recover_two_head_present_mode( temporary_parameters[1].FullScreen_RefreshRateInHz = alternate_small.RefreshRate; temporary_modes[1] = alternate_small; + sdk::d3d9::invalidate(device); HRESULT temporary_result = device->ResetEx(temporary_parameters, temporary_modes); const bool temporary_settled = temporary_result == D3D_OK @@ -574,6 +576,8 @@ HRESULT graphics_d3d9_gfdm_recover_two_head_present_mode( restore_result == D3D_OK && gfdm_wait_for_small_mode(desired_parameters[1].hDeviceWindow, desired_modes[1]); + sdk::d3d9::reset_complete(device, SUCCEEDED(restore_result)); + if (temporary_result != D3D_OK) { return temporary_result; } diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp index dacb126e..deedb4c9 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp @@ -5,6 +5,7 @@ #include "avs/game.h" #include "hooks/graphics/graphics.h" +#include "sdk/d3d9.h" #include "d3d9_backend.h" #include "d3d9_device.h" @@ -140,6 +141,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DSwapChain9::Present(const RECT *pSourc result = recovery_failure; } } + if (should_run_hooks) { + sdk::d3d9::present_complete(pDev->pReal, result); + } CHECK_RESULT(result); } HRESULT STDMETHODCALLTYPE WrappedIDirect3DSwapChain9::GetFrontBufferData(IDirect3DSurface9 *pDestSurface) { diff --git a/src/spice2x/hooks/graphics/graphics.cpp b/src/spice2x/hooks/graphics/graphics.cpp index e3fb0160..6ad67336 100644 --- a/src/spice2x/hooks/graphics/graphics.cpp +++ b/src/spice2x/hooks/graphics/graphics.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -396,7 +397,12 @@ static LRESULT CALLBACK WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM l // terminate if (uMsg == WM_CLOSE) { log_info("graphics", "detected WM_CLOSE, terminating..."); - launcher::shutdown(0); + static std::once_flag shutdown_requested; + std::call_once(shutdown_requested, [] { + std::thread([] { + launcher::shutdown(0); + }).detach(); + }); return false; } diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 2ec4007f..f2590109 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -2908,7 +2908,7 @@ int main_implementation(int argc, char *argv[]) { bt5api_dispose(); } - sdk::fini_sdk_modules(); + sdk::fini_sdk_modules(true); // stop raw input hotkeys::disable_raw_input(); diff --git a/src/spice2x/sdk/d3d9.cpp b/src/spice2x/sdk/d3d9.cpp new file mode 100644 index 00000000..479e9f89 --- /dev/null +++ b/src/spice2x/sdk/d3d9.cpp @@ -0,0 +1,403 @@ +#include "d3d9.h" + +#include +#include +#include +#include +#include +#include + +namespace sdk::d3d9 { +namespace { + +struct Renderer { + spice_sdk_d3d9_callback_func *callback; + void *userdata; + bool ready = false; + bool started = false; +}; + +// keep registration separate from serialized device access and callback execution. +std::mutex registry_mutex; +std::recursive_mutex execution_mutex; +std::condition_variable_any shutdown_condition; +std::vector> renderers; + +SPICE_SDK_D3D9_FRAME frame{sizeof(SPICE_SDK_D3D9_FRAME), nullptr, nullptr, 0, 0}; +bool stopped = false; +bool resetting = false; +bool device_lost = false; +bool reset_in_progress = false; +bool shutdown_complete = false; +DWORD device_thread = 0; + +// reject reentrant SDK graphics calls made by a renderer callback. +thread_local bool dispatching = false; +struct DispatchScope { + DispatchScope() { + dispatching = true; + } + + ~DispatchScope() { + dispatching = false; + } +}; + +template +struct ComReference { + Interface *value = nullptr; + + ~ComReference() { + if (value) { + value->Release(); + } + } +}; + +// restore game state after each renderer, including early exits during setup. +struct GraphicsState { + IDirect3DDevice9 *device; + ComReference block; + std::array, 4> targets; + ComReference depth; + D3DVIEWPORT9 viewport{}; + std::array transforms{}; + std::array have_transform{}; + DWORD target_count = 0; + bool captured = false; + + explicit GraphicsState(IDirect3DDevice9 *device) : device(device) { + D3DCAPS9 caps{}; + if (FAILED(device->GetDeviceCaps(&caps)) || + FAILED(device->CreateStateBlock(D3DSBT_ALL, &block.value)) || + FAILED(block.value->Capture()) || FAILED(device->GetViewport(&viewport))) { + return; + } + + // state blocks do not capture render targets or the depth surface. + target_count = std::min(caps.NumSimultaneousRTs, targets.size()); + for (DWORD index = 0; index < target_count; ++index) { + const auto status = device->GetRenderTarget(index, &targets[index].value); + if (FAILED(status) && status != D3DERR_NOTFOUND) { + return; + } + } + + const auto status = device->GetDepthStencilSurface(&depth.value); + if (FAILED(status) && status != D3DERR_NOTFOUND) { + return; + } + + const std::array kinds{D3DTS_WORLD, D3DTS_VIEW, D3DTS_PROJECTION}; + for (size_t index = 0; index < kinds.size(); ++index) { + have_transform[index] = SUCCEEDED( + device->GetTransform(kinds[index], &transforms[index])); + } + captured = true; + } + + ~GraphicsState() { + if (!captured) { + return; + } + + // unbind auxiliary surfaces before restoring targets of potentially different sizes. + device->SetDepthStencilSurface(nullptr); + for (DWORD index = 1; index < target_count; ++index) { + device->SetRenderTarget(index, nullptr); + } + device->SetRenderTarget(0, targets[0].value); + for (DWORD index = 1; index < target_count; ++index) { + device->SetRenderTarget(index, targets[index].value); + } + device->SetDepthStencilSurface(depth.value); + + block.value->Apply(); + const std::array kinds{D3DTS_WORLD, D3DTS_VIEW, D3DTS_PROJECTION}; + for (size_t index = 0; index < kinds.size(); ++index) { + if (have_transform[index]) { + device->SetTransform(kinds[index], &transforms[index]); + } + } + device->SetViewport(&viewport); + } +}; + +std::vector> snapshot() { + // callbacks run without the registry lock, while their entries remain alive. + std::lock_guard lock(registry_mutex); + return renderers; +} + +void destroy_renderers() { + for (const auto &renderer : snapshot()) { + if (renderer->started) { + renderer->callback(SPICE_SDK_D3D9_DESTROY, &frame, renderer->userdata); + } + renderer->ready = false; + renderer->started = false; + } + + frame = {sizeof(frame), nullptr, nullptr, 0, 0}; + resetting = false; + device_lost = false; + reset_in_progress = false; + device_thread = 0; +} + +void finish_shutdown() { + if (shutdown_complete) { + return; + } + + destroy_renderers(); + { + std::lock_guard lock(registry_mutex); + renderers.clear(); + } + + shutdown_complete = true; + shutdown_condition.notify_all(); +} + +} + +SPICE_SDK_STATUS_CODE register_d3d9(const std::vector &modules, + spice_sdk_d3d9_callback_func *callback, void *userdata) { + if (dispatching) { + return SPICE_SDK_STATUS_NOT_SUPPORTED; + } + if (!callback) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_1; + } + + // only registered SDK modules may supply callbacks. + HMODULE owner = nullptr; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(callback), &owner) || + std::none_of(modules.begin(), modules.end(), [&](const SdkModule &module) { + return module.module == owner; + })) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_1; + } + + std::lock_guard lock(registry_mutex); + if (stopped) { + return SPICE_SDK_STATUS_TOO_LATE; + } + if (std::any_of(renderers.begin(), renderers.end(), [&](const auto &renderer) { + return renderer->callback == callback && renderer->userdata == userdata; + })) { + return SPICE_SDK_STATUS_SUCCESS; + } + + // keep callback code loaded for the lifetime of the process. + try { + auto renderer = std::make_shared(Renderer{callback, userdata}); + if (!GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + reinterpret_cast(callback), &owner)) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + renderers.push_back(std::move(renderer)); + } catch (...) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + + return SPICE_SDK_STATUS_SUCCESS; +} + +void draw(HWND window, IDirect3DDevice9 *device) { + if (dispatching) { + return; + } + + std::lock_guard lock(execution_mutex); + DispatchScope scope; + + if (frame.device && frame.device != device) { + return; + } + device_thread = GetCurrentThreadId(); + if (stopped) { + if (!reset_in_progress) { + finish_shutdown(); + } + return; + } + + // latch the first presented device even before any renderers register. + frame.device = device; + frame.window = window; + + const auto current = snapshot(); + if (current.empty() || resetting || device_lost) { + return; + } + + ComReference backbuffer; + D3DSURFACE_DESC description{}; + if (FAILED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &backbuffer.value)) || + FAILED(backbuffer.value->GetDesc(&description))) { + return; + } + frame = {sizeof(frame), device, window, description.Width, description.Height}; + + for (const auto &renderer : current) { + // isolate each renderer and give it the full backbuffer in its own scene. + GraphicsState state(device); + if (!state.captured) { + continue; + } + + if (FAILED(device->SetDepthStencilSurface(nullptr))) { + continue; + } + bool target_ready = true; + for (DWORD index = 1; index < state.target_count; ++index) { + if (FAILED(device->SetRenderTarget(index, nullptr))) { + target_ready = false; + } + } + + const D3DVIEWPORT9 viewport{0, 0, frame.width, frame.height, 0.0f, 1.0f}; + if (!target_ready || FAILED(device->SetRenderTarget(0, backbuffer.value)) || + FAILED(device->SetViewport(&viewport)) || FAILED(device->BeginScene())) { + continue; + } + + // dispatch READY to also recreate resources released before a successful reset. + if (!renderer->ready) { + renderer->started = true; + renderer->callback(SPICE_SDK_D3D9_READY, &frame, renderer->userdata); + renderer->ready = true; + } + + renderer->callback(SPICE_SDK_D3D9_DRAW, &frame, renderer->userdata); + device->EndScene(); + } +} + +void present_complete(IDirect3DDevice9 *device, HRESULT result) { + if (dispatching) { + return; + } + + std::lock_guard lock(execution_mutex); + DispatchScope scope; + + if (frame.device == device) { + device_thread = GetCurrentThreadId(); + if (stopped && !reset_in_progress) { + finish_shutdown(); + return; + } + + // a busy nonblocking present does not change the known device-loss state. + if (SUCCEEDED(result)) { + device_lost = false; + } else if (result != D3DERR_WASSTILLDRAWING) { + device_lost = true; + } + } +} + +void invalidate(IDirect3DDevice9 *device) { + if (dispatching) { + return; + } + + std::lock_guard lock(execution_mutex); + DispatchScope scope; + + if (frame.device != device) { + return; + } + + device_thread = GetCurrentThreadId(); + if (stopped) { + finish_shutdown(); + return; + } + + // default-pool resources must be released before the native reset starts. + reset_in_progress = true; + resetting = true; + for (const auto &renderer : snapshot()) { + if (renderer->ready) { + renderer->callback(SPICE_SDK_D3D9_INVALIDATE, &frame, renderer->userdata); + } + renderer->ready = false; + } +} + +void reset_complete(IDirect3DDevice9 *device, bool success) { + if (dispatching) { + return; + } + + std::lock_guard lock(execution_mutex); + DispatchScope scope; + + if (frame.device == device) { + device_thread = GetCurrentThreadId(); + reset_in_progress = false; + resetting = !success; + device_lost = !success; + + if (stopped) { + finish_shutdown(); + } + } +} + +void destroy(IDirect3DDevice9 *device) { + if (dispatching) { + return; + } + + std::lock_guard lock(execution_mutex); + DispatchScope scope; + + if (frame.device == device) { + if (stopped) { + finish_shutdown(); + } else { + destroy_renderers(); + } + } +} + +bool shutdown(bool graphics_stopped) { + std::unique_lock lock(execution_mutex); + if (shutdown_complete) { + return true; + } + + // close registration before deciding where device resources can be released. + { + std::lock_guard registry_lock(registry_mutex); + stopped = true; + } + + const auto current = snapshot(); + const bool resources_started = std::any_of( + current.begin(), current.end(), [](const auto &renderer) { + return renderer->started; + }); + + if (!resources_started || (!reset_in_progress && + (graphics_stopped || device_thread == GetCurrentThreadId()))) { + DispatchScope scope; + finish_shutdown(); + return true; + } + + // a graphics boundary must drain callbacks when another thread requests shutdown. + return shutdown_condition.wait_for(lock, std::chrono::milliseconds(100), [] { + return shutdown_complete; + }); +} + +} \ No newline at end of file diff --git a/src/spice2x/sdk/d3d9.h b/src/spice2x/sdk/d3d9.h new file mode 100644 index 00000000..1b702bed --- /dev/null +++ b/src/spice2x/sdk/d3d9.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include "modules.h" + +namespace sdk::d3d9 { + +SPICE_SDK_STATUS_CODE register_d3d9(const std::vector &modules, + spice_sdk_d3d9_callback_func *callback, void *userdata); +void draw(HWND window, IDirect3DDevice9 *device); +void present_complete(IDirect3DDevice9 *device, HRESULT result); +void invalidate(IDirect3DDevice9 *device); +void reset_complete(IDirect3DDevice9 *device, bool success); +void destroy(IDirect3DDevice9 *device); +bool shutdown(bool graphics_stopped); + +} \ No newline at end of file diff --git a/src/spice2x/sdk/include/spicesdk.h b/src/spice2x/sdk/include/spicesdk.h index 2a52603b..767ea5cf 100644 --- a/src/spice2x/sdk/include/spicesdk.h +++ b/src/spice2x/sdk/include/spicesdk.h @@ -4,6 +4,7 @@ #include #include +#include #ifdef __cplusplus #define SPICE_SDK_ENTRY_POINT extern "C" __declspec(dllexport) int __cdecl @@ -247,6 +248,95 @@ typedef SPICE_SDK_STATUS_CODE (__cdecl spice_sdk_insert_coin_func)( uint8_t amount ); +typedef struct SPICE_SDK_MODULE_INFO { + uint32_t size; // initialize to sizeof(SPICE_SDK_MODULE_INFO) + uintptr_t base; // loaded address, not the preferred PE image base + uint32_t image_size; + uint32_t timestamp; + uint32_t entry_point; // RVA, not an absolute address + uint16_t machine; // PE IMAGE_FILE_MACHINE_* value +} SPICE_SDK_MODULE_INFO; + +// get_module_info (v0.4 and up) +// gets PE info about an already loaded module +// +// does not load or permanently pin the module; the caller must keep it loaded +// while using the returned base address +// returns GENERIC_ERROR if not found or unreadable, NOT_SUPPORTED for non-PE32/PE32+ +// +// module_name: null-terminated UTF-16 module name or full path +// info: receives module info; initialize size to sizeof(SPICE_SDK_MODULE_INFO) + +typedef SPICE_SDK_STATUS_CODE (__cdecl spice_sdk_get_module_info_func)( + const wchar_t *module_name, + SPICE_SDK_MODULE_INFO *info +); + +// get_plugin_directory (v0.4 and up) +// gets the directory containing a registered SDK DLL +// +// available during entry-point initialization and until destroy callbacks finish +// returns INVALID_ARGUMENT_1 for addresses outside registered SDK DLLs +// +// plugin_address: address of a function or static object in the plugin DLL +// used only to identify the DLL; the pointed-to object is not read +// address of any global variable in your DLL will work +// buffer: caller-owned UTF-16 output, or NULL to query the required size +// size: input capacity and output required wchar_t count, including the terminator +// NULL buffer or insufficient capacity returns TOO_SMALL and sets the +// required size without partial output + +typedef SPICE_SDK_STATUS_CODE (__cdecl spice_sdk_get_plugin_directory_func)( + const void *plugin_address, + wchar_t *buffer, + uint32_t *size +); + +typedef enum SPICE_SDK_D3D9_EVENT { + SPICE_SDK_D3D9_READY = 0, + SPICE_SDK_D3D9_DRAW = 1, + SPICE_SDK_D3D9_INVALIDATE = 2, + SPICE_SDK_D3D9_DESTROY = 3, +} SPICE_SDK_D3D9_EVENT; + +typedef struct SPICE_SDK_D3D9_FRAME { + uint32_t size; + void *device; // borrowed IDirect3DDevice9*, not Spice's wrapper + void *window; // HWND + uint32_t width; + uint32_t height; +} SPICE_SDK_D3D9_FRAME; + +typedef void (__cdecl spice_sdk_d3d9_callback_func)( + SPICE_SDK_D3D9_EVENT event, + const SPICE_SDK_D3D9_FRAME *frame, + void *userdata +); + +// register_d3d9 (v0.4 and up) +// registers a process-lifetime renderer for the primary D3D9 presentation target +// +// READY precedes drawing on a usable device and follows each successful reset +// DRAW runs on top of all Spice overlays, even when the overlay is closed; the host +// binds the backbuffer, brackets the scene, and restores graphics state +// INVALIDATE precedes reset; release default-pool resources, even if reset fails +// DESTROY releases all device references before device destruction or SDK shutdown +// callbacks are serialized; DRAW/READY run on the presentation thread; teardown +// runs at a graphics-thread boundary, or after the game stops rendering +// do not throw, block, reset/present the +// device, or shut down Spice from a callback; frame is valid only during the call +// no callbacks run after the plugin's SDK destroy callback begins +// registration from inside a render callback is not supported +// input capture and non-D3D9 backends are not provided +// +// callback: function in a registered plugin DLL; the DLL is retained until exit +// userdata: opaque plugin state passed unchanged to each callback + +typedef SPICE_SDK_STATUS_CODE (__cdecl spice_sdk_register_d3d9_func)( + spice_sdk_d3d9_callback_func *callback, + void *userdata +); + typedef struct SPICE_SDK_V0 { uint32_t size; @@ -274,6 +364,11 @@ typedef struct SPICE_SDK_V0 { spice_sdk_insert_coin_func *insert_coin; + spice_sdk_get_module_info_func *get_module_info; + spice_sdk_get_plugin_directory_func *get_plugin_directory; + + spice_sdk_register_d3d9_func *register_d3d9; + } SPICE_SDK_V0; typedef void (__cdecl spice_sdk_destroy_callback_func)( diff --git a/src/spice2x/sdk/modules.cpp b/src/spice2x/sdk/modules.cpp new file mode 100644 index 00000000..ce82c372 --- /dev/null +++ b/src/spice2x/sdk/modules.cpp @@ -0,0 +1,161 @@ +#include "modules.h" + +#include +#include +#include +#include + +namespace sdk::modules { + +namespace { + +// RAII wrapper for a module handle +struct ModuleReference { + HMODULE handle = nullptr; + + ModuleReference() = default; + ModuleReference(const ModuleReference &) = delete; + ModuleReference &operator=(const ModuleReference &) = delete; + ~ModuleReference() { + if (handle) { + FreeLibrary(handle); + } + } +}; + +template +bool read_module_value(uintptr_t base, size_t offset, Value &value) { + if (offset > std::numeric_limits::max() - base || + sizeof(Value) > std::numeric_limits::max() - (base + offset)) { + return false; + } + + SIZE_T copied = 0; + return ReadProcessMemory(GetCurrentProcess(), reinterpret_cast(base + offset), + &value, sizeof(value), &copied) && copied == sizeof(value); +} + +} + +SPICE_SDK_STATUS_CODE get_module_info(const wchar_t *module_name, SPICE_SDK_MODULE_INFO *info) { + if (!module_name || !module_name[0]) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_1; + } + if (!info) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_2; + } + if (info->size < sizeof(SPICE_SDK_MODULE_INFO)) { + return SPICE_SDK_STATUS_TOO_SMALL; + } + + // Keep the module loaded while inspecting its headers. + ModuleReference module; + if (!GetModuleHandleExW(0, module_name, &module.handle)) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + + // Locate the PE header through the DOS header. + const auto base = reinterpret_cast(module.handle); + IMAGE_DOS_HEADER dos{}; + if (!read_module_value(base, 0, dos) || dos.e_magic != IMAGE_DOS_SIGNATURE || + dos.e_lfanew < static_cast(sizeof(dos))) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + + const auto nt_offset = static_cast(dos.e_lfanew); + DWORD signature{}; + IMAGE_FILE_HEADER header{}; + if (!read_module_value(base, nt_offset, signature) || signature != IMAGE_NT_SIGNATURE || + !read_module_value(base, nt_offset + sizeof(signature), header)) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + + // The optional-header magic selects the PE32 or PE32+ layout. + const auto optional_offset = nt_offset + sizeof(signature) + sizeof(header); + WORD magic{}; + if (header.SizeOfOptionalHeader < sizeof(magic) || !read_module_value(base, optional_offset, magic)) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + + SPICE_SDK_MODULE_INFO result{}; + result.size = info->size; + result.base = base; + result.timestamp = header.TimeDateStamp; + result.machine = header.Machine; + + if (magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) { + IMAGE_OPTIONAL_HEADER64 optional{}; + if (header.SizeOfOptionalHeader < sizeof(optional) || !read_module_value(base, optional_offset, optional)) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + result.image_size = optional.SizeOfImage; + result.entry_point = optional.AddressOfEntryPoint; + } else if (magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) { + IMAGE_OPTIONAL_HEADER32 optional{}; + if (header.SizeOfOptionalHeader < sizeof(optional) || !read_module_value(base, optional_offset, optional)) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + result.image_size = optional.SizeOfImage; + result.entry_point = optional.AddressOfEntryPoint; + } else { + return SPICE_SDK_STATUS_NOT_SUPPORTED; + } + + // Reject headers or entry points outside the image. + if (optional_offset > result.image_size || header.SizeOfOptionalHeader > result.image_size - optional_offset || + result.entry_point >= result.image_size) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + + // Leave the caller's output untouched on failure. + *info = result; + return SPICE_SDK_STATUS_SUCCESS; +} + +SPICE_SDK_STATUS_CODE get_plugin_directory(const std::vector ®istered_modules, + const void *plugin_address, wchar_t *buffer, uint32_t *size) { + if (!plugin_address) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_1; + } + if (!size) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_3; + } + + // Resolve the address and require a registered plugin. + ModuleReference module; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + reinterpret_cast(plugin_address), &module.handle) || + std::none_of(registered_modules.begin(), registered_modules.end(), [&](const SdkModule &entry) { + return entry.module == module.handle; + })) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_1; + } + + try { + // Reject truncated paths before extracting the directory. + std::array filename{}; + const auto length = GetModuleFileNameW(module.handle, filename.data(), static_cast(filename.size())); + if (!length || length >= filename.size()) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } + + const auto path = std::filesystem::path(filename.data()).parent_path(); + + // Report the full UTF-16 capacity, including the terminator. + const auto &text = path.native(); + const auto required = static_cast(text.size() + 1); + const auto capacity = *size; + *size = required; + if (!buffer || capacity < required) { + return SPICE_SDK_STATUS_TOO_SMALL; + } + + // Copy only when the entire path fits. + std::copy_n(text.c_str(), required, buffer); + return SPICE_SDK_STATUS_SUCCESS; + } catch (const std::exception &) { + return SPICE_SDK_STATUS_GENERIC_ERROR; + } +} + +} \ No newline at end of file diff --git a/src/spice2x/sdk/modules.h b/src/spice2x/sdk/modules.h new file mode 100644 index 00000000..ce3b995b --- /dev/null +++ b/src/spice2x/sdk/modules.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "sdk/include/spicesdk.h" + +namespace sdk { + +struct SdkModule { + std::string dll; + HINSTANCE module; +}; + +namespace modules { + +SPICE_SDK_STATUS_CODE get_module_info(const wchar_t *module_name, SPICE_SDK_MODULE_INFO *info); +SPICE_SDK_STATUS_CODE get_plugin_directory(const std::vector ®istered_modules, + const void *plugin_address, wchar_t *buffer, uint32_t *size); + +} + +} \ No newline at end of file diff --git a/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp b/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp index fe7b2e16..ccdd3566 100644 --- a/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp +++ b/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp @@ -1,8 +1,12 @@ #include #include #include +#include +#include +#include #include +#include "v0_cpp_imgui.h" #include "sdk/include/spicesdk.h" #include "sdk/include/spicesdk_io.h" @@ -17,6 +21,24 @@ static spice_sdk_destroy_callback_func destroy_callback; static std::jthread worker_thread; static void worker_thread_main(std::stop_token stop_token); +static std::optional utf16_to_utf8(const wchar_t *text) { + const auto bytes = WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, text, -1, nullptr, 0, nullptr, nullptr); + if (bytes == 0) { + return std::nullopt; + } + + std::string result(bytes, '\0'); + if (WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, text, -1, + result.data(), bytes, nullptr, nullptr) != bytes) { + return std::nullopt; + } + + result.pop_back(); + return result; +} + // main entry point into the DLL // spice executable will call into this shortly after the game is partially // initialized (but not quite fully booted just yet) @@ -42,6 +64,37 @@ spice_sdk_entry_point( LOG_INFO("plugin loaded"); + if (spice.get_plugin_directory) { + std::array directory{}; + uint32_t size = static_cast(directory.size()); + if (spice.get_plugin_directory(&spice, directory.data(), &size) == + SPICE_SDK_STATUS_SUCCESS) { + if (const auto path = utf16_to_utf8(directory.data())) { + LOG_INFO(std::format("plugin directory: {}", *path).c_str()); + } + } + } + + if (spice.get_module_info) { + SPICE_SDK_MODULE_INFO info{}; + info.size = sizeof(info); + if (spice.get_module_info(L"kernel32.dll", &info) == SPICE_SDK_STATUS_SUCCESS) { + LOG_INFO(std::format( + "kernel32 PE: {:x}_{:x}, base={:x}, size={:x}", + info.timestamp, info.entry_point, info.base, info.image_size).c_str()); + } + } + + if (spice.register_d3d9) { + status = sample_imgui::initialize(spice); + if (status != SPICE_SDK_STATUS_SUCCESS) { + LOG_INFO(std::format( + "D3D9 registration failed: {}", static_cast(status)).c_str()); + } else { + LOG_INFO("D3D9 renderer registered; Ctrl+Enter toggles the sample window"); + } + } + // spin up a worker thread worker_thread = std::jthread(worker_thread_main); return 1; @@ -77,10 +130,18 @@ static ArrowButton arrow_buttons[] = { // worker thread for I/O static void worker_thread_main(std::stop_token stop_token) { bool coin_previous_state[10] = {}; + bool window_toggle_previous_state = false; while (!stop_token.stop_requested()) { - // insert coin const bool control_pressed = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; + const bool window_toggle_pressed = control_pressed && + ((GetAsyncKeyState(VK_RETURN) & 0x8000) != 0); + if (window_toggle_pressed && !window_toggle_previous_state) { + sample_imgui::toggle(); + } + window_toggle_previous_state = window_toggle_pressed; + + // insert coin for (uint8_t amount = 0; amount < 10; amount++) { const bool coin_pressed = control_pressed && ((GetAsyncKeyState('0' + amount) & 0x8000) != 0); diff --git a/src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.cpp b/src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.cpp new file mode 100644 index 00000000..afa0a712 --- /dev/null +++ b/src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.cpp @@ -0,0 +1,218 @@ +#include "v0_cpp_imgui.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "backends/imgui_impl_dx9.h" + +namespace sample_imgui { +namespace { + +struct SampleRenderer { + ImGuiContext *context = nullptr; + bool backend_initialized = false; + bool was_visible = false; + bool dummy_enabled = true; + float dummy_value = 0.5f; + int32_t dummy_mode = 0; + uint32_t click_count = 0; + std::chrono::steady_clock::time_point previous_frame; +}; + +SampleRenderer renderer; +std::atomic_bool window_visible = false; + +void update_mouse(const SPICE_SDK_D3D9_FRAME &frame) { + auto &io = ImGui::GetIO(); + const auto window = static_cast(frame.window); + const bool focused = window && + GetAncestor(GetForegroundWindow(), GA_ROOT) == GetAncestor(window, GA_ROOT); + io.AddFocusEvent(focused); + + POINT position{}; + RECT client{}; + + // can window receive mouse input? + const bool mouse_available = + focused && + GetCursorPos(&position) && + ScreenToClient(window, &position) && + GetClientRect(window, &client) && + client.right > 0 && + client.bottom > 0; + + // add mouse position + if (mouse_available) { + io.AddMousePosEvent( + static_cast(position.x) * frame.width / client.right, + static_cast(position.y) * frame.height / client.bottom); + } else { + io.AddMousePosEvent(-FLT_MAX, -FLT_MAX); + } + + // add click to imgui + const bool swapped = GetSystemMetrics(SM_SWAPBUTTON) != 0; + io.AddMouseButtonEvent(0, mouse_available && + ((GetAsyncKeyState(swapped ? VK_RBUTTON : VK_LBUTTON) & 0x8000) != 0)); + io.AddMouseButtonEvent(1, mouse_available && + ((GetAsyncKeyState(swapped ? VK_LBUTTON : VK_RBUTTON) & 0x8000) != 0)); +} + +void draw_window(SampleRenderer &state, const SPICE_SDK_D3D9_FRAME &frame) { + auto &io = ImGui::GetIO(); + + ImGui::SetNextWindowPos( + ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), + ImGuiCond_Once, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowBgAlpha(0.8f); + + if (ImGui::Begin("SDK sample", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse)) { + + ImGui::Text("%u x %u", frame.width, frame.height); + ImGui::Text("%.1f FPS", io.Framerate); + + ImGui::Separator(); + + ImGui::Checkbox("Enable dummy widgets", &state.dummy_enabled); + + ImGui::BeginDisabled(!state.dummy_enabled); + { + ImGui::SliderFloat("Value", &state.dummy_value, 0.0f, 1.0f, "%.2f", + ImGuiSliderFlags_NoInput); + ImGui::Combo("Mode", &state.dummy_mode, "Off\0Low\0High\0"); + if (ImGui::Button("Increment")) { + ++state.click_count; + } + ImGui::SameLine(); + if (ImGui::Button("Reset")) { + state.click_count = 0; + state.dummy_value = 0.5f; + state.dummy_mode = 0; + } + ImGui::Text("Count: %u", state.click_count); + } + ImGui::EndDisabled(); + } + ImGui::End(); +} + +void draw_frame(SampleRenderer &state, const SPICE_SDK_D3D9_FRAME &frame) { + if (!state.backend_initialized) { + return; + } + + const auto now = std::chrono::steady_clock::now(); + const float elapsed = std::chrono::duration(now - state.previous_frame).count(); + state.previous_frame = now; + const bool visible = window_visible.load(std::memory_order_relaxed); + if (!visible && !state.was_visible) { + return; + } + state.was_visible = visible; + + // update imgui state + auto &io = ImGui::GetIO(); + io.DisplaySize = ImVec2( + static_cast(frame.width), static_cast(frame.height)); + io.DeltaTime = elapsed > 0.0f ? elapsed : 1.0f / 60.0f; + + // update mouse i/o + if (visible) { + update_mouse(frame); + } else { + io.ClearEventsQueue(); + io.ClearInputMouse(); + io.AddFocusEvent(false); + } + + ImGui_ImplDX9_NewFrame(); + ImGui::NewFrame(); + if (!visible) { + ImGui::SetWindowFocus(nullptr); + ImGui::EndFrame(); + return; + } + + draw_window(state, frame); + ImGui::Render(); + ImGui_ImplDX9_RenderDrawData(ImGui::GetDrawData()); +} + +void __cdecl render_callback( + SPICE_SDK_D3D9_EVENT event, + const SPICE_SDK_D3D9_FRAME *frame, + void *userdata) { + + auto &state = *static_cast(userdata); + auto *previous_context = ImGui::GetCurrentContext(); + + // init imgui context + if (event == SPICE_SDK_D3D9_READY && !state.context) { + ImGui::SetAllocatorFunctions( + [](size_t size, void *) -> void * { return std::malloc(size); }, + [](void *memory, void *) { std::free(memory); }); + IMGUI_CHECKVERSION(); + state.context = ImGui::CreateContext(); + ImGui::SetCurrentContext(state.context); + auto &io = ImGui::GetIO(); + io.IniFilename = nullptr; + io.LogFilename = nullptr; + io.MouseDrawCursor = true; + ImGui::StyleColorsDark(); + state.backend_initialized = ImGui_ImplDX9_Init( + static_cast(frame->device)); + } + + if (state.context) { + ImGui::SetCurrentContext(state.context); + switch (event) { + case SPICE_SDK_D3D9_READY: + state.previous_frame = std::chrono::steady_clock::now(); + if (state.backend_initialized) { + ImGui_ImplDX9_CreateDeviceObjects(); + } + break; + case SPICE_SDK_D3D9_DRAW: + draw_frame(state, *frame); + break; + case SPICE_SDK_D3D9_INVALIDATE: + if (state.backend_initialized) { + ImGui_ImplDX9_InvalidateDeviceObjects(); + } + break; + case SPICE_SDK_D3D9_DESTROY: + if (state.backend_initialized) { + ImGui_ImplDX9_Shutdown(); + } + ImGui::DestroyContext(state.context); + state.context = nullptr; + state.backend_initialized = false; + state.was_visible = false; + break; + } + } + + ImGui::SetCurrentContext(previous_context); +} + +} + +SPICE_SDK_STATUS_CODE initialize(const SPICE_SDK_V0 &spice) { + if (!spice.register_d3d9) { + return SPICE_SDK_STATUS_NOT_SUPPORTED; + } + return spice.register_d3d9(render_callback, &renderer); +} + +void toggle() { + const bool visible = !window_visible.load(std::memory_order_relaxed); + window_visible.store(visible, std::memory_order_relaxed); +} + +} \ No newline at end of file diff --git a/src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.h b/src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.h new file mode 100644 index 00000000..8ee32d7d --- /dev/null +++ b/src/spice2x/sdk/sample/v0/cpp/v0_cpp_imgui.h @@ -0,0 +1,10 @@ +#pragma once + +#include "sdk/include/spicesdk.h" + +namespace sample_imgui { + +SPICE_SDK_STATUS_CODE initialize(const SPICE_SDK_V0 &spice); +void toggle(); + +} \ No newline at end of file diff --git a/src/spice2x/sdk/sdk.cpp b/src/spice2x/sdk/sdk.cpp index 5677ded6..ba90acb5 100644 --- a/src/spice2x/sdk/sdk.cpp +++ b/src/spice2x/sdk/sdk.cpp @@ -1,8 +1,11 @@ #include #include #include +#include #include "sdk.h" +#include "modules.h" +#include "d3d9.h" #include "avs/game.h" #include "games/io.h" #include "launcher/launcher.h" @@ -32,11 +35,9 @@ static spice_sdk_insert_card_func sdk_insert_card; static spice_sdk_set_keypad_func sdk_set_keypad; static spice_sdk_add_toast_func sdk_add_toast; static spice_sdk_insert_coin_func sdk_insert_coin; - -struct SdkModule { - std::string dll; - HINSTANCE module; -}; +static spice_sdk_get_module_info_func sdk_get_module_info; +static spice_sdk_get_plugin_directory_func sdk_get_plugin_directory; +static spice_sdk_register_d3d9_func sdk_register_d3d9; // DLLs static int sdk_modules_count = 0; @@ -46,6 +47,7 @@ static std::shared_mutex sdk_global_mutex; // internal static bool sdk_initialized = false; static bool sdk_shutting_down = false; +static bool sdk_finalizing = false; static std::vector