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:
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.
| Field | Type | Description |
|---|---|---|
device_guid | str | GUID of the virtual device (e.g. the vJoy device the game binds against) |
input_type | InputType | BUTTON, AXIS, or HAT |
input_id | int | str | Button 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.
| Field | Type | Description |
|---|---|---|
device_guid | str | GUID of the physical device |
input_type | InputType | BUTTON, AXIS, or HAT on the physical device |
input_id | int | str | Physical input identifier |
qualifier | str | Short 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.
| Parameter | Type | Description |
|---|---|---|
profile | Profile | The profile to rewrite. Mutated in place. |
union_routes | dict[RouteKey, RouteTarget] | Usually the result of union_profile_routes() |
strategy | AliasConflictStrategy | CONCATENATE (default) or MODIFIER |
device_names | dict[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:
| Container | Qualifier |
|---|---|
tempo | Short / Long |
double_tap | Single / Double |
smart_toggle | Toggle |
Any container with an activation condition | Conditional |
basic | empty 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:
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.
References
- Source of truth:
joystick_diagrams/input_routing.py - First consumer:
joystick_diagrams/plugins/joystick_gremlin_plugin/joystick_gremlin.py - Parser Plugins: the parser interface routes ride on
- Merging Bindings: the user-facing side
- GitHub Repository
- Discord