diff --git a/crates/fbuild-deploy/src/lib.rs b/crates/fbuild-deploy/src/lib.rs index aca0584b..a16fc711 100644 --- a/crates/fbuild-deploy/src/lib.rs +++ b/crates/fbuild-deploy/src/lib.rs @@ -36,6 +36,7 @@ use crate::esp32::FlashRegion; /// `/api/deploy` response message can distinguish between: /// /// * a full baseline write (all regions / non-ESP platforms), +/// * a volatile RAM-only load, /// * a verify-skip (device already held the requested image), and /// * a selective rewrite (only some ESP32 flash regions were written /// because bootloader/partitions already matched). @@ -45,6 +46,9 @@ use crate::esp32::FlashRegion; pub enum DeployOutcome { /// All regions / the full image were written to the device. FullFlash, + /// A volatile image was accepted into MCU RAM for execution without + /// programming non-volatile flash. Runtime success is not implied. + RamLoad, /// `esptool verify-flash` matched every region — no write was /// performed. The device has been hard-reset by esptool. VerifySkip, @@ -58,11 +62,13 @@ impl DeployOutcome { /// outcome. Stable — consumers may parse it. /// /// * `FullFlash` → `"full flash"` + /// * `RamLoad` → `"RAM load accepted"` /// * `VerifySkip` → `"verify skipped, device already matched"` /// * `SelectiveFlash` → `"selective flash: firmware"`, etc. pub fn describe(&self) -> String { match self { DeployOutcome::FullFlash => "full flash".to_string(), + DeployOutcome::RamLoad => "RAM load accepted".to_string(), DeployOutcome::VerifySkip => "verify skipped, device already matched".to_string(), DeployOutcome::SelectiveFlash { regions } => { let names: Vec<&'static str> = regions @@ -88,9 +94,9 @@ pub struct DeploymentResult { pub stdout: String, /// Captured stderr from the deploy tool. pub stderr: String, - /// What actually happened on the device (full / verify-skip / - /// selective). Surfaced in the daemon's HTTP response message so - /// consumers can tell an MD5-skip from a real write. + /// What actually happened on the device (full / RAM load / verify-skip / + /// selective). Surfaced in the daemon's HTTP response message so consumers + /// can tell an MD5-skip or volatile load from a real flash write. pub outcome: DeployOutcome, } @@ -165,6 +171,11 @@ mod outcome_tests { assert_eq!(DeployOutcome::FullFlash.describe(), "full flash"); } + #[test] + fn ram_load_describe() { + assert_eq!(DeployOutcome::RamLoad.describe(), "RAM load accepted"); + } + #[test] fn verify_skip_describe() { assert_eq!( diff --git a/crates/fbuild-deploy/src/rp2040.rs b/crates/fbuild-deploy/src/rp2040.rs index 6b67039e..8a3f22b0 100644 --- a/crates/fbuild-deploy/src/rp2040.rs +++ b/crates/fbuild-deploy/src/rp2040.rs @@ -33,9 +33,19 @@ const RP2350_FAMILY_ID: u32 = 0xE48B_FF59; // boot2 has already been stripped. Encoding this full image at 0x2000 leaves // stock ROM BOOTSEL in place after an apparently successful copy. const RP2040_UF2_BASE_ADDRESS: u32 = 0x1000_0000; +const RP2040_XIP_SRAM_START: u32 = 0x1500_0000; +const RP2040_XIP_SRAM_END: u32 = 0x1500_4000; +const RP2040_SRAM_START: u32 = 0x2000_0000; +const RP2040_SRAM_END: u32 = 0x2004_2000; const UF2_PAYLOAD_SIZE: usize = 256; const UF2_BLOCK_SIZE: usize = 512; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Uf2Target { + Flash, + Ram, +} + /// Build UF2 blocks for a raw RP2040 flash image. pub fn encode_uf2(binary: &[u8]) -> Vec { encode_uf2_at_address(binary, RP2040_UF2_BASE_ADDRESS, RP2040_FAMILY_ID) @@ -228,7 +238,7 @@ fn touch_1200bps(port: &str) -> Result<()> { } } -fn prepare_uf2_artifact(firmware_path: &Path, family_id: u32) -> Result { +fn prepare_uf2_artifact(firmware_path: &Path, family_id: u32) -> Result<(PathBuf, Uf2Target)> { let input_is_uf2 = firmware_path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("uf2")); @@ -271,9 +281,9 @@ fn prepare_uf2_artifact(firmware_path: &Path, family_id: u32) -> Result })?; (artifact, bytes) }; - validate_uf2(&uf2, family_id)?; + let target = validate_uf2(&uf2, family_id)?; - Ok(artifact) + Ok((artifact, target)) } fn copy_prepared_uf2(artifact: &Path, volume: &Path) -> Result { @@ -284,7 +294,7 @@ fn copy_prepared_uf2(artifact: &Path, volume: &Path) -> Result { #[cfg(test)] fn write_uf2(firmware_path: &Path, volume: &Path, family_id: u32) -> Result { - let artifact = prepare_uf2_artifact(firmware_path, family_id)?; + let (artifact, _) = prepare_uf2_artifact(firmware_path, family_id)?; copy_prepared_uf2(&artifact, volume) } @@ -501,7 +511,7 @@ fn is_device_disappearance_error(error: &std::io::Error) -> bool { ) } -fn validate_uf2(bytes: &[u8], expected_family: u32) -> Result<()> { +fn validate_uf2(bytes: &[u8], expected_family: u32) -> Result { if bytes.is_empty() || bytes.len() % UF2_BLOCK_SIZE != 0 { return Err(FbuildError::DeployFailed(format!( "malformed RP2040 UF2: size {} is not a non-zero multiple of {UF2_BLOCK_SIZE}", @@ -510,7 +520,8 @@ fn validate_uf2(bytes: &[u8], expected_family: u32) -> Result<()> { } let block_count = bytes.len() / UF2_BLOCK_SIZE; let mut seen = vec![false; block_count]; - let mut seen_targets = BTreeSet::new(); + let mut seen_ranges = Vec::with_capacity(block_count); + let mut image_target = None; let flash_end = if expected_family == RP2350_FAMILY_ID { 0x1400_0000u32 } else { @@ -539,34 +550,80 @@ fn validate_uf2(bytes: &[u8], expected_family: u32) -> Result<()> { ))); } let target_address = field(12); + let Some(target_end) = target_address.checked_add(UF2_PAYLOAD_SIZE as u32) else { + return Err(FbuildError::DeployFailed(format!( + "malformed RP2040 UF2 block metadata at block {index}" + ))); + }; let block_number = field(20) as usize; + let is_flash = target_address >= RP2040_UF2_BASE_ADDRESS + && target_end <= flash_end + && target_address % UF2_PAYLOAD_SIZE as u32 == 0; + let is_rp2040_ram = expected_family == RP2040_FAMILY_ID + && ((target_address >= RP2040_XIP_SRAM_START && target_end <= RP2040_XIP_SRAM_END) + || (target_address >= RP2040_SRAM_START && target_end <= RP2040_SRAM_END)); + let block_target = if is_flash { + Uf2Target::Flash + } else if is_rp2040_ram { + Uf2Target::Ram + } else { + return Err(FbuildError::DeployFailed(format!( + "malformed RP2040 UF2 block metadata at block {index}" + ))); + }; if field(16) != UF2_PAYLOAD_SIZE as u32 || block_number >= block_count || field(24) as usize != block_count - || target_address % UF2_PAYLOAD_SIZE as u32 != 0 - || !(RP2040_UF2_BASE_ADDRESS..flash_end).contains(&target_address) { return Err(FbuildError::DeployFailed(format!( "malformed RP2040 UF2 block metadata at block {index}" ))); } + if image_target.is_some_and(|target| target != block_target) { + return Err(FbuildError::DeployFailed( + "malformed RP2040 UF2: image mixes flash and RAM target addresses".to_string(), + )); + } + image_target = Some(block_target); if std::mem::replace(&mut seen[block_number], true) { return Err(FbuildError::DeployFailed(format!( "malformed RP2040 UF2: duplicate block number {block_number}" ))); } - if !seen_targets.insert(target_address) { - return Err(FbuildError::DeployFailed(format!( - "malformed RP2040 UF2: overlapping target address 0x{target_address:08X}" - ))); - } + seen_ranges.push((target_address, target_end)); } if seen.iter().any(|present| !present) { return Err(FbuildError::DeployFailed( "malformed RP2040 UF2: block-number sequence is incomplete".to_string(), )); } - Ok(()) + seen_ranges.sort_unstable_by_key(|&(start, _)| start); + if let Some(overlap) = seen_ranges.windows(2).find(|ranges| ranges[1].0 < ranges[0].1) { + return Err(FbuildError::DeployFailed(format!( + "malformed RP2040 UF2: overlapping target address 0x{:08X}", + overlap[1].0 + ))); + } + Ok(image_target.expect("non-empty UF2 has at least one block")) +} + +fn ram_load_result( + volume: &Path, + transfer_method: &str, + stdout: String, + stderr: String, +) -> DeploymentResult { + DeploymentResult { + success: true, + message: format!( + "RP2040 RAM image accepted for execution via {transfer_method} ({})", + volume.display(), + ), + port: None, + stdout, + stderr, + outcome: DeployOutcome::RamLoad, + } } fn wait_for_volume_disappearance(volume: &Path, timeout: Duration) -> Result<()> { @@ -813,13 +870,14 @@ impl Deployer for Rp2040Deployer { }; let firmware = firmware_path.to_path_buf(); let family_id = self.family_id; - let artifact = tokio::task::spawn_blocking(move || { - prepare_uf2_artifact(&firmware, family_id) - }) - .await - .map_err(|error| { - FbuildError::DeployFailed(format!("RP2040 UF2 preparation task failed: {error}")) - })??; + let (artifact, uf2_target) = + tokio::task::spawn_blocking(move || prepare_uf2_artifact(&firmware, family_id)) + .await + .map_err(|error| { + FbuildError::DeployFailed(format!( + "RP2040 UF2 preparation task failed: {error}" + )) + })??; let artifact_for_copy = artifact.clone(); let volume_for_copy = volume.clone(); let copy_result = tokio::task::spawn_blocking(move || { @@ -858,6 +916,14 @@ impl Deployer for Rp2040Deployer { .map_err(|error| { FbuildError::DeployFailed(format!("RP2040 eject watcher failed: {error}")) })??; + if uf2_target == Uf2Target::Ram { + return Ok(ram_load_result( + &volume, + transfer_method, + transfer_stdout, + transfer_stderr, + )); + } let recovery_port = runtime_target.as_ref().map(|target| target.port.clone()); let recovery_serial = runtime_target .as_ref() @@ -1107,7 +1173,7 @@ mod tests { assert!(error.to_string().contains("duplicate block number")); let mut bad_address = encode_uf2(&[0; 1]); - bad_address[12..16].copy_from_slice(&0x2000_0000u32.to_le_bytes()); + bad_address[12..16].copy_from_slice(&0x3000_0000u32.to_le_bytes()); let error = validate_uf2(&bad_address, RP2040_FAMILY_ID).unwrap_err(); assert!(error.to_string().contains("block metadata")); @@ -1118,6 +1184,92 @@ mod tests { assert!(error.to_string().contains("overlapping target address")); } + #[test] + fn accepts_and_classifies_rp2040_ram_uf2() { + for target_address in [ + RP2040_SRAM_START, + RP2040_SRAM_END - UF2_PAYLOAD_SIZE as u32, + RP2040_XIP_SRAM_START + 1, + RP2040_XIP_SRAM_END - UF2_PAYLOAD_SIZE as u32, + ] { + let mut ram = encode_uf2(&[0xFE, 0xE7]); + ram[12..16].copy_from_slice(&target_address.to_le_bytes()); + assert_eq!( + validate_uf2(&ram, RP2040_FAMILY_ID).unwrap(), + Uf2Target::Ram + ); + } + + assert_eq!( + validate_uf2(&encode_uf2(&[1, 2, 3]), RP2040_FAMILY_ID).unwrap(), + Uf2Target::Flash + ); + } + + #[test] + fn prepared_artifact_preserves_ram_classification_and_bin_stays_flash() { + let root = tempdir().unwrap(); + let ram_path = root.path().join("probe.uf2"); + let mut ram = encode_uf2(&[0xFE, 0xE7]); + ram[12..16].copy_from_slice(&RP2040_SRAM_START.to_le_bytes()); + fs::write(&ram_path, ram).unwrap(); + let (prepared, target) = prepare_uf2_artifact(&ram_path, RP2040_FAMILY_ID).unwrap(); + assert_eq!(prepared, ram_path); + assert_eq!(target, Uf2Target::Ram); + + let bin_path = root.path().join("firmware.bin"); + fs::write(&bin_path, [1, 2, 3]).unwrap(); + let (_, target) = prepare_uf2_artifact(&bin_path, RP2040_FAMILY_ID).unwrap(); + assert_eq!(target, Uf2Target::Flash); + } + + #[test] + fn ram_load_result_is_success_without_claiming_a_runtime_port() { + let result = ram_load_result( + Path::new("G:/"), + "BOOTSEL mass-storage", + "wrote G:/NEW.UF2".to_string(), + String::new(), + ); + assert!(result.success); + assert_eq!(result.port, None); + assert!(result.message.contains("accepted for execution")); + assert!(matches!(result.outcome, DeployOutcome::RamLoad)); + } + + #[test] + fn rejects_invalid_or_mixed_rp2040_ram_uf2() { + for target_address in [ + RP2040_XIP_SRAM_END - UF2_PAYLOAD_SIZE as u32 + 1, + RP2040_SRAM_END - UF2_PAYLOAD_SIZE as u32 + 1, + RP2040_SRAM_END, + ] { + let mut invalid = encode_uf2(&[0xFE, 0xE7]); + invalid[12..16].copy_from_slice(&target_address.to_le_bytes()); + let error = validate_uf2(&invalid, RP2040_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("block metadata")); + } + + let mut mixed = encode_uf2(&[0; UF2_PAYLOAD_SIZE + 1]); + mixed[512 + 12..512 + 16].copy_from_slice(&RP2040_SRAM_START.to_le_bytes()); + let error = validate_uf2(&mixed, RP2040_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("mixes flash and RAM")); + + let mut rp2350_ram = encode_uf2_for_family(&[0xFE, 0xE7], RP2350_FAMILY_ID); + rp2350_ram[12..16].copy_from_slice(&RP2040_SRAM_START.to_le_bytes()); + let error = validate_uf2(&rp2350_ram, RP2350_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("block metadata")); + } + + #[test] + fn rejects_overlapping_unaligned_ram_pages() { + let mut overlapping = encode_uf2(&[0; UF2_PAYLOAD_SIZE + 1]); + overlapping[12..16].copy_from_slice(&(RP2040_SRAM_START + 1).to_le_bytes()); + overlapping[512 + 12..512 + 16].copy_from_slice(&(RP2040_SRAM_START + 128).to_le_bytes()); + let error = validate_uf2(&overlapping, RP2040_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("overlapping target address")); + } + #[test] fn refuses_to_encode_elf_or_unknown_input_as_raw_flash() { let root = tempdir().unwrap();