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/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index ee2f797f..348c7ed2 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1758,6 +1758,69 @@ std::string CodeGenerator::translate_basic_block( exit_branch_addr = block.end_addr; } + /* MIPS-I load-delay value semantics for ordinary in-block pairs. The + * resident/full-function emitter already models these; overlay/game CFG + * emission must do the same. OpenBIOS's CD/card fasttrack deliberately + * uses `lw k0,...; move at,k0`, where the move must observe OLD k0. */ + auto simple_load_dest = [](uint32_t w) -> int { + uint32_t op = w >> 26; + if (op == 0x20u || op == 0x21u || op == 0x23u || + op == 0x24u || op == 0x25u) { + uint32_t rt = (w >> 16) & 31u; + return rt ? static_cast(rt) : -1; + } + return -1; + }; + auto reads_gpr = [](uint32_t w, uint32_t r) -> bool { + if (r == 0u || w == 0u) return false; + uint32_t op = w >> 26, rs = (w >> 21) & 31u; + uint32_t rt = (w >> 16) & 31u, fn = w & 63u; + if (op == 0u) { + if (fn == 0x08u || fn == 0x09u) return rs == r; + if (fn == 0x0Cu || fn == 0x0Du || fn == 0x10u || fn == 0x12u) + return false; + return rs == r || rt == r; + } + if (op == 0x02u || op == 0x03u || op == 0x0Fu) return false; + if (op == 0x01u || op == 0x06u || op == 0x07u) return rs == r; + if (op == 0x04u || op == 0x05u) return rs == r || rt == r; + if (op >= 0x08u && op <= 0x0Eu) return rs == r; + if (op == 0x10u) return ((w >> 21) & 31u) == 0x04u && rt == r; + if (op == 0x12u) { + uint32_t f = (w >> 21) & 31u; + return (f == 0x04u || f == 0x06u) && rt == r; + } + if (op == 0x22u || op == 0x26u || + (op >= 0x28u && op <= 0x2Eu)) + return rs == r || rt == r; + return rs == r; + }; + auto writes_gpr = [](uint32_t w, uint32_t r) -> bool { + if (r == 0u || w == 0u) return false; + uint32_t op = w >> 26, rt = (w >> 16) & 31u; + uint32_t rd = (w >> 11) & 31u, fn = w & 63u; + if (op == 0u) { + if (fn == 0x08u || fn == 0x0Cu || fn == 0x0Du || fn == 0x0Fu || + fn == 0x11u || fn == 0x13u || + (fn >= 0x18u && fn <= 0x1Bu)) return false; + return rd == r; + } + if (op == 0x03u) return r == 31u; + if (op == 0x01u) return (rt == 0x10u || rt == 0x11u) && r == 31u; + if (op == 0x02u || (op >= 0x04u && op <= 0x07u) || + (op >= 0x28u && op <= 0x2Eu) || op == 0x32u || op == 0x3Au) + return false; + if (op == 0x10u) return ((w >> 21) & 31u) == 0u && rt == r; + if (op == 0x12u) { + uint32_t f = (w >> 21) & 31u; + return (f == 0u || f == 2u) && rt == r; + } + return rt == r; + }; + uint32_t delayed_load_addr = 0u; + uint32_t delayed_load_dest = 0u; + bool delayed_load_active = false; + while (addr <= block.end_addr) { auto instr_opt = exe_.read_word(addr); if (!instr_opt.has_value()) { @@ -1790,7 +1853,42 @@ std::string CodeGenerator::translate_basic_block( if (!is_cf) { if (cycle_per_insn) emit_pre_icache(addr, config_.indent); if (cycle_per_insn) emit_pre_timing(instr, config_.indent); - ss << translate_instruction(addr, instr) << "\n"; + std::string emitted = translate_instruction(addr, instr); + int load_dest = simple_load_dest(instr); + bool defer_load = false; + if (!delayed_load_active && load_dest > 0 && addr + 4u <= block.end_addr && + !extra_labels_.count(addr + 4u)) { + auto next = exe_.read_word(addr + 4u); + defer_load = next.has_value() && + !ControlFlowAnalyzer::is_control_flow(*next) && + reads_gpr(*next, static_cast(load_dest)); + } + if (defer_load) { + const std::string lhs = fmt::format("cpu->gpr[{}] =", load_dest); + const size_t pos = emitted.find(lhs); + if (pos != std::string::npos) { + const std::string temp = fmt::format("psx_ldd_{:08X}", addr); + emitted.replace(pos, lhs.size(), "uint32_t " + temp + " ="); + ss << config_.indent << "{ /* MIPS-I load-delay pair */\n"; + delayed_load_addr = addr; + delayed_load_dest = static_cast(load_dest); + delayed_load_active = true; + } + } + ss << emitted << "\n"; + if (delayed_load_active && addr == delayed_load_addr + 4u) { + if (writes_gpr(instr, delayed_load_dest)) { + ss << config_.indent << fmt::format( + "(void)psx_ldd_{:08X}; /* successor write wins */\n", + delayed_load_addr); + } else { + ss << config_.indent << fmt::format( + "cpu->gpr[{}] = psx_ldd_{:08X}; /* load-delay writeback */\n", + delayed_load_dest, delayed_load_addr); + } + ss << config_.indent << "}\n"; + delayed_load_active = false; + } emit_cosim_instr(addr, config_.indent); } else { // Control flow is handled at block exit diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index 05f9cd45..dc1df97a 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -931,6 +931,38 @@ void jump_table_producer_codegen_test() { "alias regression fixture reaches the table when ownership is absent"); } +void cfg_codegen_load_delay_test() { + constexpr uint32_t base = 0x80003590u; + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = base; + exe.header.initial_pc = base; + exe.header.file_size = 20u; + append_word(exe.code_data, 0x8F5A4C38u); // lw k0,0x4c38(k0) + append_word(exe.code_data, 0x03400825u); // move at,k0 (must see old k0) + append_word(exe.code_data, 0xAC3A4C38u); // sw k0,0x4c38(at) + append_word(exe.code_data, 0x03E00008u); // jr ra + append_word(exe.code_data, 0x00000000u); // delay-slot nop + + PSXRecomp::Function function{}; + function.start_addr = base; + function.end_addr = base + 20u; + function.size = 20u; + function.name = "cfg_load_delay"; + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto cfg = analyzer.analyze_function(function); + PSXRecomp::CodeGenerator generator(exe); + const std::string code = generator.generate_function(function, cfg).full_code; + + const size_t deferred = code.find("uint32_t psx_ldd_80003590 ="); + const size_t successor = code.find("cpu->gpr[1] = cpu->gpr[26]"); + const size_t writeback = code.find( + "cpu->gpr[26] = psx_ldd_80003590; /* load-delay writeback */"); + check(deferred != std::string::npos && successor != std::string::npos && + writeback != std::string::npos && deferred < successor && + successor < writeback, + "CFG codegen preserves MIPS-I dependent load-delay value semantics"); +} + } // namespace int main() { @@ -945,6 +977,7 @@ int main() { codegen_tests(); gte_codegen_classification_tests(); jump_table_producer_codegen_test(); + cfg_codegen_load_delay_test(); } catch (const std::exception& e) { fmt::print(stderr, "FAIL unexpected exception: {}\n", e.what()); ++failures; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index bda60ea5..4c03f095 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/mod_packages.h b/runtime/include/mod_packages.h index 7dcdaa64..950d18d2 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -146,6 +146,12 @@ struct ModOverlay { std::string expected_sha256; uint64_t size = 0; std::map when; + struct FeaturePredicate { + bool present = false; + std::string package_id; + std::string feature_id; + bool enabled = true; + } when_feature; int64_t order = 0; }; diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index a4f5a66a..b9bf0992 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -38,6 +38,10 @@ extern "C" { /* Called before a guest dispatch. Applies the complete main-EXE plan * transactionally on the first dispatch to the configured entry point. */ void mod_runtime_on_dispatch(uint32_t target); +/* A full-machine savestate restores guest RAM after the initial entry-point + * application. Reapply the already-validated main-EXE plan so the current + * enabled mod selection remains authoritative after the restore. */ +void mod_runtime_on_savestate_loaded(void); /* Invokes activation callbacks for the committed plan. Call after the final * launcher commit and before renderer/window initialization. */ void mod_runtime_activate_plugins(void); diff --git a/runtime/include/sio.h b/runtime/include/sio.h index d32f87cf..36d3bc57 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -132,6 +132,12 @@ void sio_set_pad_connected(int slot, int connected); * analog/hybrid behaviour is 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 a logical * pad 0 .. PSX_MAX_PLAYERS-1. */ uint16_t sio_get_pad_buttons(void); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 9848cdc1..1d5267e8 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -623,10 +623,30 @@ function(psxrecomp_add_runtime_target target) endif() if(PSXRT_BIOS_GENERATED_FULL_C AND PSXRT_BIOS_GENERATED_DISPATCH_C) - set(generated_sources + # Per-game BIOS pin: the pinned files REPLACE the matching stem's + # fork-global generated files only. Every other backend and the + # generated psx_bios_registry.c stay linked — replacing the whole set + # (the previous behaviour) dropped the registry and the other + # backends, producing undefined references to psx_bios_registry at + # link time. The registry's extern for the pinned stem is satisfied + # by the pinned dispatch.c, which carries the same backend descriptor. + get_filename_component(_psxrt_pin_name "${PSXRT_BIOS_GENERATED_DISPATCH_C}" NAME) + string(REPLACE "_dispatch.c" "" _psxrt_pin_stem "${_psxrt_pin_name}") + set(generated_sources "") + foreach(_psxrt_src IN LISTS PSXRECOMP_BIOS_GENERATED) + get_filename_component(_psxrt_src_name "${_psxrt_src}" NAME) + if(_psxrt_src_name STREQUAL "${_psxrt_pin_stem}_full.c" OR + _psxrt_src_name STREQUAL "${_psxrt_pin_stem}_dispatch.c") + # replaced by the game's pinned copy + else() + list(APPEND generated_sources "${_psxrt_src}") + endif() + endforeach() + list(APPEND generated_sources "${PSXRT_BIOS_GENERATED_FULL_C}" "${PSXRT_BIOS_GENERATED_DISPATCH_C}") set_source_files_properties(${generated_sources} PROPERTIES GENERATED TRUE) + message(STATUS "psxrecomp: BIOS stem ${_psxrt_pin_stem} pinned to game-local copies") else() set(generated_sources ${PSXRECOMP_BIOS_GENERATED}) endif() diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 5b78d7b5..8a9ff844 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -326,6 +326,10 @@ struct PlayerInput { int deadzone = 3277; /* raw SDL axis units, ~10% default */ 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[PSX_MAX_PLAYERS]; /* Offline SIO sample loop bound (from game.toml players; clamped). */ @@ -883,6 +887,7 @@ extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) } extern "C" void psx_frontend_on_savestate_loaded(void) { + mod_runtime_on_savestate_loaded(); s_disabled_frame_presented = false; s_force_present_after_load = true; smooth_60_reset(); @@ -3384,10 +3389,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) { @@ -3440,6 +3451,61 @@ static void open_player(PlayerInput& p, int self_slot) { 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; } } @@ -5043,6 +5109,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { psx_selfcheck_finish_frame( (g_offline_pad_count >= 3 && !sio_get_multitap()) ? 1 : 0); } + 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". */ @@ -10527,6 +10594,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/mod_packages.cpp b/runtime/src/mod_packages.cpp index cee91ee8..7c5f1068 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1003,6 +1003,28 @@ bool conditions_match(const ModPackage& package, const ModSelection& selection, return true; } +bool feature_predicate_matches( + const ModOverlay::FeaturePredicate& predicate, + const std::map& active_packages, + const std::map& selections) { + if (!predicate.present) return true; + bool enabled = false; + const auto package_it = active_packages.find(predicate.package_id); + if (package_it != active_packages.end()) { + const ModPackage& package = *package_it->second; + const ModFeature* feature = + find_feature(package, predicate.feature_id); + if (feature) { + const auto selection_it = selections.find(predicate.package_id); + const ModSelection blank; + const ModSelection& selection = + selection_it == selections.end() ? blank : selection_it->second; + enabled = is_feature_enabled(package, selection, *feature); + } + } + return enabled == predicate.enabled; +} + bool valid_option_value(const ModOption& option, const std::string& value); void read_conditions(const toml::value& value, const std::vector& options, @@ -2100,6 +2122,32 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, v, "order", (int64_t)declaration_index); read_conditions(v, out.options, overlay.feature_id, overlay.when, "overlay"); + if (v.contains("when_feature")) { + if (out.format_version < 4) + throw std::runtime_error( + "overlay when_feature requires format_version 4"); + const toml::value& predicate = + toml::find(v, "when_feature"); + for (const auto& [key, unused] : predicate.as_table()) { + (void)unused; + if (key != "package" && key != "feature" && + key != "enabled") + throw std::runtime_error( + "overlay when_feature has unknown field: " + + key); + } + overlay.when_feature.present = true; + overlay.when_feature.package_id = + toml::find(predicate, "package"); + overlay.when_feature.feature_id = + toml::find(predicate, "feature"); + overlay.when_feature.enabled = + toml::find(predicate, "enabled"); + if (!valid_id(overlay.when_feature.package_id) || + !valid_id(overlay.when_feature.feature_id)) + throw std::runtime_error( + "overlay when_feature references an invalid id"); + } out.overlays.push_back(std::move(overlay)); ++declaration_index; } @@ -2967,50 +3015,6 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, write.feature_id = patch->feature_id; result.writes.push_back(std::move(write)); } - std::vector overlays; - overlays.reserve(package->overlays.size()); - for (const ModOverlay& overlay : package->overlays) { - const ModFeature* feature = - find_feature(*package, overlay.feature_id); - if (!feature || - !is_feature_enabled(*package, selected, *feature) || - !conditions_match(*package, selected, - overlay.feature_id, overlay.when)) - continue; - overlays.push_back(&overlay); - } - std::stable_sort(overlays.begin(), overlays.end(), - [](const ModOverlay* a, const ModOverlay* b) { - return a->order < b->order; - }); - for (const ModOverlay* overlay : overlays) { - std::vector payload; - std::string payload_error; - if (!read_file(overlay->file, payload, &payload_error)) { - result.errors.push_back( - package->id + "/" + overlay->feature_id + ": " + - payload_error); - continue; - } - const std::string actual = fingerprint_text(std::string( - (const char*)payload.data(), payload.size())); - if (payload.size() != overlay->size || - actual != overlay->sha256) { - result.errors.push_back( - package->id + "/" + overlay->feature_id + - ": overlay payload changed after installation"); - continue; - } - ModResolution::Overlay resolved; - resolved.target = overlay->target; - resolved.location = overlay->location; - resolved.payload = std::move(payload); - resolved.payload_sha256 = overlay->sha256; - resolved.expected_sha256 = overlay->expected_sha256; - resolved.package_id = package->id; - resolved.feature_id = overlay->feature_id; - result.overlays.push_back(std::move(resolved)); - } } else { const std::string resolver_id = package->resolver.substr(8); const auto resolver = builtin_resolvers().find(resolver_id); @@ -3021,6 +3025,52 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.errors.empty()) result.errors.push_back(package->id + ": built-in resolver failed"); } + std::vector overlays; + overlays.reserve(package->overlays.size()); + for (const ModOverlay& overlay : package->overlays) { + const ModFeature* feature = + find_feature(*package, overlay.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + overlay.feature_id, overlay.when) || + !feature_predicate_matches( + overlay.when_feature, active, selections_)) + continue; + overlays.push_back(&overlay); + } + std::stable_sort(overlays.begin(), overlays.end(), + [](const ModOverlay* a, const ModOverlay* b) { + return a->order < b->order; + }); + for (const ModOverlay* overlay : overlays) { + std::vector payload; + std::string payload_error; + if (!read_file(overlay->file, payload, &payload_error)) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + ": " + + payload_error); + continue; + } + const std::string actual = fingerprint_text(std::string( + (const char*)payload.data(), payload.size())); + if (payload.size() != overlay->size || + actual != overlay->sha256) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + + ": overlay payload changed after installation"); + continue; + } + ModResolution::Overlay resolved; + resolved.target = overlay->target; + resolved.location = overlay->location; + resolved.payload = std::move(payload); + resolved.payload_sha256 = overlay->sha256; + resolved.expected_sha256 = overlay->expected_sha256; + resolved.package_id = package->id; + resolved.feature_id = overlay->feature_id; + result.overlays.push_back(std::move(resolved)); + } std::vector plugins; plugins.reserve(package->plugins.size()); for (const ModPlugin& plugin : package->plugins) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index de77f3ae..8fc1a6a1 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -153,6 +153,66 @@ void set_error(const std::string& error) { state().error = error; } +void apply_main_write(const ModResolution::Write& write) { + if (write.fields.empty()) { + for (size_t i = 0; i < write.replacement.size(); ++i) + psx_write_byte((uint32_t)write.location + (uint32_t)i, + write.replacement[i]); + dirty_ram_mark_executable_range( + (uint32_t)write.location & 0x1FFFFFFFu, + (uint32_t)write.replacement.size()); + return; + } + for (const ModResolution::Write::Field& field : write.fields) { + for (size_t i = 0; i < field.replacement.size(); ++i) + psx_write_byte( + (uint32_t)write.location + + (uint32_t)field.offset + (uint32_t)i, + field.replacement[i]); + dirty_ram_mark_executable_range( + ((uint32_t)write.location + + (uint32_t)field.offset) & 0x1FFFFFFFu, + (uint32_t)field.replacement.size()); + } +} + +bool restored_main_matches_plan(const RuntimeMods& s, uint32_t& failed_at) { + std::map desired; + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + if (write.fields.empty()) { + for (size_t i = 0; i < write.replacement.size(); ++i) + desired[(uint32_t)write.location + (uint32_t)i] = + write.replacement[i]; + } else { + for (const ModResolution::Write::Field& field : write.fields) { + for (size_t i = 0; i < field.replacement.size(); ++i) + desired[ + (uint32_t)write.location + + (uint32_t)field.offset + (uint32_t)i] = + field.replacement[i]; + } + } + } + + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + for (size_t i = 0; i < write.expected.size(); ++i) { + const uint32_t address = + (uint32_t)write.location + (uint32_t)i; + const uint8_t observed = psx_read_byte(address); + if (observed == write.expected[i]) continue; + const auto replacement = desired.find(address); + if (replacement != desired.end() && + observed == replacement->second) + continue; + failed_at = address; + return false; + } + } + return true; +} + bool sha256_file(const std::filesystem::path& path, std::string& out, std::string* error) { out.clear(); @@ -1102,26 +1162,7 @@ extern "C" void mod_runtime_on_dispatch(uint32_t target) { } for (const ModResolution::Write& write : s.plan.writes) { if (write.target != ModPatchTarget::MainExe) continue; - if (write.fields.empty()) { - for (size_t i = 0; i < write.replacement.size(); ++i) - psx_write_byte((uint32_t)write.location + (uint32_t)i, - write.replacement[i]); - dirty_ram_mark_executable_range( - (uint32_t)write.location & 0x1FFFFFFFu, - (uint32_t)write.replacement.size()); - } else { - for (const ModResolution::Write::Field& field : write.fields) { - for (size_t i = 0; i < field.replacement.size(); ++i) - psx_write_byte( - (uint32_t)write.location + - (uint32_t)field.offset + (uint32_t)i, - field.replacement[i]); - dirty_ram_mark_executable_range( - ((uint32_t)write.location + - (uint32_t)field.offset) & 0x1FFFFFFFu, - (uint32_t)field.replacement.size()); - } - } + apply_main_write(write); } s.main_applied = true; if (!s.plan.writes.empty()) @@ -1129,6 +1170,35 @@ extern "C" void mod_runtime_on_dispatch(uint32_t target) { s.plan.fingerprint.c_str()); } +extern "C" void mod_runtime_on_savestate_loaded(void) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return; + + if (!s.main_applied) { + uint32_t failed_at = 0; + if (!restored_main_matches_plan(s, failed_at)) { + std::fprintf(stderr, + "psxrecomp: mod plan %s rejected after savestate restore at " + "0x%08X (expected-byte guard failed)\n", + s.plan.fingerprint.c_str(), (unsigned)failed_at); + return; + } + } + + bool applied = false; + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + apply_main_write(write); + applied = true; + } + s.main_applied = true; + if (applied) + std::fprintf(stdout, + "psxrecomp: reapplied mod plan %s after savestate restore\n", + s.plan.fingerprint.c_str()); +} + extern "C" void mod_runtime_enable_disc_patches(void) { PSXRecompV4::state().disc_enabled = true; } diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 47a4660e..836f5f44 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -53,6 +53,16 @@ static uint8_t pad_stick[PSX_MAX_PLAYERS][4] = { [0 ... PSX_MAX_PLAYERS - 1] = { 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[PSX_MAX_PLAYERS][6] = { + [0 ... PSX_MAX_PLAYERS - 1] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, +}; +static uint8_t pad_rumble_small[PSX_MAX_PLAYERS]; +static uint8_t pad_rumble_large[PSX_MAX_PLAYERS]; + /* Analog-mode lock, per logical pad. 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 @@ -691,6 +701,9 @@ void sio_init(void) { pad_response_idx = 0; pad_current_cmd = 0; pad_active_logical = 0; + memset(pad_rumble_map, 0xFF, sizeof(pad_rumble_map)); + memset(pad_rumble_small, 0, sizeof(pad_rumble_small)); + memset(pad_rumble_large, 0, sizeof(pad_rumble_large)); for (int i = 0; i < PSX_MAX_PLAYERS; i++) { pad_buttons[i] = 0xFFFF; pad_analog[i] = 0; @@ -830,8 +843,13 @@ void sio_connect_pad(int slot) { void sio_set_pad_connected(int slot, int connected) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; - if (connected) pad_connected |= (uint8_t)(1u << slot); - else pad_connected &= (uint8_t)~(1u << slot); + if (connected) { + pad_connected |= (uint8_t)(1u << slot); + } else { + pad_connected &= (uint8_t)~(1u << slot); + pad_rumble_small[slot] = 0; + pad_rumble_large[slot] = 0; + } } void sio_set_pad_config_capable(int slot, int capable) { @@ -840,7 +858,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 < PSX_MAX_PLAYERS && (pad_connected & (1u << 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) { @@ -1196,6 +1228,10 @@ static void pad_process_byte(uint8_t tx_byte) { * (axis5_sio_controller.md D8). */ if (tx_byte == 0x45) pad_response[3] = pad_analog[lp] ? 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[lp], 6); pad_response_len = 8; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; @@ -1219,6 +1255,33 @@ static void pad_process_byte(uint8_t tx_byte) { pad_mtap_addr == 0x01 && pad_response_idx == 1 && selected_slot >= 0 && selected_slot <= 1) mtap_req_this[selected_slot] = (tx_byte == 0x01) ? 1 : 0; + /* DualShock rumble: index by the active logical pad (multitap-aware). */ + { + const int rs = (pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) + ? pad_active_logical + : selected_slot; + if (rs >= 0 && rs < PSX_MAX_PLAYERS) { + /* The six data bytes after 0x42's leading 0x00 occupy response + * indexes 2..7. Route each through the map negotiated by 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[rs][map_index]; + if (motor == 0x00) + pad_rumble_small[rs] = tx_byte; + else if (motor == 0x01) + pad_rumble_large[rs] = 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 echo-before-write. */ + 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[rs][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 @@ -2546,18 +2609,26 @@ static int sio_snap_emit_fsm_meta(PstW *w) { pst_w_u32(w, sio_irq_pending_byte_seq); } +/* DualShock rumble map/motors — appended after meta so pre-rumble snapshots + * remain loadable and netplay digests that stop at fsm_pace stay stable. */ +static int sio_snap_emit_rumble(PstW *w) { + return 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)); +} + static int sio_snap_emit_fsm(PstW *w) { return sio_snap_emit_fsm_pace(w) && sio_snap_emit_fsm_meta(w); } static int sio_snap_emit(PstW *w) { return sio_snap_emit_regs(w) && sio_snap_emit_pads(w) && - sio_snap_emit_mc(w) && sio_snap_emit_fsm(w); + sio_snap_emit_mc(w) && sio_snap_emit_fsm(w) && sio_snap_emit_rumble(w); } /* Cumulative section end offsets in the snapshot wire: * out[0]=regs, out[1]=pads, out[2]=memcard, out[3]=fsm_pace (netplay), - * out[4]=full including fsm_meta (== sio_snapshot_bytes()). */ + * out[4]=full including fsm_meta + rumble (== sio_snapshot_bytes()). */ void sio_snapshot_section_ends(uint32_t out[5]) { PstW w; pst_w_init(&w, NULL, 0); @@ -2570,6 +2641,7 @@ void sio_snapshot_section_ends(uint32_t out[5]) { (void)sio_snap_emit_fsm_pace(&w); out[3] = (uint32_t)w.written; (void)sio_snap_emit_fsm_meta(&w); + (void)sio_snap_emit_rumble(&w); out[4] = (uint32_t)w.written; } @@ -2652,6 +2724,24 @@ static int sio_snap_parse(PstR *r) { * restored byte_seq so the next TX does not stamp a peer-divergent * seq into sio_irq_pending_byte_seq (was forking fsm digests alone). */ sio_trace_seq = sio_irq_pending_byte_seq; + /* Pre-rumble 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)); + memset(pad_rumble_small, 0, sizeof(pad_rumble_small)); + memset(pad_rumble_large, 0, sizeof(pad_rumble_large)); + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { + pad_rumble_map[s][0] = 0x00; + pad_rumble_map[s][1] = 0x01; + } + 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; } @@ -2671,7 +2761,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_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 377b1865..b9e608d3 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -25,6 +25,39 @@ extern "C" void psx_write_byte(uint32_t address, uint8_t value) { ram[address & 0x1fffffu] = value; } +extern "C" uint16_t psx_read_half(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint16_t)(ram[offset] | ((uint16_t)ram[offset + 1] << 8)); +} + +extern "C" void psx_write_half(uint32_t address, uint16_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); +} + +extern "C" uint32_t psx_read_word(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint32_t)ram[offset] | + ((uint32_t)ram[offset + 1] << 8) | + ((uint32_t)ram[offset + 2] << 16) | + ((uint32_t)ram[offset + 3] << 24); +} + +extern "C" void psx_write_word(uint32_t address, uint32_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); + ram[offset + 2] = (uint8_t)(value >> 16); + ram[offset + 3] = (uint8_t)(value >> 24); +} + +extern "C" uint32_t psx_mod_memory_alloc(uint32_t, uint32_t) { return 0; } +extern "C" uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t, uint32_t) { + return 0; +} +extern "C" int psx_ws_x_margin(void) { return 0; } + extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} extern "C" int fntrace_is_game_started(void) { return 1; } @@ -292,6 +325,21 @@ int main() { "adjacent sparse fields must compose while preserving guard-only " "bytes"); + /* A full-machine savestate replaces main RAM after the entry-point plan + * has already applied. Loading a stock checkpoint must not silently turn + * the currently enabled main-EXE features back off. */ + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; + ram[0x1200] = 2; ram[0x1201] = 0; + ram[0x1202] = 1; ram[0x1203] = 0x32; + mod_runtime_on_savestate_loaded(); + check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4 && + ram[0x1100] == 42 && ram[0x1102] == 43 && + ram[0x1200] == 1 && ram[0x1201] == 0x42 && + ram[0x1202] == 0 && ram[0x1203] == 0x32, + "savestate restore must reapply the complete enabled main plan"); + std::array sector{}; sector[10] = 0xaa; mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); @@ -374,6 +422,26 @@ int main() { "a failed sparse guard-only byte must leave the complete main plan " "untouched"); + /* Loading can be requested while the boot executable is still running, + * before the configured game entry point has dispatched. The restored + * checkpoint itself supplies the bytes used to validate and apply the + * plan in that case. */ + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; + ram[0x1200] = 2; ram[0x1201] = 0; + ram[0x1202] = 1; ram[0x1203] = 0x32; + mod_runtime_on_savestate_loaded(); + check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4 && + ram[0x1100] == 42 && ram[0x1102] == 43 && + ram[0x1200] == 1 && ram[0x1201] == 0x42 && + ram[0x1202] == 0 && ram[0x1203] == 0x32, + "pre-entry savestate restore must validate and apply the main plan"); + mod_runtime_enable_disc_patches(); std::array bad_sparse_disc{}; bad_sparse_disc[15] = 2; 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; +}