Skip to content

Commit 64c5d2e

Browse files
committed
feat(dinput): add 8BitDo Pro 2 persona (2026.6.15.1-DD7C)
1 parent f6455b5 commit 64c5d2e

27 files changed

Lines changed: 683 additions & 56 deletions

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
Parsec CouchLink lets a remote Parsec player use a real retro console as player 2.
44

5-
The Windows host reads the Parsec virtual Xbox controller, sends the button state over Wi-Fi, and a Raspberry Pi Pico 2 W or Pico W presents that input as a wired Xbox 360 controller to a USB-to-console adapter such as USB4MAPLE.
5+
The Windows host reads the Parsec virtual Xbox controller, sends the button state over Wi-Fi, and a Raspberry Pi Pico 2 W or Pico W presents that input as a USB controller to a console adapter such as USB4MAPLE.
66

7-
For games that need a keyboard instead -- Typing of the Dead on the Dreamcast, for one -- the same Pico can switch to a USB keyboard with `couchlink.exe keyboard` and forward the player's typing. For Dreamcast Maple adapters that accept Xbox 360 controllers, `couchlink.exe maple` keeps a separate Maple-labelled mode while presenting the same wired Xbox 360 USB shape. See [Controller Routing](https://github.com/RealWhyKnot/ParsecCouchLink/wiki/Controller-Routing) for keyboard and Maple modes.
7+
For games that need a keyboard instead -- Typing of the Dead on the Dreamcast, for one -- the same Pico can switch to a USB keyboard with `couchlink.exe keyboard` and forward the player's typing. For Dreamcast Maple adapters, `couchlink.exe maple` keeps a Maple-labelled Xbox 360-compatible mode, and `couchlink.exe dinput` presents an experimental 8BitDo Pro 2 D-Input HID gamepad shape for adapters that accept DInput. See [Controller Routing](https://github.com/RealWhyKnot/ParsecCouchLink/wiki/Controller-Routing) for keyboard, Maple, and DInput modes.
88

