#!/usr/bin/env python3
"""
Xbox360/DS4 Controller Key Mapper
This script helps map your physical gamepad buttons to virtual gamepad buttons
"""
import sys
import os
import time
import pygame
import argparse
import configparser
# Define constants for Xbox360 and DS4 buttons from vgamepad
XBOX360_BUTTONS = [
"XUSB_GAMEPAD_A",
"XUSB_GAMEPAD_B",
"XUSB_GAMEPAD_X",
"XUSB_GAMEPAD_Y",
"XUSB_GAMEPAD_DPAD_UP",
"XUSB_GAMEPAD_DPAD_DOWN",
"XUSB_GAMEPAD_DPAD_LEFT",
"XUSB_GAMEPAD_DPAD_RIGHT",
"XUSB_GAMEPAD_LEFT_SHOULDER",
"XUSB_GAMEPAD_RIGHT_SHOULDER",
"XUSB_GAMEPAD_LEFT_THUMB",
"XUSB_GAMEPAD_RIGHT_THUMB",
"XUSB_GAMEPAD_START",
"XUSB_GAMEPAD_BACK",
"XUSB_GAMEPAD_GUIDE"
]
DS4_BUTTONS = [
"DS4_BUTTON_CROSS",
"DS4_BUTTON_CIRCLE",
"DS4_BUTTON_SQUARE",
"DS4_BUTTON_TRIANGLE",
"DS4_BUTTON_SHOULDER_LEFT",
"DS4_BUTTON_SHOULDER_RIGHT",
"DS4_BUTTON_TRIGGER_LEFT",
"DS4_BUTTON_TRIGGER_RIGHT",
"DS4_BUTTON_SHARE",
"DS4_BUTTON_OPTIONS",
"DS4_BUTTON_THUMB_LEFT",
"DS4_BUTTON_THUMB_RIGHT"
]
# Button descriptions
XBOX360_BUTTON_NAMES = {
"XUSB_GAMEPAD_A": "A Button (Green)",
"XUSB_GAMEPAD_B": "B Button (Red)",
"XUSB_GAMEPAD_X": "X Button (Blue)",
"XUSB_GAMEPAD_Y": "Y Button (Yellow)",
"XUSB_GAMEPAD_DPAD_UP": "D-Pad Up",
"XUSB_GAMEPAD_DPAD_DOWN": "D-Pad Down",
"XUSB_GAMEPAD_DPAD_LEFT": "D-Pad Left",
"XUSB_GAMEPAD_DPAD_RIGHT": "D-Pad Right",
"XUSB_GAMEPAD_LEFT_SHOULDER": "Left Bumper (LB)",
"XUSB_GAMEPAD_RIGHT_SHOULDER": "Right Bumper (RB)",
"XUSB_GAMEPAD_LEFT_THUMB": "Left Stick Click",
"XUSB_GAMEPAD_RIGHT_THUMB": "Right Stick Click",
"XUSB_GAMEPAD_START": "Start Button",
"XUSB_GAMEPAD_BACK": "Back Button",
"XUSB_GAMEPAD_GUIDE": "Xbox Button (Guide)"
}
DS4_BUTTON_NAMES = {
"DS4_BUTTON_CROSS": "Cross Button (×)",
"DS4_BUTTON_CIRCLE": "Circle Button (○)",
"DS4_BUTTON_SQUARE": "Square Button (□)",
"DS4_BUTTON_TRIANGLE": "Triangle Button (△)",
"DS4_BUTTON_SHOULDER_LEFT": "L1 Button",
"DS4_BUTTON_SHOULDER_RIGHT": "R1 Button",
"DS4_BUTTON_TRIGGER_LEFT": "L2 Button (Digital)",
"DS4_BUTTON_TRIGGER_RIGHT": "R2 Button (Digital)",
"DS4_BUTTON_SHARE": "Share Button",
"DS4_BUTTON_OPTIONS": "Options Button",
"DS4_BUTTON_THUMB_LEFT": "L3 Button (Left Stick Click)",
"DS4_BUTTON_THUMB_RIGHT": "R3 Button (Right Stick Click)"
}
# Axis names and descriptions
AXIS_NAMES = {
"left_stick_x": "Left Stick X-axis (move left stick left)",
"left_stick_y": "Left Stick Y-axis (move left stick up)",
"right_stick_x": "Right Stick X-axis (move right stick right)",
"right_stick_y": "Right Stick Y-axis (move right stick up)",
"trigger_left": "Left Trigger (press and hold LT/L2)",
"trigger_right": "Right Trigger (press and hold RT/R2)"
}
[docs]
class ControllerMapper:
[docs]
def __init__(self, output_path, polling_interval=0.1):
# Initialize attributes
self.output_path = output_path
self.polling_interval = polling_interval
self.joystick = None
self.gamepad_type = None
self.button_mapping = {}
self.hat_mapping = {} # For D-pad if it uses a hat
self.axis_mapping = {}
self.uses_hat_for_dpad = False
self.interrupt_flag = False
# Initialize pygame
pygame.init()
pygame.joystick.init()
[docs]
def select_gamepad_type(self):
"""Allow user to select gamepad type"""
controller_name = self.joystick.get_name().lower()
# Try to auto-detect based on common controller names
if 'xbox' in controller_name or 'x-box' in controller_name:
suggested_type = 'xbox360'
elif 'dual shock' in controller_name or 'dualshock' in controller_name or 'playstation' in controller_name or 'ps4' in controller_name:
suggested_type = 'ds4'
else:
suggested_type = None
# Ask user to confirm or select
if suggested_type:
print(f"\nDetected controller type: {suggested_type} (based on '{controller_name}')")
print("Is this correct? (y/n)")
response = input().lower()
if response == 'y':
self.gamepad_type = suggested_type
return
# Manual selection
print("\nSelect gamepad type:")
print("1. Xbox 360")
print("2. DualShock 4 (PS4)")
while True:
choice = input("Enter choice (1 or 2): ")
if choice == '1':
self.gamepad_type = 'xbox360'
break
elif choice == '2':
self.gamepad_type = 'ds4'
break
else:
print("Invalid choice. Please enter 1 or 2.")
[docs]
def detect_controller(self):
"""Detect and initialize the first available controller"""
if pygame.joystick.get_count() == 0:
print("No gamepad detected! Please connect a gamepad and try again.")
return False
self.joystick = pygame.joystick.Joystick(0)
self.joystick.init()
print(f"\nController detected: {self.joystick.get_name()}")
print(f"Number of buttons: {self.joystick.get_numbuttons()}")
print(f"Number of axes: {self.joystick.get_numaxes()}")
print(f"Number of hats: {self.joystick.get_numhats()}")
# Select gamepad type
self.select_gamepad_type()
return True
[docs]
def wait_for_neutral(self, duration=1.0):
"""Wait for controller to return to neutral state"""
print("\nPlease release all controls and return to neutral position...")
time.sleep(duration)
pygame.event.pump()
[docs]
def check_for_skip(self):
"""Non-blocking check for skip input (Enter key)"""
for event in pygame.event.get([pygame.KEYDOWN]):
if event.type == pygame.KEYDOWN and event.key == pygame.K_RETURN:
return True
return False
[docs]
def wait_for_hat_movement(self, timeout=10):
"""Wait for hat (D-pad) movement and return the value"""
print("Waiting for D-pad input...")
start_time = time.time()
while time.time() - start_time < timeout:
pygame.event.pump() # Process events without retrieving them
for i in range(self.joystick.get_numhats()):
hat_value = self.joystick.get_hat(i)
if hat_value != (0, 0):
return hat_value
# Check for skip input
if self.check_for_skip():
return None
time.sleep(self.polling_interval)
return None
[docs]
def detect_axis_movement(self, axis_name, instruction, timeout=5.0, threshold=0.3):
"""
Detect significant axis movement with improved feedback and direction detection
Args:
axis_name: The name of the axis being mapped
instruction: Basic instruction to display to the user
timeout: Time in seconds to wait for movement
threshold: Minimum change to be considered significant
"""
print(f"\n{instruction}")
# Provide specific guidance based on axis type
is_trigger = "trigger" in axis_name
if "stick_y" in axis_name:
print("IMPORTANT: Move the stick UPWARD (away from you)")
elif "stick_x" in axis_name:
print("IMPORTANT: Move the stick to the RIGHT")
elif is_trigger:
print("IMPORTANT: Press the trigger all the way down")
print("You have 5 seconds... Starting in:")
for i in range(3, 0, -1):
print(f"{i}...")
time.sleep(1)
print("GO! Move and hold now...")
# Capture initial axis values
pygame.event.pump()
initial_values = [self.joystick.get_axis(i) for i in range(self.joystick.get_numaxes())]
start_time = time.time()
max_changes = [0] * self.joystick.get_numaxes()
direction_of_change = [0] * self.joystick.get_numaxes() # Store direction of change
best_axis = None
best_value = 0
while time.time() - start_time < timeout:
pygame.event.pump()
# Track changes for all axes
for i in range(self.joystick.get_numaxes()):
current = self.joystick.get_axis(i)
change = current - initial_values[i] # Keep sign for direction detection
abs_change = abs(change)
# Update max change and direction for this axis
if abs_change > max_changes[i]:
max_changes[i] = abs_change
direction_of_change[i] = 1 if change > 0 else -1
# Determine if this is a candidate based on axis type
is_candidate = abs_change > threshold
# For triggers, prefer axes that move in positive direction (0 to 1)
if is_trigger and is_candidate:
if change > 0 and (best_axis is None or abs_change > max_changes[best_axis]):
best_axis = i
best_value = current
# For non-triggers, use the axis with largest change
elif is_candidate and (best_axis is None or abs_change > max_changes[best_axis]):
best_axis = i
best_value = current
# Update progress indicator
elapsed = time.time() - start_time
progress = int((elapsed / timeout) * 10)
sys.stdout.write("\r[" + "#" * progress + " " * (10 - progress) + "] ")
if best_axis is not None:
direction = "+" if direction_of_change[best_axis] > 0 else "-"
sys.stdout.write(f"Detected axis {best_axis}: {direction}{max_changes[best_axis]:.2f}")
sys.stdout.flush()
time.sleep(self.polling_interval)
print("\nTime's up!")
if best_axis is not None:
direction = "+" if direction_of_change[best_axis] > 0 else "-"
print(f"Mapped to axis {best_axis} (value: {best_value:.2f}, change: {direction}{max_changes[best_axis]:.2f})")
return best_axis, best_value, direction_of_change[best_axis]
else:
print("No significant movement detected")
return None, None, None
[docs]
def map_axes(self):
"""Guide user through mapping each axis with improved usability"""
print("\n=== Axis Mapping ===")
print("Follow the prompts to map each axis correctly.")
print("For each axis, you'll have 5 seconds to move and hold after the countdown.")
# Map each axis
for axis_name, axis_desc in AXIS_NAMES.items():
# Wait for neutral position
self.wait_for_neutral()
# Allow retry if mapping fails
while True:
# Detect axis movement
axis_idx, axis_value, direction = self.detect_axis_movement(
axis_name=axis_name,
instruction=f"Move {axis_desc}"
)
if axis_idx is not None:
self.axis_mapping[axis_name] = axis_idx
# Special handling for Y-axis (inversion detection)
if axis_name.endswith('_y'):
# For Y-axis, moving UP should typically produce a negative value
# in Xbox360 controllers and some DS4 controllers
is_up_negative = direction < 0 # Did moving up produce a negative value?
print(f"\nWhen you moved the stick UP, it produced a {'NEGATIVE' if is_up_negative else 'POSITIVE'} value.")
if self.gamepad_type == 'xbox360':
print("For Xbox controllers, UP should normally be NEGATIVE.")
inversion_needed = is_up_negative
else: # DS4
print("For DualShock controllers, this can vary by game.")
inversion_needed = is_up_negative
print(f"Based on testing, inversion is {'needed' if inversion_needed else 'NOT needed'}.")
print(f"Apply this recommendation? [Y/n]")
response = input().lower()
# Default is to apply the recommendation
apply_recommendation = response != 'n'
if axis_name == 'left_stick_y':
self.axis_mapping['invert_left_y'] = inversion_needed if apply_recommendation else not inversion_needed
print(f"Left Y-axis inversion set to: {self.axis_mapping['invert_left_y']}")
elif axis_name == 'right_stick_y':
self.axis_mapping['invert_right_y'] = inversion_needed if apply_recommendation else not inversion_needed
print(f"Right Y-axis inversion set to: {self.axis_mapping['invert_right_y']}")
# Successfully mapped
break
else:
print("\nNo significant movement detected. Would you like to try again? [Y/n]")
if input().lower() == 'n':
print(f"Skipping {axis_desc}")
break
# Check if we've been interrupted
if self.interrupt_flag:
return
print("\nAxis mapping completed!")
[docs]
def verify_mapping(self):
"""Let user verify the mappings and make changes if needed"""
print("\n=== Verify Mappings ===")
# Show button mappings
print("\nButton Mappings:")
for button, idx in self.button_mapping.items():
if self.gamepad_type == 'xbox360':
name = XBOX360_BUTTON_NAMES[button]
else:
name = DS4_BUTTON_NAMES[button]
print(f"{name} -> Button {idx}")
# Show hat mappings if used
if self.uses_hat_for_dpad:
print("\nD-Pad Hat Mappings:")
for direction, button in self.hat_mapping.items():
print(f"D-Pad {direction} -> {button}")
# Show axis mappings
print("\nAxis Mappings:")
for axis_name, idx in self.axis_mapping.items():
if axis_name not in ['invert_left_y', 'invert_right_y']:
print(f"{axis_name} -> Axis {idx}")
# Inversion settings
print("\nInversion Settings:")
print(f"Invert Left Y-Axis: {self.axis_mapping.get('invert_left_y', False)}")
print(f"Invert Right Y-Axis: {self.axis_mapping.get('invert_right_y', False)}")
print("\nDo you want to make any changes? (y/n)")
if input().lower() == 'y':
self.manual_adjustments()
[docs]
def manual_adjustments(self):
"""Allow manual adjustments to mappings"""
while True:
print("\nWhat would you like to adjust?")
print("1. Button mapping")
print("2. Axis mapping")
print("3. Finish adjustments")
choice = input("Enter choice (1-3): ")
if choice == '1':
self.adjust_button_mapping()
elif choice == '2':
self.adjust_axis_mapping()
elif choice == '3':
break
else:
print("Invalid choice. Please enter 1-3.")
[docs]
def adjust_axis_mapping(self):
"""Allow manual adjustment of axis mappings"""
print("\nSelect axis to adjust:")
axes = list(AXIS_NAMES.keys())
for i, axis_name in enumerate(axes):
if axis_name in self.axis_mapping:
idx = self.axis_mapping[axis_name]
print(f"{i+1}. {AXIS_NAMES[axis_name]} (currently mapped to axis {idx})")
else:
print(f"{i+1}. {AXIS_NAMES[axis_name]} (not mapped)")
print(f"{len(axes)+1}. Back")
try:
choice = int(input("Enter choice: "))
if choice == len(axes)+1:
return
if 1 <= choice <= len(axes):
axis_name = axes[choice-1]
print(f"Enter new axis index for {AXIS_NAMES[axis_name]}:")
idx = int(input())
if 0 <= idx < self.joystick.get_numaxes():
self.axis_mapping[axis_name] = idx
print(f"Updated mapping: {AXIS_NAMES[axis_name]} -> Axis {idx}")
else:
print("Invalid axis index")
except ValueError:
print("Invalid input")
[docs]
def generate_config(self):
"""Generate configuration file for server"""
# Check if file exists
if os.path.exists(self.output_path):
print(f"\nWarning: {self.output_path} already exists.")
print("What would you like to do?")
print("1. Overwrite")
print("2. Choose a new filename")
choice = input("Enter choice (1 or 2): ")
if choice == '2':
print("Enter new filename:")
new_path = input()
if new_path:
self.output_path = new_path
else:
print("Using default filename with timestamp")
timestamp = int(time.time())
base, ext = os.path.splitext(self.output_path)
self.output_path = f"{base}_{timestamp}{ext}"
# Create config object
config = configparser.ConfigParser()
# Server section
config.add_section('server')
config.set('server', 'host', '127.0.0.1')
config.set('server', 'port', '9999')
config.set('server', 'protocol', 'tcp')
# Gamepad section
config.add_section('vgamepad')
config.set('vgamepad', 'type', self.gamepad_type)
# Button mapping section
section_name = f'button_mapping_{self.gamepad_type}'
config.add_section(section_name)
# Convert button mapping from {button_name: idx} to {idx: button_name}
idx_to_button = {}
for button, idx in self.button_mapping.items():
idx_to_button[str(idx)] = button
# Add each mapped button to config
for idx, button in idx_to_button.items():
config.set(section_name, idx, button)
# Add D-pad hat mapping if used
if self.uses_hat_for_dpad and self.hat_mapping:
hat_section = f'{section_name}_hat'
config.add_section(hat_section)
for direction, button in self.hat_mapping.items():
config.set(hat_section, f'hat_{direction}', button)
# Axis mapping section
config.add_section('axis_mapping')
axis_mapping_filtered = {
k: v
for k, v in self.axis_mapping.items()
if k not in ["invert_left_y", "invert_right_y"]
}
for axis_name, idx in axis_mapping_filtered.items():
config.set('axis_mapping', axis_name, str(idx))
# Axis options section
config.add_section('axis_options')
config.set('axis_options', 'dead_zone', '0.1')
config.set('axis_options', 'trigger_threshold', '0.05')
config.set('axis_options', 'invert_left_y', str(self.axis_mapping.get('invert_left_y', False)).lower())
config.set('axis_options', 'invert_right_y', str(self.axis_mapping.get('invert_right_y', False)).lower())
# Write to file
with open(self.output_path, 'w') as config_file:
config.write(config_file)
print(f"\nConfiguration saved to {self.output_path}")
print("You can use this file with your server.py using the --config option:")
print(f"python server.py --config {self.output_path}")
[docs]
def run(self):
"""Run the mapping process"""
try:
print("=== Xbox360/DS4 Controller Key Mapper ===")
print("This tool will guide you through mapping your controller buttons.")
print("Press Ctrl+C at any time to exit.")
if not self.detect_controller():
return
self.map_buttons()
self.map_axes()
self.verify_mapping()
self.generate_config()
print("\nMapping completed successfully!")
except KeyboardInterrupt:
print("\nMapping interrupted by user.")
self.interrupt_flag = True
if input("\nWould you like to save the partial mapping? (y/n): ").lower() == 'y':
self.generate_config()
finally:
# Clean up pygame
pygame.quit()
[docs]
def main():
parser = argparse.ArgumentParser(description="Xbox360/DS4 Controller Key Mapper")
parser.add_argument('--output', type=str, default='controller_config.ini',
help='Output configuration file path')
parser.add_argument('--polling-interval', type=float, default=0.1,
help='Polling interval in seconds (higher values use less CPU)')
args = parser.parse_args()
# Start the mapping process
mapper = ControllerMapper(args.output, args.polling_interval)
mapper.run()
if __name__ == "__main__":
main()