Merge origin/master into integrate/game-codegen-hooks-into-rollback

Bring upstream hotfixes (DualShock rumble, load-delay, BIOS pin, mods)
into the integrate branch. Resolve SIO conflicts by keeping multitap /
PSX_MAX_PLAYERS paths and appending rumble state after snapshot meta.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alex Vanderveen
2026-08-07 20:43:06 -04:00
14 changed files with 792 additions and 83 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
+99 -1
View File
@@ -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<int>(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<uint32_t>(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<uint32_t>(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
@@ -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;
+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
@@ -146,6 +146,12 @@ struct ModOverlay {
std::string expected_sha256;
uint64_t size = 0;
std::map<std::string, std::string> when;
struct FeaturePredicate {
bool present = false;
std::string package_id;
std::string feature_id;
bool enabled = true;
} when_feature;
int64_t order = 0;
};
+4
View File
@@ -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);
+6
View File
@@ -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);
+21 -1
View File
@@ -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()
+71
View File
@@ -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
+94 -44
View File
@@ -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<std::string, const ModPackage*>& active_packages,
const std::map<std::string, ModSelection>& 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<ModOption>& 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<std::string>(predicate, "package");
overlay.when_feature.feature_id =
toml::find<std::string>(predicate, "feature");
overlay.when_feature.enabled =
toml::find<bool>(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<const ModOverlay*> 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<uint8_t> 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<const ModOverlay*> 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<uint8_t> 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<const ModPlugin*> plugins;
plugins.reserve(package->plugins.size());
for (const ModPlugin& plugin : package->plugins) {
+90 -20
View File
@@ -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<uint32_t, uint8_t> 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;
}
+101 -6
View File
@@ -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);
}
+68
View File
@@ -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<uint8_t, 2352> 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<uint8_t, 2352> bad_sparse_disc{};
bad_sparse_disc[15] = 2;
+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;
}