From 31c16617d8060474f3fd1ad6a6dc98fc2e40117b Mon Sep 17 00:00:00 2001 From: Nicktendonick <140297302+Nicktendonick@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:58:54 -0400 Subject: [PATCH 1/2] Add fast-forward speed slider --- src/common/backends/imgui/launcher_imgui.cpp | 12 ++++++++++++ src/common/launcher_model.c | 11 +++++++++++ src/common/launcher_model.h | 2 ++ src/recomp_launcher.h | 4 ++++ 4 files changed, 29 insertions(+) diff --git a/src/common/backends/imgui/launcher_imgui.cpp b/src/common/backends/imgui/launcher_imgui.cpp index 493e493..6a39979 100644 --- a/src/common/backends/imgui/launcher_imgui.cpp +++ b/src/common/backends/imgui/launcher_imgui.cpp @@ -3260,6 +3260,18 @@ void draw_assist_tools(LauncherModel* m, const LauncherTheme& th) { "features. The game window should disclose when it is active."); ImGui::PopTextWrapPos(); ImGui::PopStyleColor(); + ImGui::Dummy(ImVec2(0, px(8))); + ImGui::PushStyleColor(ImGuiCol_Text, col(th.accent2)); + ImGui::TextUnformatted("FAST-FORWARD SPEED"); + ImGui::PopStyleColor(); + ImGui::TextColored(col(th.text_muted), + "Target emulation speed while Fast-forward is held or latched."); + int speed = m->s.assist_fast_forward_multiplier; + ImGui::SetNextItemWidth(px(360)); + if (ImGui::SliderInt("##assist_fast_forward_speed", &speed, + m->assist_fast_forward_min, + m->assist_fast_forward_max, "%dx")) + m->s.assist_fast_forward_multiplier = speed; if (m->settings_bindings) { ImGui::Dummy(ImVec2(0, px(8))); draw_assist_binding_editor(m, th, "assist_binds", 0, true); diff --git a/src/common/launcher_model.c b/src/common/launcher_model.c index a8346d0..f858c5b 100644 --- a/src/common/launcher_model.c +++ b/src/common/launcher_model.c @@ -210,6 +210,12 @@ void launcher_model_init(LauncherModel* m, clampi(game->assist_binding_count, 0, RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS); m->credits_text = game->credits_text; + m->assist_fast_forward_min = game->assist_fast_forward_min > 0 + ? game->assist_fast_forward_min : 2; + m->assist_fast_forward_max = game->assist_fast_forward_max >= + m->assist_fast_forward_min + ? game->assist_fast_forward_max + : m->assist_fast_forward_min; m->tpak_slots = clampi(game->tpak_slots, 0, RECOMP_LAUNCHER_MAX_TPAKS); m->tpak_inspect_cb = game->tpak_inspect; m->audio_device_labels = game->audio_device_labels; @@ -239,6 +245,11 @@ void launcher_model_init(LauncherModel* m, } if (io) m->s = *io; + m->s.assist_fast_forward_multiplier = clampi( + m->s.assist_fast_forward_multiplier > 0 + ? m->s.assist_fast_forward_multiplier + : m->assist_fast_forward_min, + m->assist_fast_forward_min, m->assist_fast_forward_max); if (m->has_sharp_filter) { m->s.linear_filter = m->s.linear_filter ? 1 : 0; m->s.sharp_filter = m->s.sharp_filter ? 1 : 0; diff --git a/src/common/launcher_model.h b/src/common/launcher_model.h index bb275e3..d202513 100644 --- a/src/common/launcher_model.h +++ b/src/common/launcher_model.h @@ -299,6 +299,8 @@ typedef struct { const char* const* assist_binding_labels; int assist_binding_count; const char* credits_text; + int assist_fast_forward_min; + int assist_fast_forward_max; // Number of players the GAME actually supports. Mega Man X is 1-player, so // the launcher must not show a dead Player 2 row. Games that support 2 // report 2 and the second row appears. Driven by data, never hardcoded. diff --git a/src/recomp_launcher.h b/src/recomp_launcher.h index 791c1e0..8b3d89a 100644 --- a/src/recomp_launcher.h +++ b/src/recomp_launcher.h @@ -595,6 +595,8 @@ struct RecompLauncherCSettings { [RECOMP_LAUNCHER_MAX_BINDINGS]; int assist_key_bind[RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS]; int assist_pad_bind[RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS]; + // Target emulation multiplier while the host's fast-forward action is active. + int assist_fast_forward_multiplier; }; // ---- host verification/inspection results (filled by the callbacks below) ---- @@ -1033,6 +1035,8 @@ typedef struct RecompLauncherCGameInfo { int settings_bindings; const char* const* assist_binding_labels; int assist_binding_count; + int assist_fast_forward_min; + int assist_fast_forward_max; } RecompLauncherCGameInfo; /* recomp_launcher_run_window return codes */ From 8d9003e317b0d798b4ed9f5cc6cd283c356eb93f Mon Sep 17 00:00:00 2001 From: Nicktendonick <140297302+Nicktendonick@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:07:36 -0400 Subject: [PATCH 2/2] Add IPS IPS32 and BPS patch support --- CMakeLists.txt | 14 ++ src/common/backends/imgui/launcher_imgui.cpp | 61 +++++- src/common/ips_patch.c | 190 ++++++++++++++++++- src/common/ips_patch.h | 25 ++- src/common/launcher_model.c | 187 ++++++++++++++++++ src/common/launcher_model.h | 20 ++ src/common/launcher_ng_capi.c | 2 +- src/recomp_launcher.h | 19 ++ tests/ips_patch_test.c | 153 +++++++++++++++ tools/rom_patch_tool.c | 123 ++++++++++++ 10 files changed, 780 insertions(+), 14 deletions(-) create mode 100644 tests/ips_patch_test.c create mode 100644 tools/rom_patch_tool.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 743ddb6..4f14f2e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,20 @@ recomp_target_launcher_ui(recomp-ui-launcher CONSOLE all) include(CTest) if(BUILD_TESTING) + add_executable(recomp-ui-ips-patch-test + tests/ips_patch_test.c + src/common/ips_patch.c + src/common/crc32.c) + target_include_directories(recomp-ui-ips-patch-test PRIVATE src/common) + add_test(NAME recomp-ui-ips-patch COMMAND $) + + add_executable(recomp-ui-rom-patch-tool + tools/rom_patch_tool.c + src/common/ips_patch.c + src/common/crc32.c + src/common/sha1.c) + target_include_directories(recomp-ui-rom-patch-tool PRIVATE src/common) + add_test( NAME recomp-ui-asset-manifests COMMAND ${CMAKE_COMMAND} diff --git a/src/common/backends/imgui/launcher_imgui.cpp b/src/common/backends/imgui/launcher_imgui.cpp index 6a39979..ecb88db 100644 --- a/src/common/backends/imgui/launcher_imgui.cpp +++ b/src/common/backends/imgui/launcher_imgui.cpp @@ -5669,7 +5669,7 @@ static void mod_note_error(LauncherModel* m) { static bool mod_commit_launch(LauncherModel* m) { if (!m || !m->mods || !m->mods->commit || - m->mods->commit(m->mods->ctx, launcher_model_rom_path(m))) { + m->mods->commit(m->mods->ctx, launcher_model_effective_rom_path(m))) { return true; } mod_note_error(m); @@ -6500,9 +6500,58 @@ static void draw_mod_features(LauncherModel* m, const LauncherTheme& th) { ImGui::EndChild(); } +static void draw_rom_patch(LauncherModel* m, const LauncherTheme& th) { + if (!m || !m->rom_patch_supported) return; + + if (ImGui::BeginChild("##rom_patch", ImVec2(0, px(170)), + ImGuiChildFlags_Borders)) { + eyebrow("ROM PATCH"); + bool enabled = m->s.rom_patch_enabled != 0; + if (ImGui::Checkbox("Enable ROM patch", &enabled)) + launcher_model_toggle_rom_patch(m); + + const float clear_w = px(70); + const float browse_w = px(95); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - browse_w - clear_w - + px(8)); + if (ImGui::Button("Browse...", ImVec2(browse_w, px(28)))) { + static const char* patterns[] = { "*.ips", "*.ips32", "*.bps" }; + if (launcher_pick_file("Select ROM patch", patterns, 3, + "ROM patches", g_pick_buf, + sizeof(g_pick_buf))) { + launcher_model_set_rom_patch(m, g_pick_buf); + } + } + ImGui::SameLine(0, px(8)); + ImGui::BeginDisabled(!m->s.rom_patch_path[0]); + if (ImGui::Button("Clear", ImVec2(clear_w, px(28)))) + launcher_model_clear_rom_patch(m); + ImGui::EndDisabled(); + + const char* path = m->s.rom_patch_path[0] + ? m->s.rom_patch_path : "No patch selected"; + char elided[384]; + elide_left(path, ImGui::GetContentRegionAvail().x, + elided, sizeof(elided)); + ImGui::TextColored(col(th.text_muted), "%s", elided); + if (ImGui::IsItemHovered() && m->s.rom_patch_path[0]) + ImGui::SetTooltip("%s", m->s.rom_patch_path); + if (m->rom_patch_note && m->rom_patch_note[0]) + ImGui::TextWrapped("%s", m->rom_patch_note); + if (m->rom_patch_status[0]) + ImGui::TextColored(col(th.accent), "%s", m->rom_patch_status); + } + ImGui::EndChild(); +} + void draw_mods(LauncherModel* m, const LauncherTheme& th) { const auto* mods = m ? m->mods : nullptr; - if (!mods) return; + if (!m || (!mods && !m->rom_patch_supported)) return; + if (m->rom_patch_supported) { + draw_rom_patch(m, th); + if (!mods) return; + ImGui::Dummy(ImVec2(0, px(8))); + } const bool feature_provider = mods->feature_count && mods->feature_get && mods->feature_option_get && mods->feature_enable && @@ -6703,8 +6752,10 @@ void draw_footer(LauncherModel* m, const LauncherTheme& th, float footer_h) { /* Prefer mismatch prompt over launch even if can_play races true. */ if (bios_block) launcher_model_bios_play_prompt(m); - else if (mod_commit_launch(m)) + else if (launcher_model_prepare_rom_patch(m) && mod_commit_launch(m)) m->action = LNG_ACTION_LAUNCH; + else if (m->rom_patch_supported && m->s.rom_patch_enabled) + launcher_model_set_view(m, LNG_VIEW_MODS); } else if (!play_enabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { const char* noun = m->rom_noun ? m->rom_noun : "ROM"; if (m->has_bios && !m->setup_bios_ok) { @@ -7670,7 +7721,7 @@ void draw_ui(LauncherModel* m, const LauncherTheme& th, int logical_w, int logic const float right = ImGui::GetWindowContentRegionMax().x; const float y = hdr_top + px(6.0f); if (m->view == LNG_VIEW_DASHBOARD) { - const int count = 1 + (m->mods ? 1 : 0) + + const int count = 1 + ((m->mods || m->rom_patch_supported) ? 1 : 0) + (m->has_assist_tools ? 1 : 0) + ((m->credits_text && m->credits_text[0]) ? 1 : 0); const float w = px(110.0f); @@ -7678,7 +7729,7 @@ void draw_ui(LauncherModel* m, const LauncherTheme& th, int logical_w, int logic ImGui::SetCursorPos(ImVec2(right - total, y)); if (ImGui::Button("Settings", ImVec2(w, px(34)))) launcher_model_set_view(m, LNG_VIEW_SETTINGS); - if (m->mods) { + if (m->mods || m->rom_patch_supported) { ImGui::SameLine(0, gap); if (ImGui::Button("Mods", ImVec2(w, px(34)))) launcher_model_set_view(m, LNG_VIEW_MODS); diff --git a/src/common/ips_patch.c b/src/common/ips_patch.c index 37184da..e25cb6c 100644 --- a/src/common/ips_patch.c +++ b/src/common/ips_patch.c @@ -1,7 +1,9 @@ // ips_patch.c — classic IPS patch format implementation. See ips_patch.h. #include "ips_patch.h" +#include "crc32.h" +#include #include #include @@ -12,7 +14,10 @@ static bool ips_grow(uint8_t** buf, size_t* cap, size_t need) { if (need <= *cap) return true; size_t ncap = *cap ? *cap : 4096; - while (ncap < need) ncap *= 2; + while (ncap < need) { + if (ncap > SIZE_MAX / 2) { ncap = need; break; } + ncap *= 2; + } uint8_t* nb = (uint8_t*)realloc(*buf, ncap); if (!nb) return false; *buf = nb; @@ -20,10 +25,31 @@ static bool ips_grow(uint8_t** buf, size_t* cap, size_t need) { return true; } +IpsPatchFormat ips_detect_format(const uint8_t* patch, size_t patch_len) { + if (!patch || patch_len < 4) return IPS_PATCH_UNKNOWN; + if (patch_len >= 5 && memcmp(patch, "PATCH", 5) == 0) return IPS_PATCH_CLASSIC; + if (patch_len >= 5 && memcmp(patch, "IPS32", 5) == 0) return IPS_PATCH_IPS32; + if (memcmp(patch, "BPS1", 4) == 0) return IPS_PATCH_BPS; + return IPS_PATCH_UNKNOWN; +} + +static size_t ips_read_be(const uint8_t* p, size_t width) { + size_t value = 0; + for (size_t i = 0; i < width; ++i) value = (value << 8) | p[i]; + return value; +} + bool ips_apply(const uint8_t* src, size_t src_len, const uint8_t* patch, size_t patch_len, uint8_t** out_data, size_t* out_len) { - if (!patch || patch_len < 8 || memcmp(patch, "PATCH", 5) != 0) return false; + if (!out_data || !out_len || (src_len && !src)) return false; + const IpsPatchFormat format = ips_detect_format(patch, patch_len); + if (format != IPS_PATCH_CLASSIC && format != IPS_PATCH_IPS32) + return false; + const size_t offset_width = format == IPS_PATCH_IPS32 ? 4 : 3; + const size_t footer_width = format == IPS_PATCH_IPS32 ? 4 : 3; + const char* footer = format == IPS_PATCH_IPS32 ? "EEOF" : "EOF"; + if (patch_len < 5 + footer_width) return false; size_t cap = src_len ? src_len : 4096; uint8_t* out = (uint8_t*)malloc(cap); @@ -32,16 +58,17 @@ bool ips_apply(const uint8_t* src, size_t src_len, if (src && src_len) memcpy(out, src, src_len); size_t i = 5; - while (i + 3 <= patch_len) { - if (memcmp(patch + i, "EOF", 3) == 0) { + while (i + footer_width <= patch_len) { + if (memcmp(patch + i, footer, footer_width) == 0) { + i += footer_width; *out_data = out; *out_len = len; return true; } - // 24-bit big-endian offset. - size_t off = ((size_t)patch[i] << 16) | ((size_t)patch[i + 1] << 8) | patch[i + 2]; - i += 3; + if (i + offset_width > patch_len) { free(out); return false; } + const size_t off = ips_read_be(patch + i, offset_width); + i += offset_width; if (i + 2 > patch_len) { free(out); return false; } // 16-bit big-endian length. len==0 introduces an RLE record instead. @@ -53,12 +80,14 @@ bool ips_apply(const uint8_t* src, size_t src_len, size_t run = ((size_t)patch[i] << 8) | patch[i + 1]; i += 2; uint8_t val = patch[i++]; + if (run > SIZE_MAX - off) { free(out); return false; } size_t need = off + run; if (!ips_grow(&out, &cap, need)) { free(out); return false; } if (need > len) { memset(out + len, 0, need - len); len = need; } memset(out + off, val, run); } else { if (i + rec_len > patch_len) { free(out); return false; } + if (rec_len > SIZE_MAX - off) { free(out); return false; } size_t need = off + rec_len; if (!ips_grow(&out, &cap, need)) { free(out); return false; } if (need > len) { memset(out + len, 0, need - len); len = need; } @@ -73,3 +102,150 @@ bool ips_apply(const uint8_t* src, size_t src_len, *out_len = len; return true; } + +static uint32_t bps_read_le32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static bool bps_read_number(const uint8_t* patch, size_t limit, + size_t* cursor, uint64_t* out) { + uint64_t value = 0; + uint64_t shift = 1; + for (;;) { + if (*cursor >= limit) return false; + const uint8_t x = patch[(*cursor)++]; + const uint64_t digit = x & 0x7fu; + if (digit && shift > (UINT64_MAX - value) / digit) return false; + value += digit * shift; + if (x & 0x80u) { + *out = value; + return true; + } + if (shift > UINT64_MAX / 128u) return false; + shift <<= 7; + if (value > UINT64_MAX - shift) return false; + value += shift; + } +} + +static bool bps_move_relative(int64_t* relative, uint64_t encoded) { + const uint64_t magnitude = encoded >> 1; + if (magnitude > (uint64_t)INT64_MAX) return false; + const int64_t amount = (int64_t)magnitude; + if (encoded & 1u) { + if (*relative < INT64_MIN + amount) return false; + *relative -= amount; + } else { + if (*relative > INT64_MAX - amount) return false; + *relative += amount; + } + return true; +} + +bool bps_apply(const uint8_t* src, size_t src_len, + const uint8_t* patch, size_t patch_len, + uint8_t** out_data, size_t* out_len) { + if (!out_data || !out_len || (src_len && !src) || !patch || + patch_len < 4 + 3 + 12 || memcmp(patch, "BPS1", 4) != 0) + return false; + + const size_t command_end = patch_len - 12; + const uint32_t expected_source_crc = bps_read_le32(patch + command_end); + const uint32_t expected_target_crc = bps_read_le32(patch + command_end + 4); + const uint32_t expected_patch_crc = bps_read_le32(patch + command_end + 8); + if (recompui_crc32_compute(patch, patch_len - 4) != expected_patch_crc || + recompui_crc32_compute(src, src_len) != expected_source_crc) + return false; + + size_t cursor = 4; + uint64_t source_size = 0, target_size = 0, metadata_size = 0; + if (!bps_read_number(patch, command_end, &cursor, &source_size) || + !bps_read_number(patch, command_end, &cursor, &target_size) || + !bps_read_number(patch, command_end, &cursor, &metadata_size) || + source_size != src_len || target_size > SIZE_MAX || + metadata_size > command_end - cursor) + return false; + cursor += (size_t)metadata_size; + + const size_t target_len = (size_t)target_size; + uint8_t* target = (uint8_t*)malloc(target_len ? target_len : 1); + if (!target) return false; + + size_t output = 0; + int64_t source_relative = 0; + int64_t target_relative = 0; + while (output < target_len) { + uint64_t command = 0; + if (!bps_read_number(patch, command_end, &cursor, &command)) { + free(target); + return false; + } + const unsigned action = (unsigned)(command & 3u); + const uint64_t encoded_length = (command >> 2) + 1; + if (encoded_length > target_len - output) { + free(target); + return false; + } + const size_t length = (size_t)encoded_length; + + if (action == 0) { + if (output > src_len || length > src_len - output) { + free(target); + return false; + } + memcpy(target + output, src + output, length); + output += length; + } else if (action == 1) { + if (cursor > command_end || length > command_end - cursor) { + free(target); + return false; + } + memcpy(target + output, patch + cursor, length); + cursor += length; + output += length; + } else { + uint64_t delta = 0; + int64_t* relative = action == 2 ? &source_relative + : &target_relative; + if (!bps_read_number(patch, command_end, &cursor, &delta) || + !bps_move_relative(relative, delta)) { + free(target); + return false; + } + for (size_t i = 0; i < length; ++i) { + if (*relative < 0) { + free(target); + return false; + } + const uint64_t index = (uint64_t)*relative; + if ((action == 2 && index >= src_len) || + (action == 3 && index >= output)) { + free(target); + return false; + } + target[output++] = action == 2 ? src[(size_t)index] + : target[(size_t)index]; + ++*relative; + } + } + } + + if (cursor != command_end || + recompui_crc32_compute(target, target_len) != expected_target_crc) { + free(target); + return false; + } + *out_data = target; + *out_len = target_len; + return true; +} + +bool rom_patch_apply(const uint8_t* src, size_t src_len, + const uint8_t* patch, size_t patch_len, + uint8_t** out_data, size_t* out_len) { + const IpsPatchFormat format = ips_detect_format(patch, patch_len); + if (format == IPS_PATCH_BPS) + return bps_apply(src, src_len, patch, patch_len, out_data, out_len); + return ips_apply(src, src_len, patch, patch_len, out_data, out_len); +} diff --git a/src/common/ips_patch.h b/src/common/ips_patch.h index cec47fd..4052160 100644 --- a/src/common/ips_patch.h +++ b/src/common/ips_patch.h @@ -17,6 +17,15 @@ extern "C" { #endif +typedef enum IpsPatchFormat { + IPS_PATCH_UNKNOWN = 0, + IPS_PATCH_CLASSIC, + IPS_PATCH_IPS32, + IPS_PATCH_BPS, +} IpsPatchFormat; + +IpsPatchFormat ips_detect_format(const uint8_t* patch, size_t patch_len); + // Apply an IPS patch onto `src` (src_len bytes; may be NULL/0 for an empty // source — a patch may still build a whole file from scratch via its // records). On success, mallocs *out_data (*out_len bytes) — the CALLER @@ -25,12 +34,26 @@ extern "C" { // record that runs past the patch buffer). // // Supports both normal (literal-data) and RLE (run-length, len-field == 0) -// records. A patch with no trailing "EOF" marker but otherwise well-formed +// records. Classic IPS uses PATCH/EOF and 24-bit offsets; IPS32 uses +// IPS32/EEOF and 32-bit offsets, allowing changes beyond 16 MiB. A patch +// with no trailing end marker but otherwise well-formed // records still succeeds (matches the reference implementation). bool ips_apply(const uint8_t* src, size_t src_len, const uint8_t* patch, size_t patch_len, uint8_t** out_data, size_t* out_len); +// Apply a BPS1 patch and verify all three checksums embedded in it. A source +// size/checksum mismatch, malformed command stream, or target/patch checksum +// mismatch fails without publishing an output buffer. +bool bps_apply(const uint8_t* src, size_t src_len, + const uint8_t* patch, size_t patch_len, + uint8_t** out_data, size_t* out_len); + +// Auto-detect and apply classic IPS, IPS32, or BPS1. +bool rom_patch_apply(const uint8_t* src, size_t src_len, + const uint8_t* patch, size_t patch_len, + uint8_t** out_data, size_t* out_len); + #ifdef __cplusplus } #endif diff --git a/src/common/launcher_model.c b/src/common/launcher_model.c index f858c5b..336cf01 100644 --- a/src/common/launcher_model.c +++ b/src/common/launcher_model.c @@ -229,6 +229,10 @@ void launcher_model_init(LauncherModel* m, m->has_affine_filter = game->has_affine_filter != 0; m->netplay_supported = game->netplay_supported != 0 && game->netplay != NULL; m->netplay = game->netplay; + m->rom_patch_supported = game->rom_patch_supported != 0; + m->rom_patch_note = game->rom_patch_note; + m->rom_patch_cache_dir = game->rom_patch_cache_dir; + m->rom_patch_required_sha1 = game->rom_patch_required_sha1; #if RECOMP_UI_ENABLE_MODS m->mods = game->mods; #else @@ -245,6 +249,16 @@ void launcher_model_init(LauncherModel* m, } if (io) m->s = *io; + if (!m->rom_patch_supported) { + m->s.rom_patch_enabled = 0; + m->s.rom_patch_path[0] = '\0'; + } else { + m->s.rom_patch_enabled = + m->s.rom_patch_enabled && m->s.rom_patch_path[0] ? 1 : 0; + } + m->s.rom_patch_source_path[0] = '\0'; + m->s.rom_patch_sha1[0] = '\0'; + m->s.rom_patch_crc32[0] = '\0'; m->s.assist_fast_forward_multiplier = clampi( m->s.assist_fast_forward_multiplier > 0 ? m->s.assist_fast_forward_multiplier @@ -622,6 +636,9 @@ void launcher_model_commit(const LauncherModel* m, RecompLauncherCSettings* io) void launcher_model_set_rom(LauncherModel* m, const char* path) { m->rom_present = path && path[0] != '\0'; safe_copy(m->rom_full, sizeof(m->rom_full), m->rom_present ? path : ""); + m->rom_sha1_hex[0] = '\0'; + m->rom_patch_prepared_path[0] = '\0'; + m->rom_patch_prepared_sha1[0] = '\0'; // Display just the basename (handles both / and \ separators). const char* base = m->rom_full; @@ -678,6 +695,8 @@ void launcher_model_set_rom(LauncherModel* m, const char* path) { uint8_t s1[20]; char s1hex[41]; recompui_sha1_compute(body, blen, s1); recompui_sha1_hex(s1, s1hex); + safe_copy(m->rom_sha1_hex, + sizeof(m->rom_sha1_hex), s1hex); for (size_t k = 0; k < m->num_known_sha1; ++k) { const char* want = m->known_sha1_hex[k]; if (!want) continue; @@ -756,6 +775,12 @@ const char* launcher_model_rom_path(const LauncherModel* m) { return m->rom_full; } +const char* launcher_model_effective_rom_path(const LauncherModel* m) { + if (m && m->rom_patch_prepared_path[0]) + return m->rom_patch_prepared_path; + return m ? m->rom_full : ""; +} + bool launcher_model_rom_verified(const LauncherModel* m) { if (!m->rom_present) return false; const int has_crc = m->has_expected_crc; @@ -1623,6 +1648,8 @@ bool launcher_model_can_finish_setup(const LauncherModel* m) { bool launcher_model_can_launch(const LauncherModel* m) { if (!m) return false; if (!m->rom_present || strcmp(m->rom_size, "--") == 0) return false; + if (m->rom_patch_supported && m->s.rom_patch_enabled && + !m->s.rom_patch_path[0]) return false; if (m->profile && m->profile->verify.mode == 1) { /* Disc systems: reject none/bad; warn (2) and ok (1) are playable. */ if (m->verify.verdict == 0 || m->verify.verdict == 3) return false; @@ -2466,6 +2493,166 @@ static bool lm_read_whole_file(const char* path, uint8_t** out_data, size_t* out return true; } +void launcher_model_set_rom_patch(LauncherModel* m, const char* path) { + if (!m || !m->rom_patch_supported) return; + safe_copy(m->s.rom_patch_path, sizeof(m->s.rom_patch_path), + path ? path : ""); + m->s.rom_patch_enabled = m->s.rom_patch_path[0] ? 1 : 0; + m->rom_patch_prepared_path[0] = '\0'; + m->rom_patch_prepared_sha1[0] = '\0'; + m->rom_patch_status[0] = '\0'; +} + +void launcher_model_clear_rom_patch(LauncherModel* m) { + if (!m) return; + m->s.rom_patch_enabled = 0; + m->s.rom_patch_path[0] = '\0'; + m->s.rom_patch_source_path[0] = '\0'; + m->s.rom_patch_sha1[0] = '\0'; + m->s.rom_patch_crc32[0] = '\0'; + m->rom_patch_prepared_path[0] = '\0'; + m->rom_patch_prepared_sha1[0] = '\0'; + m->rom_patch_status[0] = '\0'; +} + +void launcher_model_toggle_rom_patch(LauncherModel* m) { + if (!m || !m->rom_patch_supported) return; + if (!m->s.rom_patch_path[0]) { + m->s.rom_patch_enabled = 0; + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "Select a patch file first."); + return; + } + m->s.rom_patch_enabled = m->s.rom_patch_enabled ? 0 : 1; + m->rom_patch_prepared_path[0] = '\0'; + m->rom_patch_prepared_sha1[0] = '\0'; +} + +static const char* lm_path_extension(const char* path) { + const char* base = path; + const char* dot = NULL; + for (const char* p = path; p && *p; ++p) { + if (*p == '/' || *p == '\\') { base = p + 1; dot = NULL; } + else if (*p == '.') dot = p; + } + return dot && dot >= base && strlen(dot) <= 12 ? dot : ".rom"; +} + +bool launcher_model_prepare_rom_patch(LauncherModel* m) { + if (!m || !m->rom_patch_supported || !m->s.rom_patch_enabled) { + if (m) { + m->rom_patch_prepared_path[0] = '\0'; + m->rom_patch_prepared_sha1[0] = '\0'; + safe_copy(m->s.rom_patch_source_path, + sizeof(m->s.rom_patch_source_path), m->rom_full); + m->s.rom_patch_sha1[0] = '\0'; + m->s.rom_patch_crc32[0] = '\0'; + } + return true; + } + if (!launcher_model_rom_verified(m) || !m->rom_sha1_hex[0]) { + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "The source ROM must verify before applying a patch."); + return false; + } + if (!m->s.rom_patch_path[0] || !m->rom_patch_cache_dir || + !m->rom_patch_cache_dir[0]) { + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "Patch file or cache directory is unavailable."); + return false; + } + + uint8_t* source = NULL; size_t source_len = 0; + uint8_t* patch = NULL; size_t patch_len = 0; + if (!lm_read_whole_file(m->rom_full, &source, &source_len) || + !lm_read_whole_file(m->s.rom_patch_path, &patch, &patch_len)) { + free(source); free(patch); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "Could not read the source ROM or patch file."); + return false; + } + + uint8_t* output = NULL; size_t output_len = 0; + const bool applied = rom_patch_apply(source, source_len, patch, patch_len, + &output, &output_len); + free(source); + if (!applied) { + free(patch); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "Patch rejected: wrong source, invalid data, or checksum failure."); + return false; + } + + uint8_t patch_sha[20], output_sha[20]; + char patch_hex[41], output_hex[41]; + recompui_sha1_compute(patch, patch_len, patch_sha); + recompui_sha1_hex(patch_sha, patch_hex); + recompui_sha1_compute(output, output_len, output_sha); + recompui_sha1_hex(output_sha, output_hex); + free(patch); + if (m->rom_patch_required_sha1 && m->rom_patch_required_sha1[0] && + strcmp(output_hex, m->rom_patch_required_sha1) != 0) { + free(output); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "This executable was built for a different patch release."); + return false; + } + snprintf(m->s.rom_patch_crc32, sizeof(m->s.rom_patch_crc32), "%08x", + recompui_crc32_compute(output, output_len)); + + const size_t cache_len = strlen(m->rom_patch_cache_dir); + const char* separator = + cache_len && (m->rom_patch_cache_dir[cache_len - 1] == '/' || + m->rom_patch_cache_dir[cache_len - 1] == '\\') ? "" : "/"; + char target[512]; + const int target_chars = snprintf(target, sizeof(target), "%s%s%s-%s%s", + m->rom_patch_cache_dir, separator, m->rom_sha1_hex, patch_hex, + lm_path_extension(m->rom_full)); + if (target_chars < 0 || (size_t)target_chars >= sizeof(target)) { + free(output); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "The patched-ROM cache path is too long."); + return false; + } + char temporary[520]; + const int temporary_chars = + snprintf(temporary, sizeof(temporary), "%s.tmp", target); + if (temporary_chars < 0 || (size_t)temporary_chars >= sizeof(temporary)) { + free(output); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "The patched-ROM cache path is too long."); + return false; + } + FILE* file = fopen(temporary, "wb"); + const bool wrote = file && fwrite(output, 1, output_len, file) == output_len; + if (file) fclose(file); + free(output); + if (!wrote) { + remove(temporary); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "Could not write the patched-ROM cache."); + return false; + } + remove(target); + if (rename(temporary, target) != 0) { + remove(temporary); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "Could not publish the patched-ROM cache."); + return false; + } + + safe_copy(m->rom_patch_prepared_path, + sizeof(m->rom_patch_prepared_path), target); + safe_copy(m->rom_patch_prepared_sha1, + sizeof(m->rom_patch_prepared_sha1), output_hex); + safe_copy(m->s.rom_patch_source_path, + sizeof(m->s.rom_patch_source_path), m->rom_full); + safe_copy(m->s.rom_patch_sha1, sizeof(m->s.rom_patch_sha1), output_hex); + safe_copy(m->rom_patch_status, sizeof(m->rom_patch_status), + "Patch prepared and checksum-verified."); + return true; +} + // Build "/.msu1." beside `rom_path` (matches the legacy // launcher's std::filesystem stem()/extension() splice exactly). static void lm_msu1_target_path(const char* rom_path, char* out, size_t out_cap) { diff --git a/src/common/launcher_model.h b/src/common/launcher_model.h index d202513..e7009fd 100644 --- a/src/common/launcher_model.h +++ b/src/common/launcher_model.h @@ -282,6 +282,13 @@ typedef struct { bool mod_show_packages; char mod_search[96]; char mod_status[256]; + bool rom_patch_supported; + const char* rom_patch_note; + const char* rom_patch_cache_dir; + const char* rom_patch_required_sha1; + char rom_patch_status[256]; + char rom_patch_prepared_path[512]; + char rom_patch_prepared_sha1[41]; // Game-supplied aspect vocabulary (GameInfo.aspect_labels): when set, // the aspect cycle walks these 0..num_aspect_labels-1 instead of the // built-in 4:3/16:9/21:9 mask set; aspect_experimental tags the row. @@ -362,6 +369,7 @@ typedef struct { bool rom_present; char rom_full[512]; // absolute path (what we hand to the game) + char rom_sha1_hex[41]; // complete stock-image identity char rom_file[128]; // basename for display, e.g. "mmx.sfc" char rom_size[48]; // "1.50 MB" char rom_header[24]; // "LoROM" @@ -524,6 +532,18 @@ void launcher_model_set_rom(LauncherModel* m, const char* path); // Full path of the currently selected ROM ("" when none). const char* launcher_model_rom_path(const LauncherModel* m); +// Effective path returned to the host after a patch was prepared; otherwise +// the selected stock-ROM path. +const char* launcher_model_effective_rom_path(const LauncherModel* m); + +// Patch selection and launch preparation. Selecting a patch enables it; +// clearing disables it. Preparation verifies the stock image, applies the +// patch into the host-provided cache, and records the effective SHA-1. +void launcher_model_set_rom_patch(LauncherModel* m, const char* path); +void launcher_model_clear_rom_patch(LauncherModel* m); +void launcher_model_toggle_rom_patch(LauncherModel* m); +bool launcher_model_prepare_rom_patch(LauncherModel* m); + // True iff a ROM is loaded and every fingerprint the game provides (CRC and/or // SHA-256) matches. If the game provides no fingerprint at all, returns false // (we can't vouch for an unknown ROM). diff --git a/src/common/launcher_ng_capi.c b/src/common/launcher_ng_capi.c index e6e9d4f..3dcab8d 100644 --- a/src/common/launcher_ng_capi.c +++ b/src/common/launcher_ng_capi.c @@ -65,7 +65,7 @@ int recomp_launcher_run_window(const char* window_title, if (act == LNG_ACTION_LAUNCH || act == LNG_ACTION_RELAUNCH) { launcher_model_commit(&model, io); // edited settings back to the caller - const char* rom = launcher_model_rom_path(&model); + const char* rom = launcher_model_effective_rom_path(&model); if (out_rom_path && out_rom_path_len) { if (rom && rom[0]) snprintf(out_rom_path, out_rom_path_len, "%s", rom); diff --git a/src/recomp_launcher.h b/src/recomp_launcher.h index 8b3d89a..2e9bda0 100644 --- a/src/recomp_launcher.h +++ b/src/recomp_launcher.h @@ -597,6 +597,16 @@ struct RecompLauncherCSettings { int assist_pad_bind[RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS]; // Target emulation multiplier while the host's fast-forward action is active. int assist_fast_forward_multiplier; + + // ---- optional source-ROM patch -------------------------------------- + // The launcher always verifies rom_patch_source_path as the stock image, + // then prepares a cached effective image from rom_patch_path. The host + // uses rom_patch_sha1 as the effective runtime identity gate. + int rom_patch_enabled; + char rom_patch_path[512]; + char rom_patch_source_path[512]; + char rom_patch_sha1[41]; + char rom_patch_crc32[9]; }; // ---- host verification/inspection results (filled by the callbacks below) ---- @@ -1037,6 +1047,15 @@ typedef struct RecompLauncherCGameInfo { int assist_binding_count; int assist_fast_forward_min; int assist_fast_forward_max; + + /* Optional general ROM-patch surface. recomp-ui applies classic IPS, + * IPS32, and checksum-verified BPS to a verified stock image. The cache + * directory must already exist and should be owned by the host beside its + * other mod data. NULL note uses the shared compatibility warning. */ + int rom_patch_supported; + const char* rom_patch_note; + const char* rom_patch_cache_dir; + const char* rom_patch_required_sha1; } RecompLauncherCGameInfo; /* recomp_launcher_run_window return codes */ diff --git a/tests/ips_patch_test.c b/tests/ips_patch_test.c new file mode 100644 index 0000000..51fc1af --- /dev/null +++ b/tests/ips_patch_test.c @@ -0,0 +1,153 @@ +#include "ips_patch.h" +#include "crc32.h" + +#include +#include +#include +#include + +static int expect(int condition, const char* message) { + if (condition) return 1; + fprintf(stderr, "FAIL: %s\n", message); + return 0; +} + +static void write_le32(uint8_t* out, uint32_t value) { + out[0] = (uint8_t)value; + out[1] = (uint8_t)(value >> 8); + out[2] = (uint8_t)(value >> 16); + out[3] = (uint8_t)(value >> 24); +} + +static int test_bps_checksums_and_actions(void) { + const uint8_t source[] = {'a','b','c','d','e'}; + const uint8_t expected[] = {'a','b','X','Y','e'}; + uint8_t patch[24] = { + 'B','P','S','1', + 0x85, 0x85, 0x80, + 0x84, + 0x85, 'X', 'Y', + 0x80, + }; + write_le32(patch + 12, recompui_crc32_compute(source, sizeof(source))); + write_le32(patch + 16, recompui_crc32_compute(expected, sizeof(expected))); + write_le32(patch + 20, recompui_crc32_compute(patch, 20)); + + uint8_t* output = NULL; + size_t output_len = 0; + const int ok = rom_patch_apply(source, sizeof(source), patch, sizeof(patch), + &output, &output_len); + const int pass = + expect(ips_detect_format(patch, sizeof(patch)) == IPS_PATCH_BPS, + "BPS format detection") && + expect(ok, "BPS applies with valid checksums") && + expect(output_len == sizeof(expected), "BPS target length") && + expect(memcmp(output, expected, sizeof(expected)) == 0, + "BPS SourceRead and TargetRead bytes"); + free(output); + + const uint8_t wrong_source[] = {'a','b','c','d','f'}; + output = NULL; + output_len = 0; + return pass && expect(!rom_patch_apply( + wrong_source, sizeof(wrong_source), patch, sizeof(patch), + &output, &output_len), "BPS rejects the wrong source ROM"); +} +static int test_bps_relative_copy_actions(void) { + const uint8_t source[] = {'a','b','c','d','e','f'}; + const uint8_t expected[] = {'c','d','c','d','c','d'}; + uint8_t patch[23] = { + 'B','P','S','1', + 0x86, 0x86, 0x80, + 0x86, 0x84, + 0x8F, 0x80, + }; + write_le32(patch + 11, recompui_crc32_compute(source, sizeof(source))); + write_le32(patch + 15, recompui_crc32_compute(expected, sizeof(expected))); + write_le32(patch + 19, recompui_crc32_compute(patch, 19)); + + uint8_t* output = NULL; + size_t output_len = 0; + const int ok = rom_patch_apply(source, sizeof(source), patch, sizeof(patch), + &output, &output_len); + const int pass = + expect(ok, "BPS relative-copy patch applies") && + expect(output_len == sizeof(expected), "BPS relative-copy length") && + expect(memcmp(output, expected, sizeof(expected)) == 0, + "BPS SourceCopy and overlapping TargetCopy bytes"); + free(output); + + patch[19] ^= 1u; + output = NULL; + output_len = 0; + return pass && expect(!rom_patch_apply( + source, sizeof(source), patch, sizeof(patch), &output, &output_len), + "BPS rejects a corrupt patch checksum"); +} + + +static int test_classic_literal_and_rle(void) { + const uint8_t source[] = {0x10, 0x20, 0x30, 0x40}; + const uint8_t patch[] = { + 'P','A','T','C','H', + 0x00,0x00,0x01, 0x00,0x02, 0xAA,0xBB, + 0x00,0x00,0x04, 0x00,0x00, 0x00,0x03,0x7F, + 'E','O','F' + }; + const uint8_t expected[] = {0x10,0xAA,0xBB,0x40,0x7F,0x7F,0x7F}; + uint8_t* output = NULL; + size_t output_len = 0; + const int ok = rom_patch_apply(source, sizeof(source), patch, sizeof(patch), + &output, &output_len); + const int pass = + expect(ips_detect_format(patch, sizeof(patch)) == IPS_PATCH_CLASSIC, + "classic IPS format detection") && + expect(ok, "classic IPS applies") && + expect(output_len == sizeof(expected), "classic IPS output length") && + expect(memcmp(output, expected, sizeof(expected)) == 0, + "classic IPS literal and RLE bytes"); + free(output); + return pass; +} + +static int test_ips32_high_offset(void) { + const uint8_t patch[] = { + 'I','P','S','3','2', + 0x01,0x00,0x00,0x10, 0x00,0x02, 0x42,0x43, + 'E','E','O','F' + }; + uint8_t* output = NULL; + size_t output_len = 0; + const int ok = rom_patch_apply(NULL, 0, patch, sizeof(patch), + &output, &output_len); + const int pass = + expect(ips_detect_format(patch, sizeof(patch)) == IPS_PATCH_IPS32, + "IPS32 format detection") && + expect(ok, "IPS32 applies") && + expect(output_len == 0x01000012u, "IPS32 output extends above 16 MiB") && + expect(output[0x01000010u] == 0x42 && output[0x01000011u] == 0x43, + "IPS32 writes bytes above 16 MiB"); + free(output); + return pass; +} + +static int test_rejects_truncated_record(void) { + const uint8_t patch[] = { + 'P','A','T','C','H', 0x00,0x00,0x01, 0x00,0x02, 0xAA + }; + uint8_t* output = NULL; + size_t output_len = 0; + return expect(!rom_patch_apply(NULL, 0, patch, sizeof(patch), + &output, &output_len), + "truncated record is rejected"); +} + +int main(void) { + if (!test_bps_checksums_and_actions()) return 1; + if (!test_bps_relative_copy_actions()) return 1; + if (!test_classic_literal_and_rle()) return 1; + if (!test_ips32_high_offset()) return 1; + if (!test_rejects_truncated_record()) return 1; + puts("PASS: BPS, IPS, and IPS32 parser tests"); + return 0; +} diff --git a/tools/rom_patch_tool.c b/tools/rom_patch_tool.c new file mode 100644 index 0000000..c06ad37 --- /dev/null +++ b/tools/rom_patch_tool.c @@ -0,0 +1,123 @@ +#include "crc32.h" +#include "ips_patch.h" +#include "sha1.h" + +#include +#include +#include +#include + +static int read_file(const char* path, uint8_t** data, size_t* size) { + FILE* file = fopen(path, "rb"); + if (!file) return 0; + if (fseek(file, 0, SEEK_END) != 0) { fclose(file); return 0; } + const long end = ftell(file); + if (end < 0 || fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return 0; + } + uint8_t* bytes = (uint8_t*)malloc((size_t)end ? (size_t)end : 1); + if (!bytes) { fclose(file); return 0; } + const int ok = fread(bytes, 1, (size_t)end, file) == (size_t)end; + fclose(file); + if (!ok) { free(bytes); return 0; } + *data = bytes; + *size = (size_t)end; + return 1; +} + +static const char* format_name(IpsPatchFormat format) { + switch (format) { + case IPS_PATCH_CLASSIC: return "IPS"; + case IPS_PATCH_IPS32: return "IPS32"; + case IPS_PATCH_BPS: return "BPS"; + default: return "unknown"; + } +} + +static int byte_changed(const uint8_t* source, size_t source_size, + const uint8_t* target, size_t target_size, + size_t offset) { + if (offset >= source_size || offset >= target_size) + return offset < source_size || offset < target_size; + return source[offset] != target[offset]; +} + +static void print_change_summary(const uint8_t* source, size_t source_size, + const uint8_t* target, size_t target_size) { + const size_t span = source_size > target_size ? source_size : target_size; + size_t changed = 0, runs = 0, first = 0, last = 0; + int in_run = 0; + for (size_t i = 0; i < span; ++i) { + const int different = byte_changed(source, source_size, + target, target_size, i); + if (different) { + if (!changed) first = i; + last = i; + ++changed; + if (!in_run) { ++runs; in_run = 1; } + } else { + in_run = 0; + } + } + printf("Changed bytes: %zu in %zu contiguous runs\n", changed, runs); + if (changed) + printf("Changed range: 0x%zX-0x%zX\n", first, last); +} + +int main(int argc, char** argv) { + if (argc != 4) { + fprintf(stderr, "Usage: %s \n", + argc > 0 ? argv[0] : "rom-patch-tool"); + return 2; + } + + uint8_t* source = NULL; + uint8_t* patch = NULL; + uint8_t* output = NULL; + size_t source_size = 0, patch_size = 0, output_size = 0; + if (!read_file(argv[1], &source, &source_size)) { + fprintf(stderr, "Could not read source ROM: %s\n", argv[1]); + return 1; + } + if (!read_file(argv[2], &patch, &patch_size)) { + fprintf(stderr, "Could not read patch: %s\n", argv[2]); + free(source); + return 1; + } + + const IpsPatchFormat format = ips_detect_format(patch, patch_size); + printf("Format: %s\n", format_name(format)); + if (!rom_patch_apply(source, source_size, patch, patch_size, + &output, &output_size)) { + fprintf(stderr, "Patch rejected (wrong source, invalid data, or checksum failure).\n"); + free(source); + free(patch); + return 1; + } + + FILE* target = fopen(argv[3], "wb"); + const int wrote = target && + fwrite(output, 1, output_size, target) == output_size; + if (target) fclose(target); + if (!wrote) { + fprintf(stderr, "Could not write output ROM: %s\n", argv[3]); + free(source); free(patch); free(output); + return 1; + } + + uint8_t digest[20]; + char sha1[41]; + recompui_sha1_compute(output, output_size, digest); + recompui_sha1_hex(digest, sha1); + printf("Source size: %zu bytes\n", source_size); + printf("Target size: %zu bytes\n", output_size); + printf("Target CRC32: %08X\n", recompui_crc32_compute(output, output_size)); + printf("Target SHA1: %s\n", sha1); + print_change_summary(source, source_size, output, output_size); + + free(source); + free(patch); + free(output); + return 0; +}