feat(input): restore DualShock rumble output

This commit is contained in:
Matthew Stanley
2026-08-06 22:36:15 -07:00
parent 577f6173b9
commit 40f2fa84bd
6 changed files with 358 additions and 14 deletions
+9 -11
View File
@@ -150,14 +150,13 @@ will still see our HYBRID auto-flip yank it back to digital — exactly the kind
of "type flips underneath the game" desync the deferred-request machinery was
built to avoid but cannot prevent, because we never learned the pad is locked.
### D7 — **`0x4D` rumble-map is a canned constant, not an echo-back map** (semantic)
Beetle's `0x4D` echoes the previous `rumble_magic[]` while latching the new
mapping bytes (`dualshock.cpp:993-1024`); the map then routes `0x42` weak/strong
rumble params (`dualshock.cpp:617-644`). Ours returns a fixed
`{0xF3,0x5A,0,0,0,0,0,0}` (`sio.c:812`/`830`) and implements **no rumble at
all.** Acceptable (no rumble output device), but a game that *reads back* the
`0x4D` map to confirm rumble init will get the wrong echo and may decide rumble
is absent. Low player-visible impact (no rumble hardware anyway).
### D7 — **`0x4D` rumble-map negotiation and host output** (resolved)
Resolved: `0x4D` now echoes the previous six-byte motor map while latching the
new map. Mapped motor bytes from later `0x42` polls are retained per slot and
forwarded to SDL's low/high-frequency rumble channels by the frontend. The
protocol path is pinned by `sio_dualshock_rumble_test`; legacy savestates remain
loadable and receive the conventional `{0x00,0x01,0xFF,...}` map with stopped
motors.
### D8 — **`0x45` analog-status byte is hard-coded `0x03` (analog on)** (semantic)
Beetle `0x4501` reports `transmit_buffer[1] = analog_mode ? 0x01 : 0x00`
@@ -289,9 +288,8 @@ every title — which is exactly what the LEGACY comment predicted, letting the
The flag, legacy SIO branches, config key, debug command, helper script, and
Tomba opt-in were removed. All titles use the modern DualShock state machine.
5. **[P2] Echo-back `0x4D` rumble map** (D7). Even with no rumble output,
echo the previous `rumble_magic[]` bytes (`dualshock.cpp:993-1024`) so a game
that confirms rumble-init reads sane values. Low visible impact.
5. **[DONE 2026-08-06] Echo-back `0x4D` rumble map and forward motors.** The
SIO model now routes negotiated weak/strong values to the SDL frontend.
6. **[P2] Move pad ACK/IRQ timing onto the cycle-paced shifter** (D1). Remove
the pad fast-path bypass (`sio.c:1386`) so pad bytes use
+6
View File
@@ -52,6 +52,12 @@ if(BUILD_TESTING)
PSX_OVERLAY_DLL_BUILD=1)
add_test(NAME psx_icache_fastpath_test COMMAND psx_icache_fastpath_test)
add_executable(sio_dualshock_rumble_test
tests/test_sio_dualshock_rumble.c
src/sio.c)
target_include_directories(sio_dualshock_rumble_test PRIVATE include)
add_test(NAME sio_dualshock_rumble_test COMMAND sio_dualshock_rumble_test)
add_executable(gte_register_access_test
tests/test_gte_register_access.cpp
src/gte.cpp
+6
View File
@@ -94,6 +94,12 @@ void sio_set_pad_connected(int slot, int connected);
* unchanged. */
void sio_set_pad_config_capable(int slot, int capable);
/* Read the raw DualShock motor state selected by the guest's 0x4D rumble map
* and most recent 0x42 poll. `small` is the fixed-strength high-frequency
* motor byte; `large` is the variable-strength low-frequency motor byte.
* The frontend translates these values to its host controller API. */
void sio_get_pad_rumble(int slot, uint8_t *small, uint8_t *large);
/* Return current pad button state (for debug server). _slot targets either. */
uint16_t sio_get_pad_buttons(void);
uint16_t sio_get_pad_buttons_slot(int slot);
+70
View File
@@ -314,6 +314,10 @@ struct PlayerInput {
bool hybrid_analog = false;
SDL_GameController* handle = nullptr;
SDL_JoystickID instance = -1;
uint8_t rumble_small = 0;
uint8_t rumble_large = 0;
bool rumble_known = false;
bool rumble_warned = false;
};
static PlayerInput g_players[2];
/* ARGB8888 staging buffer. Sized for the active internal resolution:
@@ -2627,10 +2631,16 @@ static void load_input_config(const char* argv0) {
static void close_player(PlayerInput& p) {
if (p.handle) {
if (p.rumble_small || p.rumble_large)
(void)SDL_GameControllerRumble(p.handle, 0, 0, 0);
SDL_GameControllerClose(p.handle);
p.handle = nullptr;
p.instance = -1;
}
p.rumble_small = 0;
p.rumble_large = 0;
p.rumble_known = false;
p.rumble_warned = false;
}
static void close_controller(void) {
@@ -2668,6 +2678,61 @@ static void open_player(PlayerInput& p, const PlayerInput& other) {
const char* name = SDL_GameControllerName(p.handle);
std::fprintf(stdout, "psxrecomp runtime: opened controller for slot: %s\n",
name ? name : "(unnamed)");
p.rumble_known = false;
p.rumble_warned = false;
}
}
/* Forward the emulated DualShock's two motors to the SDL controller assigned
* to the same PSX port. The large motor is variable-strength/low-frequency;
* the small motor is fixed-strength/high-frequency. Active effects are renewed
* every VBlank with a short lifetime so a crash or unplug cannot leave a host
* controller vibrating indefinitely. */
static void update_controller_rumble(void) {
static const bool trace = [] {
const char* e = std::getenv("PSX_RUMBLE_TRACE");
return e && e[0] && e[0] != '0';
}();
for (int s = 0; s < 2; s++) {
PlayerInput& p = g_players[s];
uint8_t small = 0, large = 0;
sio_get_pad_rumble(s, &small, &large);
if (!p.handle) {
p.rumble_small = small;
p.rumble_large = large;
p.rumble_known = true;
continue;
}
const bool changed = !p.rumble_known || p.rumble_small != small ||
p.rumble_large != large;
if (small || large || (changed && p.rumble_known)) {
const Uint16 low_frequency = (Uint16)((unsigned)large * 257u);
const Uint16 high_frequency = small ? 0xFFFFu : 0u;
#if defined(PSX_SDL3)
const int rc = SDL_GameControllerRumble(
p.handle, low_frequency, high_frequency,
(small || large) ? 100u : 0u) ? 0 : -1;
#else
const int rc = SDL_GameControllerRumble(
p.handle, low_frequency, high_frequency,
(small || large) ? 100u : 0u);
#endif
if (rc != 0 && !p.rumble_warned) {
std::fprintf(stderr,
"psxrecomp runtime: controller for slot %d rejected rumble: %s\n",
s + 1, SDL_GetError());
p.rumble_warned = true;
}
}
if (trace && changed) {
std::fprintf(stdout,
"psxrecomp rumble: slot=%d small=%u large=%u\n",
s + 1, (unsigned)small, (unsigned)large);
}
p.rumble_small = small;
p.rumble_large = large;
p.rumble_known = true;
}
}
@@ -3765,6 +3830,7 @@ static void sdl_vblank_present(void) {
} else {
sample_pad_into_sio(override);
}
if (!g_headless) update_controller_rumble();
/* Latency ring: open this present cycle's slot, stamping when input was
* sampled into SIO. Always-on; queried via the debug server "latency". */
@@ -6813,6 +6879,10 @@ session_reboot:
* as a game controller rather than a raw HID device. */
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "0");
/* SDL3 aliases the SDL2-era PS5 rumble hint to enhanced reports. Enabling
* it also preserves DualSense rumble on the explicit SDL2 fallback. */
SDL_SetHintWithPriority(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1",
SDL_HINT_OVERRIDE);
/* ...but HIDAPI's Xbox sub-driver is OFF by default on Windows (Xbox pads are
* normally RAWINPUT/XInput there). With RAWINPUT disabled above, a PHYSICAL
* Xbox One/Series controller would be claimed by nobody -> not a GameController
+83 -3
View File
@@ -51,6 +51,17 @@ static uint8_t pad_analog[2] = { 0, 0 };
static uint8_t pad_stick[2][4] = { { 0x80, 0x80, 0x80, 0x80 },
{ 0x80, 0x80, 0x80, 0x80 } }; /* lx,ly,rx,ry */
/* DualShock command 0x4D maps the six writable bytes in a 0x42 poll onto the
* two motors: 0x00 = small/high-frequency, 0x01 = large/low-frequency,
* 0xFF = unused. The map powers up unassigned and is echoed back while a new
* map is latched, matching the physical pad/Mednafen protocol. */
static uint8_t pad_rumble_map[2][6] = {
{ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF },
{ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF },
};
static uint8_t pad_rumble_small[2] = { 0, 0 };
static uint8_t pad_rumble_large[2] = { 0, 0 };
/* Analog-mode lock, per slot. A real DualShock's config command 0x44 0x..02/0x03
* locks/unlocks the mode (dualshock.cpp:714-725); a locked pad ignores the
* physical analog button (dualshock.cpp:203). We emulate the analog button via
@@ -543,6 +554,9 @@ void sio_init(void) {
pad_response_idx = 0;
pad_current_cmd = 0;
pad_buttons[0] = pad_buttons[1] = 0xFFFF;
memset(pad_rumble_map, 0xFF, sizeof(pad_rumble_map));
pad_rumble_small[0] = pad_rumble_small[1] = 0;
pad_rumble_large[0] = pad_rumble_large[1] = 0;
pad_in_config[0] = pad_in_config[1] = 0; /* clear stale config latch on reset */
pad_type_req[0] = pad_type_req[1] = -1; /* no pending host type change */
analog_mode_locked[0] = analog_mode_locked[1] = 0; /* unlocked on reset */
@@ -610,7 +624,11 @@ void sio_connect_pad(int slot) {
void sio_set_pad_connected(int slot, int connected) {
if (slot < 0 || slot > 1) return;
if (connected) pad_connected |= (uint8_t)(1 << slot);
else pad_connected &= (uint8_t)~(1 << slot);
else {
pad_connected &= (uint8_t)~(1 << slot);
pad_rumble_small[slot] = 0;
pad_rumble_large[slot] = 0;
}
}
void sio_set_pad_config_capable(int slot, int capable) {
@@ -618,7 +636,21 @@ void sio_set_pad_config_capable(int slot, int capable) {
pad_supports_config[slot] = capable ? 1 : 0;
/* A plain digital pad can never be in config mode; clear any stale latch so
* the next poll reports the digital id (0x41), not the config id (0xF3). */
if (!capable) pad_in_config[slot] = 0;
if (!capable) {
pad_in_config[slot] = 0;
pad_rumble_small[slot] = 0;
pad_rumble_large[slot] = 0;
}
}
void sio_get_pad_rumble(int slot, uint8_t *small, uint8_t *large) {
uint8_t s = 0, l = 0;
if (slot >= 0 && slot <= 1 && (pad_connected & (1 << slot))) {
s = pad_rumble_small[slot];
l = pad_rumble_large[slot];
}
if (small) *small = s;
if (large) *large = l;
}
void sio_set_pad_state(uint16_t buttons) {
@@ -813,6 +845,10 @@ static void pad_process_byte(uint8_t tx_byte) {
* (axis5_sio_controller.md D8). */
if (tx_byte == 0x45)
pad_response[3] = pad_analog[selected_slot] ? 0x01 : 0x00;
/* 0x4D returns the previous six-byte motor map while latching the
* replacement bytes later in this same transaction. */
if (tx_byte == 0x4D)
memcpy(&pad_response[2], pad_rumble_map[selected_slot], 6);
pad_response_len = 8;
pad_state = PAD_SEND_RESPONSE;
sio_rx_data = pad_response[0];
@@ -830,6 +866,25 @@ static void pad_process_byte(uint8_t tx_byte) {
break;
case PAD_SEND_RESPONSE:
/* The six data bytes after 0x42's leading 0x00 occupy response indexes
* 2..7. Route each through the map negotiated by command 0x4D. */
if (pad_current_cmd == 0x42 &&
pad_response_idx >= 2 && pad_response_idx < 8) {
const unsigned map_index = (unsigned)pad_response_idx - 2u;
const uint8_t motor = pad_rumble_map[selected_slot][map_index];
if (motor == 0x00)
pad_rumble_small[selected_slot] = tx_byte;
else if (motor == 0x01)
pad_rumble_large[selected_slot] = tx_byte;
}
/* 0x4D uses the same six wire positions. pad_response[] was populated
* with the old values before the transaction, so updating the live map
* here preserves the required echo-before-write behavior. */
if (pad_current_cmd == 0x4D &&
pad_response_idx >= 2 && pad_response_idx < 8) {
const unsigned map_index = (unsigned)pad_response_idx - 2u;
pad_rumble_map[selected_slot][map_index] = tx_byte;
}
/* For 0x43 (enter/exit config), the data byte selecting enter(0x01)/
* exit(0x00) arrives paired with response index 2. Latch the new config
* state; it takes effect from the next transaction (the ID byte already
@@ -2166,6 +2221,10 @@ static int sio_snap_emit(PstW *w) {
!pst_w_u8(w, sio_irq_pending_delay) || !pst_w_u8(w, sio_irq_pending_mc_state) ||
!pst_w_u32(w, sio_irq_pending_byte_seq))
return 0;
if (!pst_w_bytes(w, pad_rumble_map, sizeof(pad_rumble_map)) ||
!pst_w_bytes(w, pad_rumble_small, sizeof(pad_rumble_small)) ||
!pst_w_bytes(w, pad_rumble_large, sizeof(pad_rumble_large)))
return 0;
return 1;
}
@@ -2233,6 +2292,22 @@ static int sio_snap_parse(PstR *r) {
!pst_r_u8(r, &sio_irq_pending_delay) || !pst_r_u8(r, &sio_irq_pending_mc_state) ||
!pst_r_u32(r, &sio_irq_pending_byte_seq))
return 0;
/* v1.0.4 and older snapshots end here. A mid-game legacy state cannot tell
* us the negotiated map, so assume the standard 0x00/0x01 layout used by
* commercial DualShock games while leaving both motors stopped. */
if (r->p == r->end) {
memset(pad_rumble_map, 0xFF, sizeof(pad_rumble_map));
pad_rumble_map[0][0] = pad_rumble_map[1][0] = 0x00;
pad_rumble_map[0][1] = pad_rumble_map[1][1] = 0x01;
pad_rumble_small[0] = pad_rumble_small[1] = 0;
pad_rumble_large[0] = pad_rumble_large[1] = 0;
return 1;
}
if (!pst_r_bytes(r, pad_rumble_map, sizeof(pad_rumble_map)) ||
!pst_r_bytes(r, pad_rumble_small, sizeof(pad_rumble_small)) ||
!pst_r_bytes(r, pad_rumble_large, sizeof(pad_rumble_large)))
return 0;
if (r->p != r->end) return 0;
return 1;
}
@@ -2252,7 +2327,12 @@ void sio_snapshot_write(uint8_t *p) {
int sio_snapshot_read(const uint8_t *p, uint32_t len) {
PstR r;
if (len != sio_snapshot_bytes()) return 0;
const uint32_t current = sio_snapshot_bytes();
const uint32_t rumble_bytes = (uint32_t)(sizeof(pad_rumble_map) +
sizeof(pad_rumble_small) +
sizeof(pad_rumble_large));
if (len != current && (len > current || len + rumble_bytes != current))
return 0;
pst_r_init(&r, p, len);
return sio_snap_parse(&r);
}
+184
View File
@@ -0,0 +1,184 @@
/* DualShock rumble protocol regression: 0x4D map negotiation must feed the
* small/large motor bytes from later 0x42 polls to the frontend-facing state. */
#include "sio.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
uint32_t sio_snapshot_bytes(void);
void sio_snapshot_write(uint8_t *p);
int sio_snapshot_read(const uint8_t *p, uint32_t len);
uint32_t i_stat = 0;
uint32_t g_debug_current_func_addr = 0;
int psx_get_in_exception(void) { return 0; }
uint8_t psx_read_byte(uint32_t addr) { (void)addr; return 0; }
uint32_t memory_get_sr(void) { return 0; }
void debug_server_poll(void) {}
void debug_server_log_sio_write(uint32_t a, uint32_t v, uint8_t w) {
(void)a; (void)v; (void)w;
}
void starvation_ring_record(uint8_t k, uint8_t tx, uint8_t rx, uint16_t c,
uint16_t st, uint8_t d, uint8_t ss, uint16_t ms,
uint8_t mc, uint16_t sec, uint8_t di, uint32_t f) {
(void)k; (void)tx; (void)rx; (void)c; (void)st; (void)d; (void)ss;
(void)ms; (void)mc; (void)sec; (void)di; (void)f;
}
void card_read_summary_record(uint8_t s, uint8_t c, uint16_t sec, uint8_t e,
uint8_t chk, uint8_t d0, uint8_t d1, uint32_t f) {
(void)s; (void)c; (void)sec; (void)e; (void)chk; (void)d0; (void)d1; (void)f;
}
void card_data_writes_arm(uint8_t v, uint16_t s, uint8_t i, uint8_t sl) {
(void)v; (void)s; (void)i; (void)sl;
}
void event_ring_record_aux(uint16_t k, uint8_t d, uint32_t a) {
(void)k; (void)d; (void)a;
}
void psx_irq_raise(uint32_t bit, uint32_t detail) {
(void)detail; i_stat |= 1u << bit;
}
int memcard_is_present(int card) { (void)card; return 0; }
int memcard_read_sector(int card, int sector, uint8_t *buf) {
(void)card; (void)sector; memset(buf, 0, 128); return -1;
}
int memcard_write_sector(int card, int sector, const uint8_t *buf) {
(void)card; (void)sector; (void)buf; return -1;
}
void memcard_flush(int card) { (void)card; }
#define SIO_TX_DATA 0x1F801040u
#define SIO_RX_DATA 0x1F801040u
#define SIO_CTRL 0x1F80104Au
#define CTRL_TX_EN (1u << 0)
#define CTRL_SELECT (1u << 1)
#define CTRL_ACK (1u << 4)
#define CTRL_ACK_IRQ_EN (1u << 12)
#define CTRL_SLOT (1u << 13)
static int failures;
#define EXPECT(label, expected, actual) do { \
unsigned e_ = (unsigned)(expected), a_ = (unsigned)(actual); \
if (e_ != a_) { \
fprintf(stderr, "FAIL %s: expected=0x%X actual=0x%X\n", \
label, e_, a_); \
failures++; \
} \
} while (0)
static uint8_t xchg(int slot, uint8_t tx) {
const uint16_t ctrl = (uint16_t)(CTRL_TX_EN | CTRL_SELECT |
CTRL_ACK_IRQ_EN | (slot ? CTRL_SLOT : 0));
sio_write(SIO_CTRL, ctrl);
sio_write(SIO_TX_DATA, tx);
sio_tick(2000);
const uint8_t rx = (uint8_t)sio_read(SIO_RX_DATA);
sio_write(SIO_CTRL, ctrl | CTRL_ACK);
return rx;
}
static void finish_six_data_bytes(int slot, unsigned already_sent) {
while (already_sent++ < 6) (void)xchg(slot, 0x00);
}
static void enter_config(int slot) {
EXPECT("enter.prefix", 0xFF, xchg(slot, 0x01));
EXPECT("enter.id", 0x73, xchg(slot, 0x43));
EXPECT("enter.ack", 0x5A, xchg(slot, 0x00));
(void)xchg(slot, 0x01);
finish_six_data_bytes(slot, 1);
}
static void set_rumble_map(int slot, const uint8_t map[6],
const uint8_t expected_old[6]) {
EXPECT("map.prefix", 0xFF, xchg(slot, 0x01));
EXPECT("map.id", 0xF3, xchg(slot, 0x4D));
EXPECT("map.ack", 0x5A, xchg(slot, 0x00));
for (int i = 0; i < 6; i++)
EXPECT("map.echo", expected_old[i], xchg(slot, map[i]));
}
static void exit_config(int slot) {
EXPECT("exit.prefix", 0xFF, xchg(slot, 0x01));
EXPECT("exit.id", 0xF3, xchg(slot, 0x43));
EXPECT("exit.ack", 0x5A, xchg(slot, 0x00));
(void)xchg(slot, 0x00);
finish_six_data_bytes(slot, 1);
}
static void poll_with_motors(int slot, uint8_t small, uint8_t large) {
EXPECT("poll.prefix", 0xFF, xchg(slot, 0x01));
EXPECT("poll.id", 0x73, xchg(slot, 0x42));
EXPECT("poll.ack", 0x5A, xchg(slot, 0x00));
(void)xchg(slot, small);
(void)xchg(slot, large);
finish_six_data_bytes(slot, 2);
}
int main(void) {
static const uint8_t unassigned[6] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
};
static const uint8_t standard[6] = {
0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF
};
uint8_t small = 0, large = 0;
sio_init();
sio_connect_pad(0);
sio_set_pad_analog(0, 1, 0x80, 0x80, 0x80, 0x80);
sio_set_pad_config_capable(0, 1);
enter_config(0);
set_rumble_map(0, standard, unassigned);
set_rumble_map(0, standard, standard);
exit_config(0);
poll_with_motors(0, 0x01, 0x80);
sio_get_pad_rumble(0, &small, &large);
EXPECT("rumble.small.on", 0x01, small);
EXPECT("rumble.large.strength", 0x80, large);
const uint32_t snapshot_len = sio_snapshot_bytes();
uint8_t *snapshot = (uint8_t *)malloc(snapshot_len);
if (!snapshot) {
fprintf(stderr, "FAIL snapshot allocation\n");
return 1;
}
sio_snapshot_write(snapshot);
poll_with_motors(0, 0x00, 0x00);
sio_get_pad_rumble(0, &small, &large);
EXPECT("rumble.small.off", 0x00, small);
EXPECT("rumble.large.off", 0x00, large);
EXPECT("snapshot.current.read", 1,
sio_snapshot_read(snapshot, snapshot_len));
sio_get_pad_rumble(0, &small, &large);
EXPECT("snapshot.current.small", 0x01, small);
EXPECT("snapshot.current.large", 0x80, large);
/* The final 16 bytes are the new rumble fields. Truncating them models a
* v1.0.4 SIO section and must load safely with both motors stopped. */
EXPECT("snapshot.legacy.read", 1,
sio_snapshot_read(snapshot, snapshot_len - 16));
sio_get_pad_rumble(0, &small, &large);
EXPECT("snapshot.legacy.small", 0x00, small);
EXPECT("snapshot.legacy.large", 0x00, large);
free(snapshot);
poll_with_motors(0, 0x01, 0xFF);
sio_set_pad_connected(0, 0);
sio_get_pad_rumble(0, &small, &large);
EXPECT("rumble.disconnect.small", 0x00, small);
EXPECT("rumble.disconnect.large", 0x00, large);
if (failures) {
fprintf(stderr, "DualShock rumble protocol: %d failure(s)\n", failures);
return 1;
}
fprintf(stderr, "DualShock rumble protocol: passed\n");
return 0;
}