diff --git a/accuracy/axis5_sio_controller.md b/accuracy/axis5_sio_controller.md index 52a09878..80be9781 100644 --- a/accuracy/axis5_sio_controller.md +++ b/accuracy/axis5_sio_controller.md @@ -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 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 43fa5d6d..31071249 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -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 diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 63bfbdc9..1ca42d7e 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -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); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 798a0dca..0dd11467 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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 diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 31baf504..e16ea041 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -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); } diff --git a/runtime/tests/test_sio_dualshock_rumble.c b/runtime/tests/test_sio_dualshock_rumble.c new file mode 100644 index 00000000..d6b0ca59 --- /dev/null +++ b/runtime/tests/test_sio_dualshock_rumble.c @@ -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 +#include +#include +#include + +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; +}