Guest User

Untitled

a guest
Sep 4th, 2025
38
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 49.39 KB | Software | 0 0
  1. import tkinter as tk
  2. from tkinter import ttk, messagebox, simpledialog
  3. import ctypes
  4. import ctypes.wintypes as wintypes
  5. import time
  6. import vgamepad as vg
  7. import threading
  8. import os
  9. import re
  10. import ast
  11.  
  12. try:
  13.     import wmi
  14.     WMI_AVAILABLE = True
  15. except ImportError:
  16.     WMI_AVAILABLE = False
  17.  
  18. # --- Default Configuration ---
  19. DEFAULT_SENSITIVITY = 50.0
  20. BASE_INPUT_RANGE = 50.0  # Mouse delta for 100% joystick output at 1.00x sensitivity
  21. DEFAULT_RESET_KEY = ';'
  22. DECAY_TIMEOUT = 0.05
  23. DEFAULT_CURVE_POINTS = [[0.0, 0.0], [0.25, 0.25], [0.5, 0.5], [0.75, 0.75], [1.0, 1.0]]
  24.  
  25.  
  26. # --- WinAPI Structures (Defined before use) ---
  27. class POINT(ctypes.Structure):
  28.     _fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
  29.  
  30. class MSLLHOOKSTRUCT(ctypes.Structure):
  31.     _fields_ = [("pt", POINT), ("mouseData", wintypes.DWORD), ("flags", wintypes.DWORD),
  32.                 ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.POINTER(wintypes.ULONG))]
  33.  
  34. class RAWINPUTDEVICE(ctypes.Structure):
  35.     _fields_ = [("usUsagePage", wintypes.USHORT), ("usUsage", wintypes.USHORT),
  36.                 ("dwFlags", wintypes.DWORD), ("hwndTarget", wintypes.HWND)]
  37.  
  38. class RAWMOUSE(ctypes.Structure):
  39.     _fields_ = [("usFlags", wintypes.USHORT), ("ulButtons", wintypes.ULONG),
  40.                 ("usButtonFlags", wintypes.USHORT), ("usButtonData", wintypes.USHORT),
  41.                 ("ulRawButtons", wintypes.ULONG), ("lLastX", wintypes.LONG),
  42.                 ("lLastY", wintypes.LONG), ("ulExtraInformation", wintypes.ULONG)]
  43.  
  44. class RAWINPUTHEADER(ctypes.Structure):
  45.     _fields_ = [("dwType", wintypes.DWORD), ("dwSize", wintypes.DWORD),
  46.                 ("hDevice", wintypes.HANDLE), ("wParam", wintypes.WPARAM)]
  47.  
  48. class RAWINPUT(ctypes.Structure):
  49.     _fields_ = [("header", RAWINPUTHEADER), ("mouse", RAWMOUSE)]
  50.  
  51. class RAWINPUTDEVICELIST(ctypes.Structure):
  52.     _fields_ = [('hDevice', wintypes.HANDLE), ('dwType', wintypes.DWORD)]
  53.  
  54. WNDPROC = ctypes.WINFUNCTYPE(wintypes.LPARAM, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM)
  55. class WNDCLASSW(ctypes.Structure):
  56.     _fields_ = [("style", wintypes.UINT), ("lpfnWndProc", WNDPROC), ("cbClsExtra", ctypes.c_int),
  57.                 ("cbWndExtra", ctypes.c_int), ("hInstance", wintypes.HINSTANCE),
  58.                 ("hIcon", wintypes.HANDLE),
  59.                 ("hCursor", wintypes.HANDLE),
  60.                 ("hbrBackground", wintypes.HANDLE),
  61.                 ("lpszMenuName", wintypes.LPCWSTR),
  62.                 ("lpszClassName", wintypes.LPCWSTR)]
  63.  
  64. class MSG(ctypes.Structure):
  65.     _fields_ = [("hwnd", wintypes.HWND), ("message", wintypes.UINT), ("wParam", wintypes.WPARAM),
  66.                 ("lParam", wintypes.LPARAM), ("time", wintypes.DWORD), ("pt", POINT)]
  67.  
  68.  
  69. # --- WinAPI Function Prototypes ---
  70. WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
  71. HOOKPROC = ctypes.WINFUNCTYPE(wintypes.LPARAM, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM)
  72.  
  73. user32 = ctypes.WinDLL('user32', use_last_error=True)
  74. kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
  75.  
  76. user32.GetAsyncKeyState.restype = wintypes.SHORT
  77. user32.GetAsyncKeyState.argtypes = [ctypes.c_int]
  78. user32.EnumWindows.argtypes = [WNDENUMPROC, wintypes.LPARAM]
  79. user32.IsWindowVisible.argtypes = [wintypes.HWND]
  80. user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
  81. user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
  82. user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
  83. user32.GetForegroundWindow.restype = wintypes.HWND
  84. user32.GetForegroundWindow.argtypes = []
  85. user32.DefWindowProcW.restype = wintypes.LPARAM
  86. user32.DefWindowProcW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
  87. user32.GetRawInputDeviceInfoW.restype = wintypes.UINT
  88. user32.GetRawInputDeviceInfoW.argtypes = [wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID, ctypes.POINTER(wintypes.UINT)]
  89. user32.SetWindowsHookExW.restype = wintypes.HHOOK
  90. user32.SetWindowsHookExW.argtypes = [ctypes.c_int, HOOKPROC, wintypes.HINSTANCE, wintypes.DWORD]
  91. user32.CallNextHookEx.restype = wintypes.LPARAM
  92. user32.CallNextHookEx.argtypes = [wintypes.HHOOK, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM]
  93. user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK]
  94. user32.WindowFromPoint.argtypes = [POINT]
  95. user32.GetRawInputDeviceList.argtypes = [ctypes.POINTER(RAWINPUTDEVICELIST), ctypes.POINTER(wintypes.UINT), wintypes.UINT]
  96. user32.RegisterRawInputDevices.argtypes = [ctypes.POINTER(RAWINPUTDEVICE), wintypes.UINT, wintypes.UINT]
  97. user32.GetRawInputData.argtypes = [wintypes.LPARAM, wintypes.UINT, wintypes.LPVOID, ctypes.POINTER(wintypes.UINT), wintypes.UINT]
  98. user32.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASSW)]
  99. user32.CreateWindowExW.restype = wintypes.HWND
  100. user32.CreateWindowExW.argtypes = [wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, wintypes.LPVOID]
  101. user32.DestroyWindow.argtypes = [wintypes.HWND]
  102. user32.UnregisterClassW.argtypes = [wintypes.LPCWSTR, wintypes.HINSTANCE]
  103. user32.PeekMessageW.argtypes = [ctypes.POINTER(MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT, wintypes.UINT]
  104. user32.TranslateMessage.argtypes = [ctypes.POINTER(MSG)]
  105. user32.DispatchMessageW.argtypes = [ctypes.POINTER(MSG)]
  106.  
  107.  
  108. # --- Helper Functions ---
  109. def list_windows():
  110.     windows = []
  111.     def enum_windows_proc(hwnd, lparam):
  112.         if user32.IsWindowVisible(hwnd) and user32.GetWindowTextLengthW(hwnd) > 0:
  113.             buffer = ctypes.create_unicode_buffer(user32.GetWindowTextLengthW(hwnd) + 1)
  114.             user32.GetWindowTextW(hwnd, buffer, len(buffer))
  115.             windows.append({'title': buffer.value, 'hwnd': hwnd})
  116.         return True
  117.     user32.EnumWindows(WNDENUMPROC(enum_windows_proc), 0)
  118.     return windows
  119.    
  120. def check_and_install_vgamepad():
  121.     #"""
  122.     #Checks if vgamepad is installed. If not, prompts the user to install it.
  123.     #Returns True if the application can proceed, False otherwise.
  124.     #"""
  125.     try:
  126.         import vgamepad
  127.         return True
  128.     except ImportError:
  129.         import tkinter as tk
  130.         from tkinter import messagebox
  131.         import subprocess
  132.         import sys
  133.         import threading
  134.  
  135.         # Create a hidden root window for messageboxes
  136.         root = tk.Tk()
  137.         root.withdraw()
  138.  
  139.         # Ask the user for permission to install
  140.         should_install = messagebox.askyesno(
  141.             "Dependency Missing",
  142.             "The required 'vgamepad' library is not installed.\n\n"
  143.             "This library is essential for creating the virtual joystick. "
  144.             "Would you like to install it now?"
  145.         )
  146.  
  147.         if should_install:
  148.             # Show a simple "Installing..." window
  149.             progress_window = tk.Toplevel(root)
  150.             progress_window.title("Installing...")
  151.             progress_window.geometry("250x100")
  152.             progress_window.resizable(False, False)
  153.             ttk.Label(progress_window, text="Installing vgamepad, please wait...").pack(pady=20, padx=20)
  154.             progress_window.grab_set()
  155.             root.update_idletasks()
  156.  
  157.             install_success = [False] # Use a list to make it mutable for the thread
  158.  
  159.             def do_install():
  160.                 try:
  161.                     # Use sys.executable to ensure pip installs for the correct Python version
  162.                     command = [sys.executable, "-m", "pip", "install", "vgamepad", "--quiet"]
  163.                     subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  164.                     install_success[0] = True
  165.                 except (subprocess.CalledProcessError, FileNotFoundError):
  166.                     install_success[0] = False
  167.                 finally:
  168.                     # Safely destroy the window from the main thread
  169.                     root.after(0, progress_window.destroy)
  170.  
  171.             # Run installation in a separate thread to prevent the UI from freezing
  172.             install_thread = threading.Thread(target=do_install)
  173.             install_thread.start()
  174.            
  175.             # Keep the progress window running
  176.             root.wait_window(progress_window)
  177.  
  178.             if install_success[0]:
  179.                 messagebox.showinfo("Success", "'vgamepad' has been installed. The application will now start.")
  180.                 root.destroy()
  181.                 return True
  182.             else:
  183.                 messagebox.showerror(
  184.                     "Installation Failed",
  185.                     "Failed to automatically install 'vgamepad'.\n\n"
  186.                     "Please install it manually by opening a command prompt and running:\n"
  187.                     "pip install vgamepad\n"
  188.                     "Or\n"
  189.                     "py pip -m install vgamepad\n"
  190.                     "in Powershell on Windows."
  191.                 )
  192.                 root.destroy()
  193.                 return False
  194.         else:
  195.             messagebox.showwarning(
  196.                 "Installation Skipped",
  197.                 "You have chosen not to install 'vgamepad'.\n\n"
  198.                 "The application cannot function without it and will now close."
  199.             )
  200.             root.destroy()
  201.             return False
  202.  
  203. def list_raw_mice():
  204.     mice = []
  205.     wmi_mice = []
  206.     if WMI_AVAILABLE:
  207.         try:
  208.             wmi_mice = wmi.WMI().Win32_PointingDevice()
  209.         except Exception:
  210.             pass
  211.  
  212.     num_devices = wintypes.UINT(0)
  213.     user32.GetRawInputDeviceList(None, ctypes.byref(num_devices), ctypes.sizeof(RAWINPUTDEVICELIST))
  214.     if num_devices.value == 0: return []
  215.    
  216.     device_list = (RAWINPUTDEVICELIST * num_devices.value)()
  217.     user32.GetRawInputDeviceList(device_list, ctypes.byref(num_devices), ctypes.sizeof(RAWINPUTDEVICELIST))
  218.    
  219.     for i in range(num_devices.value):
  220.         device = device_list[i]
  221.         if device.dwType == 0: # RIM_TYPEMOUSE
  222.             name_size = wintypes.UINT(0)
  223.             user32.GetRawInputDeviceInfoW(device.hDevice, 0x20000007, None, ctypes.byref(name_size))
  224.             if name_size.value <= 0: continue
  225.            
  226.             name_buffer = ctypes.create_unicode_buffer(name_size.value)
  227.             user32.GetRawInputDeviceInfoW(device.hDevice, 0x20000007, name_buffer, ctypes.byref(name_size))
  228.            
  229.             raw_name = name_buffer.value
  230.             display_name = raw_name
  231.            
  232.             match = re.search(r'VID_([0-9a-fA-F]{4})&PID_([0-9a-fA-F]{4})', raw_name, re.IGNORECASE)
  233.             if match and wmi_mice:
  234.                 vid_pid_str = f"VID_{match.group(1)}&PID_{match.group(2)}".upper()
  235.                 for wmi_mouse in wmi_mice:
  236.                     if wmi_mouse.PNPDeviceID and vid_pid_str in wmi_mouse.PNPDeviceID.upper():
  237.                         display_name = wmi_mouse.Name
  238.                         break
  239.            
  240.             mice.append({'display_name': display_name, 'raw_name': raw_name, 'handle': device.hDevice})
  241.     return mice
  242.    
  243.  
  244.  
  245. class Mouse2JoystickApp:
  246.     def __init__(self, root):
  247.         self.root = root
  248.         self.root.title("Mouse2Joystick Utility")
  249.         self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
  250.        
  251.         if not WMI_AVAILABLE:
  252.             messagebox.showinfo("Optional Dependency Missing",
  253.                                 "The 'WMI' Python package was not found.\n\n"
  254.                                 "Device names may be less descriptive. To fix this, run:\n"
  255.                                 "pip install wmi\n"
  256.                                 "or\n"
  257.                                 "py pip -m install wmi\n"
  258.                                 "in Powershell on Windows.")
  259.  
  260.         self.sensitivity = tk.DoubleVar(value=DEFAULT_SENSITIVITY)
  261.         self.sensitivity_display = tk.StringVar()
  262.         self.reset_key = tk.StringVar(value=DEFAULT_RESET_KEY)
  263.         self.selected_profile = tk.StringVar()
  264.         self.active_profile_display = tk.StringVar(value="N/A")
  265.         self.reverse_output = tk.BooleanVar(value=False)
  266.  
  267.         self.joystick_x, self.joystick_y = 0.0, 0.0
  268.         self.mouse_dx, self.mouse_dy = 0, 0
  269.         self.last_mouse_event_time = 0.0
  270.         self.selected_mouse = None
  271.         self.selected_window = None
  272.  
  273.         self.is_running = False
  274.         self.conversion_thread = None
  275.        
  276.         self.loaded_profile_name = None
  277.         self.settings_changed = True
  278.  
  279.         self.hook_id = None
  280.         self.target_pid = wintypes.DWORD(0)
  281.         self.low_level_mouse_proc_ptr = HOOKPROC(self._low_level_mouse_proc)
  282.         self.wnd_proc_ptr = WNDPROC(self._wnd_proc)
  283.  
  284.         self.curve_points = [p[:] for p in DEFAULT_CURVE_POINTS]
  285.         self.dragged_point_index = None
  286.         self.point_handles = []
  287.         self.curve_margin = 20
  288.  
  289.         self.mice_data, self.windows_data = [], []
  290.         self.selection_controls = []
  291.        
  292.         self.controller_name_var = tk.StringVar(value="Emulating: None")
  293.         self.mouse_coords_var = tk.StringVar(value="Mouse Δ: X=0, Y=0")
  294.         self.joystick_coords_var = tk.StringVar(value="Joystick: X=0.000, Y=0.000")
  295.        
  296.         ttk.Style().configure('Italic.TLabel', font=('Segoe UI', 9, 'italic'))
  297.  
  298.         self._create_menu()
  299.         self.create_widgets()
  300.         self.populate_mice_list()
  301.         self.populate_windows_list()
  302.         self.populate_profiles_list()
  303.         self._update_sensitivity_label(self.sensitivity.get())
  304.         self._update_profile_status_display()
  305.        
  306.         self.root.after(100, self._draw_curve)
  307.        
  308.         #DISCLAIMER ON STARTUP
  309.         self.root.after(200, self._show_about_dialog)
  310.  
  311.         self.sensitivity.trace_add("write", self.on_setting_change)
  312.         self.reset_key.trace_add("write", self.on_setting_change)
  313.         self.reverse_output.trace_add("write", self.on_setting_change)
  314.  
  315.     def _create_menu(self):
  316.         menubar = tk.Menu(self.root)
  317.         self.root.config(menu=menubar)
  318.  
  319.         help_menu = tk.Menu(menubar, tearoff=0)
  320.         menubar.add_cascade(label="Help", menu=help_menu)
  321.         help_menu.add_command(label="About", command=self._show_about_dialog)
  322.  
  323.     def _show_about_dialog(self):
  324.         about_window = tk.Toplevel(self.root)
  325.         about_window.title("About & Disclaimer")
  326.  
  327.         self.root.update_idletasks()
  328.         main_x = self.root.winfo_x()
  329.         main_y = self.root.winfo_y()
  330.         main_w = self.root.winfo_width()
  331.         main_h = self.root.winfo_height()
  332.        
  333.         about_w, about_h = 450, 320
  334.         pos_x = main_x + (main_w // 2) - (about_w // 2)
  335.         pos_y = main_y + (main_h // 2) - (about_h // 2)
  336.        
  337.         about_window.geometry(f"{about_w}x{about_h}+{pos_x}+{pos_y}")
  338.         about_window.resizable(False, False)
  339.  
  340.         about_window.transient(self.root)
  341.         about_window.grab_set()
  342.         about_window.protocol("WM_DELETE_WINDOW", lambda: None)
  343.  
  344.         main_frame = ttk.Frame(about_window, padding=20)
  345.         main_frame.pack(expand=True, fill=tk.BOTH)
  346.  
  347.         about_text = ("This utility translates raw input from a selected mouse device into "
  348.                       "virtual Xbox 360 joystick output. "
  349.                       "V 1.0, programmed 99.9% with Gemini 2.5 Pro.")
  350.         ttk.Label(main_frame, text=about_text, wraplength=400, justify=tk.LEFT).pack(pady=(0, 10))
  351.  
  352.         warning_text = ("WARNING: Pretty much every anti-cheat will think that this is cheats. Don't use this "
  353.                         "software for online games. This may result in account suspension or bans. "
  354.                         "By using this software, you acknowledge and assume any and all risks involved.")
  355.         warning_label = ttk.Label(main_frame, text=warning_text, wraplength=400, justify=tk.LEFT, font=("Segoe UI", 9, "bold"), foreground="red")
  356.         warning_label.pack(pady=10)
  357.        
  358.         disclaimer_text = ("This software was coded almost entirely by a large language model and is provided 'as is' "
  359.                            "without warranty of any kind. It is not copyrighted and is free to be modified and distributed.")
  360.         ttk.Label(main_frame, text=disclaimer_text, wraplength=400, justify=tk.LEFT, style='Italic.TLabel').pack(pady=10)
  361.  
  362.         ack_button = tk.Button(main_frame, text="Acknowledge", command=about_window.destroy)
  363.         ack_button.pack(pady=(15, 0))
  364.  
  365.         self.root.wait_window(about_window)
  366.  
  367.     def create_widgets(self):
  368.         self.root.columnconfigure(0, weight=1)
  369.         self.root.columnconfigure(1, weight=0)
  370.         self.root.rowconfigure(0, weight=1)
  371.  
  372.         left_frame = ttk.Frame(self.root, padding="10")
  373.         left_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
  374.         left_frame.rowconfigure(0, weight=1)
  375.         left_frame.rowconfigure(1, weight=1)
  376.        
  377.         mouse_frame = ttk.LabelFrame(left_frame, text="1. Select Mouse Device", padding="10")
  378.         mouse_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=5, pady=5)
  379.         mouse_frame.columnconfigure(0, weight=1)
  380.         mouse_frame.rowconfigure(0, weight=1)
  381.         listbox_frame_mouse = ttk.Frame(mouse_frame)
  382.         listbox_frame_mouse.grid(row=0, column=0, columnspan=2, sticky="nsew")
  383.         mouse_scrollbar = ttk.Scrollbar(listbox_frame_mouse, orient=tk.VERTICAL)
  384.         self.mouse_listbox = tk.Listbox(listbox_frame_mouse, height=5, yscrollcommand=mouse_scrollbar.set)
  385.         mouse_scrollbar.config(command=self.mouse_listbox.yview)
  386.         mouse_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  387.         self.mouse_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  388.         self.selection_controls.append(self.mouse_listbox)
  389.         mouse_refresh_btn = ttk.Button(mouse_frame, text="Refresh", command=self.populate_mice_list)
  390.         mouse_refresh_btn.grid(row=1, column=0, pady=(5,0), sticky=(tk.W, tk.E))
  391.         self.selection_controls.append(mouse_refresh_btn)
  392.         mouse_select_btn = ttk.Button(mouse_frame, text="Select", command=self.select_mouse)
  393.         mouse_select_btn.grid(row=1, column=1, pady=(5,0), sticky=(tk.W, tk.E))
  394.         self.selection_controls.append(mouse_select_btn)
  395.        
  396.         app_frame = ttk.LabelFrame(left_frame, text="2. Select Target Application", padding="10")
  397.         app_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=5, pady=5)
  398.         app_frame.columnconfigure(0, weight=1)
  399.         app_frame.rowconfigure(0, weight=1)
  400.         listbox_frame_app = ttk.Frame(app_frame)
  401.         listbox_frame_app.grid(row=0, column=0, columnspan=2, sticky="nsew")
  402.         app_scrollbar = ttk.Scrollbar(listbox_frame_app, orient=tk.VERTICAL)
  403.         self.app_listbox = tk.Listbox(listbox_frame_app, height=5, yscrollcommand=app_scrollbar.set)
  404.         app_scrollbar.config(command=self.app_listbox.yview)
  405.         app_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  406.         self.app_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  407.         self.selection_controls.append(self.app_listbox)
  408.         app_refresh_btn = ttk.Button(app_frame, text="Refresh", command=self.populate_windows_list)
  409.         app_refresh_btn.grid(row=1, column=0, pady=(5,0), sticky=(tk.W, tk.E))
  410.         self.selection_controls.append(app_refresh_btn)
  411.         app_select_btn = ttk.Button(app_frame, text="Select", command=self.select_app)
  412.         app_select_btn.grid(row=1, column=1, pady=(5,0), sticky=(tk.W, tk.E))
  413.         self.selection_controls.append(app_select_btn)
  414.        
  415.         settings_frame = ttk.LabelFrame(left_frame, text="3. Settings", padding="10")
  416.         settings_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), padx=5, pady=5)
  417.         settings_frame.columnconfigure(1, weight=1)
  418.         ttk.Label(settings_frame, text="Sensitivity:").grid(row=0, column=0, sticky=tk.W, padx=5)
  419.         ttk.Scale(settings_frame, from_=0, to=100, orient=tk.HORIZONTAL, variable=self.sensitivity, command=self._update_sensitivity_label).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5)
  420.         ttk.Label(settings_frame, textvariable=self.sensitivity_display, width=7).grid(row=0, column=2, sticky=tk.W, padx=5)
  421.         ttk.Label(settings_frame, text="Reset Key:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5)
  422.         ttk.Entry(settings_frame, textvariable=self.reset_key, width=5).grid(row=1, column=1, sticky=tk.W, padx=5, pady=5)
  423.         ttk.Button(settings_frame, text="Reset Settings", command=self.reset_defaults).grid(row=1, column=2, sticky=tk.W, padx=5, pady=5)
  424.         ttk.Checkbutton(settings_frame, text="Reverse Joystick Output", variable=self.reverse_output).grid(row=2, column=0, columnspan=3, sticky=tk.W, padx=5)
  425.  
  426.         curve_frame = ttk.LabelFrame(left_frame, text="4. Acceleration Curve", padding="10")
  427.         curve_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), padx=5, pady=5)
  428.         curve_frame.columnconfigure(0, weight=1)
  429.         self.curve_canvas = tk.Canvas(curve_frame, width=220, height=220, bg='#FFFFFF', highlightthickness=1, highlightbackground="grey")
  430.         self.curve_canvas.grid(row=0, column=0, sticky='nsew', pady=5)
  431.         self.curve_canvas.bind("<Button-1>", self._on_canvas_press)
  432.         self.curve_canvas.bind("<B1-Motion>", self._on_canvas_drag)
  433.         self.curve_canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
  434.         reset_curve_btn = ttk.Button(curve_frame, text="Reset Curve", command=self.reset_curve)
  435.         reset_curve_btn.grid(row=1, column=0, sticky='ew', pady=(5,0))
  436.  
  437.         control_frame = ttk.Frame(left_frame, padding=(5, 10))
  438.         control_frame.grid(row=4, column=0, sticky=(tk.W, tk.E))
  439.         control_frame.columnconfigure(0, weight=1); control_frame.columnconfigure(1, weight=1)
  440.         self.start_button = ttk.Button(control_frame, text="Start Conversion", command=self.start_conversion)
  441.         self.start_button.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0,2))
  442.         self.selection_controls.append(self.start_button)
  443.         self.stop_button = ttk.Button(control_frame, text="Stop Conversion", command=self.stop_conversion, state=tk.DISABLED)
  444.         self.stop_button.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(2,0))
  445.        
  446.         self.status_label = ttk.Label(left_frame, text="Status: Idle. Please select a mouse and application.")
  447.         self.status_label.grid(row=5, column=0, sticky=(tk.W, tk.E), padx=5, pady=(5,0))
  448.  
  449.         right_frame = ttk.Frame(self.root, padding=(0, 10, 10, 10))
  450.         right_frame.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.E, tk.W))
  451.         right_frame.rowconfigure(1, weight=1)
  452.         right_frame.rowconfigure(2, weight=0)
  453.        
  454.         profile_frame = ttk.LabelFrame(right_frame, text="Profiles", padding="10")
  455.         profile_frame.grid(row=0, column=0, sticky=(tk.N, tk.E, tk.W))
  456.         profile_frame.columnconfigure(0, weight=1)
  457.         profile_frame.columnconfigure(1, weight=1)
  458.         self.profile_combobox = ttk.Combobox(profile_frame, textvariable=self.selected_profile, state="readonly")
  459.         self.profile_combobox.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
  460.  
  461.         profile_load_btn = ttk.Button(profile_frame, text="Load Profile", command=self.load_profile)
  462.         profile_load_btn.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0,2), padx=(0, 1))
  463.  
  464.         profile_refresh_btn = ttk.Button(profile_frame, text="Refresh", command=self.populate_profiles_list)
  465.         profile_refresh_btn.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=(0,2), padx=(1, 0))
  466.        
  467.         profile_save_btn = ttk.Button(profile_frame, text="Save New Profile", command=self.save_profile)
  468.         profile_save_btn.grid(row=2, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(2,0))
  469.         self.profile_overwrite_btn = ttk.Button(profile_frame, text="Overwrite Active Profile", command=self.overwrite_profile, state=tk.DISABLED)
  470.         self.profile_overwrite_btn.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(2,0))
  471.         ttk.Separator(profile_frame, orient='horizontal').grid(row=4, column=0, columnspan=2, sticky='ew', pady=10)
  472.         ttk.Label(profile_frame, text="Active Profile:").grid(row=5, column=0, columnspan=2, sticky=tk.W, pady=(0, 2))
  473.         self.active_profile_label = ttk.Label(profile_frame, textvariable=self.active_profile_display, font=("Segoe UI", 9, "bold"))
  474.         self.active_profile_label.grid(row=6, column=0, columnspan=2, sticky=tk.W)
  475.  
  476.         comments_frame = ttk.LabelFrame(right_frame, text="Comments", padding=(10, 5))
  477.         comments_frame.grid(row=1, column=0, sticky="nsew", pady=(5,0))
  478.         comments_frame.rowconfigure(0, weight=1)
  479.         comments_frame.columnconfigure(0, weight=1)
  480.        
  481.         comments_scrollbar = ttk.Scrollbar(comments_frame, orient=tk.VERTICAL)
  482.         self.comments_text = tk.Text(comments_frame, height=4, yscrollcommand=comments_scrollbar.set, wrap=tk.WORD)
  483.         comments_scrollbar.config(command=self.comments_text.yview)
  484.        
  485.         comments_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
  486.         self.comments_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  487.         self.comments_text.bind("<KeyRelease>", self.on_setting_change)
  488.  
  489.         visualizer_frame = ttk.LabelFrame(right_frame, text="Output Visualizer", padding=(10, 5))
  490.         visualizer_frame.grid(row=2, column=0, sticky="nsew", pady=(5, 0))
  491.         visualizer_frame.columnconfigure(0, weight=0)
  492.         visualizer_frame.columnconfigure(1, weight=1)
  493.        
  494.         self.visualizer_canvas = tk.Canvas(visualizer_frame, width=120, height=120, bg='#FFFFFF', highlightthickness=1, highlightbackground="grey")
  495.         self.visualizer_canvas.grid(row=0, column=0, rowspan=3, sticky='nsew', padx=(0, 10))
  496.        
  497.         ttk.Label(visualizer_frame, textvariable=self.controller_name_var, font=("Segoe UI", 9, "bold")).grid(row=0, column=1, sticky='sw')
  498.         ttk.Label(visualizer_frame, textvariable=self.mouse_coords_var).grid(row=1, column=1, sticky='w')
  499.         ttk.Label(visualizer_frame, textvariable=self.joystick_coords_var).grid(row=2, column=1, sticky='nw')
  500.  
  501.         self.root.after(100, self._draw_default_visualizer_bg)
  502.  
  503.     def _draw_default_visualizer_bg(self):
  504.         if not hasattr(self, 'visualizer_canvas') or self.visualizer_canvas.winfo_width() <= 1:
  505.             self.root.after(100, self._draw_default_visualizer_bg)
  506.             return
  507.  
  508.         self.visualizer_canvas.delete("all")
  509.         w, h = self.visualizer_canvas.winfo_width(), self.visualizer_canvas.winfo_height()
  510.         center_x, center_y = w / 2, h / 2
  511.         radius = min(center_x, center_y) - 5
  512.        
  513.         self.visualizer_canvas.create_oval(center_x - radius, center_y - radius, center_x + radius, center_y + radius, outline='grey')
  514.         self.visualizer_canvas.create_line(center_x, center_y - radius, center_x, center_y + radius, fill='lightgrey')
  515.         self.visualizer_canvas.create_line(center_x - radius, center_y, center_x + radius, center_y, fill='lightgrey')
  516.  
  517.     def _update_visualizer(self):
  518.         if self.is_running:
  519.             self._draw_default_visualizer_bg()
  520.             w, h = self.visualizer_canvas.winfo_width(), self.visualizer_canvas.winfo_height()
  521.             center_x, center_y = w / 2, h / 2
  522.             radius = min(center_x, center_y) - 5
  523.  
  524.             final_x = -self.joystick_y
  525.             final_y = -self.joystick_x
  526.             if self.reverse_output.get():
  527.                 final_x *= -1
  528.                 final_y *= -1
  529.  
  530.             indicator_x = center_x + final_x * radius
  531.             indicator_y = center_y - final_y * radius
  532.  
  533.             r = 5
  534.             self.visualizer_canvas.create_oval(indicator_x - r, indicator_y - r, indicator_x + r, indicator_y + r, fill="#d9534f", outline="black")
  535.  
  536.             self.mouse_coords_var.set(f"Mouse Δ: X={self.mouse_dx}, Y={self.mouse_dy}")
  537.             self.joystick_coords_var.set(f"Joystick: X={final_x:.3f}, Y={final_y:.3f}")
  538.  
  539.             self.root.after(30, self._update_visualizer)
  540.         else:
  541.             self._draw_default_visualizer_bg()
  542.             self.mouse_dx, self.mouse_dy = 0, 0
  543.             self.mouse_coords_var.set("Mouse Δ: X=0, Y=0")
  544.             self.joystick_coords_var.set("Joystick: X=0.000, Y=0.000")
  545.  
  546.     def on_setting_change(self, *args):
  547.         self.settings_changed = True
  548.         self._update_profile_status_display()
  549.  
  550.     def _update_profile_status_display(self):
  551.         if self.loaded_profile_name:
  552.             display_name = os.path.basename(self.loaded_profile_name).replace('.txt', '')
  553.             self.profile_overwrite_btn.config(state=tk.NORMAL)
  554.             if self.settings_changed:
  555.                 self.active_profile_display.set(f"{display_name} (modified)")
  556.                 self.active_profile_label.config(foreground="#f0ad4e")
  557.             else:
  558.                 self.active_profile_display.set(display_name)
  559.                 self.active_profile_label.config(foreground="#0275d8")
  560.         else:
  561.             self.active_profile_display.set("N/A")
  562.             self.active_profile_label.config(foreground="#d9534f")
  563.             if hasattr(self, 'profile_overwrite_btn'):
  564.                 self.profile_overwrite_btn.config(state=tk.DISABLED)
  565.  
  566.     def _update_sensitivity_label(self, value):
  567.         slider_val_norm = float(value) / 100.0
  568.         # Exponential scaling: 10 * x^3.3219. Midpoint (50) gives 1.0x, Max (100) gives 10.0x
  569.         multiplier = 10.0 * (slider_val_norm ** 3.3219)
  570.         self.sensitivity_display.set(f"{multiplier:.2f}x")
  571.    
  572.     def _highlight_listbox_selection(self, listbox, index_to_highlight):
  573.         default_bg_color = listbox.cget('bg')
  574.         highlight_color = '#cce5ff'
  575.         for i in range(listbox.size()):
  576.             listbox.itemconfig(i, bg=default_bg_color)
  577.         if index_to_highlight is not None and index_to_highlight < listbox.size():
  578.             listbox.itemconfig(index_to_highlight, bg=highlight_color)
  579.  
  580.     def reset_defaults(self):
  581.         self.sensitivity.set(DEFAULT_SENSITIVITY)
  582.         self.reset_key.set(DEFAULT_RESET_KEY)
  583.         self.reverse_output.set(False)
  584.         self._update_sensitivity_label(DEFAULT_SENSITIVITY)
  585.         self.comments_text.delete("1.0", tk.END)
  586.         self.reset_curve()
  587.  
  588.     def reset_curve(self):
  589.         self.curve_points = [p[:] for p in DEFAULT_CURVE_POINTS]
  590.         self._draw_curve()
  591.         self.on_setting_change()
  592.        
  593.     def _coords_to_canvas(self, x, y):
  594.         w, h = self.curve_canvas.winfo_width(), self.curve_canvas.winfo_height()
  595.         plot_w, plot_h = w - 2 * self.curve_margin, h - 2 * self.curve_margin
  596.         return self.curve_margin + x * plot_w, self.curve_margin + (1 - y) * plot_h
  597.  
  598.     def _canvas_to_coords(self, cx, cy):
  599.         w, h = self.curve_canvas.winfo_width(), self.curve_canvas.winfo_height()
  600.         plot_w, plot_h = w - 2 * self.curve_margin, h - 2 * self.curve_margin
  601.         x = (cx - self.curve_margin) / plot_w
  602.         y = 1 - ((cy - self.curve_margin) / plot_h)
  603.         return x, y
  604.  
  605.     def _draw_curve(self):
  606.         if self.curve_canvas.winfo_width() <= 1: return
  607.         self.curve_canvas.delete("all")
  608.         self.point_handles.clear()
  609.  
  610.         w, h = self.curve_canvas.winfo_width(), self.curve_canvas.winfo_height()
  611.         self.curve_canvas.create_rectangle(self.curve_margin, self.curve_margin, w - self.curve_margin, h - self.curve_margin, outline="grey")
  612.        
  613.         p1_canvas = self._coords_to_canvas(0.0, 0.0)
  614.         p2_canvas = self._coords_to_canvas(1.0, 1.0)
  615.         self.curve_canvas.create_line(p1_canvas, p2_canvas, fill="#cccccc", width=1, dash=(2, 4))
  616.        
  617.         canvas_points = [self._coords_to_canvas(p[0], p[1]) for p in self.curve_points]
  618.         self.curve_canvas.create_line(canvas_points, fill="#0275d8", width=2)
  619.        
  620.         for i, (px, py) in enumerate(canvas_points):
  621.             r = 4
  622.             color = "#0275d8" if 0 < i < len(canvas_points) - 1 else "grey"
  623.             handle = self.curve_canvas.create_oval(px - r, py - r, px + r, py + r, fill=color, outline="black")
  624.             self.point_handles.append(handle)
  625.  
  626.     def _on_canvas_press(self, event):
  627.         for i, handle_id in enumerate(self.point_handles):
  628.             if 0 < i < len(self.point_handles) - 1:
  629.                 coords = self.curve_canvas.coords(handle_id)
  630.                 if coords[0] <= event.x <= coords[2] and coords[1] <= event.y <= coords[3]:
  631.                     self.dragged_point_index = i
  632.                     break
  633.  
  634.     def _on_canvas_drag(self, event):
  635.         if self.dragged_point_index is None: return
  636.  
  637.         x, y = self._canvas_to_coords(event.x, event.y)
  638.        
  639.         prev_x = self.curve_points[self.dragged_point_index - 1][0]
  640.         next_x = self.curve_points[self.dragged_point_index + 1][0]
  641.        
  642.         self.curve_points[self.dragged_point_index][0] = max(prev_x + 0.01, min(next_x - 0.01, x))
  643.         self.curve_points[self.dragged_point_index][1] = max(0.0, min(1.0, y))
  644.        
  645.         self._draw_curve()
  646.         self.on_setting_change()
  647.  
  648.     def _on_canvas_release(self, event):
  649.         self.dragged_point_index = None
  650.  
  651.     def populate_mice_list(self):
  652.         self.mouse_listbox.delete(0, tk.END)
  653.         self.mice_data = list_raw_mice()
  654.         for mouse in self.mice_data: self.mouse_listbox.insert(tk.END, f"  {mouse['display_name']}")
  655.    
  656.     def populate_windows_list(self):
  657.         self.app_listbox.delete(0, tk.END)
  658.         self.windows_data = [{'title': 'None (Do not hide mouse)', 'hwnd': None}] + list_windows()
  659.         for window in self.windows_data: self.app_listbox.insert(tk.END, f"  {window['title'][:80]}")
  660.  
  661.     def select_mouse(self):
  662.         selection = self.mouse_listbox.curselection()
  663.         if not selection: messagebox.showwarning("Selection Error", "Please select a mouse."); return
  664.         self._highlight_listbox_selection(self.mouse_listbox, selection[0])
  665.         self.selected_mouse = self.mice_data[selection[0]]
  666.         self.status_label.config(text=f"Mouse: {self.selected_mouse['display_name']}")
  667.         self.on_setting_change()
  668.  
  669.     def select_app(self):
  670.         selection = self.app_listbox.curselection()
  671.         if not selection: messagebox.showwarning("Selection Error", "Please select an application."); return
  672.         self._highlight_listbox_selection(self.app_listbox, selection[0])
  673.         self.selected_window = self.windows_data[selection[0]]
  674.         self.status_label.config(text=f"App: {self.selected_window['title'][:50]}...")
  675.         self.on_setting_change()
  676.  
  677.     def populate_profiles_list(self):
  678.         try:
  679.             current_selection = self.selected_profile.get()
  680.             profiles = sorted([f for f in os.listdir('.') if f.endswith('.txt')])
  681.             self.profile_combobox['values'] = profiles
  682.             if current_selection in profiles:
  683.                 self.selected_profile.set(current_selection)
  684.             elif not self.selected_profile.get() and profiles:
  685.                 self.selected_profile.set(profiles[0])
  686.         except Exception as e:
  687.             messagebox.showerror("Profile Error", f"Could not read profile files: {e}")
  688.  
  689.     def _write_profile_to_file(self, filename):
  690.         try:
  691.             with open(filename, 'w', encoding='utf-8') as f:
  692.                 f.write(f"reset_key: {self.reset_key.get()}\n")
  693.                 f.write(f"sensitivity: {self.sensitivity.get()}\n")
  694.                 f.write(f"reverse_output: {self.reverse_output.get()}\n")
  695.                 f.write(f"curve_points: {self.curve_points}\n")
  696.                 if self.selected_mouse:
  697.                     f.write(f"mouse_name: {self.selected_mouse['raw_name']}\n")
  698.                 if self.selected_window:
  699.                     f.write(f"app_title: {self.selected_window['title']}\n")
  700.                
  701.                 comments = self.comments_text.get("1.0", tk.END).strip()
  702.                 if comments:
  703.                     f.write("comments_start:\n")
  704.                     f.write(comments + "\n")
  705.                     f.write("comments_end:\n")
  706.             return True
  707.         except Exception as e:
  708.             messagebox.showerror("Save Error", f"Failed to save profile: {e}")
  709.             return False
  710.  
  711.     def save_profile(self):
  712.         if not self.selected_mouse or not self.selected_window:
  713.             messagebox.showwarning("Save Error", "Please select a mouse and an application before saving.")
  714.             return
  715.  
  716.         profile_name = simpledialog.askstring("Save Profile", "Enter profile name:")
  717.         if not profile_name or not profile_name.strip():
  718.             return
  719.  
  720.         safe_name = re.sub(r'[\\/*?:"<>|]', "", profile_name.strip())
  721.         if not safe_name:
  722.             messagebox.showerror("Save Error", "The provided profile name contains only invalid characters.")
  723.             return
  724.  
  725.         filename = f"{safe_name}.txt"
  726.         if os.path.exists(filename) and not messagebox.askyesno("Overwrite", f"Profile '{filename}' already exists. Overwrite?"):
  727.             return
  728.  
  729.         if self._write_profile_to_file(filename):
  730.             messagebox.showinfo("Success", f"Profile '{filename}' saved.")
  731.             self.populate_profiles_list()
  732.             self.selected_profile.set(filename)
  733.             self.loaded_profile_name = filename
  734.             self.settings_changed = False
  735.             self._update_profile_status_display()
  736.  
  737.     def overwrite_profile(self):
  738.         if not self.loaded_profile_name:
  739.             messagebox.showerror("Overwrite Error", "No profile is currently active to overwrite.")
  740.             return
  741.        
  742.         if not self.selected_mouse or not self.selected_window:
  743.             messagebox.showwarning("Save Error", "A mouse and an application must be selected.")
  744.             return
  745.            
  746.         if messagebox.askyesno("Confirm Overwrite", f"Are you sure you want to overwrite '{self.loaded_profile_name}' with the current settings?"):
  747.             if self._write_profile_to_file(self.loaded_profile_name):
  748.                 messagebox.showinfo("Success", f"Profile '{self.loaded_profile_name}' has been updated.")
  749.                 self.settings_changed = False
  750.                 self._update_profile_status_display()
  751.  
  752.     def load_profile(self):
  753.         filename = self.selected_profile.get()
  754.         if not filename:
  755.             messagebox.showwarning("Load Error", "No profile selected.")
  756.             return
  757.  
  758.         try:
  759.             settings = {}
  760.             comments_lines = []
  761.             is_reading_comments = False
  762.             with open(filename, 'r', encoding='utf-8') as f:
  763.                 for line in f:
  764.                     stripped_line = line.strip()
  765.                     if stripped_line == "comments_start:":
  766.                         is_reading_comments = True
  767.                         continue
  768.                     if stripped_line == "comments_end:":
  769.                         is_reading_comments = False
  770.                         continue
  771.                    
  772.                     if is_reading_comments:
  773.                         comments_lines.append(line)
  774.                     elif ':' in line:
  775.                         key, value = line.split(':', 1)
  776.                         settings[key.strip()] = value.strip()
  777.  
  778.             self.reset_key.set(settings.get('reset_key', DEFAULT_RESET_KEY))
  779.             new_sens = float(settings.get('sensitivity', settings.get('input_range', DEFAULT_SENSITIVITY)))
  780.             self.sensitivity.set(new_sens)
  781.             self._update_sensitivity_label(new_sens)
  782.  
  783.             reverse_val_str = settings.get('reverse_output', 'False')
  784.             self.reverse_output.set(reverse_val_str.lower() == 'true')
  785.  
  786.             try:
  787.                 curve_str = settings.get('curve_points')
  788.                 if curve_str:
  789.                     self.curve_points = ast.literal_eval(curve_str)
  790.                 else:
  791.                     self.curve_points = [p[:] for p in DEFAULT_CURVE_POINTS]
  792.             except (ValueError, SyntaxError):
  793.                 self.curve_points = [p[:] for p in DEFAULT_CURVE_POINTS]
  794.             self._draw_curve()
  795.            
  796.             self.comments_text.delete("1.0", tk.END)
  797.             if comments_lines:
  798.                 self.comments_text.insert("1.0", "".join(comments_lines).strip())
  799.  
  800.             mouse_name = settings.get('mouse_name')
  801.             found_mouse = False
  802.             if mouse_name:
  803.                 self.populate_mice_list()
  804.                 for i, mouse in enumerate(self.mice_data):
  805.                     if mouse['raw_name'] == mouse_name:
  806.                         self.mouse_listbox.selection_clear(0, tk.END)
  807.                         self.mouse_listbox.selection_set(i)
  808.                         self.selected_mouse = self.mice_data[i]
  809.                         self._highlight_listbox_selection(self.mouse_listbox, i)
  810.                         found_mouse = True
  811.                         break
  812.                 if not found_mouse:
  813.                     messagebox.showwarning("Load Warning", f"Could not find saved mouse:\n{mouse_name}")
  814.                     self.selected_mouse = None
  815.                     self._highlight_listbox_selection(self.mouse_listbox, None)
  816.  
  817.             app_title = settings.get('app_title')
  818.             found_app = False
  819.             if app_title:
  820.                 self.populate_windows_list()
  821.                 for i, window in enumerate(self.windows_data):
  822.                     if app_title in window['title']:
  823.                         self.app_listbox.selection_clear(0, tk.END)
  824.                         self.app_listbox.selection_set(i)
  825.                         self.selected_window = self.windows_data[i]
  826.                         self._highlight_listbox_selection(self.app_listbox, i)
  827.                         found_app = True
  828.                         break
  829.                 if not found_app:
  830.                     messagebox.showwarning("Load Warning", f"Could not find an open application with title containing:\n{app_title}")
  831.                     self.selected_window = None
  832.                     self._highlight_listbox_selection(self.app_listbox, None)
  833.            
  834.             self.status_label.config(text=f"Profile '{filename}' loaded. Ready to start.")
  835.             self.loaded_profile_name = filename
  836.             self.settings_changed = False
  837.             self._update_profile_status_display()
  838.  
  839.         except FileNotFoundError:
  840.             messagebox.showerror("Load Error", f"Profile file not found: {filename}"); self.populate_profiles_list()
  841.         except Exception as e:
  842.             messagebox.showerror("Load Error", f"Failed to load profile '{filename}':\n{e}")
  843.  
  844.     def start_conversion(self):
  845.         if not self.selected_mouse or not self.selected_window:
  846.              messagebox.showerror("Error", "A mouse and application must be selected."); return
  847.        
  848.         self.is_running = True
  849.         self.controller_name_var.set("Emulating: XBox 360 Controller")
  850.         self.stop_button.config(state=tk.NORMAL)
  851.         for widget in self.selection_controls: widget.config(state=tk.DISABLED)
  852.         self.status_label.config(text=f"Running... Use TAB and SPACE to select and activate the Stop Conversion button.")
  853.  
  854.         user32.GetWindowThreadProcessId(self.selected_window['hwnd'], ctypes.byref(self.target_pid))
  855.         self.hook_id = user32.SetWindowsHookExW(14, self.low_level_mouse_proc_ptr, 0, 0)
  856.         if not self.hook_id:
  857.             messagebox.showerror("Error", "Failed to set mouse hook.")
  858.             self._finalize_stop()
  859.             return
  860.  
  861.         self.conversion_thread = threading.Thread(target=self._conversion_loop, daemon=True)
  862.         self.conversion_thread.start()
  863.         self.root.after(100, self._update_visualizer)
  864.  
  865.     def stop_conversion(self):
  866.         self.status_label.config(text="Status: Stopping...")
  867.         self.is_running = False
  868.         self.stop_button.config(state=tk.DISABLED)
  869.         self.root.after(100, self._check_thread_stopped)
  870.  
  871.  
  872.     def _check_thread_stopped(self):
  873.         if self.conversion_thread and self.conversion_thread.is_alive():
  874.             self.root.after(100, self._check_thread_stopped)
  875.         else:
  876.             self._finalize_stop()
  877.  
  878.     def _finalize_stop(self):
  879.         if self.hook_id:
  880.             user32.UnhookWindowsHookEx(self.hook_id)
  881.             self.hook_id = None
  882.         self.status_label.config(text="Status: Stopped.")
  883.         self.controller_name_var.set("Emulating: None")
  884.        
  885.         for widget in self.selection_controls: widget.config(state=tk.NORMAL)
  886.    
  887.     def on_closing(self):
  888.         if self.is_running: self.is_running = False
  889.         self.root.destroy()
  890.  
  891.     def _apply_curve(self, value):
  892.         sensitivity_val = self.sensitivity.get()
  893.         if sensitivity_val <= 0: return 0.0
  894.  
  895.         # Normalize slider value from 0-100 to 0.0-1.0
  896.         slider_val_norm = sensitivity_val / 100.0
  897.         # Apply an exponential scale (4 * x^2). This keeps the midpoint (50) at 1.0x
  898.         # while making the high end (100) much more sensitive at 4.0x.
  899.         sensitivity_multiplier = 10.0 * (slider_val_norm ** 3.3219)
  900.  
  901.         if sensitivity_multiplier <= 0: return 0.0
  902.  
  903.         # An effective input range is calculated. Higher sensitivity means a smaller range,
  904.         # so less mouse movement is needed for max joystick output.
  905.         effective_range = BASE_INPUT_RANGE / sensitivity_multiplier
  906.  
  907.         sign = 1.0 if value >= 0 else -1.0
  908.         norm_input_x = min(1.0, abs(value) / effective_range)
  909.  
  910.         p1, p2 = self.curve_points[0], self.curve_points[1]
  911.         for i in range(1, len(self.curve_points)):
  912.             if norm_input_x <= self.curve_points[i][0]:
  913.                 p1 = self.curve_points[i-1]
  914.                 p2 = self.curve_points[i]
  915.                 break
  916.        
  917.         x_range = p2[0] - p1[0]
  918.         if x_range == 0:
  919.             norm_output_y = p1[1]
  920.         else:
  921.             y_range = p2[1] - p1[1]
  922.             t = (norm_input_x - p1[0]) / x_range
  923.             norm_output_y = p1[1] + t * y_range
  924.        
  925.         return norm_output_y * sign
  926.  
  927.     def _conversion_loop(self):
  928.         hwnd, class_atom, gamepad = None, None, None
  929.         hInstance = kernel32.GetModuleHandleW(None)
  930.         class_name = f"RawInputWindowClass-{time.time()}"
  931.         try:
  932.             gamepad = vg.VX360Gamepad()
  933.             wnd_class = WNDCLASSW(lpfnWndProc=self.wnd_proc_ptr, lpszClassName=class_name, hInstance=hInstance)
  934.             class_atom = user32.RegisterClassW(ctypes.byref(wnd_class))
  935.             if not class_atom: raise RuntimeError("Failed to register window class.")
  936.             hwnd = user32.CreateWindowExW(0, class_name, None, 0, 0, 0, 0, 0, -3, 0, hInstance, None)
  937.             if not hwnd: raise RuntimeError("Failed to create message-only window.")
  938.             RIDEV_INPUTSINK, RIDEV_NOLEGACY = 0x00000100, 0x00000030
  939.             device = RAWINPUTDEVICE(1, 2, RIDEV_INPUTSINK | RIDEV_NOLEGACY, hwnd)
  940.             if not user32.RegisterRawInputDevices(ctypes.byref(device), 1, ctypes.sizeof(RAWINPUTDEVICE)):
  941.                 raise RuntimeError("Failed to register for raw input.")
  942.  
  943.             msg = MSG(); p_msg = ctypes.byref(msg)
  944.             while self.is_running:
  945.                 while user32.PeekMessageW(p_msg, None, 0, 0, 1):
  946.                     user32.TranslateMessage(p_msg); user32.DispatchMessageW(p_msg)
  947.                 if time.time() - self.last_mouse_event_time > DECAY_TIMEOUT: self.joystick_x, self.joystick_y = 0.0, 0.0
  948.                 key_str = self.reset_key.get()
  949.                 if key_str:
  950.                     vk_code = ord(key_str[0].upper())
  951.                     if user32.GetAsyncKeyState(vk_code) & 0x8000:
  952.                         self.joystick_x, self.joystick_y = 0.0, 0.0
  953.  
  954.                 final_x = -self.joystick_y
  955.                 final_y = -self.joystick_x
  956.                 if self.reverse_output.get():
  957.                     final_x *= -1
  958.                     final_y *= -1
  959.                
  960.                 gamepad.left_joystick_float(x_value_float=final_x, y_value_float=final_y)
  961.                 gamepad.update(); time.sleep(0.001)
  962.         except Exception as e:
  963.             self.root.after(0, lambda: messagebox.showerror("Runtime Error", f"An error occurred in worker thread: {e}"))
  964.             self.root.after(0, self.stop_conversion)
  965.         finally:
  966.             if hwnd: user32.DestroyWindow(hwnd)
  967.             if class_atom: user32.UnregisterClassW(class_name, hInstance)
  968.  
  969.     def _wnd_proc(self, hwnd, msg, wparam, lparam):
  970.         if msg == 0x00FF: # WM_INPUT
  971.             size = wintypes.UINT(0)
  972.             user32.GetRawInputData(lparam, 0x10000003, None, ctypes.byref(size), ctypes.sizeof(RAWINPUTHEADER))
  973.             if size.value > 0:
  974.                 buf = ctypes.create_string_buffer(size.value)
  975.                 user32.GetRawInputData(lparam, 0x10000003, buf, ctypes.byref(size), ctypes.sizeof(RAWINPUTHEADER))
  976.                 raw = ctypes.cast(buf, ctypes.POINTER(RAWINPUT)).contents
  977.                 if self.selected_mouse and raw.header.hDevice == self.selected_mouse['handle']:
  978.                     self.last_mouse_event_time = time.time()
  979.                     dx, dy = raw.mouse.lLastX, raw.mouse.lLastY
  980.                     self.mouse_dx, self.mouse_dy = dx, dy
  981.                     self.joystick_x = max(-1.0, min(1.0, self._apply_curve(dx)))
  982.                     self.joystick_y = max(-1.0, min(1.0, self._apply_curve(-dy)))
  983.         return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
  984.    
  985.     def _low_level_mouse_proc(self, nCode, wParam, lParam):
  986.         if nCode >= 0 and self.is_running and self.target_pid.value != 0:
  987.             foreground_hwnd = user32.GetForegroundWindow()
  988.             if foreground_hwnd:
  989.                 foreground_pid = wintypes.DWORD(0)
  990.                 user32.GetWindowThreadProcessId(foreground_hwnd, ctypes.byref(foreground_pid))
  991.                 if foreground_pid.value == self.target_pid.value:
  992.                     return 1
  993.         return user32.CallNextHookEx(self.hook_id, nCode, wParam, lParam)
  994.  
  995. if __name__ == '__main__':
  996.     if check_and_install_vgamepad():
  997.         root = tk.Tk()
  998.         app = Mouse2JoystickApp(root)
  999.         root.mainloop()
Advertisement
Add Comment
Please, Sign In to add comment