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