Input Routing API

Input Routing is a primitive parser plugins can use to declare “a command bound to input X on device A should actually display on input Y of device B.” The Joystick Gremlin parser uses it to route <remap> actions on physical buttons back to the vJoy device the game actually sees, which is how DCS-native bindings and Gremlin vJoy macros now end up on the same diagram.

When to Use Routing

Use Input Routing when your plugin parses a tool that re-emits inputs from a physical device as a virtual device. The physical device is where the user presses the button; the virtual device is what the downstream game sees. Without routing, those bindings end up on two separate diagrams, neither of them useful.

If your plugin parses a game directly (DCS, IL-2, MSFS), you don't need routing.

Quick Start

A parser plugin emits routes alongside its normal bindings. Joystick Diagrams merges the routes across all profiles, then applies them to the bindings before they reach the diagram. Here's the minimum shape:

main.py
from joystick_diagrams.input.profile_collection import ProfileCollection
from joystick_diagrams.input.button import Button
from joystick_diagrams.input_routing import RouteKey, RouteTarget, InputType


class ParserPlugin(PluginInterface):
    def process(self) -> ProfileCollection:
        collection = ProfileCollection()
        profile = collection.create_profile("My Profile")

        # Your normal device + binding setup
        physical = profile.add_device(PHYSICAL_GUID, "Virpil Throttle")
        physical.create_input(Button(3), "Fire Mav macro")

        # Declare: commands on vJoy Device 1 / Button 108 should appear
        # on the Virpil throttle's Button 3 instead.
        profile.add_route(
            RouteKey(VJOY_GUID, InputType.BUTTON, 108),
            RouteTarget(PHYSICAL_GUID, InputType.BUTTON, 3, qualifier=""),
        )

        return collection

Once the plugin returns, Joystick Diagrams calls union_profile_routes() to merge every plugin's routes into a single map, then apply_routes() rewrites each profile so that bindings on the key input land on the target input instead. The user-facing conflict strategies decide what happens when the target already has its own binding.

RouteKey

Identifies the input that external profiles bind commands to (the virtual side of the relationship). Frozen dataclass.

FieldTypeDescription
device_guidstrGUID of the virtual device (e.g. the vJoy device the game binds against)
input_typeInputTypeBUTTON, AXIS, or HAT
input_idint | strButton number, axis direction, or hat identifier

RouteTarget

Where that bound command should actually display (the physical input that feeds the virtual device). Also a frozen dataclass.

FieldTypeDescription
device_guidstrGUID of the physical device
input_typeInputTypeBUTTON, AXIS, or HAT on the physical device
input_idint | strPhysical input identifier
qualifierstrShort label describing the container type (Short, Long, Toggle, Conditional, or "" for bare)

union_profile_routes()

Merges routes across a set of profiles into a single key→target map. First seen key wins; duplicates from later profiles are ignored. Called by the framework; you normally won't invoke this directly.

from joystick_diagrams.input_routing import union_profile_routes

union = union_profile_routes(profiles)
# union: dict[RouteKey, RouteTarget]

apply_routes()

Applies a route map to a profile in place. For every binding the profile has on a key input, the binding is moved to the target input. When the target already has a binding, the AliasConflictStrategy decides how they combine.

ParameterTypeDescription
profileProfileThe profile to rewrite. Mutated in place.
union_routesdict[RouteKey, RouteTarget]Usually the result of union_profile_routes()
strategyAliasConflictStrategyCONCATENATE (default) or MODIFIER
device_namesdict[str, str]GUID → display name map, used when building concatenated text

Qualifier Semantics

A qualifier is the short label that appears in the concatenated/modifier output when two bindings collide on the same target input. It's typically derived from the source plugin's concept of “what kind of action is this?” The Joystick Gremlin plugin maps container shapes to qualifiers as follows:

ContainerQualifier
tempoShort / Long
double_tapSingle / Double
smart_toggleToggle
Any container with an activation conditionConditional
basicempty string, unqualified

Under CONCATENATE, a non-empty qualifier is wrapped in brackets: winner | [Short] loser. An empty qualifier produces a bare join: winner | loser. Under MODIFIER, the qualifier (or the source input id for bare containers) becomes the modifier key set on the winner's input.

Route Inheritance

Joystick Gremlin profiles support mode inheritance. When a child mode doesn't have its own <remap> on a physical input, the parent's route is copied into the child. If the child has its own remap on that input, the child's route wins and the parent's is dropped for that input only.

Plugins that expose their own inheritance can implement the same pattern: before returning, walk the child → parent chain and copy any route whose key isn't already present in the child.

End to End: Joystick Gremlin

The qualifier derivation in the shipped Joystick Gremlin plugin:

joystick_gremlin.py
def _resolve_qualifier(container) -> str:
    container_type = container.get("type")

    if container_type == "tempo":
        return "Long" if container.has_long_action else "Short"
    if container_type == "double_tap":
        return "Double" if container.has_double_action else "Single"
    if container_type == "smart_toggle":
        return "Toggle"
    if container.has_activation_condition:
        return "Conditional"
    return ""  # basic container, bare join

# Inside the parser loop:
for remap in container.find_remaps():
    key = RouteKey(
        remap.vjoy_device_guid,
        InputType[remap.input_type.upper()],
        remap.input_id,
    )
    target = RouteTarget(
        physical_device_guid,
        physical_input_type,
        physical_input_id,
        qualifier=_resolve_qualifier(container),
    )
    profile.add_route(key, target)

Once every profile has declared its routes, the framework unions them and applies them to every profile in the collection. The user's chosen alias conflict strategy (Settings → Merging) takes over from there.

Routing is independent of user-configured device aliases. Routes are declared by plugins based on what they find in config files; aliases are set by the user in Settings. They both end up passing through the same conflict strategy, but they're separate inputs to it.

References