Compare commits

..

11 Commits

Author SHA1 Message Date
bicarus adf4cccd4a overlay: fix subscreen overlay rendering for popn (#869)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
For popn HC, some transparency effects, like the one for touch feedback,
rendered incorrectly; this is due to how alpha channel is handled for
subscreen overlay drawing on top of ImGui.

## Testing
Tested popn and iidx.
2026-08-16 04:24:14 -07:00
bicarus bf8e194685 sdvx: fix api touch and -sdvxnosub sub window (#868)
## Link to GitHub Issue or related Pull Request, if one exists
Related: #859 and #862.

## Description of change

Three SDVX (Valkyrie model) fixes:

* **API touch went to the wrong window.** SDVX registers touch on both
its Main Screen and Sub Screen windows, and the native injector kept
whichever attached last. The touch surface is now published explicitly:
sub screen window when windowed, main window in fullscreen.
* **Landscape never rotated API coordinates.** Synthetic contacts bypass
`transform::hardware_to_game`, so they missed the rotation a real finger
gets. Applied before injection now, as portrait already did. Extracted
to `sdvx_landscape_rotate` so both paths share it; gated on the native
path since `wintouchemu` rotates via the subscreen overlay instead.
* **`-sdvxnosub` didn't hide the sub window in fullscreen.**
`ShowWindow_hook` had branches for GITADORA, pop'n and IIDX but not
SDVX. Added the missing one.

## Testing
Tested Nabla
2026-08-16 04:03:42 -07:00
bicarus a2e508208c gitadora: fix synthetic touch (mouse and api) (#867)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change
Fix mouse and touch API handling for gitadora.

* hook native touch in every arena window mode (previously only using
wintouchemu for 1 windowed mode)
* publish the touch window (SMALL panel, or the main window drawing the
overlay) and only attach injection there
* route touch on the SMALL window straight through instead of the
overlay transform, and accept the mouse only there
* fix spiceapi by forcing 800x1280 touch canvas size
* run API capture before the subscreen present to fix api not showing
sub image


## Testing
Test:

gitadora 1/2/4 windowed mode, fullscreen

iidx /sdvx / popn windowed/fullscreen for regressions
2026-08-16 01:43:20 -07:00
bicarus f5888609a8 graphics: option to include subscreens in screenshot (#866)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change
Adds `-screenshotsub`. Off by default. When on, screenshots include the
subscreens. All subscreens are captured, even if they are hidden from
view. Works for all four screens of gitadora arena model as well.

This should also fix an issue with API / companion app not capturing
gitadora subscreen correctly.

Note: only done for DX9... DX11 will need another PR to make this work.

Unrelated to this PR, there seems to be a bug with gitadora not
accepting mouse or api touch input.

## Testing
2026-08-14 04:05:50 -07:00
bicarus c6cd72c528 graphics: exclude overlay from screenshots (#865)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change

Adds the `-screenshotoverlay` option.

By default, file screenshots exclude the Spice overlay. Enabling this
option captures screenshots after the overlay is rendered.

Also updates DX11 backend to allow screenshots even when overlay is
disabled.

## Testing
2026-08-13 23:21:46 -07:00
bicarus 82e0c053d0 overlay: create a dedicated thread for polling hotkeys (#864)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Create a new thread that polls for the following hotkeys:

* super exit (both alt+f4 and bound key)
* coin insert
* screenshot

The goal is to make the capture of these more reliable, because before
this PR it wasn't.

## Testing
2026-08-12 20:04:59 -07:00
James Liu 3b29227dbc gitadora: fix XG2 gibberish text showing (#863)
> [!NOTE]
> Before submitting code changes... 
> * Please do note that this is a GPL v3.0 open source project.
> * Please read the
[CONTRIBUTING](https://github.com/spice2x/spice2x.github.io/blob/main/CONTRIBUTING.md)
guide.
> * Maintainers reserve the right to reject or modify your submission
without reason.
> * No new compiler warnings must be introduced. Check the CI build
results.
> 
> Feel free to remove this section after you have read it.

## Link to GitHub Issue or related Pull Request, if one exists

## Description of change
Fix gibberish in dynamic Japanese text in GuitarFreaks/DrumMania XG2
(K32/K33) when Windows uses a non-Japanese system code page.

XG2 converts UTF-8 property strings through WideCharToMultiByte(CP_ACP)
before rendering. On systems where the active code page is not
Shift-JIS, these strings are converted using the system code page and
become corrupted or turn into question marks.

Spice2x already hooks WideCharToMultiByte and redirects these
conversions to CP932 for newer 64-bit Gitadora models. This change makes
the existing hook available to 32-bit builds and enables it specifically
for XG2 models K32 and K33.

The existing 64-bit Gitadora Arena and T44 conditions remain unchanged.

## Testing
Before
<img width="1280" height="745" alt="image"
src="https://github.com/user-attachments/assets/34aa2c93-89e8-4c4f-b267-6cbc236f19de"
/>
<img width="2054" height="1188" alt="image"
src="https://github.com/user-attachments/assets/d88b5770-5545-4402-b9e7-3126fe048fb9"
/>

After
<img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/d43b4e47-b6af-4b97-8898-53849c9a0ca4"
/>
<img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/884301e2-1edd-4ad2-92ac-ece656853fe7"
/>

- Tested GuitarFreaks XG2 on a non-Japanese (English US) Windows.
- Verified that Community Log preset comments render correctly in
Japanese.
- Verified that Cooperation Challenge descriptions, rewards, and
progress text render correctly.
- Confirmed that the previous mojibake and question marks no longer
appear.
- GitHub Actions build completed successfully for both architectures.
2026-08-12 00:08:05 -07:00
bicarus ce2f93d234 graphics: refactor DX9 screenshot (part 2) (#861) 2026-08-11 01:11:42 -07:00
bicarus 13a171f199 sdvx: fix api touch in windowed mode (#862)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #858 

## Description of change
Fix orientation being wrong in windowed mode when API touch is used

Fix mouse no longer working when API client is in use.

This PR only affects windowed mode SDVX.

## Testing
2026-08-10 18:58:48 -07:00
bicarus 3da352488e graphics: refactor screenshots (#860)
First commit - pure refactoring, no functional changes

Second commit - clean up & minor bug fix.
2026-08-09 20:47:38 -07:00
bicarus 2386047c2f sdvx: fix touchscreen and API touch in landscape mode (#859)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #858 

## Description of change
Landscape mode needs another adjustment applied to touch coordinates.

This fixex both native touch and wintouchemu paths.

## Testing
Tested landscape, also with full screen resolution changes.
2026-08-08 16:22:29 -07:00
33 changed files with 1235 additions and 588 deletions
+2
View File
@@ -536,6 +536,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
hooks/graphics/nvapi_hook.cpp
hooks/graphics/nvenc_hook.cpp
hooks/graphics/backends/d3d9/d3d9_backend.cpp
hooks/graphics/backends/d3d9/d3d9_screenshot.cpp
hooks/graphics/backends/d3d9/d3d9_device.cpp
hooks/graphics/backends/d3d9/d3d9_gfdm.cpp
hooks/graphics/backends/d3d9/d3d9_live2d.cpp
@@ -577,6 +578,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
misc/device.cpp
misc/eamuse.cpp
misc/extdev.cpp
misc/hotkeys.cpp
misc/sciunit.cpp
misc/sde.cpp
misc/wintouchemu.cpp
+44 -11
View File
@@ -12,7 +12,9 @@
#include "touch/touch.h"
#include "touch/native/inject.h"
#include "touch/native/nativetouchhook.h"
#include "touch/native/transform.h"
#include "util/utils.h"
#include "games/gitadora/gitadora.h"
#include "games/iidx/iidx.h"
using namespace std::placeholders;
@@ -40,6 +42,35 @@ namespace api::modules {
return nativetouch::inject::inject_synthetic_touch(position, true);
}
// map API coordinates onto the touch space SDVX reads, which depends on how it is displayed
static void sdvx_touch_errata(
int &x, int &y, bool use_native, int canvas_w, int canvas_h) {
// windowed coordinates already match the sub screen window they land on
if (GRAPHICS_WINDOWED) {
return;
}
// landscape mode: native injection hands the game these coordinates
// unchanged, so apply the rotation the touchscreen gets, while wintouchemu instead
// rotates them later through the subscreen overlay
if (GRAPHICS_FS_ORIENTATION_SWAP) {
if (use_native) {
POINT position { x, y };
if (nativetouch::transform::sdvx_landscape_rotate(&position, canvas_w, canvas_h)) {
x = position.x;
y = position.y;
}
}
return;
}
// rotate into the portrait touch space
const int x_raw = x;
x = canvas_w - y;
y = x_raw;
}
Touch::Touch() : Module("touch") {
is_sdvx = avs::game::is_model("KFC");
@@ -55,9 +86,12 @@ namespace api::modules {
native_canvas_w = 0;
native_canvas_h = 0;
if (is_sdvx) {
// exceed gear subscreen, portrait after the rotation applied in apply_touch_errata
native_canvas_w = 1080;
native_canvas_h = 1920;
// windowed API coordinates land on the sub screen window as-is; fullscreen
// coordinates are rotated into the game's touch space by apply_touch_errata
const bool landscape_coordinates =
GRAPHICS_WINDOWED || GRAPHICS_FS_ORIENTATION_SWAP;
native_canvas_w = landscape_coordinates ? 1920 : 1080;
native_canvas_h = landscape_coordinates ? 1080 : 1920;
} else if (avs::game::is_model("LDJ")) {
// TDJ subscreen; FHD models are upscaled to 1080p by apply_touch_errata
native_canvas_w = is_tdj_fhd ? 1920 : 1280;
@@ -66,6 +100,10 @@ namespace api::modules {
// pop'n music API touch surface
native_canvas_w = 1280;
native_canvas_h = 800;
} else if (games::gitadora::is_arena_model()) {
// GITADORA arena SMALL subscreen, either in its own window or in the overlay
native_canvas_w = games::gitadora::ARENA_SUBSCREEN_WIDTH;
native_canvas_h = games::gitadora::ARENA_SUBSCREEN_HEIGHT;
}
functions["read"] = std::bind(&Touch::read, this, _1, _2);
@@ -209,19 +247,14 @@ namespace api::modules {
}
void Touch::apply_touch_errata(int &x, int &y) {
int x_raw = x;
int y_raw = y;
if (is_tdj_fhd) {
// deal with TDJ FHD resolution mismatch (upgrade 720p to 1080p)
// we don't know what screen is being shown on the companion and the API doesn't specify
// the target of the touch events so just assume it's the sub screen
x = x_raw * 1920 / 1280;
y = y_raw * 1080 / 720;
x = x * 1920 / 1280;
y = y * 1080 / 720;
} else if (is_sdvx) {
// for exceed gear, they are both 1080p screens, but need to apply transformation
x = 1080 - y_raw;
y = x_raw;
sdvx_touch_errata(x, y, use_native, native_canvas_w, native_canvas_h);
}
}
}
+11 -11
View File
@@ -41,6 +41,7 @@ namespace games::gitadora {
bool ARENA_TWO_HEAD_EXCLUSIVE = false;
std::optional<std::string> ASIO_DRIVER = std::nullopt;
bool ALLOW_REALTEK_AUDIO = false;
bool NATIVE_TOUCH = false;
/*
* Prevent GitaDora from creating folders on F drive
@@ -809,17 +810,16 @@ namespace games::gitadora {
hooks::audio::INJECT_FAKE_REALTEK_AUDIO = true;
}
// Single-window mode needs touch injection for its overlay. Two-head fullscreen
// mode uses the real SMALL output, so it only needs the display-topology shim.
if (GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
// enable touch hook for subscreen overlay
const auto native_touch_ready = !wintouchemu::FORCE &&
nativetouch::hook(avs::game::DLL_INSTANCE);
if (!native_touch_ready) {
wintouchemu::FORCE = true;
wintouchemu::INJECT_MOUSE_AS_WM_TOUCH = true;
wintouchemu::hook("GITADORA", avs::game::DLL_INSTANCE);
}
// touch injection drives mouse-as-touch and API touch for the subscreen,
// no matter whether it is drawn by the overlay (single-window mode) or by
// the dedicated SMALL window
NATIVE_TOUCH = !wintouchemu::FORCE &&
nativetouch::hook(avs::game::DLL_INSTANCE);
if (!NATIVE_TOUCH && GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
// the legacy fallback can only feed the subscreen overlay
wintouchemu::FORCE = true;
wintouchemu::INJECT_MOUSE_AS_WM_TOUCH = true;
wintouchemu::hook("GITADORA", avs::game::DLL_INSTANCE);
}
#if !SPICE_XP
+5
View File
@@ -23,6 +23,11 @@ namespace games::gitadora {
extern bool ARENA_TWO_HEAD_EXCLUSIVE;
extern std::optional<std::string> ASIO_DRIVER;
extern bool ALLOW_REALTEK_AUDIO;
extern bool NATIVE_TOUCH;
// arena SMALL subscreen (touch panel) resolution
static constexpr int ARENA_SUBSCREEN_WIDTH = 800;
static constexpr int ARENA_SUBSCREEN_HEIGHT = 1280;
class GitaDoraGame : public games::Game {
public:
@@ -23,10 +23,7 @@
#include "external/imgui/backends/imgui_impl_dx11.h"
#include "overlay/imgui/impl_spice.h"
#include "games/io.h"
#include "hooks/graphics/graphics.h"
#include "launcher/launcher.h"
#include "misc/eamuse.h"
#include "util/utils.h"
// --------------------------------------------------------------------------
@@ -108,8 +105,62 @@ Present1_t Present1_orig = nullptr;
bool g_swapchain_hooked = false;
bool g_swapchain1_hooked = false;
// sub-screens / IME helpers are usually child or zero-sized windows.
// visibility isn't checked - the game may present before showing the window.
bool looks_like_game_window(HWND hwnd) {
RECT client {};
return GetAncestor(hwnd, GA_ROOT) == hwnd
&& GetClientRect(hwnd, &client)
&& client.right > client.left
&& client.bottom > client.top;
}
// only the main game window; ignore sub-screens / IME helpers.
bool is_main_game_swapchain(IDXGISwapChain *swapchain) {
DXGI_SWAP_CHAIN_DESC desc {};
if (!swapchain || FAILED(swapchain->GetDesc(&desc)) || !desc.OutputWindow) {
return false;
}
HWND main = d3d11_hooks::main_hwnd();
if (!main) {
// no creation hook recorded a window, so fall back to the presenting one;
// the choice is permanent, so require a plausible game window
if (!looks_like_game_window(desc.OutputWindow)) {
return false;
}
log_misc(
"graphics::d3d11",
"try to notemain hwnd from swapchain present: 0x{:x}",
(uintptr_t)desc.OutputWindow);
d3d11_hooks::note_main_hwnd(desc.OutputWindow);
// it may have been ignored, or another thread may have won the slot
main = d3d11_hooks::main_hwnd();
}
return desc.OutputWindow == main;
}
// checks are ordered cheapest first, since this runs on every present
void try_create_overlay(IDXGISwapChain *swapchain) {
if (!swapchain || overlay::OVERLAY) {
if (!swapchain) {
return;
}
// overlay is disabled by user
if (!overlay::ENABLED) {
return;
}
// overlay is already enabled and attached
if (overlay::OVERLAY) {
return;
}
// ignore sub windows
if (!is_main_game_swapchain(swapchain)) {
return;
}
@@ -118,12 +169,6 @@ void try_create_overlay(IDXGISwapChain *swapchain) {
return;
}
// only attach to the main game window; ignore sub-screens / IME helpers.
HWND main = d3d11_hooks::main_hwnd();
if (main && desc.OutputWindow != main) {
return;
}
// theme the native title bar; first present is the only reliable point for
// windows whose swapchain bypasses our factory hooks (e.g. UnityPlayer.dll)
set_window_dark_titlebar(desc.OutputWindow);
@@ -149,43 +194,42 @@ void try_create_overlay(IDXGISwapChain *swapchain) {
device->Release();
}
// rising-edge screenshot hotkey poll (mirrors d3d9 backend behaviour).
void poll_screenshot_hotkey() {
static bool s_down = false;
auto buttons = games::get_buttons_overlay(eamuse_get_game());
const bool pressed = buttons
&& (!overlay::OVERLAY || overlay::OVERLAY->hotkeys_triggered())
&& GameAPI::Buttons::getState(RI_MGR,
buttons->at(games::OverlayButtons::Screenshot));
if (pressed && !s_down) {
graphics_screenshot_trigger();
}
s_down = pressed;
}
void pump_overlay(IDXGISwapChain *swapchain) {
if (!overlay::OVERLAY || !overlay::OVERLAY->uses_swapchain(swapchain)) {
// screenshots have to keep working with the overlay disabled, so they are not gated on it
void pump_frame(IDXGISwapChain *swapchain) {
const bool has_overlay =
overlay::OVERLAY && overlay::OVERLAY->uses_swapchain(swapchain);
if (!has_overlay && !is_main_game_swapchain(swapchain)) {
return;
}
poll_screenshot_hotkey();
graphics_poll_screenshot_hotkey();
// size imgui to the backbuffer (not window client). dxgi may upscale
// a small backbuffer into a larger client rect; without this override
// imgui would draw past the RTV and the mouse mapping would be off.
DXGI_SWAP_CHAIN_DESC desc {};
if (SUCCEEDED(swapchain->GetDesc(&desc))) {
ImGui_ImplSpice_SetDisplaySizeOverride(
(float) desc.BufferDesc.Width,
(float) desc.BufferDesc.Height);
// before the overlay render so the screenshot excludes it
if (!GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
d3d11_hooks::try_screenshot(swapchain);
}
overlay::OVERLAY->update();
overlay::OVERLAY->new_frame();
overlay::OVERLAY->render();
if (has_overlay) {
// after overlay render so toasts/menus end up in the saved image.
d3d11_hooks::try_screenshot(swapchain);
// size imgui to the backbuffer (not window client). dxgi may upscale
// a small backbuffer into a larger client rect; without this override
// imgui would draw past the RTV and the mouse mapping would be off.
DXGI_SWAP_CHAIN_DESC desc {};
if (SUCCEEDED(swapchain->GetDesc(&desc))) {
ImGui_ImplSpice_SetDisplaySizeOverride(
(float) desc.BufferDesc.Width,
(float) desc.BufferDesc.Height);
}
overlay::OVERLAY->update();
overlay::OVERLAY->new_frame();
overlay::OVERLAY->render();
}
// after the overlay render so the screenshot includes toasts / menus
if (GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
d3d11_hooks::try_screenshot(swapchain);
}
}
// ----------------------------------------------------------------------
@@ -194,8 +238,11 @@ void pump_overlay(IDXGISwapChain *swapchain) {
HRESULT STDMETHODCALLTYPE Present_hook(
IDXGISwapChain *swapchain, UINT SyncInterval, UINT Flags)
{
try_create_overlay(swapchain);
pump_overlay(swapchain);
// a test present doesn't display anything; don't pick a window or take a screenshot off it
if (!(Flags & DXGI_PRESENT_TEST)) {
try_create_overlay(swapchain);
pump_frame(swapchain);
}
return Present_orig(swapchain, SyncInterval, Flags);
}
@@ -203,8 +250,10 @@ HRESULT STDMETHODCALLTYPE Present1_hook(
IDXGISwapChain1 *swapchain, UINT SyncInterval, UINT Flags,
const DXGI_PRESENT_PARAMETERS *pParams)
{
try_create_overlay(swapchain);
pump_overlay(swapchain);
if (!(Flags & DXGI_PRESENT_TEST)) {
try_create_overlay(swapchain);
pump_frame(swapchain);
}
return Present1_orig(swapchain, SyncInterval, Flags, pParams);
}
@@ -5,12 +5,8 @@
#include <memory>
#include <thread>
#include <vector>
#include <external/robin_hood.h>
#include <d3d9.h>
#ifdef __GNUC__
#include <d3dx9tex.h>
#endif
#include "avs/game.h"
#include "cfg/screen_resize.h"
@@ -19,28 +15,23 @@
#include "games/popn/popn.h"
#include "games/sdvx/sdvx.h"
#include "games/mfc/mfc.h"
#include "games/io.h"
#include "hooks/graphics/graphics.h"
#include "launcher/launcher.h"
#include "launcher/options.h"
#include "launcher/signal.h"
#include "launcher/shutdown.h"
#include "misc/clipboard.h"
#include "misc/eamuse.h"
#include "misc/wintouchemu.h"
#include "overlay/overlay.h"
#include "overlay/notifications.h"
#include "util/detour.h"
#include "util/deferlog.h"
#include "util/fileutils.h"
#include "util/flags_helper.h"
#include "util/libutils.h"
#include "util/logging.h"
#include "util/utils.h"
#include "util/memutils.h"
#include "util/threadpool.h"
#include "d3d9_device.h"
#include "d3d9_screenshot.h"
#ifdef min
#undef min
@@ -64,19 +55,6 @@
return __ret; \
} while (0)
#ifdef __GNUC__
typedef decltype(D3DXSaveSurfaceToFileA) *D3DXSaveSurfaceToFileA_t;
#else
#define D3DXIFF_PNG ((DWORD) 3)
typedef HRESULT (WINAPI *D3DXSaveSurfaceToFileA_t)(
LPCSTR pDestFile,
DWORD DestFormat,
LPDIRECT3DSURFACE9 pSrcSurface,
CONST PALETTEENTRY *pSrcPalette,
CONST RECT *pSrcRect);
#endif
/*
* 9 on 12
*/
@@ -100,8 +78,6 @@ typedef IDirect3D9* (WINAPI *Direct3DCreate9On12_t)(
static void *D3D9_DIRECT3D_CREATE9_ADR = nullptr;
static char D3D9_DIRECT3D_CREATE9_CONTENTS[16];
static bool ATTEMPTED_D3DX9_LOAD_LIBRARY = false;
// settings
std::optional<UINT> D3D9_ADAPTER = std::nullopt;
DWORD D3D9_BEHAVIOR_DISABLE = 0;
@@ -1499,182 +1475,10 @@ static void graphics_d3d9_ldj_on_present(IDirect3DDevice9 *wrapped_device) {
}
}
static void save_capture(
int screen,
D3DFORMAT format,
UINT width,
UINT height,
IDirect3DSurface9 *surface) {
HRESULT hr;
// lock surface to be able to access the data
D3DLOCKED_RECT finished_copy {};
hr = surface->LockRect(&finished_copy, nullptr, 0);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to lock screenshot surface, hr={}", FMT_HRESULT(hr));
graphics_capture_skip(screen);
return;
}
// copy pixel data
size_t pitch = finished_copy.Pitch;
auto data = reinterpret_cast<uint8_t *>(finished_copy.pBits);
auto pixels = new uint8_t[width * height * 3];
for (size_t row = 0; row < height; row++) {
size_t offset_pixels = 0;
size_t offset_row = row * width * 3;
switch (format) {
case D3DFMT_R8G8B8: {
for (size_t offset = 0; offset < pitch; offset += 3) {
auto cell = data + row * pitch + offset;
auto pixel = &pixels[offset_row + offset_pixels];
pixel[0] = cell[0];
pixel[1] = cell[1];
pixel[2] = cell[2];
offset_pixels += 3;
}
break;
}
case D3DFMT_X8R8G8B8:
case D3DFMT_A8R8G8B8: {
for (size_t offset = 0; offset < pitch; offset += 4) {
auto cell = data + row * pitch + offset;
auto pixel = &pixels[offset_row + offset_pixels];
pixel[0] = cell[2];
pixel[1] = cell[1];
pixel[2] = cell[0];
offset_pixels += 3;
}
break;
}
case D3DFMT_X8B8G8R8:
case D3DFMT_A8B8G8R8: {
for (size_t offset = 0; offset < pitch; offset += 4) {
auto cell = data + row * pitch + offset;
auto pixel = &pixels[offset_row + offset_pixels];
pixel[0] = cell[0];
pixel[1] = cell[1];
pixel[2] = cell[2];
offset_pixels += 3;
}
break;
}
default: {
for (size_t offset = 0; offset < width; offset++) {
auto pixel = &pixels[offset_row + offset_pixels];
pixel[0] = 0;
pixel[1] = 0;
pixel[2] = 0;
offset_pixels += 3;
}
}
}
}
// unlock surface
hr = surface->UnlockRect();
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to unlock screenshot surface, hr={}", FMT_HRESULT(hr));
graphics_capture_skip(screen);
return;
}
// enqueue
graphics_capture_enqueue(screen, pixels, width, height);
}
static void save_screenshot(const std::string &file_path, UINT height, IDirect3DSurface9 *surface) {
HRESULT hr;
D3DLOCKED_RECT finished_copy {};
hr = surface->LockRect(&finished_copy, nullptr, 0);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to lock screenshot surface, hr={}", FMT_HRESULT(hr));
return;
}
// set alpha channel to 255
{
auto pitch = finished_copy.Pitch;
auto data = reinterpret_cast<uint8_t *>(finished_copy.pBits);
for (size_t i = 0; i < height; i++) {
for (int j = 3; j < pitch; j += 4) {
data[i * pitch + j] = 255;
}
}
}
hr = surface->UnlockRect();
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to unlock screenshot surface, hr={}", FMT_HRESULT(hr));
return;
}
// lazy load function
static D3DXSaveSurfaceToFileA_t D3DXSaveSurfaceToFileA_ptr = nullptr;
if (D3DXSaveSurfaceToFileA_ptr == nullptr) {
D3DXSaveSurfaceToFileA_ptr = libutils::try_proc<D3DXSaveSurfaceToFileA_t>("D3DXSaveSurfaceToFileA");
// check if function was not found, likely because d3dx9 is not loaded
if (!ATTEMPTED_D3DX9_LOAD_LIBRARY && D3DXSaveSurfaceToFileA_ptr == nullptr) {
ATTEMPTED_D3DX9_LOAD_LIBRARY = true;
for (size_t i = 43; i >= 24; i--) {
auto lib_name = fmt::format("d3dx9_{}.dll", i);
auto d3dx9 = libutils::try_library(lib_name);
// Check if library was not found
if (d3dx9 == nullptr) {
continue;
}
D3DXSaveSurfaceToFileA_ptr = libutils::try_proc<D3DXSaveSurfaceToFileA_t>(
d3dx9, "D3DXSaveSurfaceToFileA");
// Check if function was not found
if (D3DXSaveSurfaceToFileA_ptr == nullptr) {
FreeLibrary(d3dx9);
d3dx9 = nullptr;
continue;
}
log_info("graphics::d3d9", "found surface save function in '{}'", lib_name);
break;
}
}
}
if (D3DXSaveSurfaceToFileA_ptr != nullptr) {
// save to file
log_info("graphics::d3d9", "saving screenshot to {}", file_path);
auto hr = D3DXSaveSurfaceToFileA_ptr(file_path.c_str(), D3DXIFF_PNG, surface, nullptr, nullptr);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "Failed to save screenshot");
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to save");
return;
}
// save to clipboard
clipboard::copy_image(file_path);
overlay::notifications::add(
overlay::notifications::Severity::Success,
fmt::format("Screenshot saved: {}", fileutils::basename(file_path)));
} else {
log_warning("graphics::d3d9", "Direct3D save helper function not available");
}
}
void graphics_d3d9_on_present(
HWND hFocusWindow,
IDirect3DDevice9 *device,
IDirect3DDevice9 *wrapped_device) {
WrappedIDirect3DDevice9 *wrapped_device) {
// image resize / orientation swap. run here (the present path) rather than from `EndScene`,
// which may fire several times per frame on multi-pass / render-to-texture games. this is the
@@ -1685,6 +1489,13 @@ void graphics_d3d9_on_present(
SurfaceHook(device);
}
graphics_poll_screenshot_hotkey();
// before the overlay render so the screenshot excludes it
if (!GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
graphics_d3d9_process_screenshot(device, wrapped_device);
}
// Do overlay init as many d3d9 hooks create a dummy instance to get vtable offsets and never
// call `Present`. This avoids race conditions on `IDirect3D9::CreateDevice` like with
// `dx9osd.dll` for pfreepanic.
@@ -1704,6 +1515,15 @@ void graphics_d3d9_on_present(
device->EndScene();
}
// after the overlay render so the screenshot includes toasts / menus
if (GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
graphics_d3d9_process_screenshot(device, wrapped_device);
}
// API capture always includes the overlay; it must run before the subscreen present
// below, which leaves the arena SMALL back buffer black
graphics_d3d9_process_capture(device, wrapped_device);
// for IIDX TDJ / SDVX UFC, handle subscreen
const bool is_vm = games::sdvx::is_valkyrie_model();
const bool is_tdj = avs::game::is_model("LDJ") && games::iidx::TDJ_MODE;
@@ -1717,136 +1537,6 @@ void graphics_d3d9_on_present(
if (is_mfc) {
wintouchemu::update();
}
// check screenshot key
static bool trigger_last = false;
auto buttons = games::get_buttons_overlay(eamuse_get_game());
if (buttons && (!overlay::OVERLAY || overlay::OVERLAY->hotkeys_triggered()) &&
GameAPI::Buttons::getState(RI_MGR, buttons->at(games::OverlayButtons::Screenshot)))
{
if (!trigger_last) {
graphics_screenshot_trigger();
}
trigger_last = true;
} else {
trigger_last = false;
}
// process pending screenshot
bool screenshot = false;
bool capture = false;
int capture_screen = 0;
if ((screenshot = graphics_screenshot_consume())
|| ((capture = graphics_capture_consume(&capture_screen)))) {
HRESULT hr = S_OK;
// TODO: verify capture_screen is a valid swapchain
// get back buffer
IDirect3DSurface9 *buffer = nullptr;
if (SUB_SWAP_CHAIN != nullptr && capture_screen & 1) {
hr = SUB_SWAP_CHAIN->GetBackBuffer(0, D3DBACKBUFFER_TYPE_MONO, &buffer);
} else {
hr = device->GetBackBuffer(capture_screen, 0, D3DBACKBUFFER_TYPE_MONO, &buffer);
}
if (FAILED(hr) || buffer == nullptr) {
log_warning("graphics::d3d9",
"failed to get back buffer, hr={}",
FMT_HRESULT(hr));
if (capture) {
graphics_capture_skip(capture_screen);
}
return;
}
D3DSURFACE_DESC desc {};
hr = buffer->GetDesc(&desc);
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to acquire back buffer descriptor, hr={}",
FMT_HRESULT(hr));
buffer->Release();
if (capture) {
graphics_capture_skip(capture_screen);
}
return;
}
// TODO: cache render targets
IDirect3DSurface9 *temp_surface = nullptr;
hr = device->CreateRenderTarget(
desc.Width, desc.Height, desc.Format, desc.MultiSampleType,
desc.MultiSampleQuality, TRUE, &temp_surface, nullptr);
if (FAILED(hr) || temp_surface == nullptr) {
log_warning("graphics::d3d9",
"failed to acquire temporary surface, hr={}",
FMT_HRESULT(hr));
buffer->Release();
if (capture) {
graphics_capture_skip(capture_screen);
}
return;
}
hr = device->StretchRect(buffer, nullptr, temp_surface, nullptr, D3DTEXF_NONE);
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to copy back buffer contents, hr={}",
FMT_HRESULT(hr));
temp_surface->Release();
buffer->Release();
if (capture) {
graphics_capture_skip(capture_screen);
}
return;
}
// release original back buffer reference
buffer->Release();
// function for storing the surface
auto surface_process = [=]() {
// capture
if (capture) {
save_capture(capture_screen, desc.Format, desc.Width, desc.Height, temp_surface);
}
// screenshot
if (screenshot) {
// check where we can save it
auto file_path = graphics_screenshot_genpath();
if (!file_path.empty()) {
// write to file
save_screenshot(file_path, desc.Height, temp_surface);
}
}
// release surface
temp_surface->Release();
};
// list of games that crash when running the screenshot processor on another thread
static const robin_hood::unordered_set<std::string> THREAD_BAN {
"JMA",
#ifndef SPICE64
"KFC",
#endif
"KMA",
"KLP",
"LMA",
};
// run the save operation on another thread for supported games
if (THREAD_BAN.contains(avs::game::MODEL)) {
surface_process();
} else {
static auto pool = ThreadPool(2);
pool.add(surface_process);
}
}
}
void update_backbuffer_dimensions(D3DPRESENT_PARAMETERS *params) {
@@ -4,6 +4,8 @@
#include "d3d9_gfdm.h"
struct WrappedIDirect3DDevice9;
// {EEE9CCF6-53D6-4326-9AE5-60921B3DB394}
static const GUID IID_WrappedIDirect3D9 = {
0xeee9ccf6, 0x53d6, 0x4326, { 0x9a, 0xe5, 0x60, 0x92, 0x1b, 0x3d, 0xb3, 0x94 }
@@ -13,7 +15,7 @@ void graphics_d3d9_init();
void graphics_d3d9_on_present(
HWND hFocusWindow,
IDirect3DDevice9 *device,
IDirect3DDevice9 *wrapped_device);
WrappedIDirect3DDevice9 *wrapped_device);
void graphics_d3d9_notify_subscreen_present();
@@ -23,6 +23,11 @@
#include "shaders/vertex_shader.h"
#endif
// maps arena's cached additional swap chains (SMALL, LEFT, RIGHT) to screen numbers.
// MAIN is the implicit swap chain, is not in those slots, and is always screen 0.
// screen 1 is the subscreen for every other game, so SMALL takes that number here too.
static constexpr int GFDM_ARENA_SLOT_SCREENS[] { 1, 2, 3 };
#define CHECK_RESULT_FMT(x, fmt, ...) \
HRESULT __ret = (x); \
if (GRAPHICS_LOG_HRESULT && FAILED(__ret)) [[unlikely]] { \
@@ -336,20 +341,25 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreateAdditionalSwapChain(
int index = 0;
bool create_swap_chain = false;
bool create_fake_swap_chain = false;
bool arena_slot = false;
if (avs::game::is_model({"LDJ", "KFC", "M39"})) {
create_swap_chain = true;
} else if (games::gitadora::is_arena_model() &&
(GRAPHICS_PREVENT_SECONDARY_WINDOWS || GRAPHICS_GITADORA_HIDE_SIDE_WINDOWS)) {
(GRAPHICS_SCREENSHOT_SUBSCREENS ||
GRAPHICS_PREVENT_SECONDARY_WINDOWS ||
GRAPHICS_GITADORA_HIDE_SIDE_WINDOWS)) {
if (pPresentationParameters->BackBufferWidth == 800) {
// SMALL (subscreen)
create_swap_chain = true;
arena_slot = true;
index = 0;
} else if (pPresentationParameters->BackBufferWidth == 1080) {
// LEFT/RIGHT
create_swap_chain = true;
arena_slot = true;
index = 1;
if (sub_swapchain[index] || fake_sub_swapchain[index]) {
index = 2;
@@ -361,6 +371,11 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreateAdditionalSwapChain(
}
}
// the api lists screens from this registry, so arena heads need their logical numbers in it
if (arena_slot) {
graphics_screens_register(GFDM_ARENA_SLOT_SCREENS[index]);
}
if (create_fake_swap_chain) {
if (!fake_sub_swapchain[index]) {
log_info(
@@ -539,6 +554,64 @@ UINT STDMETHODCALLTYPE WrappedIDirect3DDevice9::GetNumberOfSwapChains() {
return n;
}
void WrappedIDirect3DDevice9::get_screenshot_screens(std::vector<int> &screens) const {
if (games::gitadora::is_arena_model()) {
screens.push_back(0);
// every head the game renders into, whether or not it reaches a display
for (int slot = 0; slot < 3; slot++) {
if (sub_swapchain[slot] != nullptr || fake_sub_swapchain[slot] != nullptr) {
screens.push_back(GFDM_ARENA_SLOT_SCREENS[slot]);
}
}
return;
}
graphics_screens_get(screens);
// the sub screen is only registered once the game asks for it by index
if (sub_swapchain[0] != nullptr &&
avs::game::is_model({"LDJ", "KFC", "M39"}) &&
std::find(screens.begin(), screens.end(), 1) == screens.end())
{
screens.push_back(1);
}
}
HRESULT WrappedIDirect3DDevice9::get_screenshot_swap_chain(
UINT iSwapChain,
IDirect3DSwapChain9 **ppSwapChain)
{
if (ppSwapChain == nullptr) {
return D3DERR_INVALIDCALL;
}
// the game numbers the two-head SMALL head itself; keep screen 1 meaning SMALL
if (games::gitadora::is_arena_model() && is_gfdm_two_head_exclusive() && iSwapChain == 1) {
return GetSwapChain(gfdm_logical_small_swapchain, ppSwapChain);
}
if (games::gitadora::is_arena_model()) {
for (int slot = 0; slot < 3; slot++) {
if (GFDM_ARENA_SLOT_SCREENS[slot] != (int) iSwapChain) {
continue;
}
if (sub_swapchain[slot] != nullptr) {
sub_swapchain[slot]->AddRef();
*ppSwapChain = sub_swapchain[slot];
return D3D_OK;
}
if (fake_sub_swapchain[slot] != nullptr) {
fake_sub_swapchain[slot]->AddRef();
*ppSwapChain = fake_sub_swapchain[slot];
return D3D_OK;
}
}
}
return GetSwapChain(iSwapChain, ppSwapChain);
}
HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::Reset(
D3DPRESENT_PARAMETERS *pPresentationParameters)
{
@@ -3,6 +3,7 @@
#include <array>
#include <atomic>
#include <mutex>
#include <vector>
#include <initguid.h>
#include <d3d9.h>
@@ -234,6 +235,10 @@ struct WrappedIDirect3DDevice9 : IDirect3DDevice9Ex {
virtual HRESULT STDMETHODCALLTYPE GetDisplayModeEx(UINT iSwapChain, D3DDISPLAYMODEEX *pMode, D3DDISPLAYROTATION *pRotation) override;
#pragma endregion
// logical screens the game draws, and the swap chain each one lives on
void get_screenshot_screens(std::vector<int> &screens) const;
HRESULT get_screenshot_swap_chain(UINT iSwapChain, IDirect3DSwapChain9 **ppSwapChain);
bool is_gfdm_two_head_exclusive() const;
bool is_gfdm_logical_small_swapchain(UINT swapchain) const;
bool is_gfdm_logical_side_swapchain(UINT swapchain) const;
@@ -4,10 +4,12 @@
#include <d3d9.h>
#include "games/gitadora/gitadora.h"
inline constexpr UINT GFDM_SIDE_WIDTH = 1080;
inline constexpr UINT GFDM_SIDE_HEIGHT = 1920;
inline constexpr UINT GFDM_SMALL_WIDTH = 800;
inline constexpr UINT GFDM_SMALL_HEIGHT = 1280;
inline constexpr UINT GFDM_SMALL_WIDTH = games::gitadora::ARENA_SUBSCREEN_WIDTH;
inline constexpr UINT GFDM_SMALL_HEIGHT = games::gitadora::ARENA_SUBSCREEN_HEIGHT;
inline constexpr UINT GFDM_LOGICAL_HEAD_COUNT = 4;
inline constexpr UINT GFDM_NATIVE_SMALL_SWAPCHAIN = 1;
@@ -0,0 +1,470 @@
#include "d3d9_screenshot.h"
#include <cstdint>
#include <filesystem>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include <external/robin_hood.h>
#ifdef __GNUC__
#include <d3dx9tex.h>
#endif
#include "avs/game.h"
#include "hooks/graphics/graphics.h"
#include "misc/clipboard.h"
#include "overlay/notifications.h"
#include "util/fileutils.h"
#include "util/libutils.h"
#include "util/logging.h"
#include "util/threadpool.h"
#include "d3d9_device.h"
#ifdef __GNUC__
typedef decltype(D3DXSaveSurfaceToFileA) *D3DXSaveSurfaceToFileA_t;
#else
#define D3DXIFF_PNG ((DWORD) 3)
typedef HRESULT (WINAPI *D3DXSaveSurfaceToFileA_t)(
LPCSTR pDestFile,
DWORD DestFormat,
LPDIRECT3DSURFACE9 pSrcSurface,
CONST PALETTEENTRY *pSrcPalette,
CONST RECT *pSrcRect);
#endif
static bool ATTEMPTED_D3DX9_LOAD_LIBRARY = false;
// genpath picks free filenames by probing the disk, so only one save may run at a time
static std::mutex SCREENSHOT_SAVE_M;
namespace {
enum class ImageRequestKind {
Screenshot,
Capture,
};
struct ImageRequest {
ImageRequestKind kind;
int screen;
};
struct SurfaceReleaser {
void operator()(IDirect3DSurface9 *surface) const {
surface->Release();
}
};
using SurfacePtr = std::unique_ptr<IDirect3DSurface9, SurfaceReleaser>;
struct BackbufferCopy {
int screen {};
D3DSURFACE_DESC desc {};
SurfacePtr surface;
};
} // namespace
static void save_capture(
int screen,
D3DFORMAT format,
UINT width,
UINT height,
IDirect3DSurface9 *surface) {
HRESULT hr;
// lock surface to be able to access the data
D3DLOCKED_RECT finished_copy {};
hr = surface->LockRect(&finished_copy, nullptr, 0);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to lock screenshot surface, hr={}", FMT_HRESULT(hr));
graphics_capture_skip(screen);
return;
}
// normalize supported D3D formats to packed RGB for API capture
size_t pitch = finished_copy.Pitch;
auto data = reinterpret_cast<uint8_t *>(finished_copy.pBits);
auto pixels = std::unique_ptr<uint8_t[]>(new uint8_t[width * height * 3]);
for (size_t row = 0; row < height; row++) {
size_t offset_row = row * width * 3;
switch (format) {
case D3DFMT_R8G8B8: {
for (size_t column = 0; column < width; column++) {
auto cell = data + row * pitch + column * 3;
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = cell[0];
pixel[1] = cell[1];
pixel[2] = cell[2];
}
break;
}
case D3DFMT_X8R8G8B8:
case D3DFMT_A8R8G8B8: {
for (size_t column = 0; column < width; column++) {
auto cell = data + row * pitch + column * 4;
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = cell[2];
pixel[1] = cell[1];
pixel[2] = cell[0];
}
break;
}
case D3DFMT_X8B8G8R8:
case D3DFMT_A8B8G8R8: {
for (size_t column = 0; column < width; column++) {
auto cell = data + row * pitch + column * 4;
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = cell[0];
pixel[1] = cell[1];
pixel[2] = cell[2];
}
break;
}
default: {
for (size_t column = 0; column < width; column++) {
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = 0;
pixel[1] = 0;
pixel[2] = 0;
}
}
}
}
// unlock surface
hr = surface->UnlockRect();
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to unlock screenshot surface, hr={}", FMT_HRESULT(hr));
graphics_capture_skip(screen);
return;
}
// enqueue
graphics_capture_enqueue(screen, pixels.release(), width, height);
}
static bool save_screenshot(
const std::string &file_path,
D3DFORMAT format,
UINT width,
UINT height,
IDirect3DSurface9 *surface) {
// 32-bit XRGB and ARGB surfaces use byte 3 as alpha; force opaque PNG output
if (format == D3DFMT_X8R8G8B8 || format == D3DFMT_A8R8G8B8 ||
format == D3DFMT_X8B8G8R8 || format == D3DFMT_A8B8G8R8) {
D3DLOCKED_RECT finished_copy {};
HRESULT hr = surface->LockRect(&finished_copy, nullptr, 0);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to lock screenshot surface, hr={}", FMT_HRESULT(hr));
return false;
}
const size_t pitch = finished_copy.Pitch;
auto data = reinterpret_cast<uint8_t *>(finished_copy.pBits);
for (size_t row = 0; row < height; row++) {
for (size_t column = 0; column < width; column++) {
data[row * pitch + column * 4 + 3] = 255;
}
}
hr = surface->UnlockRect();
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to unlock screenshot surface, hr={}", FMT_HRESULT(hr));
return false;
}
}
// lazy load function
static D3DXSaveSurfaceToFileA_t D3DXSaveSurfaceToFileA_ptr = nullptr;
if (D3DXSaveSurfaceToFileA_ptr == nullptr) {
D3DXSaveSurfaceToFileA_ptr = libutils::try_proc<D3DXSaveSurfaceToFileA_t>("D3DXSaveSurfaceToFileA");
// check if function was not found, likely because d3dx9 is not loaded
if (!ATTEMPTED_D3DX9_LOAD_LIBRARY && D3DXSaveSurfaceToFileA_ptr == nullptr) {
ATTEMPTED_D3DX9_LOAD_LIBRARY = true;
// prefer the newest installed helper while supporting older D3DX9 runtimes
for (size_t i = 43; i >= 24; i--) {
auto lib_name = fmt::format("d3dx9_{}.dll", i);
auto d3dx9 = libutils::try_library(lib_name);
// Check if library was not found
if (d3dx9 == nullptr) {
continue;
}
D3DXSaveSurfaceToFileA_ptr = libutils::try_proc<D3DXSaveSurfaceToFileA_t>(
d3dx9, "D3DXSaveSurfaceToFileA");
// Check if function was not found
if (D3DXSaveSurfaceToFileA_ptr == nullptr) {
FreeLibrary(d3dx9);
d3dx9 = nullptr;
continue;
}
log_info("graphics::d3d9", "found surface save function in '{}'", lib_name);
break;
}
}
}
if (D3DXSaveSurfaceToFileA_ptr == nullptr) {
log_warning("graphics::d3d9", "Direct3D save helper function not available");
return false;
}
// save to file
log_info("graphics::d3d9", "saving screenshot to {}", file_path);
const HRESULT save_result = D3DXSaveSurfaceToFileA_ptr(
file_path.c_str(), D3DXIFF_PNG, surface, nullptr, nullptr);
if (FAILED(save_result)) {
log_warning("graphics::d3d9", "Failed to save screenshot");
return false;
}
return true;
}
// screen 0 keeps the plain name so existing tooling and the clipboard copy are unaffected
static std::string screenshot_path_for_screen(const std::string &primary_path, int screen) {
if (screen == 0) {
return primary_path;
}
const std::filesystem::path path(primary_path);
return (path.parent_path() /
fmt::format("{}_{}{}", path.stem().string(), screen, path.extension().string()))
.string();
}
static std::optional<BackbufferCopy> acquire_backbuffer_copy(
IDirect3DDevice9 *device, IDirect3DSwapChain9 *swap_chain, int screen) {
IDirect3DSurface9 *buffer = nullptr;
HRESULT hr = swap_chain->GetBackBuffer(0, D3DBACKBUFFER_TYPE_MONO, &buffer);
if (FAILED(hr) || buffer == nullptr) {
log_warning("graphics::d3d9",
"failed to get back buffer for screen {}, hr={}",
screen,
FMT_HRESULT(hr));
return std::nullopt;
}
D3DSURFACE_DESC desc {};
hr = buffer->GetDesc(&desc);
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to acquire back buffer descriptor, hr={}",
FMT_HRESULT(hr));
buffer->Release();
return std::nullopt;
}
// TODO: cache render targets
IDirect3DSurface9 *temp_surface = nullptr;
hr = device->CreateRenderTarget(
desc.Width, desc.Height, desc.Format, desc.MultiSampleType,
desc.MultiSampleQuality, TRUE, &temp_surface, nullptr);
if (FAILED(hr) || temp_surface == nullptr) {
log_warning("graphics::d3d9",
"failed to acquire temporary surface, hr={}",
FMT_HRESULT(hr));
buffer->Release();
return std::nullopt;
}
hr = device->StretchRect(buffer, nullptr, temp_surface, nullptr, D3DTEXF_NONE);
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to copy back buffer contents, hr={}",
FMT_HRESULT(hr));
temp_surface->Release();
buffer->Release();
return std::nullopt;
}
// release original back buffer reference
buffer->Release();
return BackbufferCopy {
.screen = screen,
.desc = desc,
.surface = SurfacePtr(temp_surface),
};
}
static void dispatch_surface_save(
const ImageRequest &request,
std::vector<BackbufferCopy> copies,
size_t screen_count) {
auto surface_process = [request, screen_count, copies = std::move(copies)]() {
switch (request.kind) {
case ImageRequestKind::Capture: {
const auto &copy = copies.front();
save_capture(
request.screen,
copy.desc.Format,
copy.desc.Width,
copy.desc.Height,
copy.surface.get());
break;
}
case ImageRequestKind::Screenshot: {
std::lock_guard<std::mutex> lock(SCREENSHOT_SAVE_M);
std::vector<int> screens;
screens.reserve(copies.size());
for (const auto &copy : copies) {
screens.push_back(copy.screen);
}
const auto base_path = graphics_screenshot_genpath(screens);
if (base_path.empty()) {
break;
}
// screens missing from copies already failed to be acquired
size_t failed = screen_count - copies.size();
std::string primary_path;
std::string notify_path;
for (const auto &copy : copies) {
const auto path = screenshot_path_for_screen(base_path, copy.screen);
if (!save_screenshot(
path,
copy.desc.Format,
copy.desc.Width,
copy.desc.Height,
copy.surface.get())) {
failed++;
continue;
}
if (notify_path.empty()) {
notify_path = path;
}
if (copy.screen == 0) {
primary_path = path;
}
}
// only the primary screen goes to the clipboard, but any saved file is a success
if (!primary_path.empty()) {
clipboard::copy_image(primary_path);
}
if (!notify_path.empty()) {
overlay::notifications::add(
overlay::notifications::Severity::Success,
fmt::format("Screenshot saved: {}", fileutils::basename(notify_path)));
} else {
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to save");
}
if (failed > 0) {
log_warning("graphics::d3d9", "{} screenshot screen(s) missing", failed);
}
break;
}
}
};
// list of games that crash when running the screenshot processor on another thread
static const robin_hood::unordered_set<std::string> THREAD_BAN {
"JMA",
#ifndef SPICE64
// KFC only crashes under threaded processing in 32-bit builds
"KFC",
#endif
"KMA",
"KLP",
"LMA",
};
// run the save operation on another thread for supported games
if (THREAD_BAN.contains(avs::game::MODEL)) {
surface_process();
} else {
static auto pool = ThreadPool(2);
pool.add(std::move(surface_process));
}
}
static void process_image_request(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device,
const ImageRequest &request) {
const bool screenshot = request.kind == ImageRequestKind::Screenshot;
std::vector<int> screens { request.screen };
if (screenshot && GRAPHICS_SCREENSHOT_SUBSCREENS) {
screens.clear();
wrapped_device->get_screenshot_screens(screens);
}
std::vector<BackbufferCopy> copies;
copies.reserve(screens.size());
for (const int screen : screens) {
std::optional<BackbufferCopy> copy;
IDirect3DSwapChain9 *swap_chain = nullptr;
HRESULT hr = wrapped_device->get_screenshot_swap_chain(screen, &swap_chain);
if (FAILED(hr) || swap_chain == nullptr) {
log_warning("graphics::d3d9",
"failed to get swap chain for screen {}, hr={}",
screen,
FMT_HRESULT(hr));
} else {
copy = acquire_backbuffer_copy(device, swap_chain, screen);
swap_chain->Release();
}
if (copy.has_value()) {
copies.emplace_back(std::move(*copy));
} else if (!screenshot) {
graphics_capture_skip(request.screen);
return;
}
}
if (copies.empty()) {
return;
}
dispatch_surface_save(request, std::move(copies), screens.size());
}
void graphics_d3d9_process_screenshot(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device) {
if (graphics_screenshot_consume()) {
process_image_request(device, wrapped_device, ImageRequest {
.kind = ImageRequestKind::Screenshot,
.screen = 0,
});
}
}
void graphics_d3d9_process_capture(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device) {
int screen = 0;
if (graphics_capture_consume(&screen)) {
process_image_request(device, wrapped_device, ImageRequest {
.kind = ImageRequestKind::Capture,
.screen = screen,
});
}
}
@@ -0,0 +1,13 @@
#pragma once
#include <d3d9.h>
struct WrappedIDirect3DDevice9;
void graphics_d3d9_process_screenshot(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device);
void graphics_d3d9_process_capture(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device);
+70 -5
View File
@@ -17,11 +17,13 @@
#include "games/ddr/ddr.h"
#include "games/gitadora/gitadora.h"
#include "games/iidx/iidx.h"
#include "games/io.h"
#include "games/sdvx/sdvx.h"
#include "games/popn/popn.h"
#include "hooks/graphics/backends/d3d9/d3d9_backend.h"
#include "hooks/graphics/backends/d3d11/d3d11_backend.h"
#include "launcher/shutdown.h"
#include "misc/hotkeys.h"
#include "overlay/overlay.h"
#include "touch/touch.h"
#include "touch/touch_gestures.h"
@@ -31,6 +33,7 @@
#include "util/utils.h"
#include "misc/wintouchemu.h"
#include "touch/native/inject.h"
#include "touch/native/nativetouchhook.h"
#include "util/time.h"
#include "rawinput/rawinput.h"
@@ -116,6 +119,8 @@ uint32_t GRAPHICS_FS_ORIGINAL_HEIGHT = 0;
// settings
std::string GRAPHICS_DEVICEID = "PCI\\VEN_1002&DEV_7146";
std::string GRAPHICS_SCREENSHOT_DIR = ".\\screenshots";
bool GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY = false;
bool GRAPHICS_SCREENSHOT_SUBSCREENS = false;
static decltype(ChangeDisplaySettingsA) *ChangeDisplaySettingsA_orig = nullptr;
static decltype(ChangeDisplaySettingsExA) *ChangeDisplaySettingsExA_orig = nullptr;
@@ -212,6 +217,18 @@ static void gitadora_remember_window(HWND hWnd, const std::string &window_name)
} else if (window_name == "SMALL") {
GFDM_SUBSCREEN_WINDOW = hWnd;
}
// touch belongs to the SMALL panel when it exists, otherwise to the main window
// that draws the subscreen overlay
const bool hosts_touch = window_name == "SMALL" ||
(window_name == "GITADORA" && !graphics_gitadora_has_dedicated_subscreen());
if (nativetouch::is_hooked() && hWnd != nullptr && hosts_touch) {
nativetouch::inject::set_preferred_injection_window(hWnd);
}
}
bool graphics_gitadora_has_dedicated_subscreen() {
return GFDM_SUBSCREEN_WINDOW != nullptr;
}
bool graphics_gitadora_prepare_two_head_device_window(
@@ -612,8 +629,10 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC
}
}
const bool is_sdvx = avs::game::is_model("KFC");
bool is_tdj_sub_window = avs::game::is_model("LDJ") && window_name.ends_with(" sub");
bool is_sdvx_sub_window = avs::game::is_model("KFC") && window_name.ends_with(" Sub Screen");
bool is_sdvx_sub_window = is_sdvx && window_name.ends_with(" Sub Screen");
bool is_sdvx_main_window = is_sdvx && window_name.ends_with(" Main Screen");
bool is_popn_sub_window = avs::game::is_model("M39") && window_name.ends_with("Sub Screen");
const std::string gfdm_window_name = games::gitadora::is_arena_model()
? gitadora_canonical_window_name(effective_window_name)
@@ -716,6 +735,20 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC
graphics_hook_subscreen_window(SDVX_SUBSCREEN_WINDOW);
}
// SDVX registers touch on both windows, so name the one synthetic touches must land on
// instead of letting window creation order decide: the sub screen window when windowed,
// the main window in fullscreen since the game reads it in primary-display coordinates
if (nativetouch::is_hooked() &&
result != nullptr &&
(GRAPHICS_WINDOWED ? is_sdvx_sub_window : is_sdvx_main_window)) {
log_misc(
"graphics",
"SDVX touch surface is {}, {}",
fmt::ptr(result),
window_name);
nativetouch::inject::set_preferred_injection_window(result);
}
// only hook touch window if multiple windows are allowed
if (gfdm_window_name == "LEFT" || gfdm_window_name == "RIGHT") {
gitadora_remember_window(result, gfdm_window_name);
@@ -727,6 +760,11 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC
if (GRAPHICS_WINDOWED && !GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
graphics_hook_subscreen_window(GFDM_SUBSCREEN_WINDOW);
}
// the dedicated SMALL window is the touch panel; mouse and API touch target it
if (nativetouch::is_hooked() && result != nullptr) {
nativetouch::inject::register_and_attach_window(result);
}
}
if (is_gfdm_window && GRAPHICS_WINDOWED && !GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
gitadora_force_window_style(result);
@@ -1100,6 +1138,15 @@ static BOOL WINAPI ShowWindow_hook(HWND hWnd, int nCmdShow) {
return true;
}
// fullscreen SDVX keeps two adapters so the subscreen overlay can draw, so the game still
// creates the sub window even when the user asked for it to be gone
if (avs::game::is_model("KFC") &&
GRAPHICS_PREVENT_SECONDARY_WINDOWS &&
hWnd == SDVX_SUBSCREEN_WINDOW) {
log_info("graphics", "ShowWindow_hook - hiding sub window {}", fmt::ptr(hWnd));
return true;
}
// call original
return ShowWindow_orig(hWnd, nCmdShow);
}
@@ -1316,7 +1363,9 @@ void graphics_hook_window(HWND hWnd, D3DPRESENT_PARAMETERS *pPresentationParamet
const bool native_touch_overlay =
(games::iidx::NATIVE_TOUCH && games::iidx::TDJ_MODE && !GRAPHICS_IIDX_WSUB) ||
(games::popn::NATIVE_TOUCH &&
games::popn::is_pikapika_model() && GRAPHICS_PREVENT_SECONDARY_WINDOWS);
games::popn::is_pikapika_model() && GRAPHICS_PREVENT_SECONDARY_WINDOWS) ||
(games::gitadora::NATIVE_TOUCH &&
games::gitadora::is_arena_model() && GRAPHICS_PREVENT_SECONDARY_WINDOWS);
if (native_touch_overlay) {
nativetouch::inject::register_and_attach_window(hWnd);
}
@@ -1374,6 +1423,12 @@ void graphics_screens_get(std::vector<int> &screens) {
screens.insert(screens.end(), GRAPHICS_SCREENS.begin(), GRAPHICS_SCREENS.end());
}
void graphics_poll_screenshot_hotkey() {
if (hotkeys::consume_screenshot()) {
graphics_screenshot_trigger();
}
}
void graphics_screenshot_trigger() {
GRAPHICS_SCREENSHOT_TRIGGER = true;
}
@@ -1522,7 +1577,7 @@ bool graphics_capture_receive_jpeg(int screen, TooJpeg::WRITE_ONE_BYTE receiver,
return success;
}
std::string graphics_screenshot_genpath() {
std::string graphics_screenshot_genpath(const std::vector<int> &screens) {
// verify dir path
if (GRAPHICS_SCREENSHOT_DIR.empty()) {
@@ -1547,11 +1602,21 @@ std::string graphics_screenshot_genpath() {
auto tm_now = *std::gmtime(&t_now);
auto prefix = to_string(std::put_time(&tm_now, "%Y%m%d"));
// find next filename
// find next filename; the whole set has to be free so one shot stays numbered together
size_t id = 0;
while (true) {
auto filepath = fmt::format("{}\\{}_{}.png", GRAPHICS_SCREENSHOT_DIR, prefix, id);
if (!fileutils::file_exists(filepath)) {
bool available = !fileutils::file_exists(filepath);
for (const auto screen : screens) {
if (!available) {
break;
}
if (screen != 0) {
available = !fileutils::file_exists(fmt::format(
"{}\\{}_{}_{}.png", GRAPHICS_SCREENSHOT_DIR, prefix, id, screen));
}
}
if (available) {
return filepath;
}
+6 -1
View File
@@ -111,6 +111,8 @@ extern bool FAKE_SUBSCREEN_ADAPTER;
// settings
extern std::string GRAPHICS_DEVICEID;
extern std::string GRAPHICS_SCREENSHOT_DIR;
extern bool GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY;
extern bool GRAPHICS_SCREENSHOT_SUBSCREENS;
// Direct3D 9 settings
extern std::optional<UINT> D3D9_ADAPTER;
@@ -119,6 +121,7 @@ extern bool D3D9_DEVICE_HOOK_DISABLE;
void graphics_init();
void graphics_hook_window(HWND hWnd, D3DPRESENT_PARAMETERS *pPresentationParameters);
bool graphics_gitadora_has_dedicated_subscreen();
// The native GITADORA two-head D3D9 group uses the game's named SMALL
// device window for the native physical SMALL head. The game requests
// D3DCREATE_NOWINDOWCHANGES, so this host must be made borderless and sized
@@ -134,6 +137,7 @@ void graphics_hook_subscreen_window(HWND hWnd);
void graphics_screens_register(int screen);
void graphics_screens_unregister(int screen);
void graphics_screens_get(std::vector<int> &screens);
void graphics_poll_screenshot_hotkey();
void graphics_screenshot_trigger();
bool graphics_screenshot_consume();
void graphics_capture_trigger(int screen);
@@ -144,7 +148,8 @@ bool graphics_capture_receive_jpeg(int screen, TooJpeg::WRITE_ONE_BYTE receiver,
bool rgb = true, int quality = 80, bool downsample = true, int divide = 0,
uint64_t *timestamp = nullptr,
int *width = nullptr, int *height = nullptr);
std::string graphics_screenshot_genpath();
// the returned path is for screen 0; any extra screens only reserve their suffixed names
std::string graphics_screenshot_genpath(const std::vector<int> &screens = {});
// graphics_windowed.cpp
void graphics_windowed_wndproc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
+22 -10
View File
@@ -26,13 +26,13 @@ constexpr UINT CODEPAGE_SHIFT_JIS = 932;
static decltype(GetACP) *GetACP_orig = nullptr;
static decltype(GetOEMCP) *GetOEMCP_orig = nullptr;
static decltype(MultiByteToWideChar) *MultiByteToWideChar_orig = nullptr;
static decltype(WideCharToMultiByte) *WideCharToMultiByte_orig = nullptr;
static decltype(GetLocaleInfoEx) *GetLocaleInfoEx_orig = nullptr;
#ifdef SPICE64
static decltype(GetSystemDefaultLCID) *GetSystemDefaultLCID_orig = nullptr;
static decltype(IsDBCSLeadByte) *IsDBCSLeadByte_orig = nullptr;
static decltype(IsDBCSLeadByteEx) *IsDBCSLeadByteEx_orig = nullptr;
static decltype(WideCharToMultiByte) *WideCharToMultiByte_orig = nullptr;
static decltype(GetLocaleInfoA) *GetLocaleInfoA_orig = nullptr;
static decltype(GetThreadLocale) *GetThreadLocale_orig = nullptr;
#endif
@@ -209,6 +209,8 @@ static BOOL WINAPI IsDBCSLeadByteEx_hook(
return IsDBCSLeadByteEx_orig(CodePage, TestChar);
}
#endif
static
int
WINAPI
@@ -244,6 +246,8 @@ WideCharToMultiByte_hook(
lpUsedDefaultChar);
}
#ifdef SPICE64
int
WINAPI
GetLocaleInfoA_hook(
@@ -343,15 +347,6 @@ void hooks::lang::early_init() {
&IsDBCSLeadByte_orig);
}
if (games::gitadora::is_arena_model() || avs::game::is_model("T44")) {
log_info("hooks::lang", "hooking WideCharToMultiByte");
detour::trampoline_try(
"kernel32.dll",
"WideCharToMultiByte",
WideCharToMultiByte_hook,
&WideCharToMultiByte_orig);
}
if (games::popn::is_pikapika_model() && native_code_page == CP_UTF8) {
detour::trampoline_try(
"kernel32.dll",
@@ -362,6 +357,23 @@ void hooks::lang::early_init() {
#endif
#ifdef SPICE64
const auto hook_wide_char_to_multi_byte =
games::gitadora::is_arena_model() || avs::game::is_model("T44");
#else
// XG2 converts UTF-8 property strings through CP_ACP before rendering.
const auto hook_wide_char_to_multi_byte = avs::game::is_model({ "K32", "K33" });
#endif
if (hook_wide_char_to_multi_byte) {
log_info("hooks::lang", "hooking WideCharToMultiByte");
detour::trampoline_try(
"kernel32.dll",
"WideCharToMultiByte",
WideCharToMultiByte_hook,
&WideCharToMultiByte_orig);
}
}
void hooks::lang::init() {
+19 -12
View File
@@ -87,13 +87,13 @@
#include "launcher/launcher.h"
#include "launcher/logger.h"
#include "launcher/signal.h"
#include "launcher/superexit.h"
#include "launcher/richpresence.h"
#include "launcher/shutdown.h"
#include "launcher/options.h"
#include "misc/bt5api.h"
#include "misc/device.h"
#include "misc/eamuse.h"
#include "misc/hotkeys.h"
#include "misc/extdev.h"
#include "misc/ami2000.h"
#include "misc/sciunit.h"
@@ -1103,6 +1103,12 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::ScreenshotFolder].is_active()) {
GRAPHICS_SCREENSHOT_DIR = options[launcher::Options::ScreenshotFolder].value_text();
}
if (options[launcher::Options::ScreenshotIncludeOverlay].value_bool()) {
GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY = true;
}
if (options[launcher::Options::ScreenshotSubscreens].value_bool()) {
GRAPHICS_SCREENSHOT_SUBSCREENS = true;
}
if (options[launcher::Options::DisableColoredOutput].value_bool()) {
logger::COLOR = false;
}
@@ -1777,9 +1783,8 @@ int main_implementation(int argc, char *argv[]) {
nvapi::initialize();
// add application profile to nvcp
nvapi::set_profile_settings();
// enable super exit
superexit::enable();
// keep ALT+F4 available during lengthy non-standalone boot
hotkeys::start();
// enable subscreen touch emulation
if (options[launcher::Options::spice2x_IIDXEmulateSubscreenKeypadTouch].is_active()) {
games::iidx::ENABLE_POKE = true;
@@ -2452,6 +2457,7 @@ int main_implementation(int argc, char *argv[]) {
// initialize raw input
RI_MGR = std::make_unique<rawinput::RawInputManager>();
hotkeys::enable_raw_input();
for (const auto &device : sextet_devices) {
RI_MGR->sextet_register(device);
}
@@ -2474,6 +2480,9 @@ int main_implementation(int argc, char *argv[]) {
log_misc("rawinput", "Analog mappings:");
dump_analog_bindings();
// mappings are ready; begin screenshot and coin polling during late startup
hotkeys::enable_input();
// for certain games, show cursor if no touch is available (must be called after RI_MGR is available)
if (show_cursor_if_no_touch && !is_touch_available("launcher::main_implementation")) {
GRAPHICS_SHOW_CURSOR = true;
@@ -2703,9 +2712,6 @@ int main_implementation(int argc, char *argv[]) {
API_CONTROLLER->listen_serial(api_serial_port[i], api_serial_baud[i]);
}
// start coin input thread
eamuse_coin_start_thread();
// pin macro
if (!cfg::CONFIGURATOR_STANDALONE && PIN_MACRO_ENABLED) {
eamuse_pin_macro_start_thread();
@@ -2755,6 +2761,9 @@ int main_implementation(int argc, char *argv[]) {
log_info("launcher", "calling game entry");
avs::game::entry_main();
// stop screenshot and coin polling; mapped SuperExit and ALT+F4 remain active
hotkeys::disable_input();
// clear presence
richpresence::shutdown();
@@ -2792,9 +2801,6 @@ int main_implementation(int argc, char *argv[]) {
// free api controller
API_CONTROLLER.reset();
// stop coin input thread
eamuse_coin_stop_thread();
eamuse_pin_macro_stop_thread();
// BT5API
@@ -2805,6 +2811,7 @@ int main_implementation(int argc, char *argv[]) {
sdk::fini_sdk_modules();
// stop raw input
hotkeys::disable_raw_input();
RI_MGR.reset();
// debug hook
@@ -2837,8 +2844,8 @@ int main_implementation(int argc, char *argv[]) {
// dispose crypt
crypt::dispose();
// disable super exit
superexit::disable();
// end early/late ALT+F4 monitoring at the same teardown point as legacy SuperExit
hotkeys::stop();
// disable poke
games::iidx::poke::disable();
+16
View File
@@ -3397,6 +3397,22 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.type = OptionType::Bool,
.category = "OBS Control",
},
{
// ScreenshotIncludeOverlay
.title = "Include Overlay in Screenshots",
.name = "screenshotoverlay",
.desc = "Includes Spice overlay in screenshots.",
.type = OptionType::Bool,
.category = "General Overlay",
},
{
// ScreenshotSubscreens
.title = "Include Subscreens in Screenshots",
.name = "screenshotsub",
.desc = "Saves each subscreen as a separate PNG alongside the primary screenshot.",
.type = OptionType::Bool,
.category = "General Overlay",
},
};
const std::vector<std::string> &launcher::get_categories(Options::OptionsCategory category) {
+3 -1
View File
@@ -320,7 +320,9 @@ namespace launcher {
OBSWebSocketHost,
OBSWebSocketPort,
OBSWebSocketPassword,
OBSWebSocketDebug
OBSWebSocketDebug,
ScreenshotIncludeOverlay,
ScreenshotSubscreens
};
enum class OptionsCategory {
+18 -95
View File
@@ -1,21 +1,14 @@
#include "superexit.h"
#include <thread>
#include "windows.h"
#include "cfg/configurator.h"
#include "launcher/shutdown.h"
#include "rawinput/rawinput.h"
#include "util/logging.h"
#include "touch/touch.h"
#include "misc/eamuse.h"
#include "games/io.h"
#include "overlay/overlay.h"
#include "util/logging.h"
namespace superexit {
static std::thread *THREAD = nullptr;
static bool THREAD_RUNNING = false;
bool has_focus() {
HWND fg_wnd = GetForegroundWindow();
if (fg_wnd == NULL) {
@@ -29,92 +22,22 @@ namespace superexit {
return fg_pid == GetCurrentProcessId();
}
void enable() {
// check if already running
if (THREAD)
return;
// create new thread
THREAD_RUNNING = true;
THREAD = new std::thread([] {
// log
log_info("superexit", "enabled");
// set variable to false to stop
while (THREAD_RUNNING) {
// check rawinput for ALT+F4
bool rawinput_exit = false;
if (RI_MGR != nullptr) {
auto devices = RI_MGR->devices_get();
for (auto &device : devices) {
switch (device.type) {
case rawinput::KEYBOARD: {
auto &key_states = device.keyboardInfo->key_states;
for (int page_index = 0; page_index < 1024; page_index += 256) {
if (key_states[page_index + VK_MENU]
&& key_states[page_index + VK_F4]) {
rawinput_exit = true;
}
}
break;
}
default:
break;
}
}
}
bool async_key_exit =
(GetAsyncKeyState(VK_MENU) & 0x8000) != 0 &&
(GetAsyncKeyState(VK_F4) & 0x8000) != 0;
bool overlay_exit = false;
auto buttons = games::get_buttons_overlay(eamuse_get_game());
if (buttons &&
(!overlay::OVERLAY || overlay::OVERLAY->hotkeys_triggered()) &&
GameAPI::Buttons::getState(RI_MGR, buttons->at(games::OverlayButtons::SuperExit))) {
overlay_exit = true;
}
// check for exit
if (rawinput_exit || async_key_exit) {
if (has_focus()) {
log_info("superexit", "detected ALT+F4, exiting...");
launcher::shutdown();
}
}
if (overlay_exit) {
if (has_focus()) {
log_info("superexit", "detected Force Exit Game overlay shortcut, exiting...");
launcher::shutdown();
}
}
// slow down
Sleep(100);
}
return nullptr;
});
}
void disable() {
if (!THREAD) {
void handle_hotkeys(bool alt_f4, bool mapped_exit) {
if (!alt_f4 && !mapped_exit) {
return;
}
// stop old thread
THREAD_RUNNING = false;
THREAD->join();
// delete thread
delete THREAD;
THREAD = nullptr;
// log
log_info("superexit", "disabled");
if (cfg::CONFIGURATOR_STANDALONE) {
return;
}
if (!has_focus()) {
return;
}
if (alt_f4) {
log_info("superexit", "detected ALT+F4, exiting...");
launcher::shutdown();
return;
}
log_info("superexit", "detected Force Exit Game overlay shortcut, exiting...");
launcher::shutdown();
}
}
+1 -2
View File
@@ -2,6 +2,5 @@
namespace superexit {
bool has_focus();
void enable();
void disable();
void handle_hotkeys(bool alt_f4, bool mapped_exit);
}
+24 -55
View File
@@ -25,10 +25,8 @@ static double CARD_INSERT_TIME[2] = {0, 0};
static double CARD_INSERT_TIMEOUT = 2.0;
static char CARD_INSERT_UID[2][8] = {{0}, {0}};
static char CARD_INSERT_UID_ENABLE[2] = {false, false};
static int COIN_STOCK = 0;
static bool COIN_BLOCK = false;
static std::thread *COIN_INPUT_THREAD;
static bool COIN_INPUT_THREAD_ACTIVE = false;
static std::atomic_int COIN_STOCK {0};
static std::atomic_bool COIN_BLOCK {false};
static uint16_t KEYPAD_STATE[] = {0, 0};
static uint16_t KEYPAD_STATE_OVERRIDES[] = {0, 0};
static uint16_t KEYPAD_STATE_OVERRIDES_BT5[] = {0, 0};
@@ -292,78 +290,49 @@ bool eamuse_card_insert_consume(int active_count, int unit_id) {
}
bool eamuse_coin_get_block() {
return COIN_BLOCK;
return COIN_BLOCK.load(std::memory_order_relaxed);
}
void eamuse_coin_set_block(bool block) {
COIN_BLOCK = block;
COIN_BLOCK.store(block, std::memory_order_relaxed);
}
int eamuse_coin_get_stock() {
return COIN_STOCK;
return COIN_STOCK.load(std::memory_order_relaxed);
}
void eamuse_coin_set_stock(int amount) {
COIN_STOCK = amount;
COIN_STOCK.store(amount, std::memory_order_relaxed);
}
bool eamuse_coin_consume(int amount) {
if (COIN_STOCK < amount) {
return false;
} else {
COIN_STOCK -= amount;
return true;
auto stock = COIN_STOCK.load(std::memory_order_relaxed);
while (stock >= amount) {
if (COIN_STOCK.compare_exchange_weak(
stock,
stock - amount,
std::memory_order_relaxed)) {
return true;
}
}
return false;
}
int eamuse_coin_consume_stock() {
int stock = COIN_STOCK;
COIN_STOCK = 0;
return stock;
return COIN_STOCK.exchange(0, std::memory_order_relaxed);
}
int eamuse_coin_add() {
return ++COIN_STOCK;
return COIN_STOCK.fetch_add(1, std::memory_order_relaxed) + 1;
}
void eamuse_coin_start_thread() {
// set active
COIN_INPUT_THREAD_ACTIVE = true;
// create thread
COIN_INPUT_THREAD = new std::thread([]() {
auto overlay_buttons = games::get_buttons_overlay(eamuse_get_game());
static bool COIN_INPUT_KEY_STATE = false;
while (COIN_INPUT_THREAD_ACTIVE) {
// check input key
if (overlay_buttons && GameAPI::Buttons::getState(RI_MGR, overlay_buttons->at(
games::OverlayButtons::InsertCoin))) {
if (!COIN_INPUT_KEY_STATE) {
if (COIN_BLOCK)
log_info("eamuse", "coin inserted while blocked");
else {
log_info("eamuse", "coin insert");
COIN_STOCK++;
}
}
COIN_INPUT_KEY_STATE = true;
} else {
COIN_INPUT_KEY_STATE = false;
}
// once every two frames
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / 30));
}
});
}
void eamuse_coin_stop_thread() {
COIN_INPUT_THREAD_ACTIVE = false;
COIN_INPUT_THREAD->join();
delete COIN_INPUT_THREAD;
COIN_INPUT_THREAD = nullptr;
void eamuse_coin_insert() {
if (COIN_BLOCK.load(std::memory_order_relaxed)) {
log_info("eamuse", "coin inserted while blocked");
} else {
log_info("eamuse", "coin insert");
COIN_STOCK.fetch_add(1, std::memory_order_relaxed);
}
}
void eamuse_pin_macro_start_thread() {
+1 -3
View File
@@ -58,9 +58,7 @@ bool eamuse_coin_consume(int amount);
int eamuse_coin_consume_stock();
int eamuse_coin_add();
void eamuse_coin_start_thread();
void eamuse_coin_stop_thread();
void eamuse_coin_insert();
void eamuse_pin_macro_start_thread();
void eamuse_pin_macro_stop_thread();
+154
View File
@@ -0,0 +1,154 @@
#include "hotkeys.h"
#include <atomic>
#include <chrono>
#include <mutex>
#include <stop_token>
#include <thread>
#include <vector>
#include <windows.h>
#include "games/io.h"
#include "launcher/superexit.h"
#include "misc/eamuse.h"
#include "overlay/overlay.h"
#include "rawinput/rawinput.h"
#include "util/logging.h"
namespace hotkeys {
namespace {
// 8 ms targets short screenshot pulses; sleep_for may use a coarser scheduler
// interval during early boot or when process timer adjustments are disabled
constexpr auto MIN_SAMPLE_INTERVAL = std::chrono::milliseconds(8);
std::atomic_bool SCREENSHOT_PENDING {false};
std::mutex INPUT_MUTEX;
bool INPUT_ENABLED = false;
bool RAW_INPUT_ENABLED = false;
std::jthread WORKER;
bool read_button(std::vector<Button> *buttons, size_t index) {
// getState retains each binding's focus, modifier, inversion, and debounce policy
return RI_MGR && buttons && index < buttons->size() &&
GameAPI::Buttons::getState(RI_MGR, buttons->at(index));
}
bool read_alt_f4() {
// preserve both legacy detection paths whenever raw input is available
bool pressed = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0 &&
(GetAsyncKeyState(VK_F4) & 0x8000) != 0;
if (!RAW_INPUT_ENABLED || !RI_MGR) {
return pressed;
}
return pressed || RI_MGR->keyboard_combo_pressed(VK_MENU, VK_F4);
}
bool rising_edge(bool current, bool &previous) {
const bool edge = current && !previous;
previous = current;
return edge;
}
void run(std::stop_token stop_token) {
bool screenshot_previous = false;
bool coin_previous = false;
while (!stop_token.stop_requested()) {
bool coin_edge = false;
bool super_exit_current = false;
bool alt_f4_current = false;
{
// lifecycle functions hold this mutex until raw-input polling is complete
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
if (INPUT_ENABLED || RAW_INPUT_ENABLED) {
auto *buttons = games::get_buttons_overlay(eamuse_get_game());
const bool screenshot_down = INPUT_ENABLED && read_button(
buttons, games::OverlayButtons::Screenshot);
const bool coin_current = INPUT_ENABLED && read_button(
buttons, games::OverlayButtons::InsertCoin);
const bool super_exit_down = RAW_INPUT_ENABLED && read_button(
buttons, games::OverlayButtons::SuperExit);
// global_hotkeys_triggered takes OVERLAY_MUTEX, then the overlay's
// hotkeys_mutex; its button reads may then take device mutexes. polling
// every mapped-input tick is intentional so HotkeyToggle releases cannot
// be missed when the render thread stalls.
const bool gate_active = overlay::global_hotkeys_triggered();
const bool screenshot_current = screenshot_down && gate_active;
super_exit_current = super_exit_down && gate_active;
if (rising_edge(screenshot_current, screenshot_previous)) {
SCREENSHOT_PENDING.store(true, std::memory_order_relaxed);
}
coin_edge = rising_edge(coin_current, coin_previous);
} else {
screenshot_previous = false;
coin_previous = false;
}
alt_f4_current = read_alt_f4();
}
if (coin_edge) {
eamuse_coin_insert();
}
// pass held state so returning focus can exit without another key press
superexit::handle_hotkeys(alt_f4_current, super_exit_current);
std::this_thread::sleep_for(MIN_SAMPLE_INTERVAL);
}
}
}
void start() {
if (WORKER.joinable()) {
return;
}
SCREENSHOT_PENDING.store(false, std::memory_order_relaxed);
WORKER = std::jthread(run);
log_info("hotkeys", "sampler started");
}
void enable_raw_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
RAW_INPUT_ENABLED = true;
}
void enable_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
SCREENSHOT_PENDING.store(false, std::memory_order_relaxed);
INPUT_ENABLED = true;
log_info("hotkeys", "configured input enabled");
}
void disable_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
INPUT_ENABLED = false;
SCREENSHOT_PENDING.store(false, std::memory_order_relaxed);
log_info("hotkeys", "configured input disabled");
}
void disable_raw_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
RAW_INPUT_ENABLED = false;
}
void stop() {
if (!WORKER.joinable()) {
return;
}
WORKER.request_stop();
WORKER.join();
log_info("hotkeys", "sampler stopped");
}
bool consume_screenshot() {
return SCREENSHOT_PENDING.exchange(false, std::memory_order_relaxed);
}
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
namespace hotkeys {
// ALT+F4 monitoring spans boot and teardown; configured actions are enabled separately
void start();
void enable_raw_input();
void enable_input();
void disable_input();
void disable_raw_input();
void stop();
bool consume_screenshot();
}
+8
View File
@@ -158,6 +158,11 @@ void overlay::destroy(HWND hWnd) {
}
}
bool overlay::global_hotkeys_triggered() {
const std::lock_guard<std::mutex> lock(OVERLAY_MUTEX);
return !overlay::OVERLAY || overlay::OVERLAY->hotkeys_triggered();
}
overlay::SpiceOverlay::SpiceOverlay(HWND hWnd, IDirect3D9 *d3d, IDirect3DDevice9 *device)
: renderer(OverlayRenderer::D3D9), hWnd(hWnd), d3d(d3d), device(device) {
log_info("overlay", "initializing (D3D9)");
@@ -924,6 +929,9 @@ bool overlay::SpiceOverlay::has_focus() {
}
bool overlay::SpiceOverlay::hotkeys_triggered() {
const std::lock_guard<std::mutex> lock(this->hotkeys_mutex);
// this query also consumes the shared HotkeyToggle edge; the mutex gives all callers one latch
// prevent hotkeys in spicecfg
if (cfg::CONFIGURATOR_STANDALONE) {
return false;
+2
View File
@@ -212,6 +212,7 @@ namespace overlay {
bool fps_down = false;
bool hotkey_toggle = false;
bool hotkey_toggle_last = false;
std::mutex hotkeys_mutex;
// true between new_frame()'s ImGui::NewFrame() and render()'s ImGui::Render(),
// so render() never runs without a matching NewFrame.
@@ -235,4 +236,5 @@ namespace overlay {
#endif
void create_software(HWND hWnd);
void destroy(HWND hWnd = nullptr);
bool global_hotkeys_triggered();
}
@@ -25,6 +25,15 @@ namespace overlay::windows {
const ImVec4 YELLOW(1.f, 1.f, 0.f, 1.f);
const ImVec4 WHITE(1.f, 1.f, 1.f, 1.f);
// a subscreen is an already-composited backbuffer, so its alpha bits are not
// meaningful for display. Use AddImage's diffuse alpha (opaque by default)
// instead of the texture alpha while preserving ImGui's normal blend state.
static void ignore_texture_alpha(const ImDrawList *, const ImDrawCmd *command) {
auto device = static_cast<IDirect3DDevice9 *>(command->UserCallbackData);
device->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
device->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
}
GenericSubScreen::GenericSubScreen(SpiceOverlay *overlay) : Window(overlay), device(overlay->get_device()) {
this->remove_window_padding = true;
// ImGuiWindowFlags_NoBackground is needed as the background is drawn on top of the subscreen image
@@ -239,10 +248,13 @@ namespace overlay::windows {
auto draw_list = this->draws_window
? ImGui::GetWindowDrawList()
: ImGui::GetBackgroundDrawList();
draw_list->AddCallback(ignore_texture_alpha, this->device);
draw_list->AddImage(
reinterpret_cast<ImTextureID>(this->texture),
overlay_content_top_left,
bottom_right);
// following ImGui draws should resume normal texture-alpha modulation
draw_list->AddCallback(ImGui::GetPlatformIO().DrawCallback_ResetRenderState);
if (this->draws_window) {
// draw an invisible button so that it swallows mouse input
+25
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include <chrono>
#include <cstdarg>
#include <iterator>
#include <map>
#include <thread>
#include <utility>
@@ -2543,6 +2544,30 @@ rawinput::Device *rawinput::RawInputManager::devices_get(const std::string &name
return nullptr;
}
bool rawinput::RawInputManager::keyboard_combo_pressed(uint16_t first, uint16_t second) {
if (first >= 256 || second >= 256) {
return false;
}
std::lock_guard<std::recursive_mutex> devices_lock(this->devices_mutex);
for (auto &device : this->devices) {
if (device.type != rawinput::KEYBOARD ||
device.keyboardInfo == nullptr ||
device.mutex == nullptr) {
continue;
}
std::lock_guard<std::mutex> device_lock(*device.mutex);
const auto &states = device.keyboardInfo->key_states;
for (size_t page = 0; page < std::size(states); page += 256) {
if (states[page + first] && states[page + second]) {
return true;
}
}
}
return false;
}
void rawinput::RawInputManager::add_callback_add(void *data, std::function<void (void *, Device *)> callback) {
this->callback_add.push_back(DeviceCallback {
.data = data,
+1
View File
@@ -171,6 +171,7 @@ namespace rawinput {
void __stdcall devices_print();
Device *devices_get(const std::string &name, bool updated = false);
bool keyboard_combo_pressed(uint16_t first, uint16_t second);
inline std::list<Device> &devices_get() {
return devices;
+38 -3
View File
@@ -123,6 +123,30 @@ namespace nativetouch::inject {
// asks the touch window thread to send an UPDATE when the game loop runs elsewhere
static UINT contact_refresh_message;
// the window that shows the touch surface; contacts can be registered from any thread,
// so this is published by the graphics window hooks rather than derived from the globals
static std::atomic<HWND> preferred_injection_window { nullptr };
void set_preferred_injection_window(HWND window) {
preferred_injection_window.store(window, std::memory_order_release);
}
// a game that publishes its touch surface needs injection only on that window
static bool is_published_touch_window(HWND window) {
const auto preferred = preferred_injection_window.load(std::memory_order_acquire);
return preferred == nullptr || window == preferred;
}
// games can register several windows for touch; keep synthetic contacts on the one
// that actually shows the touch surface
static bool is_preferred_injection_window(HWND window) {
if (GRAPHICS_IIDX_WSUB) {
return window == TDJ_SUBSCREEN_WINDOW;
}
return is_published_touch_window(window);
}
// submit one synthetic contact frame to Windows touch injection
static bool inject_touch_frame(
POINT position, POINTER_FLAGS pointer_flags, bool retry_if_not_ready = false) {
@@ -325,11 +349,22 @@ namespace nativetouch::inject {
if (contact_state.input_window == window) {
release_active_contact();
}
HWND expected_window = window;
injection_window.compare_exchange_strong(
expected_window, nullptr, std::memory_order_acq_rel);
HWND expected_preferred_window = window;
preferred_injection_window.compare_exchange_strong(
expected_preferred_window, nullptr, std::memory_order_acq_rel);
if (GRAPHICS_IIDX_WSUB) {
HWND expected_delivery_window = window;
touch_delivery_window.compare_exchange_strong(
expected_delivery_window, nullptr, std::memory_order_acq_rel);
}
contact_refresh_pending.store(false, std::memory_order_release);
touch_delivery_window.store(nullptr, std::memory_order_release);
RemoveWindowSubclass(window, touch_window_subclass_proc, subclass_id);
}
@@ -370,7 +405,7 @@ namespace nativetouch::inject {
}
// publish the UI-thread target for synthetic touch requests
if (!GRAPHICS_IIDX_WSUB || window == TDJ_SUBSCREEN_WINDOW) {
if (is_preferred_injection_window(window)) {
injection_window.store(window, std::memory_order_release);
}
log_misc(
@@ -403,7 +438,7 @@ namespace nativetouch::inject {
// call original
const auto result = RegisterTouchWindow_orig(window, flags);
if (result) {
if (result && is_published_touch_window(window)) {
// attach but don't register for touch messages
// (we're already in the middle of RegisterTouchWindow as a result
// of the game calling it)
+1
View File
@@ -7,6 +7,7 @@ struct tagTOUCHINPUT;
namespace nativetouch::inject {
void attach_window(HWND window);
void register_and_attach_window(HWND window);
void set_preferred_injection_window(HWND window);
bool hook_available(HMODULE module);
bool hook(HMODULE module);
bool inject_synthetic_touch(POINT position, bool down);
+58 -3
View File
@@ -1,5 +1,6 @@
#include "transform.h"
#include "avs/game.h"
#include "hooks/graphics/graphics.h"
#include "overlay/overlay.h"
#include "settings.h"
@@ -37,6 +38,11 @@ namespace nativetouch::transform {
window == TDJ_SUBSCREEN_WINDOW;
}
// mouse-as-touch only applies while the cursor is over the target window
static bool is_cursor_over_window(HWND window, POINT position) {
return screen_to_game_client(window, &position);
}
// convert game touch coordinates to Windows desktop coordinates
bool game_to_screen(HWND window, POINT *position) {
if (settings::SYNTHETIC_TOUCH_USES_CLIENT_COORDINATES) {
@@ -65,7 +71,13 @@ namespace nativetouch::transform {
return ClientToScreen(window, position) != FALSE;
}
static bool has_active_overlay_transform() {
static bool overlay_owns_touch_input() {
// the arena SMALL window is the touch surface whenever it exists, so the
// subscreen overlay must not claim touch input in those window modes
if (graphics_gitadora_has_dedicated_subscreen()) {
return false;
}
return overlay::OVERLAY != nullptr &&
overlay::OVERLAY->get_active() &&
overlay::OVERLAY->has_subscreen_touch_transform();
@@ -82,6 +94,30 @@ namespace nativetouch::transform {
return overlay::OVERLAY->transform_touch_point(&position->x, &position->y);
}
// SDVX still expects portrait coordinates when its image is rendered in landscape:
// (x, y) -> (width * (1 - y / height), height * x / width).
bool sdvx_landscape_rotate(POINT *position, LONG width, LONG height) {
if (width <= 0 || height <= 0) {
return false;
}
const auto input_x = position->x;
position->x = width - MulDiv(position->y, width, height);
position->y = MulDiv(input_x, height, width);
return true;
}
// the digitizer is mapped to the zero-based primary display, so the contact is already
// in the effective landscape resolution the rotation is based on
static bool transform_sdvx_landscape_touch_position(POINT *position) {
const auto landscape_width = static_cast<LONG>(GRAPHICS_FS_CUSTOM_RESOLUTION.has_value() ?
GRAPHICS_FS_CUSTOM_RESOLUTION.value().first : GRAPHICS_FS_ORIGINAL_HEIGHT);
const auto landscape_height = static_cast<LONG>(GRAPHICS_FS_CUSTOM_RESOLUTION.has_value() ?
GRAPHICS_FS_CUSTOM_RESOLUTION.value().second : GRAPHICS_FS_ORIGINAL_WIDTH);
return sdvx_landscape_rotate(position, landscape_width, landscape_height);
}
// convert physical screen coordinates to game touch coordinates for a known target
bool screen_to_game(HWND window, POINT *position) {
if (settings::SYNTHETIC_TOUCH_USES_CLIENT_COORDINATES) {
@@ -112,7 +148,7 @@ namespace nativetouch::transform {
// check if subscreen overlay is active and can transform the touch point;
// if not, the touch point is valid as-is
if (!has_active_overlay_transform()) {
if (!overlay_owns_touch_input()) {
return true;
}
@@ -127,6 +163,18 @@ namespace nativetouch::transform {
return screen_to_game(window, position);
}
// exception: sdvx windowed subscreen does not use the subscreen overlay transform
if (GRAPHICS_WINDOWED && window == SDVX_SUBSCREEN_WINDOW) {
return is_cursor_over_window(window, *position);
}
// exception: the arena SMALL window is the touch panel, so accept the mouse there
// (and only there) with the coordinates a real contact on it would produce
if (graphics_gitadora_has_dedicated_subscreen()) {
return window == GFDM_SUBSCREEN_WINDOW &&
is_cursor_over_window(window, *position);
}
// if this game has a subscreen overlay that can transform touch input
// but the window is hidden or not under the cursor, reject mouse-as-touch
// (e.g., iidx/sdvx are rejected here, but nostalgia is allowed)
@@ -142,7 +190,14 @@ namespace nativetouch::transform {
// route hardware screen coordinates through dedicated or overlay mapping and report the result
Result hardware_to_game(POINT *position) {
const auto dedicated_subscreen = is_tdj_dedicated_subscreen(TDJ_SUBSCREEN_WINDOW);
const auto active_overlay = has_active_overlay_transform();
const auto active_overlay = overlay_owns_touch_input();
// special case for SDVX landscape mode
if (!dedicated_subscreen && !active_overlay &&
GRAPHICS_FS_ORIENTATION_SWAP && avs::game::is_model("KFC")) {
return transform_sdvx_landscape_touch_position(position) ?
Result::Transformed : Result::Rejected;
}
// no dedicated subscreen or active overlay mapping; pass the point through unchanged
if (!dedicated_subscreen && !active_overlay) {
+1
View File
@@ -10,6 +10,7 @@ namespace nativetouch::transform {
};
bool is_tdj_dedicated_subscreen(HWND window);
bool sdvx_landscape_rotate(POINT *position, LONG width, LONG height);
bool game_to_screen(HWND window, POINT *position);
bool screen_to_game(HWND window, POINT *position);
bool mouse_to_game(HWND window, POINT *position);