diff --git a/helpers/mister/src/scanner.rs b/helpers/mister/src/scanner.rs index 2965977..da41a06 100644 --- a/helpers/mister/src/scanner.rs +++ b/helpers/mister/src/scanner.rs @@ -1170,10 +1170,11 @@ fn is_plausible_save_for_system(ext: &str, size: u64, slug: &str) -> bool { // canonical layout) up to ~64 bytes for variants with extra // metadata. Don't apply the SRAM size profile to it. "rtc" => (1..=64).contains(&size), - _ => matches!( - size, - 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768 | 65536 - ), + // Authoritative GB/GBC SRAM sizes (save-format spec): 512B, 2K, 8K, + // 32K, 64K, 128K (MBC5 max). Drops non-real power-of-two values and + // ADDS 131072 — the old ceiling silently rejected legit 128K MBC5 + // saves. + _ => matches!(size, 512 | 2048 | 8192 | 32768 | 65536 | 131072), }, "gba" => matches!(size, 512 | 8192 | 32768 | 65536 | 131072), "n64" => match ext { diff --git a/helpers/steamdeck/src/api.rs b/helpers/steamdeck/src/api.rs index 98d4dd6..140370d 100644 --- a/helpers/steamdeck/src/api.rs +++ b/helpers/steamdeck/src/api.rs @@ -73,6 +73,12 @@ pub struct LatestSaveResponse { pub sha256: Option, pub version: Option, pub id: Option, + /// Bare lowercased on-disk extension of the canonical record (e.g. "srm", + /// "rtc"), from RSM's enriched /latest. Absent on older RSM and on + /// exists=false responses — `None` then, which the download guard treats as + /// "no cross-ext check" (degrades to prior behavior). + #[serde(default)] + pub format: Option, } #[derive(Debug, Clone)] diff --git a/helpers/steamdeck/src/cli.rs b/helpers/steamdeck/src/cli.rs index 10e7254..dc4226a 100644 --- a/helpers/steamdeck/src/cli.rs +++ b/helpers/steamdeck/src/cli.rs @@ -143,6 +143,26 @@ pub enum Commands { #[arg(long = "poll-interval", default_value_t = 5)] poll_interval: u64, }, + /// Black-box decision dump for the save-format verification harness: for each + /// fixture, print classify (system/format/sidecar), upload-slot, the simulated + /// cloud-latest format, the write-guard verdict (allow/skip) and the target. + /// Drives the REAL classify / slot / guard code over real save fixtures + a + /// simulated cloud-latest state — no network, no sync, deterministic. + ExplainSync { + /// Directory of save fixtures. Lay them under system-hinted subdirs + /// (e.g. `gbc/`, `n64/`, `psx/`) — the classifier resolves the system + /// from the path. Also holds `fixtures-manifest.tsv` + /// (`\t` per line) unless --manifest overrides. + #[arg(long = "fixtures-dir")] + fixtures_dir: PathBuf, + /// JSON map keyed `::` -> the /latest object + /// (`{exists, format?, version?, id?}`): the simulated cloud state. + #[arg(long = "cloud-state")] + cloud_state: PathBuf, + /// Manifest path override (default `/fixtures-manifest.tsv`). + #[arg(long)] + manifest: Option, + }, } #[derive(Debug, Subcommand)] diff --git a/helpers/steamdeck/src/lib.rs b/helpers/steamdeck/src/lib.rs index 1f4f179..ba4ade8 100644 --- a/helpers/steamdeck/src/lib.rs +++ b/helpers/steamdeck/src/lib.rs @@ -52,6 +52,17 @@ pub fn run() -> Result<()> { bail!("`--verbose` en `--quiet` kunnen niet tegelijk actief zijn"); } + // The verification harness is fully offline (no eGauge config / auth): handle + // it before any config load so it runs anywhere with just the fixtures. + if let Commands::ExplainSync { + fixtures_dir, + cloud_state, + manifest, + } = &cli.command + { + return run_explain_sync(fixtures_dir, cloud_state, manifest.as_deref()); + } + let global_overrides = ConfigOverrides { url: cli.url.clone(), api_url: cli.api_url.clone(), @@ -687,11 +698,106 @@ fn dispatch(cli: Cli, loaded: LoadedConfig) -> Result<()> { let cfg = loaded.config.clone(); run_device_auth(&cfg, poll_interval, cli.quiet)?; } + Commands::ExplainSync { .. } => unreachable!("handled offline in run()"), } Ok(()) } +/// Black-box decision dump for the save-format verification harness. For each +/// fixture (named in `/fixtures-manifest.tsv` as +/// `\t`), drive the REAL classify / slot / write-guard +/// code over the file's bytes + a simulated cloud-latest state, and print one +/// tab-separated decision row. No network, no sync — deterministic, so a fixture +/// matrix can assert the bytes never get clobbered or mis-routed. +fn run_explain_sync( + fixtures_dir: &Path, + cloud_state: &Path, + manifest: Option<&Path>, +) -> Result<()> { + use std::collections::BTreeMap; + + let manifest_path = manifest.map_or_else( + || fixtures_dir.join("fixtures-manifest.tsv"), + Path::to_path_buf, + ); + let manifest_text = fs::read_to_string(&manifest_path) + .with_context(|| format!("reading manifest {}", manifest_path.display()))?; + let mut identities: BTreeMap = BTreeMap::new(); + for line in manifest_text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut parts = line.splitn(2, '\t'); + if let (Some(file), Some(identity)) = (parts.next(), parts.next()) { + identities.insert( + file.trim().to_string(), + identity.trim().to_ascii_lowercase(), + ); + } + } + + let cloud_text = fs::read_to_string(cloud_state) + .with_context(|| format!("reading cloud-state {}", cloud_state.display()))?; + let cloud: serde_json::Value = serde_json::from_str(&cloud_text) + .with_context(|| format!("parsing cloud-state JSON {}", cloud_state.display()))?; + + println!( + "# explain-sync: {} fixture(s) from {}", + identities.len(), + fixtures_dir.display() + ); + println!("file\tsystem\tformat\tsidecar\tupload_slot\tcloud_key\tcloud_format\tguard"); + for (file, identity) in &identities { + let path = fixtures_dir.join(file); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(err) => { + println!("{file}\t"); + continue; + } + }; + let Some(classification) = crate::scanner::classify(&path, &bytes) else { + println!("{file}\t\t-\t-\t-\t-\t-\t-"); + continue; + }; + let slot = + crate::syncer::resolve_slot_name_for_sync(&classification.system, &path, "default"); + let key = format!("{identity}::{slot}"); + let entry = cloud.get(&key); + let exists = entry + .and_then(|value| value.get("exists")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let cloud_format = entry + .and_then(|value| value.get("format")) + .and_then(serde_json::Value::as_str) + .map(str::to_ascii_lowercase); + let guard = match (exists, cloud_format.as_deref()) { + (true, Some(canonical)) => { + if crate::scanner::allow_write(canonical, &classification.format) { + "ALLOW" + } else { + "SKIP" + } + } + _ => "n/a (no canonical)", + }; + println!( + "{file}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + classification.system, + classification.format, + classification.is_sidecar, + slot, + key, + cloud_format.as_deref().unwrap_or("-"), + guard, + ); + } + Ok(()) +} + fn parse_emulator_profile(value: &str) -> Result { EmulatorProfile::parse(value).ok_or_else(|| anyhow!("ongeldig emulatorprofiel '{}'", value)) } diff --git a/helpers/steamdeck/src/scanner.rs b/helpers/steamdeck/src/scanner.rs index df4e6c7..2b0f952 100644 --- a/helpers/steamdeck/src/scanner.rs +++ b/helpers/steamdeck/src/scanner.rs @@ -545,9 +545,62 @@ pub fn classify_supported_save( rom_path: Option<&Path>, ) -> Option { let save_ext = path_extension(save_path)?; - let save_size = save_path.metadata().ok()?.len(); - if !is_plausible_save_size(save_size) || looks_plain_text(save_path) { + let plain_text = looks_plain_text(save_path); + classify_core(save_path, save_ext, save_size, plain_text, rom_path) +} + +/// Black-box classification for the verification harness: the system slug plus +/// the bare lowercased on-disk `format` (ext) and whether it's a sidecar. Driven +/// from raw bytes (size/content) + the path (name/dir hints) — no filesystem +/// read — so it is table-driveable against real save fixtures. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Classification { + pub system: String, + pub format: String, + pub is_sidecar: bool, +} + +/// Classify a save from its `save_path` (name + directory hints) and its raw +/// `bytes` (size + plain-text screen). Returns `None` for an unrecognized / +/// implausible save. The `format` is the bare lowercased extension and matches +/// RSM's `/latest` `format` field + the on-disk target extension. +pub fn classify(save_path: &Path, bytes: &[u8]) -> Option { + let save_ext = path_extension(save_path)?; + let classified = classify_core( + save_path, + save_ext.clone(), + bytes.len() as u64, + bytes_look_plain_text(bytes), + None, + )?; + Some(Classification { + system: classified.system_slug, + is_sidecar: is_sidecar(&save_ext), + format: save_ext, + }) +} + +fn bytes_look_plain_text(bytes: &[u8]) -> bool { + let sample = &bytes[..bytes.len().min(4096)]; + if sample.is_empty() || sample.contains(&0) { + return false; + } + let printable = sample + .iter() + .filter(|value| matches!(**value, b'\n' | b'\r' | b'\t' | 0x20..=0x7e)) + .count(); + printable.saturating_mul(100) >= sample.len().saturating_mul(95) +} + +fn classify_core( + save_path: &Path, + save_ext: String, + save_size: u64, + plain_text: bool, + rom_path: Option<&Path>, +) -> Option { + if !is_plausible_save_size(save_size) || plain_text { return None; } @@ -903,6 +956,50 @@ fn path_extension(path: &Path) -> Option { .map(|value| value.to_ascii_lowercase()) } +/// Save-format extensions that are SIDECARS — independent companion state that +/// must never slot-collide with, or overwrite, the primary battery save. +/// Authoritative set owned by the save-format spec: +/// rtc — GB/GBC real-time clock (Pokemon Crystal/Gold/Silver, Harvest Moon). +/// mpk / cpk — N64 Controller-Pak / mempak (separate from the .eep/.fla/.sra +/// battery save). +/// Extensible later (e.g. Dreamcast VMU); these three are the in-scope set. +pub const SIDECAR_FORMATS: [&str; 3] = ["rtc", "mpk", "cpk"]; + +/// Whether a bare lowercased extension is a sidecar format. +pub fn is_sidecar(ext: &str) -> bool { + SIDECAR_FORMATS.contains(&ext) +} + +/// Download write-guard: may a cloud record of `canonical_format` be written to +/// a local file of extension `target_ext`? Both are bare lowercased extensions +/// (matching the RSM `/latest` `format` field and the on-disk target ext). +/// +/// If EITHER side is a sidecar, the write is allowed ONLY when the formats are +/// identical — a `.rtc` canonical may land only on a `.rtc` target, never on a +/// `.srm` (and vice versa). If NEITHER side is a sidecar, the write is always +/// allowed: primary↔primary differences are legitimate container conversions +/// (PSX `mcr`→`vmp`, raw `srm`→`sav`) handled elsewhere and must not be blocked. +pub fn allow_write(canonical_format: &str, target_ext: &str) -> bool { + let canonical = canonical_format.to_ascii_lowercase(); + let target = target_ext.to_ascii_lowercase(); + if is_sidecar(&canonical) || is_sidecar(&target) { + return canonical == target; + } + true +} + +/// Human-readable slot LABEL for a sidecar extension (save-format spec). +/// Used to build the RSM-native distinct slot name `" (