Files
spice2x.github.io/src/spice2x/touch/native/inject.cpp
T
bicarus bd2fbfcb67 touch: restore wintouchemu, fall back to it when native touch hooks fail (#834)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #833

## Description of change
Last couple PRs - such as #820 #827 #828 - made the native touch hook &
touch injection using `InjectTouchInput` the default path, since it
performs much more reliably with both real touch screens and mouse (or
any other synthetic source).

However, user has reported that WINE lacks `InjectTouchInput` which
means this won't work.

As a fix, revive the old wintouchemu code. Native touch is still the
default, but under following circumstances:

1. if `-touchemuforce` is set, or
2. if any of the required Windows touch APIs are unavailable

then we fail over from native touch to wintouchemu code. 

For Linux, condition #2 would be hit during init, and gracefully switch
over.

Caveat: the poke code for IIDX and Nost will continue to require native
touch, I do not want to maintain two paths for this. This means that
iidx poke will stop working on Linux, unfortunately.

## Testing
Tested on Windows with `-touchemuforce` set. This is mostly reverting
Linux code path back to where we were last release, so this should just
work with wine.
2026-07-26 04:29:45 -07:00

495 lines
18 KiB
C++

// enable Windows 8 touch injection types; the functions are loaded dynamically
#define _WIN32_WINNT 0x0602
#include <atomic>
#include <mutex>
#include <windows.h>
#include <commctrl.h>
#include "inject.h"
#include "inject_internal.h"
#include "transform.h"
#include "hooks/graphics/graphics.h"
#include "util/detour.h"
#include "util/libutils.h"
#include "util/logging.h"
namespace nativetouch::inject {
constexpr DWORD INJECTION_RETRY_DELAY_MS = 1;
constexpr POINTER_FLAGS CONTACT_DOWN_FLAGS =
POINTER_FLAG_DOWN | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT;
constexpr POINTER_FLAGS CONTACT_UPDATE_FLAGS =
POINTER_FLAG_UPDATE | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT;
// Windows 8 APIs are resolved dynamically to preserve older-OS compatibility
static decltype(RegisterTouchWindow) *RegisterTouchWindow_orig = nullptr;
static decltype(InitializeTouchInjection) *InitializeTouchInjection_ptr = nullptr;
static decltype(InjectTouchInput) *InjectTouchInput_ptr = nullptr;
// state for the single synthetic touch contact
struct ContactState {
ContactOwner owner = ContactOwner::None;
HWND input_window = nullptr;
POINT position {};
UINT_PTR timer_id = 0;
bool is_active() const {
return owner != ContactOwner::None;
}
};
// tracks an injector-owned contact without matching unrelated hardware touches
struct SyntheticTouchIdentity {
POINT down_position {};
HANDLE source = nullptr;
DWORD id = 0;
bool identified = false;
bool pending = false;
bool transform_coordinates = false;
std::atomic<HWND> transform_window { nullptr };
void begin(POINT position, HWND window, bool transform_returned_coordinates) {
down_position = position;
source = nullptr;
id = 0;
identified = false;
pending = true;
transform_coordinates = transform_returned_coordinates;
transform_window.store(window, std::memory_order_release);
}
void reset(HWND expected_window) {
source = nullptr;
id = 0;
identified = false;
pending = false;
transform_coordinates = false;
transform_window.compare_exchange_strong(
expected_window, nullptr, std::memory_order_acq_rel);
}
HWND get_transform_window() const {
return transform_window.load(std::memory_order_acquire);
}
bool matches(PTOUCHINPUT point) {
// InjectTouchInput provides no application marker in TOUCHINPUT. Claim the first
// matching DOWN, then use the source and ID assigned by Windows for this contact.
if (!identified) {
// correlate the pending injection's pixel position
constexpr LONG POSITION_TOLERANCE = 200;
const auto delta_x = point->x - down_position.x * 100;
const auto delta_y = point->y - down_position.y * 100;
if (!pending || !(point->dwFlags & TOUCHEVENTF_DOWN) ||
delta_x < -POSITION_TOLERANCE || delta_x > POSITION_TOLERANCE ||
delta_y < -POSITION_TOLERANCE || delta_y > POSITION_TOLERANCE) {
return false;
}
// save the identity that remains stable through this contact's MOVE and UP.
source = point->hSource;
id = point->dwID;
identified = true;
}
// require both the provider and contact identities to match.
return point->hSource == source && point->dwID == id;
}
bool should_transform_coordinates() const {
return transform_coordinates;
}
};
static ContactState contact_state;
static SyntheticTouchIdentity synthetic_touch_identity;
// main game window that receives WM_TOUCH forwarded from the dedicated TDJ subscreen
static std::atomic<HWND> touch_delivery_window { nullptr };
// window whose UI thread owns synthetic contact state and synthetic touch requests;
// normally the active touch window, while dedicated TDJ uses the subscreen window
static std::atomic<HWND> injection_window { nullptr };
// cross-thread game-loop refreshes are coalesced so they cannot flood the window queue
static std::atomic<bool> contact_refresh_pending { false };
static std::once_flag initialization_once;
static int window_subclass_token;
// asks the touch window thread to send an UPDATE when the game loop runs elsewhere
static UINT contact_refresh_message;
// 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) {
POINTER_TOUCH_INFO contact {};
contact.pointerInfo.pointerType = PT_TOUCH;
contact.pointerInfo.pointerId = 0;
contact.pointerInfo.pointerFlags = pointer_flags;
contact.pointerInfo.ptPixelLocation = position;
contact.touchFlags = TOUCH_FLAG_NONE;
BOOL result = InjectTouchInput_ptr(1, &contact);
if (result) {
return true;
}
// drop ordinary UPDATE frames when Windows is still processing the prior frame
const auto error = GetLastError();
if (error == ERROR_NOT_READY &&
(pointer_flags & POINTER_FLAG_UPDATE) && !retry_if_not_ready) {
return true;
}
// retry required frames once after a brief delay
if (error == ERROR_NOT_READY) {
Sleep(INJECTION_RETRY_DELAY_MS);
result = InjectTouchInput_ptr(1, &contact);
}
if (result) {
return true;
}
static bool error_logged = false;
if (!error_logged) {
error_logged = true;
log_warning("touch::native", "failed to inject synthetic touch input: {}", GetLastError());
}
return false;
}
// rewrite only the contact created by this injector into game touch coordinates
bool transform_touch_input(PTOUCHINPUT point) {
const auto transform_window = synthetic_touch_identity.get_transform_window();
if (transform_window == nullptr) {
return false;
}
if (!synthetic_touch_identity.matches(point)) {
// this contact is not owned by this injector; leave it unchanged
return false;
}
if (synthetic_touch_identity.should_transform_coordinates()) {
// Windows receives the physical position; the game receives the mapped position
POINT position { point->x / 100, point->y / 100 };
if (transform::screen_to_game(transform_window, &position)) {
point->x = position.x * 100;
point->y = position.y * 100;
}
}
if (point->dwFlags & TOUCHEVENTF_UP) {
synthetic_touch_identity.reset(transform_window);
}
return true;
}
bool contact_is_active() {
return contact_state.is_active();
}
bool contact_is_owned_by(ContactOwner owner, HWND window) {
return contact_state.is_active() &&
contact_state.owner == owner &&
contact_state.input_window == window;
}
bool begin_contact(
ContactOwner owner,
HWND window,
POINT position,
bool transform_returned_coordinates) {
contact_state.owner = owner;
contact_state.input_window = window;
contact_state.position = position;
contact_state.timer_id = 0;
synthetic_touch_identity.begin(position, window, transform_returned_coordinates);
if (inject_touch_frame(position, CONTACT_DOWN_FLAGS)) {
return true;
}
contact_state = {};
synthetic_touch_identity.reset(window);
return false;
}
bool update_contact(ContactOwner owner, HWND window, POINT position) {
if (!contact_is_owned_by(owner, window) ||
!inject_touch_frame(position, CONTACT_UPDATE_FLAGS)) {
return false;
}
contact_state.position = position;
return true;
}
// contact state belongs to the injection window thread; this helper must run there
static void refresh_contact_lifetime_on_window_thread(HWND window) {
if (!contact_is_owned_by(contact_state.owner, window)) {
return;
}
inject_touch_frame(contact_state.position, CONTACT_UPDATE_FLAGS, true);
}
// PAN calls this from ac_io_update to keep a stationary contact alive. Contact state is
// owned by the touch window thread, so same-thread calls refresh immediately. Calls from
// another thread post one coalesced private message for the window thread to handle.
void refresh_contact_lifetime() {
const auto window = injection_window.load(std::memory_order_acquire);
if (window == nullptr) {
return;
}
// preserve synchronous game-loop timing when it already runs on the window thread
const auto window_thread = GetWindowThreadProcessId(window, nullptr);
if (window_thread == GetCurrentThreadId()) {
refresh_contact_lifetime_on_window_thread(window);
return;
}
// otherwise marshal one outstanding refresh instead of sharing contact state across threads
if (contact_refresh_message == 0 ||
contact_refresh_pending.exchange(true, std::memory_order_acq_rel)) {
return;
}
if (!PostMessageW(window, contact_refresh_message, 0, 0)) {
contact_refresh_pending.store(false, std::memory_order_release);
}
}
void set_contact_timer(
ContactOwner owner, HWND window, UINT_PTR timer_id) {
if (contact_is_owned_by(owner, window)) {
contact_state.timer_id = timer_id;
}
}
// end whichever producer currently owns the single synthetic contact
bool release_active_contact() {
if (!contact_state.is_active()) {
return true;
}
const auto input_window = contact_state.input_window;
if (input_window != nullptr && contact_state.timer_id != 0) {
KillTimer(input_window, contact_state.timer_id);
}
const auto result = inject_touch_frame(contact_state.position, POINTER_FLAG_UP);
contact_state = {};
// release mouse capture if this contact owns it
if (input_window != nullptr && GetCapture() == input_window) {
ReleaseCapture();
}
return result;
}
// translate primary mouse messages on a touch window into one touch contact
static LRESULT CALLBACK touch_window_subclass_proc(
HWND window, UINT message, WPARAM w_param, LPARAM l_param,
UINT_PTR subclass_id, DWORD_PTR) {
// consume marshalled lifetime refreshes on the contact-owning window thread
if (contact_refresh_message != 0 && message == contact_refresh_message) {
contact_refresh_pending.store(false, std::memory_order_release);
refresh_contact_lifetime_on_window_thread(window);
return 0;
}
// IIDX handles touch on its main window even when input belongs to the subscreen
// forward the touch message there
if (message == WM_TOUCH &&
transform::is_tdj_dedicated_subscreen(window)) {
const auto delivery_window =
touch_delivery_window.load(std::memory_order_acquire);
if (delivery_window != nullptr) {
return SendMessageW(delivery_window, message, w_param, l_param);
}
}
if (handle_synthetic_message(window, message, w_param, l_param) ||
handle_mouse_message(window, message, w_param)) {
return 0;
}
if (message == WM_NCDESTROY) {
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);
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);
}
return DefSubclassProc(window, message, w_param, l_param);
}
// attach mouse injection without replacing the window's existing procedure
static void attach_window_impl(HWND window, bool register_touch) {
if (!initialize_touch_injection()) {
if (register_touch) {
log_warning(
"touch::native",
"touch injection unavailable; touch window was not registered");
}
return;
}
// if requested, register for touch messages (for cases where the game didn't call it)
if (register_touch &&
(RegisterTouchWindow_orig == nullptr ||
!RegisterTouchWindow_orig(window, TWF_WANTPALM))) {
log_warning(
"touch::native", "failed to register mouse touch window: {}", GetLastError());
return;
}
// set window subclass to intercept messages
if (!SetWindowSubclass(
window,
touch_window_subclass_proc,
reinterpret_cast<UINT_PTR>(&window_subclass_token),
0)) {
log_warning(
"touch::native",
"failed to attach mouse touch injection to window: {}",
GetLastError());
return;
}
// publish the UI-thread target for synthetic touch requests
if (!GRAPHICS_IIDX_WSUB || window == TDJ_SUBSCREEN_WINDOW) {
injection_window.store(window, std::memory_order_release);
}
log_misc(
"touch::native", "mouse touch injection attached to window {}", fmt::ptr(window));
}
HWND get_injection_window() {
return injection_window.load(std::memory_order_acquire);
}
void attach_window(HWND window) {
// attach window, but don't register for Windows touch messages
attach_window_impl(window, false);
}
void register_and_attach_window(HWND window) {
// attach window and register for Windows touch messages
attach_window_impl(window, true);
}
// preserve native registration and attach mouse injection to the touch window
static BOOL WINAPI RegisterTouchWindowHook(HWND window, ULONG flags) {
// for TDJ in windowed subscreen mode, the main game window is
// target for delivering touch messages, not the subscreen
if (GRAPHICS_IIDX_WSUB && window != TDJ_SUBSCREEN_WINDOW) {
touch_delivery_window.store(window, std::memory_order_release);
return TRUE;
}
// call original
const auto result = RegisterTouchWindow_orig(window, flags);
if (result) {
// attach but don't register for touch messages
// (we're already in the middle of RegisterTouchWindow as a result
// of the game calling it)
attach_window(window);
}
return result;
}
static void clear_touch_injection_functions() {
InitializeTouchInjection_ptr = nullptr;
InjectTouchInput_ptr = nullptr;
}
// load and initialize Windows 8 touch injection without a static API dependency
bool initialize_touch_injection() {
std::call_once(initialization_once, [] {
initialize_synthetic_touch();
contact_refresh_message =
RegisterWindowMessageW(L"spice2x.native_touch.refresh_contact");
if (contact_refresh_message == 0) {
log_warning(
"touch::native", "failed to register contact refresh message: {}", GetLastError());
}
// load all APIs from user32 without adding static imports
//
// note that these are expected to be present in Windows 8 and above;
// however, on WINE, touch implementation remains in Windows 7 era
// and therefore the hooks below will fail, hence the fallback to
// legacy wintouchemu code
const auto user32 = libutils::load_library("user32.dll");
InitializeTouchInjection_ptr = libutils::try_proc<decltype(InitializeTouchInjection_ptr)>(
user32, "InitializeTouchInjection");
if (InitializeTouchInjection_ptr == nullptr) {
log_warning(
"touch::native", "InitializeTouchInjection unavailable; mouse touch injection disabled");
return;
}
InjectTouchInput_ptr = libutils::try_proc<decltype(InjectTouchInput_ptr)>(
user32, "InjectTouchInput");
if (InjectTouchInput_ptr == nullptr) {
clear_touch_injection_functions();
log_warning(
"touch::native", "InjectTouchInput unavailable; mouse touch injection disabled");
return;
}
// reserve one synthetic contact and disable Windows' visual touch feedback
if (!InitializeTouchInjection_ptr(1, TOUCH_FEEDBACK_NONE)) {
log_warning(
"touch::native", "failed to initialize mouse touch injection: {}", GetLastError());
clear_touch_injection_functions();
return;
}
log_misc("touch::native", "mouse touch injection initialized");
});
return InitializeTouchInjection_ptr != nullptr && InjectTouchInput_ptr != nullptr;
}
// install injection support for touch windows registered by the game module
bool hook_available(HMODULE module) {
if (detour::iat_find("RegisterTouchWindow", module) == nullptr) {
log_warning("touch::native", "RegisterTouchWindow unavailable");
return false;
}
return true;
}
bool hook(HMODULE module) {
if (!initialize_touch_injection()) {
return false;
}
RegisterTouchWindow_orig = detour::iat_try(
"RegisterTouchWindow", RegisterTouchWindowHook, module);
if (RegisterTouchWindow_orig == nullptr) {
log_warning("touch::native", "failed to hook RegisterTouchWindow");
return false;
}
log_misc("touch::native", "RegisterTouchWindow hooked");
return true;
}
}