diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/generators/model2emu/model2emuGenerator.py b/package/batocera/core/batocera-configgen/configgen/configgen/generators/model2emu/model2emuGenerator.py index add23c14133..d8e06aca2a6 100644 --- a/package/batocera/core/batocera-configgen/configgen/configgen/generators/model2emu/model2emuGenerator.py +++ b/package/batocera/core/batocera-configgen/configgen/configgen/generators/model2emu/model2emuGenerator.py @@ -132,6 +132,10 @@ def generate(self, system, rom, playersControllers, metadata, guns, wheels, game Config.set("Renderer","MeshTransparency", system.config.get("model2_meshTransparency", "0")) Config.set("Renderer","FSAA", system.config.get("model2_fullscreenAA", "0")) Config.set("Input","UseRawInput", system.config.get("model2_useRawInput", "0")) + # Force RawInput when lightguns are detected + # This is required for multi-gun support (each gun gets its own hDevice) + if system.config.use_guns and guns: + Config.set("Input","UseRawInput", "1") if crosshairs := system.config.get("model2_crossHairs"): Config.set("Renderer","DrawCross", crosshairs) else: @@ -179,6 +183,14 @@ def generate(self, system, rom, playersControllers, metadata, guns, wheels, game } ) + # Multi-gun rawinput support for lightguns + # Tell patched Wine to expose separate rawinput devices per gun + if system.config.use_guns and guns: + environment.update({ + "WINE_FORCE_RAWINPUT": "1", + "WINE_RAWMOUSE_COUNT": str(len(guns)) + }) + # now run the emulator return Command.Command(array=commandArray, env=environment) diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py b/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py index 5c92e579e4a..281ba29e6dd 100644 --- a/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py +++ b/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py @@ -1,7 +1,10 @@ from __future__ import annotations +import json +import logging import os import subprocess +from glob import glob from pathlib import Path from typing import TYPE_CHECKING @@ -13,6 +16,106 @@ if TYPE_CHECKING: from ...types import HotkeysContext +eslog = logging.getLogger("ESLog") + +# ─── Gun button mapping ──────────────────────────────────── +# +# Virtual lightgun button layout (universal Batocera standard): +# BTN_LEFT = Trigger (passthrough via X11) +# BTN_RIGHT = Action/Secondary (passthrough via X11) +# BTN_MIDDLE = Start (mapped per player) +# BTN_1 = Select/Coin (mapped per player) +# BTN_2 = Sub1 (unmapped by default) +# BTN_3 = Sub2 (unmapped by default) +# BTN_4 = Sub3 (unmapped by default) +# BTN_5..8 = D-Pad (shared across all players) + +PLAYER_MAPPING = [ + {"BTN_MIDDLE": "KEY_1", "BTN_1": "KEY_5"}, # P1: Start=1, Coin=5 + {"BTN_MIDDLE": "KEY_2", "BTN_1": "KEY_6"}, # P2: Start=2, Coin=6 + {"BTN_MIDDLE": "KEY_3", "BTN_1": "KEY_7"}, # P3: Start=3, Coin=7 + {"BTN_MIDDLE": "KEY_4", "BTN_1": "KEY_8"}, # P4: Start=4, Coin=8 +] + +SHARED_MAPPING = { + "BTN_2": None, + "BTN_3": None, + "BTN_4": None, + "BTN_5": "KEY_UP", + "BTN_6": "KEY_DOWN", + "BTN_7": "KEY_LEFT", + "BTN_8": "KEY_RIGHT", +} + +GUN_CONFIG_PATH = "/var/run/wine-guns.json" + +# ─── DemulShooter game detection ─────────────────────────── +# +# Maps BepInEx plugin DLL prefix to ds-bridge game format. +# DLL naming: {Prefix}_BepInEx_DemulShooter_Plugin.dll + +DEMULSHOOTER_GAMES = { + "PointBlankX": "pbx", + "Drakon": "pbx", + "NerfArcade": "pbx", + "RabbidsHollywood": "rha", + "WildWestShootout": "wws", + "TombRaider": "tra", + "OperationWolf": "owr", + "MIB": "mib", + "MissionImpossible": "mia", + "NightHunter2": "nha2", + "MarsSortie": "marss", + "PVZ": "pvz", +} + + +def _detect_demulshooter(rom_path): + #Detect DemulShooter BepInEx plugin in the game directory. + #Returns the game format string (e.g. "rha") or None. + plugins_dir = os.path.join(rom_path, "BepInEx", "plugins") + if not os.path.isdir(plugins_dir): + return None + + for dll in glob(os.path.join(plugins_dir, "*_BepInEx_DemulShooter_Plugin.dll")): + basename = os.path.basename(dll) + prefix = basename.split("_BepInEx_DemulShooter_Plugin")[0] + game_fmt = DEMULSHOOTER_GAMES.get(prefix) + if game_fmt: + eslog.info("Wine gun: DemulShooter detected: %s -> %s", prefix, game_fmt) + return game_fmt + + return None + + +def _build_player_mapping(player_index, metadata): + #Build the full mapping for one player + mapping = dict(SHARED_MAPPING) + + if player_index < len(PLAYER_MAPPING): + mapping.update(PLAYER_MAPPING[player_index]) + else: + mapping.update(PLAYER_MAPPING[0]) + + if metadata: + for btn_name in list(mapping.keys()): + player_key = "gun_p{}_{}".format(player_index + 1, btn_name.lower()) + override = metadata.get(player_key) + + if override is None: + global_key = "gun_" + btn_name.lower() + override = metadata.get(global_key) + + if override is not None: + if override == "" or override.lower() == "none": + mapping[btn_name] = None + else: + mapping[btn_name] = override.upper() + eslog.info("Wine gun P%d: override %s -> %s", + player_index + 1, btn_name, override.upper()) + + return {k: v for k, v in mapping.items() if v is not None} + class WineGenerator(Generator): @@ -31,37 +134,49 @@ def generate(self, system, rom, playersControllers, metadata, guns, wheels, game commandArray = ["batocera-wine", "windows", "play", rom] environment: dict[str, str | Path] = {} - #system.language + + # ── Language ── try: - language = subprocess.check_output("batocera-settings-get system.language", shell=True, text=True).strip() + language = subprocess.check_output( + "batocera-settings-get system.language", + shell=True, text=True + ).strip() except subprocess.CalledProcessError: language = 'en_US' if language: environment.update({ "LANG": language + ".UTF-8", "LC_ALL": language + ".UTF-8" - } - ) - # sdl controller option - default is on + }) + + # ── SDL controller config ── if system.config.get_bool("sdl_config", True): - environment.update( - { - "SDL_GAMECONTROLLERCONFIG": generate_sdl_game_controller_config(playersControllers), - "SDL_JOYSTICK_HIDAPI": "0" - } - ) - # ensure nvidia driver used for vulkan + environment.update({ + "SDL_GAMECONTROLLERCONFIG": generate_sdl_game_controller_config(playersControllers), + "SDL_JOYSTICK_HIDAPI": "0" + }) + + # ── NVIDIA Vulkan ── if Path('/var/tmp/nvidia.prime').exists(): - variables_to_remove = ['__NV_PRIME_RENDER_OFFLOAD', '__VK_LAYER_NV_optimus', '__GLX_VENDOR_LIBRARY_NAME'] + variables_to_remove = [ + '__NV_PRIME_RENDER_OFFLOAD', + '__VK_LAYER_NV_optimus', + '__GLX_VENDOR_LIBRARY_NAME' + ] for variable_name in variables_to_remove: if variable_name in os.environ: del os.environ[variable_name] + environment.update({ + 'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json:/usr/share/vulkan/icd.d/nvidia_icd.i686.json', + }) - environment.update( - { - 'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json:/usr/share/vulkan/icd.d/nvidia_icd.i686.json', - } - ) + # ── Light gun support via rawinput ── + if system.config.use_guns and guns: + self._setup_guns(guns, metadata, rom, gameResolution) + environment.update({ + "WINE_FORCE_RAWINPUT": "1", + "WINE_RAWMOUSE_COUNT": str(len(guns)) + }) return Command.Command(array=commandArray, env=environment) @@ -69,3 +184,38 @@ def generate(self, system, rom, playersControllers, metadata, guns, wheels, game def getMouseMode(self, config, rom): return config.get_bool('force_mouse') + + # ─── Light gun setup ─── + + def _setup_guns(self, guns, metadata, rom, gameResolution): + #Write per-player gun config + optional DemulShooter bridge config. + num_guns = len(guns) + eslog.info("Wine: %d light gun(s) detected", num_guns) + + gun_configs = [] + for i in range(num_guns): + mapping = _build_player_mapping(i, metadata) + gun_configs.append({"player": i + 1, "mapping": mapping}) + eslog.info("Wine gun P%d: %s", i + 1, + ", ".join("{}->{}".format(k, v) for k, v in mapping.items())) + + config = {"guns": gun_configs} + + ds_game = _detect_demulshooter(str(rom)) + if ds_game: + width = gameResolution.get("width", 1920) if gameResolution else 1920 + height = gameResolution.get("height", 1080) if gameResolution else 1080 + config["ds_bridge"] = { + "game": ds_game, + "width": int(width), + "height": int(height), + } + eslog.info("Wine gun: DemulShooter bridge: game=%s %dx%d", + ds_game, width, height) + + try: + with open(GUN_CONFIG_PATH, 'w') as f: + json.dump(config, f, indent=2) + eslog.info("Wine gun: config written to %s", GUN_CONFIG_PATH) + except Exception as e: + eslog.error("Wine gun: failed to write config: %s", e) diff --git a/package/batocera/utils/batocera-wine/batocera-wine b/package/batocera/utils/batocera-wine/batocera-wine old mode 100755 new mode 100644 index d3cab945b11..bc70590410e --- a/package/batocera/utils/batocera-wine/batocera-wine +++ b/package/batocera/utils/batocera-wine/batocera-wine @@ -43,6 +43,28 @@ AUTORUN_FOUNDEXE= SYSTEM_DISPLAY_MODE="" CMD_PREFIX="" +# Light gun support +GUN_CONFIG_FILE="/var/run/wine-guns.json" +GUN_SCRIPT="/usr/bin/batocera-wine-guns" +GUN_PIDFILE="/var/run/wine-guns.pid" + +start_guns() { + if [[ -e "${GUN_CONFIG_FILE}" ]] && [[ -e "${GUN_SCRIPT}" ]]; then + echo "Starting light gun support..." + "${GUN_SCRIPT}" "${GUN_CONFIG_FILE}" & + fi +} + +stop_guns() { + if [[ -e "${GUN_PIDFILE}" ]]; then + local pid + pid=$(cat "${GUN_PIDFILE}" 2>/dev/null) + [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null + rm -f "${GUN_PIDFILE}" + fi + rm -f "${GUN_CONFIG_FILE}" +} + stopWineServer() { [[ -z "${WINESERVER}" || -z "${WINEPOINT}" ]] && exit 0 @@ -662,6 +684,11 @@ play_pc() { dxvk_install "${WINEPOINT}" || return 1 saveFilesToUserdata "${ROMGAMENAME}" "${WINE_SAVEDIR}" "${WINE_SAVEFILES}" || return 1 + # BepInEx (DemulShooter) needs native winhttp.dll to inject into Unity games + if [[ -d "${GAMENAME}/BepInEx" ]]; then + export WINEDLLOVERRIDES="${WINEDLLOVERRIDES};winhttp=n,b" + fi + env if [[ -n "${WINE_LANG}" ]]; then (cd "${GAMENAME}/${WINE_DIR}" && LC_ALL=${WINE_LANG} WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) @@ -943,6 +970,7 @@ requestFileSystem() { } cleanAndExit() { + stop_guns RESNEW=$(batocera-resolution currentMode) if [[ "${RESNEW}" != "${G_RESCUR}" ]]; then batocera-resolution setMode "${G_RESCUR}" @@ -1066,6 +1094,7 @@ case "${ACTION}" in # case selections will provide 2 variables here, GAMENAME and WINEPOINT "play") init_wine + start_guns case "${GAMEEXT,,}" in "wine") requestFileSystem "${GAMENAME}" diff --git a/package/batocera/utils/batocera-wine/batocera-wine-guns b/package/batocera/utils/batocera-wine/batocera-wine-guns new file mode 100644 index 00000000000..8699865f980 --- /dev/null +++ b/package/batocera/utils/batocera-wine/batocera-wine-guns @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +# +#batocera-wine-guns - Light gun mapping & homemade DemulShooter support (thanks to argon le fou's open code) +# +#Two roles in one daemon: +# 1. Button translation: extra gun buttons -> keyboard keys via uinput +# 2. DemulShooter bridge: coordinates + triggers -> TCP to BepInEx plugins +# +#Default virtual lightgun button layout: +# BTN_LEFT = Trigger (x11 passthrough) +# BTN_RIGHT = Action/Secondary (x11 passthrough) +# BTN_MIDDLE = Start (mapped per player: KEY_1..KEY_4) +# BTN_1 = Select/Coin (mapped per player: KEY_5..KEY_8) +# BTN_2 = Sub1 (rarely used) +# BTN_5 = D-Pad Up (KEY_UP) +# BTN_6 = D-Pad Down (KEY_DOWN) +# BTN_7 = D-Pad Left (KEY_LEFT) +# BTN_8 = D-Pad Right (KEY_RIGHT) +# +#Launched and stopped by batocera-wine. +# +#Usage: batocera-wine-guns /var/run/wine-guns.json + +import sys +import os +import json +import struct +import signal +import select +import socket +import time +import logging + +logger = logging.getLogger("wine-guns") + +PIDFILE = "/var/run/wine-guns.pid" +DS_PORT = 33610 + +# ─── Gun discovery ─── + +def find_gun_devices(): + #Detect lightgun evdev devices, up to 4 + try: + import pyudev + context = pyudev.Context() + guns = [] + for device in context.list_devices(subsystem='input'): + if device.properties.get('ID_INPUT_GUN') == '1': + node = device.device_node + if node and '/dev/input/event' in node: + guns.append(node) + return sorted(guns)[:4] + except ImportError: + logger.warning("pyudev not available, falling back to evdev scan") + return _find_gun_devices_fallback() + + +def _find_gun_devices_fallback(): + #Fallback, unsure if needed + try: + import evdev + from evdev import ecodes + guns = [] + for path in sorted(evdev.list_devices()): + try: + dev = evdev.InputDevice(path) + caps = dev.capabilities(verbose=False) + abs_codes = [c[0] if isinstance(c, tuple) else c + for c in caps.get(ecodes.EV_ABS, [])] + key_codes = caps.get(ecodes.EV_KEY, []) + if (ecodes.ABS_X in abs_codes + and ecodes.ABS_Y in abs_codes + and ecodes.BTN_LEFT in key_codes): + guns.append(path) + dev.close() + except Exception: + pass + return guns[:4] + except ImportError: + return [] + + +# ─── Config ─── + +def load_config(config_path): + #e.g.: Rabbids Hollywood Arcade (rha) + # + #{ + # "guns": [ + # {"player": 1, "mapping": {"BTN_MIDDLE": "KEY_1", ...}}, + # {"player": 2, "mapping": {"BTN_MIDDLE": "KEY_2", ...}} + # {"player": 3, "mapping": {"BTN_MIDDLE": "KEY_3", ...}} + # {"player": 4, "mapping": {"BTN_MIDDLE": "KEY_4", ...}} + # ], + # "ds_bridge": {"game": "rha", "width": 1920, "height": 1080} + #} + from evdev import ecodes + + with open(config_path) as f: + raw = json.load(f) + + gun_configs = [] + if "guns" in raw: + for gun_cfg in raw["guns"]: + btn_to_key = _resolve_mapping(gun_cfg["mapping"], ecodes) + player = gun_cfg.get("player", len(gun_configs) + 1) + gun_configs.append({"player": player, "mapping": btn_to_key}) + elif raw: + btn_to_key = _resolve_mapping(raw, ecodes) + gun_configs.append({"player": 1, "mapping": btn_to_key}) + + ds_bridge = raw.get("ds_bridge", None) + return gun_configs, ds_bridge + + +def _resolve_mapping(raw_mapping, ecodes): + btn_to_key = {} + for src_name, dst_name in raw_mapping.items(): + if dst_name is None: + continue + src_code = ecodes.ecodes.get(src_name) + dst_code = ecodes.ecodes.get(dst_name) + if src_code is not None and dst_code is not None: + btn_to_key[src_code] = dst_code + else: + logger.warning("Unknown evdev code: %s -> %s", src_name, dst_name) + return btn_to_key + + +# ─── DemulShooter packet formats ─── +# +# Fields serialized alphabetically (C# reflection). +# Array sizes = MAX_PLAYERS per game. +# Little-endian: floats 4B, bytes 1B. +# Bytes size is crucial here + +def build_ds_packet(states, game, scr_w, scr_h): + ax = [s.pixel_x(scr_w) for s in states] + ay = [scr_h - s.pixel_y(scr_h) for s in states] + t = [s.trigger for s in states] + r = [s.reload for s in states] + + while len(ax) < 4: + ax.append(0.0); ay.append(0.0); t.append(0); r.append(0) + + if game == 'rha': + # 4P: Axis_X[4] Axis_Y[4] EnableInputsHack HideCrosshairs Trigger[4] = 38 + return struct.pack('", sys.argv[0]) + sys.exit(1) + + try: + import evdev + from evdev import UInput, ecodes + except ImportError: + logger.error("python-evdev is not installed") + sys.exit(1) + + try: + gun_configs, ds_bridge = load_config(sys.argv[1]) + except Exception as e: + logger.error("Failed to load config: %s", e) + sys.exit(1) + + has_mappings = any(gc["mapping"] for gc in gun_configs) + has_ds = ds_bridge is not None + + if not has_mappings and not has_ds: + logger.info("No mappings and no DemulShooter bridge configured") + return + + gun_paths = find_gun_devices() + if not gun_paths: + logger.info("No light guns detected") + return + + logger.info("Found %d gun(s): %s", len(gun_paths), gun_paths) + + # Open devices + guns = [] + uinputs = [] + + for i, path in enumerate(gun_paths): + try: + dev = evdev.InputDevice(path) + except Exception as e: + logger.warning("Cannot open %s: %s", path, e) + continue + + state = GunState() + try: + info = dev.absinfo(ecodes.ABS_X) + state.x_min, state.x_max = info.min, info.max + except Exception: + pass + try: + info = dev.absinfo(ecodes.ABS_Y) + state.y_min, state.y_max = info.min, info.max + except Exception: + pass + + if i < len(gun_configs): + cfg = gun_configs[i] + else: + cfg = gun_configs[0] if gun_configs else {"player": i + 1, "mapping": {}} + + mapping = cfg["mapping"] + player = cfg.get("player", i + 1) + + ui = None + if mapping: + ui = UInput({ecodes.EV_KEY: list(set(mapping.values()))}, + name="Wine Gun Keys P{}".format(player)) + uinputs.append(ui) + + guns.append({ + "dev": dev, "state": state, "mapping": mapping, + "uinput": ui, "player": player, + }) + logger.info("Gun %d -> P%d: %s (%s) [%d,%d]", + i, player, path, dev.name, state.x_min, state.x_max) + + if not guns: + logger.error("Could not set up any gun") + for ui in uinputs: + ui.close() + return + + # Homemade DemulShooter bridge + ds_sock = None + ds_game = ds_bridge["game"] if has_ds else None + ds_w = ds_bridge.get("width", 1920) if has_ds else 1920 + ds_h = ds_bridge.get("height", 1080) if has_ds else 1080 + + if has_ds: + logger.info("DemulShooter bridge: game=%s %dx%d", ds_game, ds_w, ds_h) + + write_pidfile() + running = True + + def stop(sig, frame): + nonlocal running + running = False + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + # ─── Event loop ─── + + try: + while running: + fds = [g["dev"].fd for g in guns] + + if has_ds and ds_sock is None: + try: + ds_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + ds_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + ds_sock.connect(('127.0.0.1', DS_PORT)) + logger.info("DemulShooter: connected on port %d", DS_PORT) + except Exception: + try: + ds_sock.close() + except Exception: + pass + ds_sock = None + + if ds_sock is not None: + fds.append(ds_sock.fileno()) + + try: + readable, _, _ = select.select(fds, [], [], 0.5) + except Exception: + continue + + if ds_sock is not None and ds_sock.fileno() in readable: + try: + if not ds_sock.recv(4096): + logger.info("DemulShooter: plugin disconnected, reconnecting...") + ds_sock.close() + ds_sock = None + except Exception: + pass + + ds_changed = False + + for gun in list(guns): + if gun["dev"].fd not in readable: + continue + try: + for event in gun["dev"].read(): + if (event.type == ecodes.EV_KEY + and event.code in gun["mapping"] + and gun["uinput"] is not None): + gun["uinput"].write(ecodes.EV_KEY, + gun["mapping"][event.code], + event.value) + gun["uinput"].syn() + + if has_ds: + st = gun["state"] + if event.type == ecodes.EV_ABS: + if event.code == ecodes.ABS_X: + st.x = event.value + elif event.code == ecodes.ABS_Y: + st.y = event.value + elif event.type == ecodes.EV_KEY: + if event.code == ecodes.BTN_LEFT: + st.trigger = 1 if event.value else 0 + elif event.code == ecodes.BTN_RIGHT: + st.reload = 1 if event.value else 0 + elif event.type == ecodes.EV_SYN: + ds_changed = True + + except OSError: + logger.warning("P%d disconnected: %s", gun["player"], gun["dev"].path) + try: + gun["dev"].close() + except Exception: + pass + guns.remove(gun) + + if has_ds and ds_changed and ds_sock is not None: + try: + states = [g["state"] for g in guns] + ds_sock.sendall(build_ds_packet(states, ds_game, ds_w, ds_h)) + except Exception: + logger.info("DemulShooter: connection lost, reconnecting...") + try: + ds_sock.close() + except Exception: + pass + ds_sock = None + + finally: + if ds_sock: + try: + ds_sock.close() + except Exception: + pass + for gun in guns: + try: + gun["dev"].close() + except Exception: + pass + for ui in uinputs: + try: + ui.close() + except Exception: + pass + cleanup_pidfile() + logger.info("Stopped cleanly") + + +if __name__ == "__main__": + main() diff --git a/package/batocera/utils/batocera-wine/batocera-wine.mk b/package/batocera/utils/batocera-wine/batocera-wine.mk index 3491804d9d6..1042c660af8 100644 --- a/package/batocera/utils/batocera-wine/batocera-wine.mk +++ b/package/batocera/utils/batocera-wine/batocera-wine.mk @@ -20,6 +20,8 @@ define BATOCERA_WINE_INSTALL_TARGET_CMDS $(TARGET_DIR)/usr/bin/batocera-wine-runners install -m 0755 $(BATOCERA_WINE_SOURCE_PATH)/bsod.py \ $(TARGET_DIR)/usr/bin/bsod-wine + install -m 0755 $(BATOCERA_WINE_SOURCE_PATH)/batocera-wine-guns \ + $(TARGET_DIR)/usr/bin/batocera-wine-guns ln -fs /userdata/system/99-nvidia.conf $(TARGET_DIR)/etc/X11/xorg.conf.d/99-nvidia.conf mkdir -p $(TARGET_DIR)/usr/share/evmapy diff --git a/package/batocera/wine/wine-tkg/002-rawinput-multi-gun-support.patch b/package/batocera/wine/wine-tkg/002-rawinput-multi-gun-support.patch new file mode 100644 index 00000000000..ee96cea9b04 --- /dev/null +++ b/package/batocera/wine/wine-tkg/002-rawinput-multi-gun-support.patch @@ -0,0 +1,367 @@ +diff --git a/dlls/win32u/rawinput.c b/dlls/win32u/rawinput.c +index 6650d65..69f0863 100644 +--- a/dlls/win32u/rawinput.c ++++ b/dlls/win32u/rawinput.c +@@ -29,6 +29,7 @@ + #include "win32u_private.h" + #include "ntuser_private.h" + #define WIN32_NO_STATUS ++#include + #include "winioctl.h" + #include "ddk/hidclass.h" + #include "wine/hid.h" +@@ -282,6 +283,57 @@ static void enumerate_devices( DWORD type, const WCHAR *class ) + NtClose( class_key ); + } + ++static void add_virtual_mice(void) ++{ ++ static const RID_DEVICE_INFO_MOUSE mouse_info = {1, 5, 0, FALSE}; ++ const char *env; ++ int count, i; ++ struct device *device, *next; ++ ++ env = getenv( "WINE_RAWMOUSE_COUNT" ); ++ count = env ? atoi( env ) : 0; ++ FIXME( "WINE_RAWMOUSE_COUNT env=%s count=%d\n", env ? env : "(null)", count ); ++ if (count <= 0 || count > 8) return; ++ ++ /* Remove the built-in mouse device so apps only see our virtual mice */ ++ LIST_FOR_EACH_ENTRY_SAFE( device, next, &devices, struct device, entry ) ++ { ++ if (device->handle == WINE_MOUSE_HANDLE) ++ { ++ FIXME( "Removing built-in mouse handle=%#x\n", (unsigned int)(UINT_PTR)device->handle ); ++ list_remove( &device->entry ); ++ if (device->file) NtClose( device->file ); ++ free( device->data ); ++ free( device ); ++ break; ++ } ++ } ++ ++ for (i = 0; i < count; i++) ++ { ++ unsigned int handle = 0x10001 + i; ++ ++ LIST_FOR_EACH_ENTRY( device, &devices, struct device, entry ) ++ if (device->handle == ULongToHandle( handle )) goto skip; ++ ++ if (!(device = calloc( 1, sizeof(*device) ))) continue; ++ ++ swprintf( device->path, ARRAY_SIZE(device->path), ++ L"\\\\?\\WINE_RAWMOUSE_%u", i + 1 ); ++ device->file = NULL; ++ device->handle = ULongToHandle( handle ); ++ device->info.cbSize = sizeof(RID_DEVICE_INFO); ++ device->info.dwType = RIM_TYPEMOUSE; ++ device->info.mouse = mouse_info; ++ device->data = NULL; ++ list_add_tail( &devices, &device->entry ); ++ ++ FIXME( "Added virtual mouse %d: handle=%#x\n", i + 1, handle ); ++ continue; ++ skip:; ++ } ++} ++ + static void rawinput_update_device_list( BOOL force ) + { + unsigned int ticks = NtGetTickCount(); +@@ -296,7 +348,7 @@ static void rawinput_update_device_list( BOOL force ) + LIST_FOR_EACH_ENTRY_SAFE( device, next, &devices, struct device, entry ) + { + list_remove( &device->entry ); +- NtClose( device->file ); ++ if (device->file) NtClose( device->file ); + free( device->data ); + free( device ); + } +@@ -304,6 +356,7 @@ static void rawinput_update_device_list( BOOL force ) + enumerate_devices( RIM_TYPEMOUSE, guid_devinterface_mouseW ); + enumerate_devices( RIM_TYPEKEYBOARD, guid_devinterface_keyboardW ); + enumerate_devices( RIM_TYPEHID, guid_devinterface_hidW ); ++ add_virtual_mice(); + } + + static struct device *find_device_from_handle( HANDLE handle, BOOL refresh ) +@@ -692,7 +745,7 @@ BOOL WINAPI NtUserRegisterRawInputDevices( const RAWINPUTDEVICE *devices, UINT d + return FALSE; + } + +- if (devices[i].dwFlags & ~(RIDEV_REMOVE|RIDEV_NOLEGACY|RIDEV_INPUTSINK|RIDEV_DEVNOTIFY)) ++ if (devices[i].dwFlags & ~(RIDEV_REMOVE|RIDEV_NOLEGACY|RIDEV_INPUTSINK|RIDEV_DEVNOTIFY|RIDEV_CAPTUREMOUSE)) + FIXME( "Unhandled flags %#x for device %u.\n", devices[i].dwFlags, i ); + } + +diff --git a/dlls/winex11.drv/mouse.c b/dlls/winex11.drv/mouse.c +index c55d680..dfdd298 100644 +--- a/dlls/winex11.drv/mouse.c ++++ b/dlls/winex11.drv/mouse.c +@@ -143,6 +143,34 @@ MAKE_FUNCPTR(XIQueryDevice); + MAKE_FUNCPTR(XIQueryVersion); + MAKE_FUNCPTR(XISelectEvents); + #undef MAKE_FUNCPTR ++ ++#define WINE_RAW_MOUSE_MAX 8 ++static struct { ++ int sourceid; ++ unsigned int handle; ++ BOOL is_absolute; ++ int x_number, y_number; ++ double x_min, x_max, y_min, y_max; ++} raw_mouse_map[WINE_RAW_MOUSE_MAX]; ++static int raw_mouse_count; ++ ++static unsigned int get_raw_mouse_handle( int sourceid ) ++{ ++ int i; ++ for (i = 0; i < raw_mouse_count; i++) ++ if (raw_mouse_map[i].sourceid == sourceid) ++ return raw_mouse_map[i].handle; ++ return 0; ++} ++ ++static int find_raw_mouse_index( int sourceid ) ++{ ++ int i; ++ for (i = 0; i < raw_mouse_count; i++) ++ if (raw_mouse_map[i].sourceid == sourceid) ++ return i; ++ return -1; ++} + #endif + + #ifdef HAVE_X11_EXTENSIONS_XINPUT_H +@@ -440,6 +468,66 @@ void x11drv_xinput2_init( struct x11drv_thread_data *data ) + } + + TRACE( "XInput2 %d.%d available\n", major, minor ); ++ ++ /* Enumerate slave pointers for multi-mouse rawinput */ ++ { ++ int ndevices, i, j; ++ XIDeviceInfo *all_devices = pXIQueryDevice( data->display, XIAllDevices, &ndevices ); ++ raw_mouse_count = 0; ++ for (i = 0; i < ndevices && raw_mouse_count < WINE_RAW_MOUSE_MAX; i++) ++ { ++ if (all_devices[i].use != XISlavePointer) continue; ++ if (all_devices[i].attachment != data->xinput2_pointer) continue; ++ if (strstr( all_devices[i].name, "XTEST" )) continue; ++ ++ raw_mouse_map[raw_mouse_count].sourceid = all_devices[i].deviceid; ++ raw_mouse_map[raw_mouse_count].handle = 0x10001 + raw_mouse_count; ++ raw_mouse_map[raw_mouse_count].is_absolute = FALSE; ++ raw_mouse_map[raw_mouse_count].x_number = -1; ++ raw_mouse_map[raw_mouse_count].y_number = -1; ++ ++ for (j = 0; j < all_devices[i].num_classes; j++) ++ { ++ XIValuatorClassInfo *v = (XIValuatorClassInfo *)all_devices[i].classes[j]; ++ if (all_devices[i].classes[j]->type != XIValuatorClass) continue; ++ if (v->number == 0) ++ { ++ raw_mouse_map[raw_mouse_count].x_number = 0; ++ raw_mouse_map[raw_mouse_count].x_min = v->min; ++ raw_mouse_map[raw_mouse_count].x_max = v->max; ++ if (v->mode == XIModeAbsolute) ++ raw_mouse_map[raw_mouse_count].is_absolute = TRUE; ++ } ++ else if (v->number == 1) ++ { ++ raw_mouse_map[raw_mouse_count].y_number = 1; ++ raw_mouse_map[raw_mouse_count].y_min = v->min; ++ raw_mouse_map[raw_mouse_count].y_max = v->max; ++ } ++ } ++ ++ FIXME( "Slave pointer [%d]: id=%d name='%s' -> handle=%#x %s x=[%.0f,%.0f] y=[%.0f,%.0f]\n", ++ raw_mouse_count, all_devices[i].deviceid, ++ all_devices[i].name, raw_mouse_map[raw_mouse_count].handle, ++ raw_mouse_map[raw_mouse_count].is_absolute ? "ABS" : "REL", ++ raw_mouse_map[raw_mouse_count].x_min, raw_mouse_map[raw_mouse_count].x_max, ++ raw_mouse_map[raw_mouse_count].y_min, raw_mouse_map[raw_mouse_count].y_max ); ++ raw_mouse_count++; ++ } ++ pXIFreeDeviceInfo( all_devices ); ++ FIXME( "Found %d slave pointer(s) for rawinput\n", raw_mouse_count ); ++ } ++ ++ /* Force rawinput mode if requested */ ++ { ++ const char *force_ri = getenv( "WINE_FORCE_RAWINPUT" ); ++ if (force_ri && atoi( force_ri ) && raw_mouse_count > 0) ++ { ++ data->xinput2_rawinput = TRUE; ++ x11drv_xinput2_enable( data->display, DefaultRootWindow( data->display ) ); ++ FIXME( "Forced rawinput mode via WINE_FORCE_RAWINPUT\n" ); ++ } ++ } + } + + #else /* HAVE_X11_EXTENSIONS_XINPUT2_H */ +@@ -1851,13 +1939,34 @@ static BOOL map_raw_event_coords( XIRawEvent *event, INPUT *input, BOOL send_raw + static BOOL X11DRV_RawMotion( XGenericEventCookie *xev ) + { + struct x11drv_thread_data *thread_data = x11drv_thread_data(); +- UINT flags = thread_data->xinput2_rawinput ? SEND_HWMSG_NO_MSG : SEND_HWMSG_NO_RAW; ++ UINT flags = thread_data->xinput2_rawinput ? 0 : SEND_HWMSG_NO_RAW; + XIRawEvent *event = xev->data; + INPUT input; ++ int dev_idx; ++ ++ /* Early diagnostic: log ALL events before any filtering */ ++ { ++ static int early_count; ++ static int src_counts[32]; ++ int sid = event->sourceid < 32 ? event->sourceid : 31; ++ src_counts[sid]++; ++ if (++early_count <= 100) ++ FIXME( "ENTRY: deviceid=%d sourceid=%d mask_len=%d\n", ++ event->deviceid, event->sourceid, event->valuators.mask_len ); ++ if (early_count % 200 == 0) ++ { ++ int j; ++ for (j = 0; j < 32; j++) ++ if (src_counts[j]) ++ FIXME( "STATS: total=%d src[%d]=%d\n", early_count, j, src_counts[j] ); ++ } ++ } + + if (broken_rawevents && is_old_motion_event( xev->serial )) + { +- TRACE( "old serial %lu, ignoring\n", xev->serial ); ++ static int old_count; ++ if (++old_count <= 20) ++ FIXME( "OLDSERIAL: sourceid=%d serial=%lu\n", event->sourceid, xev->serial ); + return FALSE; + } + +@@ -1865,11 +1974,64 @@ static BOOL X11DRV_RawMotion( XGenericEventCookie *xev ) + input.mi.mouseData = 0; + input.mi.dwFlags = MOUSEEVENTF_MOVE; + input.mi.time = EVENT_x11_time_to_win32_time( event->time ); +- input.mi.dwExtraInfo = 0; ++ input.mi.dwExtraInfo = get_raw_mouse_handle( event->sourceid ); + input.mi.dx = 0; + input.mi.dy = 0; +- if (!map_raw_event_coords( event, &input, flags & SEND_HWMSG_NO_MSG )) return FALSE; +- if (!(input.mi.dwFlags & MOUSEEVENTF_MOVE)) return FALSE; ++ ++ dev_idx = find_raw_mouse_index( event->sourceid ); ++ if (dev_idx >= 0 && raw_mouse_map[dev_idx].is_absolute) ++ { ++ /* Absolute device (lightgun): convert from device range to screen pixels */ ++ /* Use raw_values (pre-X-server-transformation) for absolute devices */ ++ const double *raw_vals = event->raw_values; ++ double x_raw = 0, y_raw = 0; ++ double x_range, y_range; ++ int i; ++ ++ if (!event->valuators.mask_len) return FALSE; ++ ++ for (i = 0; i <= max( raw_mouse_map[dev_idx].x_number, raw_mouse_map[dev_idx].y_number ); i++) ++ { ++ if (!XIMaskIsSet( event->valuators.mask, i )) continue; ++ if (i == raw_mouse_map[dev_idx].x_number) x_raw = *raw_vals; ++ if (i == raw_mouse_map[dev_idx].y_number) y_raw = *raw_vals; ++ raw_vals++; ++ } ++ ++ x_range = raw_mouse_map[dev_idx].x_max - raw_mouse_map[dev_idx].x_min; ++ y_range = raw_mouse_map[dev_idx].y_max - raw_mouse_map[dev_idx].y_min; ++ if (x_range <= 0) x_range = 1; ++ if (y_range <= 0) y_range = 1; ++ ++ { ++ /* NtUserSendHardwareInput expects pixel coordinates, not 0-65535 normalized */ ++ int screen_w = DisplayWidth( thread_data->display, DefaultScreen( thread_data->display ) ); ++ int screen_h = DisplayHeight( thread_data->display, DefaultScreen( thread_data->display ) ); ++ ++ input.mi.dx = (LONG)round( (x_raw - raw_mouse_map[dev_idx].x_min) / x_range * (screen_w - 1) ); ++ input.mi.dy = (LONG)round( (y_raw - raw_mouse_map[dev_idx].y_min) / y_range * (screen_h - 1) ); ++ input.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE; ++ ++ { ++ static int diag_count; ++ if (++diag_count <= 80) ++ FIXME( "ABS: src=%d h=%#x raw=(%.0f,%.0f) scr=%dx%d -> dx=%d dy=%d\n", ++ event->sourceid, raw_mouse_map[dev_idx].handle, ++ x_raw, y_raw, screen_w, screen_h, (int)input.mi.dx, (int)input.mi.dy ); ++ } ++ } ++ } ++ else ++ { ++ { ++ static int rel_count; ++ if (++rel_count <= 20) ++ FIXME( "REL motion: src=%d idx=%d deviceid=%d pointer=%d\n", ++ event->sourceid, dev_idx, event->deviceid, thread_data->xinput2_pointer ); ++ } ++ if (!map_raw_event_coords( event, &input, flags & SEND_HWMSG_NO_MSG )) return FALSE; ++ if (!(input.mi.dwFlags & MOUSEEVENTF_MOVE)) return FALSE; ++ } + + NtUserSendHardwareInput( 0, flags, &input, 0 ); + return TRUE; +@@ -1914,12 +2076,43 @@ static BOOL X11DRV_RawButtonEvent( XGenericEventCookie *cookie ) + input.mi.mouseData = button_data[button]; + input.mi.dwFlags = button_flags[button] | MOUSEEVENTF_MOVE; + input.mi.time = EVENT_x11_time_to_win32_time( event->time ); +- input.mi.dwExtraInfo = 0; ++ input.mi.dwExtraInfo = get_raw_mouse_handle( event->sourceid ); + input.mi.dx = 0; + input.mi.dy = 0; +- map_raw_event_coords( event, &input, TRUE ); + +- NtUserSendHardwareInput( 0, SEND_HWMSG_NO_MSG, &input, 0 ); ++ { ++ int dev_idx = find_raw_mouse_index( event->sourceid ); ++ if (dev_idx >= 0 && raw_mouse_map[dev_idx].is_absolute) ++ { ++ const double *raw_vals = event->raw_values; ++ double x_val = 0, y_val = 0, x_range, y_range; ++ int i; ++ for (i = 0; event->valuators.mask_len && i <= max( raw_mouse_map[dev_idx].x_number, raw_mouse_map[dev_idx].y_number ); i++) ++ { ++ if (!XIMaskIsSet( event->valuators.mask, i )) continue; ++ if (i == raw_mouse_map[dev_idx].x_number) x_val = *raw_vals; ++ if (i == raw_mouse_map[dev_idx].y_number) y_val = *raw_vals; ++ raw_vals++; ++ } ++ x_range = raw_mouse_map[dev_idx].x_max - raw_mouse_map[dev_idx].x_min; ++ y_range = raw_mouse_map[dev_idx].y_max - raw_mouse_map[dev_idx].y_min; ++ if (x_range <= 0) x_range = 1; ++ if (y_range <= 0) y_range = 1; ++ { ++ int screen_w = DisplayWidth( thread_data->display, DefaultScreen( thread_data->display ) ); ++ int screen_h = DisplayHeight( thread_data->display, DefaultScreen( thread_data->display ) ); ++ input.mi.dx = (LONG)round( (x_val - raw_mouse_map[dev_idx].x_min) / x_range * (screen_w - 1) ); ++ input.mi.dy = (LONG)round( (y_val - raw_mouse_map[dev_idx].y_min) / y_range * (screen_h - 1) ); ++ } ++ input.mi.dwFlags |= MOUSEEVENTF_ABSOLUTE; ++ } ++ else ++ { ++ map_raw_event_coords( event, &input, TRUE ); ++ } ++ } ++ ++ NtUserSendHardwareInput( 0, 0, &input, 0 ); + return TRUE; + } + +diff --git a/server/queue.c b/server/queue.c +index b126162..9078589 100644 +--- a/server/queue.c ++++ b/server/queue.c +@@ -2051,7 +2051,7 @@ static void rawmouse_init( struct rawinput *header, RAWMOUSE *rawmouse, int x, i + unsigned int i; + + header->type = RIM_TYPEMOUSE; +- header->device = WINE_MOUSE_HANDLE; ++ header->device = info ? (unsigned int)info : WINE_MOUSE_HANDLE; + header->wparam = 0; + header->usage = MAKELONG(HID_USAGE_GENERIC_MOUSE, HID_USAGE_PAGE_GENERIC); +