Getting my Dual T16000M to work on Linux

The place to discuss scripting and game modifications for X4: Foundations.

Moderators: Scripting / Modding Moderators, Moderators for English X Forum

manindahat
Posts: 1
Joined: Tue, 26. Aug 25, 23:28

Getting my Dual T16000M to work on Linux

Post by manindahat »

Not sure if this is the right forum, but it's a kind of modding so I picked this one...

I had a problem with my dual T16000M joysticks being detected as a single device, rather than as two separate joysticks.

On Windows I used VJoy to setup a single virtual joystick, but now I've moved to Linux (windows 10 won't run on my functional yet old PC), I ran into the same issue.

To work around it, I created a script which creates two virtual joysticks with a unique name.
Here it is for those of you who want or need it.

I execute it via "sudo python3 joystick_emulator.py"

Package Dependencies:
python3-evdev → lets Python read real joystick events (from /dev/input/event*).
python3-uinput → lets Python create virtual joystick devices.
jstest-gtk → GUI tool to test/calibrate joysticks.
joystick (sometimes installed with jstest-gtk) → provides jstest and other low-level joystick tools.
libsdl2-dev (attempted, not always needed) → development headers for SDL2, useful if you want to rebuild joystick GUIDs for games.

Code: Select all

#!/usr/bin/env python3
# Requires: pip install evdev

import asyncio
from evdev import InputDevice, list_devices, ecodes, UInput, AbsInfo

# -----------------------------
# Configuration
# -----------------------------
TARGET_NAME = 'Thrustmaster T.16000M'

# Virtual axis full range for games
VIRTUAL_MIN = -32768
VIRTUAL_MAX = 32767

# -----------------------------
# Helper functions
# -----------------------------

def find_joysticks(name):
    """
    Return all input devices matching the given name.
    Lesson: Must find both sticks separately; using order is safer than relying on /dev/input/eventX numbering.
    """
    devices = [InputDevice(path) for path in list_devices()]
    return [dev for dev in devices if dev.name == name]

def build_abs_map(dev: InputDevice):
    """
    Build a dictionary of absolute axes for UInput.
    Lesson: Must use positional args for AbsInfo in Python 3.12+; keyword args cause TypeError.
    """
    abs_map = {}
    for code, absinfo in dev.capabilities().get(ecodes.EV_ABS, []):
        # Initialize virtual axis with min/max range, starting value is irrelevant because we forward scaled events
        abs_map[code] = AbsInfo(
            0,                  # Starting value ignored
            VIRTUAL_MIN,        # Virtual min for full range mapping
            VIRTUAL_MAX,        # Virtual max
            absinfo.fuzz,       # Keep device fuzz/noise filtering
            absinfo.flat,       # Preserve physical dead zone
            absinfo.resolution  # Preserve resolution
        )
    return abs_map

def build_key_map(dev: InputDevice):
    """
    Build a list of button codes for UInput.
    Lesson: evdev.capabilities() may return ints or tuples; must handle both to avoid TypeError.
    """
    keys = dev.capabilities().get(ecodes.EV_KEY, [])
    key_codes = []
    for k in keys:
        if isinstance(k, tuple):
            key_codes.append(k[0])
        else:
            key_codes.append(k)
    return key_codes

def scale_axis(value, phys_min, phys_max):
    """
    Scale the physical axis to the virtual axis range.
    Lesson: T.16000M raw axes are nonlinear or partial range; virtual device must remap full travel.
    """
    if phys_max == phys_min:
        return VIRTUAL_MIN
    scaled = int((value - phys_min) / (phys_max - phys_min) * (VIRTUAL_MAX - VIRTUAL_MIN) + VIRTUAL_MIN)
    return max(VIRTUAL_MIN, min(VIRTUAL_MAX, scaled))

async def forward_events(dev: InputDevice, ui: UInput):
    """
    Forward events from the physical stick to the virtual UInput device.
    Lessons learned:
      - Must forward both EV_KEY and EV_ABS.
      - Use ui.write_event(event) for keys; for axes, scale values.
      - Always call ui.syn() after each event to update device state.
    """
    # Read axis info once for scaling
    abs_infos = {code: dev.absinfo(code) for code, _ in dev.capabilities().get(ecodes.EV_ABS, [])}

    async for event in dev.async_read_loop():
        if event.type == ecodes.EV_KEY:
            # Forward button presses exactly
            ui.write_event(event)
            ui.syn()
        elif event.type == ecodes.EV_ABS:
            # Scale axis values to full virtual range
            info = abs_infos[event.code]
            scaled = scale_axis(event.value, info.min, info.max)
            ui.write(ecodes.EV_ABS, event.code, scaled)
            ui.syn()

# -----------------------------
# Main
# -----------------------------

async def main():
    # Detect both sticks
    sticks = find_joysticks(TARGET_NAME)
    if len(sticks) < 2:
        print(f"Error: Found {len(sticks)} '{TARGET_NAME}' sticks, need 2.")
        return

    # Assign left/right based on detection order
    dev_left, dev_right = sticks[1], sticks[0]

    """
    Create virtual devices for left/right sticks
    Lessons learned:
      - Need a unique Product code so as not to conflict with the physical joysticks.
      - The OOTB product code is 0xb10a
      - The codes I chose might conflict with other devices.
    """
    # Read axis info once for scaling
    ui_left = UInput(
        events={ecodes.EV_KEY: build_key_map(dev_left), ecodes.EV_ABS: build_abs_map(dev_left)},
        name='T16000M Left',
        vendor=0x044f,  # Thrustmaster VID
        product=0xb10b   # Modified T.16000M PID
    )

    ui_right = UInput(
        events={ecodes.EV_KEY: build_key_map(dev_right), ecodes.EV_ABS: build_abs_map(dev_right)},
        name='T16000M Right',
        vendor=0x044f,
        product=0xb10c
    )

    print(f"Forwarding {dev_left.path} → T16000M Left")
    print(f"Forwarding {dev_right.path} → T16000M Right")

    # Forward events concurrently for both sticks
    await asyncio.gather(
        forward_events(dev_left, ui_left),
        forward_events(dev_right, ui_right)
    )

if __name__ == "__main__":
    asyncio.run(main())

Return to “X4: Foundations - Scripts and Modding”