Skip to content

Data Flow

This page explains how controller input travels from a physical device all the way to a console or USB output, step by step.

The Pipeline

  Controller          Input Driver          Router            Output Driver         Console
  ──────────          ────────────          ──────            ─────────────         ───────

  [Button Press] --> [Normalize to    ] --> [Transform   ] --> [Read from    ] --> [Send to
                      input_event_t]        Profile           router_outputs]      console]
                                            Merge
                                            Store

  [Rumble Motor] <-- [Send SET_REPORT] <-- [Player Mgr ] <-- [get_rumble() ] <-- [Console
                                            feedback]                              command]

Data flows forward through the input path and backward through the feedback path. Both paths are lock-free on the critical read/write operations.

Step 1: Input Normalization

Every input driver -- whether USB HID, Bluetooth, SNES GPIO, or WiFi -- converts raw controller data into a common input_event_t structure:

typedef struct {
    uint8_t dev_addr;             // Device address (unique per controller)
    int8_t instance;              // Instance within device (e.g., hub port)
    input_device_type_t type;     // GAMEPAD, MOUSE, or KEYBOARD
    input_transport_t transport;  // USB, BT_CLASSIC, BLE, WIFI, NATIVE

    uint32_t buttons;             // JP_BUTTON_* bitmap (W3C Gamepad order)

    uint8_t analog[7];            // LX, LY, RX, RY, L2, R2, RZ
                                  // All normalized: 0-255, 128 = center

    int8_t delta_x, delta_y;     // Mouse deltas
} input_event_t;

Key normalization rules:

  • Buttons use W3C Gamepad API order (JP_BUTTON_B1 through JP_BUTTON_A1). See the Glossary for the full list.
  • Analog axes are normalized to 0-255 with 128 as center. All drivers must follow HID convention: 0 = up/left, 255 = down/right.
  • Y-axis inversion: Nintendo-style controllers (N64, GameCube) use inverted Y (0 = down). Input drivers invert this during normalization so the rest of the system sees standard HID convention.

Step 2: Router Submit

The input driver calls router_submit_input(&event) to hand off the event. The router processes it through a five-stage inline pipeline -- there is no queue or thread boundary here:

router_submit_input(&event)
    |
    v
1. TRANSFORM    Apply transform flags (mouse-to-analog, spinner
                accumulation, instance merging)
    |
    v
2. PROFILE      Apply the active button remapping profile
                (defined per-app in profiles.h)
    |
    v
3. MERGE        Combine with other inputs targeting the same
                output slot (based on routing mode)
    |
    v
4. STORE        Write to router_outputs[target][slot]
                (atomic single-writer from Core 0)
    |
    v
5. TAP          Call push callbacks for outputs that need
                immediate notification

This entire pipeline runs inline -- the function returns only after the event is stored and any callbacks have fired.

Step 3: Routing Modes

The router mode determines how input devices map to output slots:

SIMPLE -- One controller per slot. Device N maps to player slot N. This is the default for console adapters. When controller 1 connects, it gets slot 1; controller 2 gets slot 2; and so on up to the output's player limit.

MERGE -- All controllers merge into a single slot. All button presses and analog values are blended together (OR for buttons, priority-based for analog). Used by bt2usb and copilot/accessibility setups.

BROADCAST -- Every input is sent to every output slot. Used for specialized multi-output configurations.

When multiple inputs target the same slot (in MERGE or CONFIGURABLE modes), a merge mode determines how they combine:

  • MERGE_BLEND -- OR all button states together. If either controller presses A, the output sees A pressed.
  • MERGE_PRIORITY -- The highest-priority input wins on conflicts.
  • MERGE_ALL -- The most recently active input wins.

Step 4: Profile Application

Before an event reaches the output, the router applies the active button remapping profile. Profiles are defined per-app in profiles.h and support:

  • 1:1 remapping -- Map B1 to B2, swap sticks, etc.
  • Button combos -- Multiple inputs produce a single output.
  • Analog targets -- A button press produces a specific analog axis value.
  • Analog sensitivity -- Scale stick ranges.

Users step profiles by holding SELECT + D-pad Up (previous) or Down (next) together for about 0.7 s -- pressing them one after the other does nothing, and the selection clamps at the ends rather than wrapping. The NeoPixel LED flashes to confirm the change. The selected profile persists to flash so it survives power cycles.

Step 5: Output Read

Output drivers call router_get_output(target, slot) to read the latest state for their player slot. This returns a direct pointer to internal router storage -- zero-copy, no mutex, no allocation.

Console outputs running on Core 1 call this in their tight PIO loop. USB device outputs running on Core 0 call it in their periodic task. Either way, the read is lock-free because the router uses atomic single-writer semantics (only Core 0 writes; reads are always consistent).

Step 6: Console Protocol

The output driver translates the common format into the console's native protocol:

  • GameCube: Packs buttons and analog into a joybus response frame, sent via PIO at 130MHz.
  • PCEngine: Maps buttons into multiplexed select/clock scan lines via PIO.
  • Dreamcast: Builds a maple bus packet with button/analog/trigger data.
  • USB Device: Fills a HID report descriptor matching the selected output mode (SInput, XInput, PS4, Switch, etc.).

Feedback Path

Feedback flows backward through the system. When a console sends a rumble command:

  1. The output driver receives the command (e.g., GameCube sends a rumble bit in its poll request).
  2. OutputInterface.get_rumble() returns the motor state.
  3. players_task() reads feedback for each player slot and routes it to the correct input device by dev_addr and instance.
  4. The input driver sends the feedback to the physical controller (USB SET_REPORT, Bluetooth HID output report, N64 rumble pak command, etc.).

This is how a DualSense vibrates when a GameCube game triggers rumble -- the feedback crosses the entire stack.

Dual-Core Execution

On RP2040, timing-critical work is split across two CPU cores:

Core 0 runs the main loop:

while (1) {
    services: leds_task(), players_task(), storage_task()
    inputs:   for each input_interface -> task()
    outputs:  for each output_interface -> task()
    app:      app_task()
}

Core 1 runs the output's core1_task() -- a tight PIO loop for console protocols. Only one output can claim Core 1. Core 1 reads from the router (lock-free) and interacts with PIO state machines.

Input polling on Core 0 runs before output tasks on Core 0, so outputs always see the freshest data from the current loop iteration.

Latency Design

The architecture minimizes input-to-output latency through several deliberate choices:

  1. Input before output -- Outputs always read data from the current loop iteration.
  2. Zero-copy router -- router_get_output() returns a pointer, not a copy.
  3. No mutexes on critical path -- Single-writer atomic stores; lock-free reads.
  4. No queuing -- router_submit_input() processes the event inline. No event queue between layers.
  5. RAM placement -- Timing-critical code is placed in SRAM with __not_in_flash_func to avoid flash cache misses.
  6. Core isolation -- Console PIO loops on Core 1 are never interrupted by USB or Bluetooth processing.

Next Steps