Skip to content

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).

Controller 0 --> Slot 0
Controller 1 --> Slot 1
Controller 2 --> Slot 2

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).

Controller 0 --\
Controller 1 ---+--> Slot 0
Controller 2 --/

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, or MOUSE_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:

  1. Null/route check -- If the event is NULL or no routes are configured (route_count == 0), return immediately.

  2. CDC streaming -- If CONFIG_USB is defined, the raw input is streamed to CDC for the web configuration tool.

  3. Find output target -- The first active route in the routing table determines the primary output target.

  4. 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 to router_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:

router_add_route(INPUT_SOURCE_USB_HOST, OUTPUT_TARGET_GAMECUBE, 0);

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);

See Also

  • Buttons -- JP_BUTTON_* constants the router passes through
  • Profiles -- Button remapping applied after routing
  • Players -- Player slot assignment the router delegates to
  • Data Flow -- Full input-to-output pipeline