From d5eb49f42df48e3bb8ad9ab4788dd73095b2cbae Mon Sep 17 00:00:00 2001 From: "Gregory Markou (ai)" Date: Mon, 6 Jul 2026 15:13:30 +0200 Subject: [PATCH] feat: allow applying patches on hash mismatch with explicit override Hash checks (BPS/UPS CRC32s, RUP MD5s, APS-N64 cart id/CRC, PMSR CRC, and --verify-* specs) can now be downgraded to warnings instead of aborting the apply. The override must be explicit: the CLI requires --force (and hints at it when a mismatch aborts), and the GUI shows a confirmation dialog offering "Apply anyway" before retrying. Structural checks (sizes, offsets, encoding) still fail hard. RUP patches whose source MD5 matches no file scope fall back to the first scope in lenient mode. Bypassed mismatches are reported via ApplyOutcome::hash_warnings, printed by the CLI, and surfaced as a warning toast in the GUI. Co-Authored-By: Claude --- crates/rompatch-core/src/apply.rs | 122 +++++++++++++++++- crates/rompatch-core/src/error.rs | 20 +++ crates/rompatch-core/src/format/aps.rs | 13 +- crates/rompatch-core/src/format/bps.rs | 19 ++- crates/rompatch-core/src/format/mod.rs | 61 +++++++++ crates/rompatch-core/src/format/pmsr.rs | 9 +- crates/rompatch-core/src/format/rup.rs | 41 +++++- crates/rompatch-core/src/format/ups.rs | 17 ++- crates/rompatch-core/tests/golden_bps.rs | 28 +++- crates/rompatch-core/tests/golden_rup.rs | 35 ++++- crates/rompatch-core/tests/golden_ups.rs | 36 +++++- crates/rompatch-gui/src/commands.rs | 3 + crates/rompatch-gui/src/error.rs | 10 ++ .../ui/src/components/ApplyPanel.tsx | 101 +++++++++++---- .../ui/src/components/ConfirmDialog.tsx | 15 ++- crates/rompatch-gui/ui/src/lib/errors.ts | 11 ++ crates/rompatch-gui/ui/src/lib/types.ts | 8 ++ crates/rompatch/README.md | 4 + crates/rompatch/src/cli.rs | 4 + crates/rompatch/src/commands/apply.rs | 5 + crates/rompatch/src/commands/mod.rs | 6 + crates/rompatch/src/main.rs | 2 + 22 files changed, 512 insertions(+), 58 deletions(-) diff --git a/crates/rompatch-core/src/apply.rs b/crates/rompatch-core/src/apply.rs index cbea8a0..ae988b0 100644 --- a/crates/rompatch-core/src/apply.rs +++ b/crates/rompatch-core/src/apply.rs @@ -112,6 +112,13 @@ pub struct ApplyOptions { pub verify_input: Option, pub verify_output: Option, pub format_override: Option, + /// Downgrade every hash check (patch-embedded checksums and the + /// `verify_*` specs) from a hard error to a warning collected in + /// [`ApplyOutcome::hash_warnings`]. Meant to be set only after the user + /// explicitly confirms patching a non-matching ROM; structural checks + /// (sizes, offsets, encoding) still fail hard. + #[cfg_attr(feature = "serde", serde(default))] + pub ignore_hash_mismatch: bool, } /// Result of a successful [`run`] call. @@ -125,6 +132,10 @@ pub struct ApplyOutcome { /// `Some(family)` if [`ApplyOptions::fix_checksum`] was set and a /// known cartridge family was detected. pub fixed_checksum: Option, + /// Human-readable hash mismatches that were bypassed because + /// [`ApplyOptions::ignore_hash_mismatch`] was set. Empty when every + /// check passed. + pub hash_warnings: Vec, } /// Errors returned by [`run`]. @@ -164,6 +175,19 @@ impl fmt::Display for ApplyError { } } +impl ApplyError { + /// True when the failure is a hash/checksum mismatch that + /// [`ApplyOptions::ignore_hash_mismatch`] would have bypassed. + #[must_use] + pub fn is_hash_mismatch(&self) -> bool { + match self { + Self::HashMismatch { .. } => true, + Self::Patch(e) => e.is_hash_mismatch(), + _ => false, + } + } +} + impl std::error::Error for ApplyError {} impl From for ApplyError { @@ -190,8 +214,16 @@ pub fn run( (None, rom) }; + let mut hash_warnings = Vec::new(); + if let Some(spec) = &opts.verify_input { - verify(body, spec, HashCheckKind::Input)?; + if let Err(e) = verify(body, spec, HashCheckKind::Input) { + if opts.ignore_hash_mismatch { + hash_warnings.push(e.to_string()); + } else { + return Err(e); + } + } } let kind = opts @@ -199,7 +231,13 @@ pub fn run( .or_else(|| format::detect(patch)) .ok_or(ApplyError::UnknownFormat)?; - let mut output = kind.apply(patch, body)?; + let mut output = if opts.ignore_hash_mismatch { + let (output, warnings) = kind.apply_lenient(patch, body)?; + hash_warnings.extend(warnings.iter().map(ToString::to_string)); + output + } else { + kind.apply(patch, body)? + }; let fixed_checksum = if opts.fix_checksum { checksum_fix::sniff_and_fix(&mut output) @@ -208,7 +246,13 @@ pub fn run( }; if let Some(spec) = &opts.verify_output { - verify(&output, spec, HashCheckKind::Output)?; + if let Err(e) = verify(&output, spec, HashCheckKind::Output) { + if opts.ignore_hash_mismatch { + hash_warnings.push(e.to_string()); + } else { + return Err(e); + } + } } let final_output = if let Some(kind) = stripped_header { @@ -226,6 +270,7 @@ pub fn run( output: final_output, stripped_header, fixed_checksum, + hash_warnings, }) } @@ -406,6 +451,77 @@ mod tests { run(&rom, &patch, &opts).unwrap(); } + #[test] + fn ignore_hash_mismatch_downgrades_verify_input_to_warning() { + let rom = vec![0xAB; 1024]; + let patch = ips_identity_patch(); + let opts = ApplyOptions { + verify_input: Some(HashSpec { + algo: HashAlgo::Crc32, + expected_hex: "deadbeef".into(), + }), + ignore_hash_mismatch: true, + ..ApplyOptions::default() + }; + let outcome = run(&rom, &patch, &opts).unwrap(); + assert_eq!(outcome.output, rom); + assert_eq!(outcome.hash_warnings.len(), 1); + assert!(outcome.hash_warnings[0].contains("input crc32 hash mismatch")); + } + + #[test] + fn ignore_hash_mismatch_downgrades_verify_output_to_warning() { + let rom = vec![0xAB; 1024]; + let patch = ips_identity_patch(); + let opts = ApplyOptions { + verify_output: Some(HashSpec { + algo: HashAlgo::Sha1, + expected_hex: "deadbeef".into(), + }), + ignore_hash_mismatch: true, + ..ApplyOptions::default() + }; + let outcome = run(&rom, &patch, &opts).unwrap(); + assert_eq!(outcome.hash_warnings.len(), 1); + assert!(outcome.hash_warnings[0].contains("output sha1 hash mismatch")); + } + + #[test] + fn hash_warnings_empty_when_checks_pass() { + let rom = vec![0xAB; 1024]; + let patch = ips_identity_patch(); + let opts = ApplyOptions { + ignore_hash_mismatch: true, + ..ApplyOptions::default() + }; + let outcome = run(&rom, &patch, &opts).unwrap(); + assert!(outcome.hash_warnings.is_empty()); + } + + #[test] + fn apply_error_classifies_hash_mismatches() { + let hash_err = ApplyError::HashMismatch { + kind: HashCheckKind::Input, + algo: HashAlgo::Crc32, + expected: "0".into(), + actual: "1".into(), + }; + assert!(hash_err.is_hash_mismatch()); + assert!(ApplyError::Patch(PatchError::InputHashMismatch { + expected: 0, + actual: 1 + }) + .is_hash_mismatch()); + assert!(ApplyError::Patch(PatchError::NoMatchingFile).is_hash_mismatch()); + assert!(!ApplyError::UnknownFormat.is_hash_mismatch()); + assert!(!ApplyError::Patch(PatchError::Truncated).is_hash_mismatch()); + assert!(!ApplyError::Patch(PatchError::InputSizeMismatch { + expected: 0, + actual: 1 + }) + .is_hash_mismatch()); + } + #[test] fn verify_output_mismatch_returns_error() { let rom = vec![0xAB; 1024]; diff --git a/crates/rompatch-core/src/error.rs b/crates/rompatch-core/src/error.rs index 8dfd368..26531de 100644 --- a/crates/rompatch-core/src/error.rs +++ b/crates/rompatch-core/src/error.rs @@ -41,6 +41,26 @@ pub enum PatchError { UnsupportedFeature(&'static str), } +impl PatchError { + /// True for errors that report a hash/checksum mismatch (embedded CRC32 + /// or MD5 checks, including RUP's MD5-keyed file lookup). These are the + /// checks a caller may deliberately downgrade to warnings via + /// [`crate::apply::ApplyOptions::ignore_hash_mismatch`]; structural + /// errors (sizes, offsets, encoding) are not. + #[must_use] + pub fn is_hash_mismatch(&self) -> bool { + matches!( + self, + Self::InputHashMismatch { .. } + | Self::OutputHashMismatch { .. } + | Self::PatchHashMismatch { .. } + | Self::InputMd5Mismatch { .. } + | Self::OutputMd5Mismatch { .. } + | Self::NoMatchingFile + ) + } +} + fn fmt_md5(bytes: &[u8; 16]) -> String { use core::fmt::Write; let mut s = String::with_capacity(32); diff --git a/crates/rompatch-core/src/format/aps.rs b/crates/rompatch-core/src/format/aps.rs index 0c30f59..8df1536 100644 --- a/crates/rompatch-core/src/format/aps.rs +++ b/crates/rompatch-core/src/format/aps.rs @@ -27,6 +27,7 @@ use crate::bin_file::BinReader; use crate::error::{PatchError, Result}; +use crate::format::HashChecks; use crate::MAX_PATCH_OUTPUT_SIZE; const MAGIC_GBA: &[u8] = b"APS1"; @@ -117,6 +118,10 @@ pub fn apply_gba(patch: &[u8], rom: &[u8]) -> Result> { } pub fn apply_n64(patch: &[u8], rom: &[u8]) -> Result> { + apply_n64_with(patch, rom, &mut HashChecks::strict()) +} + +pub(crate) fn apply_n64_with(patch: &[u8], rom: &[u8], checks: &mut HashChecks) -> Result> { let mut r = BinReader::new(patch); if r.read_bytes(MAGIC_N64.len())? != MAGIC_N64 { return Err(PatchError::InvalidMagic); @@ -139,16 +144,16 @@ pub fn apply_n64(patch: &[u8], rom: &[u8]) -> Result> { }); } if &rom[0x3C..0x3F] != cart_id.as_slice() { - return Err(PatchError::InputHashMismatch { + checks.record(PatchError::InputHashMismatch { expected: u32::from_be_bytes([0, cart_id[0], cart_id[1], cart_id[2]]), actual: u32::from_be_bytes([0, rom[0x3C], rom[0x3D], rom[0x3E]]), - }); + })?; } if &rom[0x10..0x18] != crc.as_slice() { - return Err(PatchError::InputHashMismatch { + checks.record(PatchError::InputHashMismatch { expected: u32::from_be_bytes([crc[0], crc[1], crc[2], crc[3]]), actual: u32::from_be_bytes([rom[0x10], rom[0x11], rom[0x12], rom[0x13]]), - }); + })?; } } else if header_type != N64_HEADER_TYPE_SIMPLE { return Err(PatchError::InvalidEncoding); diff --git a/crates/rompatch-core/src/format/bps.rs b/crates/rompatch-core/src/format/bps.rs index edbd373..4ad55a7 100644 --- a/crates/rompatch-core/src/format/bps.rs +++ b/crates/rompatch-core/src/format/bps.rs @@ -1,5 +1,6 @@ use crate::bin_file::BinReader; use crate::error::{PatchError, Result}; +use crate::format::HashChecks; use crate::hash; use crate::MAX_PATCH_OUTPUT_SIZE; @@ -11,8 +12,12 @@ const ACTION_TARGET_READ: u64 = 1; const ACTION_SOURCE_COPY: u64 = 2; const ACTION_TARGET_COPY: u64 = 3; -#[allow(clippy::too_many_lines)] pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { + apply_with(patch, rom, &mut HashChecks::strict()) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn apply_with(patch: &[u8], rom: &[u8], checks: &mut HashChecks) -> Result> { if patch.len() < MAGIC.len() + FOOTER_LEN { return Err(PatchError::Truncated); } @@ -193,26 +198,26 @@ pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { let source_crc_actual = hash::crc32(rom); if source_crc_actual != source_crc_expected { - return Err(PatchError::InputHashMismatch { + checks.record(PatchError::InputHashMismatch { expected: source_crc_expected, actual: source_crc_actual, - }); + })?; } let target_crc_actual = hash::crc32(&output); if target_crc_actual != target_crc_expected { - return Err(PatchError::OutputHashMismatch { + checks.record(PatchError::OutputHashMismatch { expected: target_crc_expected, actual: target_crc_actual, - }); + })?; } let patch_crc_actual = hash::crc32(&patch[..patch.len() - 4]); if patch_crc_actual != patch_crc_expected { - return Err(PatchError::PatchHashMismatch { + checks.record(PatchError::PatchHashMismatch { expected: patch_crc_expected, actual: patch_crc_actual, - }); + })?; } Ok(output) diff --git a/crates/rompatch-core/src/format/mod.rs b/crates/rompatch-core/src/format/mod.rs index c0ef05f..ccf19ff 100644 --- a/crates/rompatch-core/src/format/mod.rs +++ b/crates/rompatch-core/src/format/mod.rs @@ -9,6 +9,44 @@ pub mod ups; use crate::error::{PatchError, Result}; +/// Policy for patch-embedded hash checks. Strict mode turns a mismatch into +/// an error; lenient mode records it in `warnings` and lets the apply +/// continue. Structural checks (sizes, offsets, encoding) are unaffected. +pub(crate) struct HashChecks { + enforce: bool, + pub(crate) warnings: Vec, +} + +impl HashChecks { + pub(crate) fn strict() -> Self { + Self { + enforce: true, + warnings: Vec::new(), + } + } + + pub(crate) fn lenient() -> Self { + Self { + enforce: false, + warnings: Vec::new(), + } + } + + pub(crate) fn enforcing(&self) -> bool { + self.enforce + } + + /// Fail with `err` in strict mode; record it and continue in lenient mode. + pub(crate) fn record(&mut self, err: PatchError) -> Result<()> { + if self.enforce { + Err(err) + } else { + self.warnings.push(err); + Ok(()) + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum FormatKind { @@ -60,6 +98,9 @@ impl FormatKind { } /// Apply a patch of this format to `rom`, returning the patched output. + /// + /// Hash checks embedded in the patch are enforced; use + /// [`FormatKind::apply_lenient`] to downgrade them to warnings. pub fn apply(self, patch: &[u8], rom: &[u8]) -> Result> { match self { Self::Ips => ips::apply(patch, rom), @@ -73,6 +114,26 @@ impl FormatKind { Self::Bdf => bdf::apply(patch, rom), } } + + /// Like [`FormatKind::apply`], but hash mismatches (patch-embedded CRC32 + /// and MD5 checks) are collected and returned alongside the output + /// instead of aborting the apply. Structural checks (sizes, offsets, + /// encoding) still fail hard. + pub fn apply_lenient(self, patch: &[u8], rom: &[u8]) -> Result<(Vec, Vec)> { + let mut checks = HashChecks::lenient(); + let output = match self { + Self::Ips => ips::apply(patch, rom), + Self::Ups => ups::apply_with(patch, rom, &mut checks), + Self::Bps => bps::apply_with(patch, rom, &mut checks), + Self::Pmsr => pmsr::apply_with(patch, rom, &mut checks), + Self::ApsGba => aps::apply_gba(patch, rom), + Self::ApsN64 => aps::apply_n64_with(patch, rom, &mut checks), + Self::Ppf => ppf::apply(patch, rom), + Self::Rup => rup::apply_with(patch, rom, &mut checks), + Self::Bdf => bdf::apply(patch, rom), + }?; + Ok((output, checks.warnings)) + } } #[must_use] diff --git a/crates/rompatch-core/src/format/pmsr.rs b/crates/rompatch-core/src/format/pmsr.rs index 8e28254..6820bde 100644 --- a/crates/rompatch-core/src/format/pmsr.rs +++ b/crates/rompatch-core/src/format/pmsr.rs @@ -9,6 +9,7 @@ use crate::bin_file::BinReader; use crate::error::{PatchError, Result}; +use crate::format::HashChecks; use crate::hash; const MAGIC: &[u8] = b"PMSR"; @@ -17,6 +18,10 @@ const PAPER_MARIO_USA10_SIZE: usize = 41_943_040; const PAPER_MARIO_USA10_CRC32: u32 = 0xa7f5_cd7e; pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { + apply_with(patch, rom, &mut HashChecks::strict()) +} + +pub(crate) fn apply_with(patch: &[u8], rom: &[u8], checks: &mut HashChecks) -> Result> { if rom.len() != PAPER_MARIO_USA10_SIZE { return Err(PatchError::InputSizeMismatch { expected: PAPER_MARIO_USA10_SIZE as u64, @@ -25,10 +30,10 @@ pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { } let rom_crc = hash::crc32(rom); if rom_crc != PAPER_MARIO_USA10_CRC32 { - return Err(PatchError::InputHashMismatch { + checks.record(PatchError::InputHashMismatch { expected: PAPER_MARIO_USA10_CRC32, actual: rom_crc, - }); + })?; } apply_records(patch, rom) } diff --git a/crates/rompatch-core/src/format/rup.rs b/crates/rompatch-core/src/format/rup.rs index a2da94b..ff8bb9f 100644 --- a/crates/rompatch-core/src/format/rup.rs +++ b/crates/rompatch-core/src/format/rup.rs @@ -19,6 +19,7 @@ use crate::bin_file::BinReader; use crate::error::{PatchError, Result}; +use crate::format::HashChecks; use crate::hash; use crate::MAX_PATCH_OUTPUT_SIZE; @@ -32,6 +33,10 @@ const CMD_XOR_RECORD: u8 = 0x02; const MD5_LEN: usize = 16; pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { + apply_with(patch, rom, &mut HashChecks::strict()) +} + +pub(crate) fn apply_with(patch: &[u8], rom: &[u8], checks: &mut HashChecks) -> Result> { if patch.len() < HEADER_LEN { return Err(PatchError::Truncated); } @@ -43,9 +48,27 @@ pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { let rom_md5 = hash::md5(rom); + // In lenient mode remember the first file scope so a ROM matching no + // scope's source MD5 can still be patched against it (with a warning). + let mut first_scope: Option<(FileHeader, usize)> = None; + loop { - let cmd = r.read_u8()?; + // A patch may simply end after its last file scope instead of + // writing a top-level END, so EOF also means "no more scopes". + let cmd = if r.pos() >= patch.len() { + CMD_END + } else { + r.read_u8()? + }; if cmd == CMD_END { + if let Some((file, records_pos)) = first_scope { + checks.record(PatchError::InputMd5Mismatch { + expected: file.source_md5, + actual: rom_md5, + })?; + r.seek(records_pos)?; + return apply_file(&mut r, &file, rom, checks); + } return Err(PatchError::NoMatchingFile); } if cmd != CMD_OPEN_FILE { @@ -54,7 +77,10 @@ pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { let file = read_file_header(&mut r)?; if file.source_md5 == rom_md5 { - return apply_file(&mut r, &file, rom); + return apply_file(&mut r, &file, rom, checks); + } + if !checks.enforcing() && first_scope.is_none() { + first_scope = Some((file, r.pos())); } // Skip this file's records (cmd=0x02 ... 0x00). loop { @@ -128,7 +154,12 @@ fn read_file_header(r: &mut BinReader<'_>) -> Result { }) } -fn apply_file(r: &mut BinReader<'_>, file: &FileHeader, rom: &[u8]) -> Result> { +fn apply_file( + r: &mut BinReader<'_>, + file: &FileHeader, + rom: &[u8], + checks: &mut HashChecks, +) -> Result> { let target_size = usize::try_from(file.target_size).map_err(|_| PatchError::OffsetOutOfRange { offset: file.target_size, @@ -215,10 +246,10 @@ fn apply_file(r: &mut BinReader<'_>, file: &FileHeader, rom: &[u8]) -> Result Result> { + apply_with(patch, rom, &mut HashChecks::strict()) +} + +pub(crate) fn apply_with(patch: &[u8], rom: &[u8], checks: &mut HashChecks) -> Result> { if patch.len() < MAGIC.len() + FOOTER_LEN { return Err(PatchError::Truncated); } @@ -94,26 +99,26 @@ pub fn apply(patch: &[u8], rom: &[u8]) -> Result> { let input_crc_actual = hash::crc32(rom); if input_crc_actual != input_crc_expected { - return Err(PatchError::InputHashMismatch { + checks.record(PatchError::InputHashMismatch { expected: input_crc_expected, actual: input_crc_actual, - }); + })?; } let output_crc_actual = hash::crc32(&output); if output_crc_actual != output_crc_expected { - return Err(PatchError::OutputHashMismatch { + checks.record(PatchError::OutputHashMismatch { expected: output_crc_expected, actual: output_crc_actual, - }); + })?; } let patch_crc_actual = hash::crc32(&patch[..patch.len() - 4]); if patch_crc_actual != patch_crc_expected { - return Err(PatchError::PatchHashMismatch { + checks.record(PatchError::PatchHashMismatch { expected: patch_crc_expected, actual: patch_crc_actual, - }); + })?; } Ok(output) diff --git a/crates/rompatch-core/tests/golden_bps.rs b/crates/rompatch-core/tests/golden_bps.rs index ddb67f6..af31196 100644 --- a/crates/rompatch-core/tests/golden_bps.rs +++ b/crates/rompatch-core/tests/golden_bps.rs @@ -1,4 +1,4 @@ -use rompatch_core::{format::bps, hash}; +use rompatch_core::{format::bps, hash, FormatKind, PatchError}; fn write_vlv(out: &mut Vec, mut value: u64) { loop { @@ -84,6 +84,32 @@ fn applies_pure_target_read_replaces_all() { assert_eq!(bps::apply(&patch, &src).unwrap(), dst); } +#[test] +fn lenient_applies_despite_source_crc_mismatch() { + // Pure TargetRead output is independent of the source bytes, so a wrong + // (same-size) ROM produces the intended target. Strict apply still fails + // on the source CRC; lenient apply succeeds and warns only about it. + let src = vec![0u8; 8]; + let dst: Vec = (1u8..=8).collect(); + let patch = build_bps( + &src, + &dst, + b"", + &[Action::TargetRead { bytes: dst.clone() }], + ); + let wrong = vec![0xFFu8; 8]; + + assert!(matches!( + bps::apply(&patch, &wrong).unwrap_err(), + PatchError::InputHashMismatch { .. } + )); + + let (output, warnings) = FormatKind::Bps.apply_lenient(&patch, &wrong).unwrap(); + assert_eq!(output, dst); + assert_eq!(warnings.len(), 1); + assert!(matches!(warnings[0], PatchError::InputHashMismatch { .. })); +} + #[test] fn applies_source_copy_with_negative_delta() { // Take src[4..8] and place it at output[0..4], then take src[0..4] for output[4..8]. diff --git a/crates/rompatch-core/tests/golden_rup.rs b/crates/rompatch-core/tests/golden_rup.rs index 7be37d0..2008c1a 100644 --- a/crates/rompatch-core/tests/golden_rup.rs +++ b/crates/rompatch-core/tests/golden_rup.rs @@ -1,4 +1,4 @@ -use rompatch_core::{format::rup, hash}; +use rompatch_core::{format::rup, hash, FormatKind, PatchError}; fn write_rup_vlv(out: &mut Vec, value: u64) { let mut bytes = Vec::new(); @@ -75,6 +75,39 @@ fn rup_rejects_no_matching_file() { assert!(rup::apply(&patch, &other_rom).is_err()); } +#[test] +fn rup_lenient_falls_back_to_first_file_scope() { + // The wrong ROM matches no file scope's source MD5. Lenient apply + // patches against the first scope anyway, warning about the input MD5 + // and the (now wrong) output MD5. + let real_rom = vec![0x10, 0x20, 0x30, 0x40]; + let mut target = real_rom.clone(); + target[1] = 0xAA; + let xor = vec![0x20 ^ 0xAA]; + let patch = build_rup_single(&real_rom, &target, &[(1, xor)]); + + let wrong_rom = vec![0x11, 0x21, 0x31, 0x41]; + let (output, warnings) = FormatKind::Rup.apply_lenient(&patch, &wrong_rom).unwrap(); + assert_eq!(output, vec![0x11, 0x21 ^ (0x20 ^ 0xAA), 0x31, 0x41]); + assert!(warnings + .iter() + .any(|w| matches!(w, PatchError::InputMd5Mismatch { .. }))); + assert!(warnings + .iter() + .any(|w| matches!(w, PatchError::OutputMd5Mismatch { .. }))); +} + +#[test] +fn rup_lenient_matching_rom_yields_no_warnings() { + let rom = vec![0x10, 0x20, 0x30, 0x40]; + let mut target = rom.clone(); + target[1] = 0xAA; + let patch = build_rup_single(&rom, &target, &[(1, vec![0x20 ^ 0xAA])]); + let (output, warnings) = FormatKind::Rup.apply_lenient(&patch, &rom).unwrap(); + assert_eq!(output, target); + assert!(warnings.is_empty()); +} + #[test] fn rup_rejects_invalid_magic() { let mut p = b"WRONG!".to_vec(); diff --git a/crates/rompatch-core/tests/golden_ups.rs b/crates/rompatch-core/tests/golden_ups.rs index c07b9c1..c3c955a 100644 --- a/crates/rompatch-core/tests/golden_ups.rs +++ b/crates/rompatch-core/tests/golden_ups.rs @@ -1,4 +1,4 @@ -use rompatch_core::{format::ups, hash}; +use rompatch_core::{format::ups, hash, FormatKind, PatchError}; fn write_vlv(out: &mut Vec, mut value: u64) { loop { @@ -74,6 +74,40 @@ fn applies_multibyte_xor_run() { assert_eq!(ups::apply(&patch, &src).unwrap(), dst); } +#[test] +fn lenient_applies_despite_crc_mismatches() { + // Patch built for `src`, applied to a different same-size ROM. Strict + // apply fails on the input CRC; lenient apply XORs through and reports + // both input and output CRC mismatches as warnings. + let src = vec![0u8; 8]; + let mut dst = vec![0u8; 8]; + dst[4] = 0xAA; + + let mut body = Vec::new(); + write_vlv(&mut body, 4); + body.push(0xAA); + body.push(0x00); + + let patch = build_ups(8, 8, &body, &src, &dst); + let wrong = vec![0x55u8; 8]; + + assert!(matches!( + ups::apply(&patch, &wrong).unwrap_err(), + PatchError::InputHashMismatch { .. } + )); + + let (output, warnings) = FormatKind::Ups.apply_lenient(&patch, &wrong).unwrap(); + let mut expected = wrong.clone(); + expected[4] = 0x55 ^ 0xAA; + assert_eq!(output, expected); + assert!(warnings + .iter() + .any(|w| matches!(w, PatchError::InputHashMismatch { .. }))); + assert!(warnings + .iter() + .any(|w| matches!(w, PatchError::OutputHashMismatch { .. }))); +} + #[test] fn rejects_wrong_input_size() { let src = vec![0u8; 4]; diff --git a/crates/rompatch-gui/src/commands.rs b/crates/rompatch-gui/src/commands.rs index 811b4f3..ff9068c 100644 --- a/crates/rompatch-gui/src/commands.rs +++ b/crates/rompatch-gui/src/commands.rs @@ -69,6 +69,8 @@ pub struct ApplyReport { pub out_size: u64, pub stripped_header: Option, pub fixed_checksum: Option, + /// Hash mismatches bypassed via `ApplyOptions::ignore_hash_mismatch`. + pub hash_warnings: Vec, } /// Read ROM + patch, run the apply pipeline, write the output to disk, @@ -92,6 +94,7 @@ pub fn apply_patch( out_path, stripped_header: outcome.stripped_header, fixed_checksum: outcome.fixed_checksum, + hash_warnings: outcome.hash_warnings, }) } diff --git a/crates/rompatch-gui/src/error.rs b/crates/rompatch-gui/src/error.rs index 635745a..c600c28 100644 --- a/crates/rompatch-gui/src/error.rs +++ b/crates/rompatch-gui/src/error.rs @@ -24,6 +24,10 @@ pub enum GuiError { struct IpcError { kind: &'static str, message: String, + /// True when the failure is a hash/checksum mismatch the user may + /// deliberately override by retrying with + /// `ApplyOptions::ignore_hash_mismatch`. + hash_mismatch: bool, } impl serde::Serialize for GuiError { @@ -39,9 +43,15 @@ impl serde::Serialize for GuiError { GuiError::Library(_) => "library", GuiError::Tauri(_) => "tauri", }; + let hash_mismatch = match self { + GuiError::Apply(e) => e.is_hash_mismatch(), + GuiError::Patch(e) => e.is_hash_mismatch(), + _ => false, + }; IpcError { kind, message: self.to_string(), + hash_mismatch, } .serialize(serializer) } diff --git a/crates/rompatch-gui/ui/src/components/ApplyPanel.tsx b/crates/rompatch-gui/ui/src/components/ApplyPanel.tsx index f02e882..40cde90 100644 --- a/crates/rompatch-gui/ui/src/components/ApplyPanel.tsx +++ b/crates/rompatch-gui/ui/src/components/ApplyPanel.tsx @@ -4,12 +4,14 @@ import { AdvancedSection } from './AdvancedSection'; import { ApplyTile } from './ApplyTile'; import type { ApplyState } from './ApplyTile'; import { Badge } from './Badge'; +import { ConfirmDialog } from './ConfirmDialog'; import { DropTile } from './DropTile'; import { RomSourceMenu } from './RomSourceMenu'; import { useToast } from './Toast'; import { cn } from '../lib/cn'; -import { formatIpcError } from '../lib/errors'; +import { formatIpcError, isHashMismatchError } from '../lib/errors'; import { + AlertIcon, FolderIcon, PackageIcon, PlusCircleIcon, @@ -101,6 +103,13 @@ export function ApplyPanel() { const [lastError, setLastError] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); + // A hash mismatch pauses the apply for explicit user confirmation; the + // failed options are kept so "Apply anyway" can retry with the override. + const [overridePrompt, setOverridePrompt] = useState<{ + options: ApplyOptions; + message: string; + } | null>(null); + // Success state auto-reverts to ready after a few seconds. const successTimer = useRef(null); useEffect(() => { @@ -192,37 +201,28 @@ export function ApplyPanel() { return { algo, expected_hex: trimmed }; } - async function handleApply() { - // From success or error, a click clears the state and re-arms. - if (applyState === 'success' || applyState === 'error') { - if (successTimer.current !== null) { - window.clearTimeout(successTimer.current); - successTimer.current = null; - } - setLastReport(null); - setLastError(null); - return; - } - if (applyState !== 'ready' || !romPath || !patchPath || !outPath) return; + async function runApply(options: ApplyOptions) { + if (!romPath || !patchPath || !outPath) return; setRunning(true); setLastReport(null); setLastError(null); - const options: ApplyOptions = { - strip_header: stripHeader, - fix_checksum: fixChecksum, - verify_input: showVerify ? buildSpec(verifyInputAlgo, verifyInputHex) : null, - verify_output: showVerify ? buildSpec(verifyOutputAlgo, verifyOutputHex) : null, - format_override: formatOverride, - }; try { const report = await applyPatch(romPath, patchPath, outPath, options); setLastReport(report); - toast({ - title: 'Patch applied', - description: `${FORMAT_DISPLAY[report.format]} — wrote ${report.out_size.toLocaleString()} bytes`, - variant: 'success', - }); + if (report.hash_warnings.length > 0) { + toast({ + title: 'Patch applied despite hash mismatch', + description: report.hash_warnings.join('; '), + variant: 'warning', + }); + } else { + toast({ + title: 'Patch applied', + description: `${FORMAT_DISPLAY[report.format]} — wrote ${report.out_size.toLocaleString()} bytes`, + variant: 'success', + }); + } if (successTimer.current !== null) window.clearTimeout(successTimer.current); successTimer.current = window.setTimeout(() => { setLastReport(null); @@ -248,6 +248,12 @@ export function ApplyPanel() { }); } } catch (err) { + // A hash mismatch is overridable: ask for explicit confirmation + // instead of failing, unless this run was already an override. + if (!options.ignore_hash_mismatch && isHashMismatchError(err)) { + setOverridePrompt({ options, message: formatIpcError(err) }); + return; + } const message = formatIpcError(err); setLastError(message); toast({ @@ -260,6 +266,29 @@ export function ApplyPanel() { } } + async function handleApply() { + // From success or error, a click clears the state and re-arms. + if (applyState === 'success' || applyState === 'error') { + if (successTimer.current !== null) { + window.clearTimeout(successTimer.current); + successTimer.current = null; + } + setLastReport(null); + setLastError(null); + return; + } + if (applyState !== 'ready' || !romPath || !patchPath || !outPath) return; + + await runApply({ + strip_header: stripHeader, + fix_checksum: fixChecksum, + verify_input: showVerify ? buildSpec(verifyInputAlgo, verifyInputHex) : null, + verify_output: showVerify ? buildSpec(verifyOutputAlgo, verifyOutputHex) : null, + format_override: formatOverride, + ignore_hash_mismatch: false, + }); + } + // ROM came from a file picker or drag-drop: real on-disk path, no library // entry tied to it. Clear any prior library display name. function handleRomFromTile(path: string | null) { @@ -494,6 +523,28 @@ export function ApplyPanel() { onVerifyOutputHexChange={setVerifyOutputHex} /> + + } + confirmIcon={} + busyLabel="Applying…" + onConfirm={() => { + const pending = overridePrompt; + setOverridePrompt(null); + if (pending) { + void runApply({ ...pending.options, ignore_hash_mismatch: true }); + } + }} + onCancel={() => setOverridePrompt(null)} + /> ); } diff --git a/crates/rompatch-gui/ui/src/components/ConfirmDialog.tsx b/crates/rompatch-gui/ui/src/components/ConfirmDialog.tsx index 1bce903..d5a0514 100644 --- a/crates/rompatch-gui/ui/src/components/ConfirmDialog.tsx +++ b/crates/rompatch-gui/ui/src/components/ConfirmDialog.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import type { ReactNode } from 'react'; import { Button } from './Button'; import { cn } from '../lib/cn'; import { TrashIcon } from '../lib/icons'; @@ -9,6 +10,11 @@ interface ConfirmDialogProps { body: string; confirmLabel: string; busy?: boolean; + /** Header + confirm-button icon. Defaults to the delete trash can. */ + icon?: ReactNode; + confirmIcon?: ReactNode; + /** Confirm-button label while `busy`. Defaults to "Deleting…". */ + busyLabel?: string; onConfirm: () => void; onCancel: () => void; } @@ -19,6 +25,9 @@ export function ConfirmDialog({ body, confirmLabel, busy, + icon, + confirmIcon, + busyLabel, onConfirm, onCancel, }: ConfirmDialogProps) { @@ -60,7 +69,7 @@ export function ConfirmDialog({ 'bg-danger/15 text-danger', )} > - + {icon ?? }
@@ -78,12 +87,12 @@ export function ConfirmDialog({
diff --git a/crates/rompatch-gui/ui/src/lib/errors.ts b/crates/rompatch-gui/ui/src/lib/errors.ts index 1869116..a1532e2 100644 --- a/crates/rompatch-gui/ui/src/lib/errors.ts +++ b/crates/rompatch-gui/ui/src/lib/errors.ts @@ -10,3 +10,14 @@ export function formatIpcError(err: unknown): string { } return String(err); } + +// True when the rejection is a hash/checksum mismatch the user may override +// by retrying with `ignore_hash_mismatch` (see IpcError in types.ts). +export function isHashMismatchError(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'hash_mismatch' in err && + (err as { hash_mismatch: unknown }).hash_mismatch === true + ); +} diff --git a/crates/rompatch-gui/ui/src/lib/types.ts b/crates/rompatch-gui/ui/src/lib/types.ts index 6aa38c3..a729ad5 100644 --- a/crates/rompatch-gui/ui/src/lib/types.ts +++ b/crates/rompatch-gui/ui/src/lib/types.ts @@ -34,6 +34,9 @@ export interface ApplyOptions { verify_input: HashSpec | null; verify_output: HashSpec | null; format_override: FormatKind | null; + /** Downgrade hash mismatches to warnings. Set only after the user + * explicitly confirms patching a non-matching ROM. */ + ignore_hash_mismatch: boolean; } export interface PatchInfo { @@ -56,11 +59,16 @@ export interface ApplyReport { out_size: number; stripped_header: HeaderKind | null; fixed_checksum: ChecksumFamily | null; + /** Hash mismatches bypassed via `ignore_hash_mismatch`. */ + hash_warnings: string[]; } export interface IpcError { kind: 'io' | 'apply' | 'patch' | 'json' | 'library' | 'tauri'; message: string; + /** True when the failure is a hash mismatch the user may override by + * retrying with `ignore_hash_mismatch`. */ + hash_mismatch: boolean; } // ---------- library ---------- diff --git a/crates/rompatch/README.md b/crates/rompatch/README.md index 4448b88..5c423cc 100644 --- a/crates/rompatch/README.md +++ b/crates/rompatch/README.md @@ -27,6 +27,7 @@ input. | `--fix-checksum` | Recompute Game Boy or Mega Drive cartridge checksum after patching | | `--verify-input ` | Check input ROM hash before patching | | `--verify-output ` | Check output ROM hash after patching | +| `--force` | Apply even when hash checks fail (embedded patch checksums and `--verify-*`); each mismatch is printed as a warning. Size and structure checks still fail hard | `` is one of `crc32`, `md5`, `sha1`, `adler32`. `` is the expected digest as lowercase hex. @@ -46,6 +47,9 @@ rompatch apply headered.smc hack.ips --strip-header --fix-checksum # Force a format when autodetect would be ambiguous or wrong rompatch apply rom.gba weird.bin --format ips + +# Patch a ROM the patch was not made for (hash mismatch becomes a warning) +rompatch apply rom-rev-b.gba hack.ups --force ``` ### `detect` - print the detected format diff --git a/crates/rompatch/src/cli.rs b/crates/rompatch/src/cli.rs index 713f34d..1df394d 100644 --- a/crates/rompatch/src/cli.rs +++ b/crates/rompatch/src/cli.rs @@ -14,6 +14,7 @@ pub enum Command { verify_input: Option, verify_output: Option, format_override: Option, + force: bool, }, Detect { patch: PathBuf, @@ -81,11 +82,13 @@ fn parse_apply(mut p: lexopt::Parser) -> Result { let mut verify_input: Option = None; let mut verify_output: Option = None; let mut format_override: Option = None; + let mut force = false; while let Some(arg) = p.next()? { match arg { Short('o') | Long("output") => out = Some(PathBuf::from(p.value()?)), Long("strip-header") => strip_header = true, Long("fix-checksum") => fix_checksum = true, + Long("force") => force = true, Long("verify-input") => { verify_input = Some( p.value()? @@ -122,6 +125,7 @@ fn parse_apply(mut p: lexopt::Parser) -> Result { verify_input, verify_output, format_override, + force, }) } diff --git a/crates/rompatch/src/commands/apply.rs b/crates/rompatch/src/commands/apply.rs index f66f92b..50e49b4 100644 --- a/crates/rompatch/src/commands/apply.rs +++ b/crates/rompatch/src/commands/apply.rs @@ -15,6 +15,7 @@ pub struct Args<'a> { pub verify_input: Option, pub verify_output: Option, pub format_override: Option, + pub force: bool, } pub fn run(args: Args<'_>) -> Result<(), CommandError> { @@ -42,10 +43,14 @@ pub fn run(args: Args<'_>) -> Result<(), CommandError> { .map(HashSpec::parse) .transpose()?, format_override, + ignore_hash_mismatch: args.force, }; let outcome = apply::run(&rom, &patch, &opts)?; + for warning in &outcome.hash_warnings { + eprintln!("warning: {warning} (ignored via --force)"); + } if let Some(kind) = outcome.stripped_header { eprintln!("stripped {} header", kind.name()); } diff --git a/crates/rompatch/src/commands/mod.rs b/crates/rompatch/src/commands/mod.rs index bbbc37b..a7254e0 100644 --- a/crates/rompatch/src/commands/mod.rs +++ b/crates/rompatch/src/commands/mod.rs @@ -18,6 +18,7 @@ pub fn dispatch(cmd: Command) -> Result<(), CommandError> { verify_input, verify_output, format_override, + force, } => apply::run(apply::Args { rom_path: &rom, patch_path: &patch, @@ -27,6 +28,7 @@ pub fn dispatch(cmd: Command) -> Result<(), CommandError> { verify_input, verify_output, format_override, + force, }), Command::Detect { patch } => detect::run(&patch), Command::Info { patch } => info::run(&patch), @@ -57,6 +59,10 @@ impl fmt::Display for CommandError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Apply(e) if e.is_hash_mismatch() => write!( + f, + "{e}\nhint: pass --force to apply anyway (the result may not work)" + ), Self::Apply(e) => write!(f, "{e}"), Self::Patch(e) => write!(f, "{e}"), Self::UnknownFormat => f.write_str("unknown patch format (no recognized magic bytes)"), diff --git a/crates/rompatch/src/main.rs b/crates/rompatch/src/main.rs index 0b75e7e..8728d58 100644 --- a/crates/rompatch/src/main.rs +++ b/crates/rompatch/src/main.rs @@ -51,6 +51,8 @@ APPLY OPTIONS: checksum after patching --verify-input Check input ROM hash before patching --verify-output Check output ROM hash after patching + --force Apply even when hash checks fail; + mismatches are printed as warnings HASH ALGORITHMS: crc32, md5, sha1, adler32