99
**[Releases](https://github.com/RealWhyKnot/ParsecCouchLink/releases)** | **[Wiki](https://github.com/RealWhyKnot/ParsecCouchLink/wiki)** | **[Quick Start](https://github.com/RealWhyKnot/ParsecCouchLink/wiki/Quick-Start)** | **[Troubleshooting](https://github.com/RealWhyKnot/ParsecCouchLink/wiki/Troubleshooting)**
1010

@@ -16,7 +16,7 @@ For games that need a keyboard instead -- Typing of the Dead on the Dreamcast, f
1616
- Raspberry Pi Pico W or Pico WH (RP2040 + Wi-Fi) -- also fully supported
1717
- Micro-USB data cable
1818
- 2.4 GHz Wi-Fi name and password (both boards use a 2.4 GHz-only radio)
19-
- USB4MAPLE or another USB-to-console adapter that accepts a wired Xbox 360 controller
19+
- USB4MAPLE or another USB-to-console adapter that accepts Xbox 360/XInput or DInput-style USB controllers
2020
- The console and controller adapter you want to use
2121

2222
## Quick Start
@@ -58,6 +58,8 @@ Useful commands:
5858
.\couchlink.exe doctor
5959
.\couchlink.exe keyboard
6060
.\couchlink.exe controller
61+
.\couchlink.exe maple
62+
.\couchlink.exe dinput
6163
.\couchlink.exe logs --tail
6264
.\couchlink.exe configure-wifi
6365
.\couchlink.exe recover

bridge/src/cmd_persona.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
//! `couchlink keyboard` / `couchlink controller` / `couchlink maple` --
2-
//! switch a Pico's output persona over Wi-Fi and optionally start
3-
//! streaming to it.
1+
//! `couchlink keyboard` / `couchlink controller` / `couchlink maple` /
2+
//! `couchlink dinput` -- switch a Pico's output persona over Wi-Fi and
3+
//! optionally start streaming to it.
44
//!
55
//! The persona is persisted on the Pico and applied at the next boot, so
66
//! switching reboots the board. Because the Pico lives plugged into the

bridge/src/cmd_run.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ impl StreamRoute {
113113
pub fn source_label(&self) -> String {
114114
match self.pico.persona {
115115
Persona::Keyboard => "Keyboard".to_string(),
116-
Persona::Controller | Persona::Maple => xinput::user_slot_label(self.source_slot),
116+
Persona::Controller | Persona::Maple | Persona::Dinput => {
117+
xinput::user_slot_label(self.source_slot)
118+
}
117119
}
118120
}
119121
}
@@ -829,7 +831,7 @@ impl RouteRuntime {
829831

830832
async fn send_tick(&mut self, socket: &UdpSocket) -> std::io::Result<()> {
831833
let packet = match self.route.pico.persona {
832-
Persona::Controller | Persona::Maple => self.next_controller_packet(),
834+
Persona::Controller | Persona::Maple | Persona::Dinput => self.next_controller_packet(),
833835
Persona::Keyboard => self.next_keyboard_packet(),
834836
};
835837
self.seq = self.seq.wrapping_add(1);
@@ -942,7 +944,7 @@ fn print_status(routes: &mut [RouteRuntime]) {
942944
"waiting for source"
943945
};
944946
let detail = match route.route.pico.persona {
945-
Persona::Controller | Persona::Maple => format!(
947+
Persona::Controller | Persona::Maple | Persona::Dinput => format!(
946948
"buttons=0x{:04X} lt={} rt={} lx={} ly={} rx={} ry={}",
947949
route.last_state.buttons,
948950
route.last_state.left_trigger,

bridge/src/cmd_usb_diag.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ fn print_usb_diag(diag: &protocol::UsbDiag, persona: protocol::Persona) {
173173
protocol::Persona::Controller => "XInput",
174174
protocol::Persona::Keyboard => "HID keyboard",
175175
protocol::Persona::Maple => "XInput (Maple mode)",
176+
protocol::Persona::Dinput => "8BitDo Pro 2 DInput",
176177
};
177178
println!(" {}", usb_verdict(diag, device_label));
178179
println!(
@@ -318,6 +319,12 @@ mod tests {
318319
assert!(verdict.contains("XInput (Maple mode) report"));
319320
}
320321

322+
#[test]
323+
fn verdict_uses_dinput_label() {
324+
let verdict = usb_verdict(&diag(true, false, false, true), "8BitDo Pro 2 DInput");
325+
assert!(verdict.contains("8BitDo Pro 2 DInput report"));
326+
}
327+
321328
#[test]
322329
fn age_label_formats_never_and_seconds() {
323330
let d = diag(true, true, false, true);

bridge/src/main.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,20 @@ enum Command {
129129
#[arg(long)]
130130
no_stream: bool,
131131
},
132+
/// Switch a Pico to 8BitDo Pro 2 D-Input HID mode for USB4MAPLE adapters.
133+
Dinput {
134+
/// Select a Pico by UID, IP, or board name. Repeat to select more than one.
135+
#[arg(long = "pico")]
136+
picos: Vec<String>,
137+
138+
/// Switch every Pico currently visible on Wi-Fi.
139+
#[arg(long)]
140+
all: bool,
141+
142+
/// Switch the persona but don't start streaming afterwards.
143+
#[arg(long)]
144+
no_stream: bool,
145+
},
132146
/// First-time setup wizard: flash the Pico, provision Wi-Fi, and check LAN discovery.
133147
Setup {
134148
/// Path to the .uf2 firmware to flash.
@@ -346,6 +360,11 @@ fn main() {
346360
all,
347361
no_stream,
348362
}) => cmd_persona::run(protocol::Persona::Maple, picos, all, !no_stream).await,
363+
Some(Command::Dinput {
364+
picos,
365+
all,
366+
no_stream,
367+
}) => cmd_persona::run(protocol::Persona::Dinput, picos, all, !no_stream).await,
349368
Some(Command::Setup { uf2 }) => cmd_setup::run(uf2).await,
350369
Some(Command::Doctor) => cmd_doctor::run().await,
351370
Some(Command::Flash { uf2, all, from_usb }) => cmd_flash::run(uf2, all, from_usb).await,
@@ -581,4 +600,23 @@ mod tests {
581600
other => panic!("expected maple command, got {other:?}"),
582601
}
583602
}
603+
604+
#[test]
605+
fn dinput_command_parses_target_and_no_stream() {
606+
let cli = Cli::try_parse_from(["couchlink", "dinput", "--pico", "07D37EB6", "--no-stream"])
607+
.unwrap();
608+
609+
match cli.command {
610+
Some(Command::Dinput {
611+
picos,
612+
all,
613+
no_stream,
614+
}) => {
615+
assert_eq!(picos, vec!["07D37EB6"]);
616+
assert!(!all);
617+
assert!(no_stream);
618+
}
619+
other => panic!("expected dinput command, got {other:?}"),
620+
}
621+
}
584622
}

bridge/src/protocol.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,13 @@ pub const ACK_FLAG_KEYBOARD_PERSONA: u8 = 1 << 3;
7676
/// `TYPE_VERSION`.
7777
pub const ACK_FLAG_FULL_VERSION_SUPPORTED: u8 = 1 << 4;
7878
/// Set in the ACK flags byte when the Pico is presenting the Dreamcast
79-
/// Dreamcast Maple adapter persona. The bridge still streams normal
80-
/// controller STATE packets; the Pico presents Xbox 360-compatible USB.
79+
/// Maple adapter persona. The bridge still streams normal controller STATE
80+
/// packets; the Pico presents Xbox 360-compatible USB.
8181
pub const ACK_FLAG_MAPLE_PERSONA: u8 = 1 << 5;
82+
/// Set in the ACK flags byte when the Pico is presenting the 8BitDo Pro 2
83+
/// D-Input HID persona. The bridge still streams normal controller STATE
84+
/// packets; the Pico maps them into HID gamepad reports.
85+
pub const ACK_FLAG_DINPUT_PERSONA: u8 = 1 << 6;
8286

8387
pub const USB_DIAG_WIRE_SIZE: usize = 78;
8488
pub const USB_DIAG_VERSION: u8 = 1;
@@ -140,13 +144,16 @@ pub enum Persona {
140144
Controller,
141145
Keyboard,
142146
Maple,
147+
Dinput,
143148
}
144149

145150
impl Persona {
146151
/// Read the active persona from an ACK packet's flags byte.
147152
pub fn from_ack_flags(flags: u8) -> Self {
148153
if flags & ACK_FLAG_KEYBOARD_PERSONA != 0 {
149154
Persona::Keyboard
155+
} else if flags & ACK_FLAG_DINPUT_PERSONA != 0 {
156+
Persona::Dinput
150157
} else if flags & ACK_FLAG_MAPLE_PERSONA != 0 {
151158
Persona::Maple
152159
} else {
@@ -161,6 +168,7 @@ impl Persona {
161168
Persona::Controller => 0,
162169
Persona::Keyboard => 1,
163170
Persona::Maple => 2,
171+
Persona::Dinput => 3,
164172
}
165173
}
166174

@@ -169,6 +177,7 @@ impl Persona {
169177
Persona::Controller => "controller",
170178
Persona::Keyboard => "keyboard",
171179
Persona::Maple => "maple",
180+
Persona::Dinput => "dinput",
172181
}
173182
}
174183
}
@@ -1074,13 +1083,26 @@ mod tests {
10741083
Persona::from_ack_flags(ACK_FLAG_MAPLE_PERSONA | ACK_FLAG_USB_DIAG_SUPPORTED),
10751084
Persona::Maple
10761085
);
1086+
assert_eq!(
1087+
Persona::from_ack_flags(ACK_FLAG_DINPUT_PERSONA | ACK_FLAG_USB_DIAG_SUPPORTED),
1088+
Persona::Dinput
1089+
);
10771090
assert_eq!(
10781091
Persona::from_ack_flags(ACK_FLAG_KEYBOARD_PERSONA | ACK_FLAG_MAPLE_PERSONA),
10791092
Persona::Keyboard
10801093
);
1094+
assert_eq!(
1095+
Persona::from_ack_flags(ACK_FLAG_DINPUT_PERSONA | ACK_FLAG_MAPLE_PERSONA),
1096+
Persona::Dinput
1097+
);
1098+
assert_eq!(
1099+
Persona::from_ack_flags(ACK_FLAG_KEYBOARD_PERSONA | ACK_FLAG_DINPUT_PERSONA),
1100+
Persona::Keyboard
1101+
);
10811102
assert_eq!(Persona::Controller.flash_byte(), 0);
10821103
assert_eq!(Persona::Keyboard.flash_byte(), 1);
10831104
assert_eq!(Persona::Maple.flash_byte(), 2);
1105+
assert_eq!(Persona::Dinput.flash_byte(), 3);
10841106
}
10851107

10861108
#[test]

pico-bridge/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ add_executable(pico_bridge
8989
src/cdc_handlers.c
9090
src/cdc_proto.c
9191
src/diag_log.c
92+
src/dinput.c
93+
src/dinput_report.c
9294
src/fault_handler.c
9395
src/flash_creds.c
9496
src/heartbeat.c

pico-bridge/src/boot_mode.c

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
// flash_creds.h dependency (and thus host-compilable for unit tests).
1818
_Static_assert((int)FLASH_PERSONA_CONTROLLER == (int)RUN_PERSONA_CONTROLLER &&
1919
(int)FLASH_PERSONA_KEYBOARD == (int)RUN_PERSONA_KEYBOARD &&
20-
(int)FLASH_PERSONA_MAPLE == (int)RUN_PERSONA_MAPLE,
20+
(int)FLASH_PERSONA_MAPLE == (int)RUN_PERSONA_MAPLE &&
21+
(int)FLASH_PERSONA_DINPUT == (int)RUN_PERSONA_DINPUT,
2122
"flash persona byte values must match run_persona_t");
2223

2324
static boot_mode_t current = BOOT_MODE_SETUP;

pico-bridge/src/boot_mode_policy.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ run_persona_t boot_mode_persona_from_flash(bool have_creds, uint8_t persona_byte
2323
return RUN_PERSONA_KEYBOARD;
2424
if (persona_byte == (uint8_t)RUN_PERSONA_MAPLE)
2525
return RUN_PERSONA_MAPLE;
26+
if (persona_byte == (uint8_t)RUN_PERSONA_DINPUT)
27+
return RUN_PERSONA_DINPUT;
2628
return RUN_PERSONA_CONTROLLER;
2729
}
2830

pico-bridge/src/boot_mode_policy.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ typedef enum {
2121
RUN_PERSONA_CONTROLLER = 0, // wired Xbox 360 / XInput (default)
2222
RUN_PERSONA_KEYBOARD = 1, // USB HID boot keyboard
2323
RUN_PERSONA_MAPLE = 2, // Xbox-compatible Dreamcast Maple adapter mode
24+
RUN_PERSONA_DINPUT = 3, // 8BitDo Pro 2 D-Input HID gamepad
2425
} run_persona_t;
2526

2627
bootsel_setup_action_t boot_mode_bootsel_setup_action(bool still_pressed, int64_t elapsed_us);
@@ -34,5 +35,5 @@ run_persona_t boot_mode_persona_from_flash(bool have_creds, uint8_t persona_byte
3435
// True for runtime personas that present the wired Xbox 360 USB device
3536
// shape. Maple mode keeps a separate persisted label, but deliberately
3637
// matches this USB shape for Dreamcast Maple adapters that already
37-
// support Xbox 360 controllers.
38+
// support Xbox 360 controllers. DInput is HID and returns false.
3839
bool boot_mode_persona_uses_xinput_usb(run_persona_t persona);

0 commit comments

Comments
 (0)