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 cycle profiles by holding SELECT + D-pad Up (next) or Down (previous) for 2 seconds. 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:
- The output driver receives the command (e.g., GameCube sends a rumble bit in its poll request).
OutputInterface.get_rumble()returns the motor state.players_task()reads feedback for each player slot and routes it to the correct input device bydev_addrandinstance.- 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:
- Input before output -- Outputs always read data from the current loop iteration.
- Zero-copy router --
router_get_output()returns a pointer, not a copy. - No mutexes on critical path -- Single-writer atomic stores; lock-free reads.
- No queuing --
router_submit_input()processes the event inline. No event queue between layers. - RAM placement -- Timing-critical code is placed in SRAM with
__not_in_flash_functo avoid flash cache misses. - Core isolation -- Console PIO loops on Core 1 are never interrupted by USB or Bluetooth processing.
Next Steps¶
- Architecture -- The four-layer model
- Glossary -- Key terms and definitions
- Joypad Core -- Router, profiles, players, and other services