Router¶
The router is the central data plane of Joypad Core. It receives input_event_t events from input drivers, applies transforms and profiles, and stores the result for output drivers to read. All routing is inline on Core 0 -- there are no queues, threads, or copies on the hot path (when possible).
Source: src/core/router/router.c and router.h
Routing Modes¶
The router supports four modes, configured at init time via router_config_t.mode:
SIMPLE (1:1)¶
Each controller maps to its own player slot. Controller N goes to output slot N. Used by most console adapters (usb2pce, usb2dc, snes2usb).
MERGE (N:1)¶
All controllers merge into a single output slot (slot 0). Used by usb2gc (all USB inputs feed one GC port) and bt2usb (all BT controllers feed one USB gamepad).
How inputs are combined depends on the merge mode (see below).
BROADCAST (1:N)¶
Every input goes to every active output target. Used by multi-output apps where the same controller drives multiple consoles simultaneously.
Controller 0 --> Slot 0 (GameCube)
Controller 0 --> Slot 0 (USB Device)
Controller 0 --> Slot 0 (BLE)
CONFIGURABLE (N:M)¶
User-defined routing table. Each route entry maps an input source to an output target with optional filters (device address, instance, target player). Routes are matched at runtime via router_find_routes().
Merge Modes¶
When multiple inputs target the same slot (MERGE mode or multiple controllers in SIMPLE mode), the merge mode determines how they combine:
BLEND¶
All inputs are OR'd together. For each output slot, the router tracks every contributing device in a blend_device_state_t array and re-blends on every update:
- Buttons: OR together (any controller pressing A = A pressed)
- Sticks: Use the value furthest from center (128)
- Triggers: Use the maximum value
- Mouse deltas: Accumulate, then clear per-device to prevent re-adding
- Motion/Pressure/Touch: Use the first device that reports data
This is the default for usb2gc -- two players can both contribute to a single GameCube controller.
PRIORITY¶
Higher-priority input wins. Used by composite adapters (e.g., USB input has priority over SNES fallback). Lower-priority sources only update the output when no higher-priority source is active.
ALL¶
Most recent input overwrites the entire output state. Simple last-writer-wins semantics.
Transform Pipeline¶
Before storing to an output slot, the router can apply transformations to the input event. Transformations are enabled via router_config_t.transform_flags (a bitfield):
| Flag | Description |
|---|---|
TRANSFORM_MOUSE_TO_ANALOG |
Accumulate mouse deltas into analog stick positions. Configurable target axes (mouse_target_x/y) and drain rate. |
TRANSFORM_MERGE_INSTANCES |
Merge multi-instance devices (e.g., Joy-Con Grip left+right into single controller). |
TRANSFORM_SPINNER |
Accumulate X-axis deltas for spinner input (Nuon, etc.). |
Transformations modify the event in-place. When no transforms are enabled, the event passes through zero-copy.
Mouse-to-Analog¶
Converts mouse delta_x/delta_y into analog stick positions. Per-player accumulators track position:
mouse_target_x/mouse_target_y: Which analog axis to write (e.g.,ANALOG_LX,ANALOG_RX, orMOUSE_AXIS_DISABLED)mouse_drain_rate: How fast the accumulated position drains back to center each frame. 0 = hold position (no drain).- Values are clamped to [-127, 127] then mapped to [1, 255] (centered at 128)
What Happens When router_submit_input() Is Called¶
Step-by-step walkthrough of a single input event:
-
Null/route check -- If the event is NULL or no routes are configured (
route_count == 0), return immediately. -
CDC streaming -- If
CONFIG_USBis defined, the raw input is streamed to CDC for the web configuration tool. -
Find output target -- The first active route in the routing table determines the primary output target.
-
Dispatch by mode:
SIMPLE mode:
- a. Look up the player index for this (dev_addr, instance) pair via find_player_index().
- b. If not found and the controller has buttons pressed or analog stick deflected beyond threshold (~40%), call add_player() to assign a new slot. The device name is looked up from USB HID registry, BT device table, or transport type.
- c. If transform_flags is set, copy the event and call apply_transformations(). Otherwise use the event pointer directly (zero-copy).
- d. Unless this output has an exclusive tap, write the final event to router_outputs[output][player_index] and set updated = true.
- e. If a tap callback is registered for this output, call it with the final event.
MERGE mode:
- a. Register the player (same as SIMPLE step a/b) for LED and rumble tracking.
- b. Apply transformations if enabled.
- c. Dispatch to the configured merge sub-mode:
- MERGE_ALL: Overwrite slot 0 with this event.
- MERGE_BLEND: Update this device's entry in blend_devices[], then re-blend all active devices into slot 0 (OR buttons, furthest-from-center sticks, max triggers).
- MERGE_PRIORITY: Only update slot 0 if this source has higher priority than the current one.
- d. Set updated = true and call tap callback if registered.
BROADCAST mode:
- Call router_simple_mode() for each active output target.
CONFIGURABLE mode:
- Find all matching routes via router_find_routes() (checks input source, device address, instance filters).
- For each match, write the event to the specified output target and player slot.
Host-Injected Input¶
A host on the other end of USB CDC can press buttons and move sticks with
INPUT.INJECT. This lets an external program — a chat
bot, an accessibility device, an AI agent — drive the console through the same
pipeline a real controller uses.
{"cmd":"INPUT.INJECT","buttons":1}
{"cmd":"INPUT.INJECT","buttons":0,"analog":[200,128,128,128,0,0,128]}
{"cmd":"INPUT.INJECT","buttons":0,"analog":false}
Injected state is an overlay, not a player slot. It is merged into every real
input event at the top of router_submit_input(), before profiles and overlays,
so it works in every routing mode rather than only the ones that merge:
- Buttons are OR'd, so an injected press cannot cancel a held one.
- Analog takes whichever value is further from the axis's resting position. Sticks rest at 128 and triggers at 0, so the distance is measured from a different place for each — measured from centre, a released trigger would look like a large deflection and beat a real pull.
The state is held until replaced. Omitting analog leaves the axes as they were,
so a host sending only buttons never disturbs the sticks; "analog":false stops
injecting them and hands the axes back to the real controller. A short array is
padded with resting values, so a host that only cares about the sticks can send
four.
With no controller attached there is no event to overlay onto, and injected
state would never reach the output at all. router_inject_task() covers that: it
submits a synthetic event from ROUTER_INJECT_ADDR at 60Hz, but only while no
real input has arrived for 100ms — with a controller attached, its events already
carry the injection, and a second source would press everything twice. Call it
from the main loop; on USB builds cdc_commands_task() already does.
Output Retrieval¶
Output drivers on Core 1 read state via:
router_get_output(output, player_id)-- Returns a pointer torouter_outputs[output][player_id].current_state. Zero-copy, lock-free. Returns NULL if the slot has not been updated.router_has_updates(output)-- Fast scan: are any player slots updated for this output?router_get_player_count(output)-- How many player slots are occupied?
Output Taps¶
For push-based outputs (UART, BLE) that do not poll router_get_output(), register a tap callback:
// Standard tap: event stored to router_outputs AND callback called
router_set_tap(OUTPUT_TARGET_UART, my_uart_callback);
// Exclusive tap: callback called, router_outputs NOT written (avoids copy)
router_set_tap_exclusive(OUTPUT_TARGET_BLE_PERIPHERAL, my_ble_callback);
Routing Table¶
The routing table holds up to MAX_ROUTES (32) entries. Each entry (route_entry_t) specifies:
| Field | Description |
|---|---|
input |
Input source enum (USB_HOST, BLE_CENTRAL, NATIVE_SNES, etc.) |
output |
Output target enum (GAMECUBE, PCENGINE, USB_DEVICE, etc.) |
priority |
Route priority (0 = highest) |
input_dev_addr |
Filter by device address (0 = wildcard) |
input_instance |
Filter by instance (-1 = wildcard) |
output_player_id |
Target player slot (0xFF = auto-assign) |
Apps configure routes at init time:
Device Disconnect¶
When a device disconnects, call router_device_disconnected(dev_addr, instance) before removing the player from the player manager. This clears the device's output state and removes it from blend tracking.
Configuration Example¶
From usb2gc/app.c:
router_config_t router_cfg = {
.mode = ROUTING_MODE_MERGE,
.merge_mode = MERGE_BLEND,
.transform_flags = TRANSFORM_MOUSE_TO_ANALOG,
.mouse_drain_rate = 8,
.mouse_target_x = ANALOG_LX,
.mouse_target_y = ANALOG_LY,
};
router_cfg.max_players_per_output[OUTPUT_TARGET_GAMECUBE] = 4;
router_init(&router_cfg);
router_add_route(INPUT_SOURCE_USB_HOST, OUTPUT_TARGET_GAMECUBE, 0);