diff --git a/README.md b/README.md index b34a396..9025b29 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,12 @@ deletes the dump. 4. **Mount.** The upload writes the same hints Game Compressor writes: `image_sector=.ffpfsc:65536` in `/data/shadowmount/autotune.ini` and `image_ro=<TITLE>.exfat` in `config.ini`. Both files are backed up first to - `~/.local/share/ps5-packer/backups/` and replaced atomically. Packer then polls - `/user/app/<TITLE>/mount_img.lnk` for up to two minutes. + `~/.local/share/ps5-packer/backups/` and replaced atomically. It also adds the image to + `manual.lst`, as Game Compressor does. Rewriting that file is what makes + ShadowMountPlus scan; a new file in `/data/homebrew` alone can sit unnoticed for + minutes. Packer then polls `/user/app/<TITLE>/mount_img.lnk` for up to five minutes, + rewriting `manual.lst` every 30 s. If the mount isn't confirmed, "Check again" repeats + this step. ## Using it diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index 34008a1..00fcf3c 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub const STAGES: [&str; 4] = ["compress", "verify", "upload", "mount"]; +const MOUNT_WAIT_SECS: u64 = 300; #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -267,9 +268,13 @@ impl Queue { /// or starts the upload of a verified job. pub fn resume(&self, id: u64) -> Result<(), String> { self.with(id, |j| { - if !matches!(j.status, JobStatus::Failed | JobStatus::Cancelled | JobStatus::Ready) { + let recheck = j.status == JobStatus::Done && j.stages[3].state == StageState::Warn; + if !recheck && !matches!(j.status, JobStatus::Failed | JobStatus::Cancelled | JobStatus::Ready) { return Err("Nothing to resume".to_string()); } + if recheck { + j.stages[3].state = StageState::Pending; + } let mut from = j.stages.iter().position(|s| s.state != StageState::Done).unwrap_or(STAGES.len()); // A failed verify means the image is bad: rebuild it rather than re-checking the same file. if j.status == JobStatus::Failed && from == 1 { @@ -720,26 +725,36 @@ impl Queue { }; ftp.quit(); } + match ps5::connect(s).and_then(|mut ftp| { + let r = ps5::request_scan(&mut ftp, &title, &remote, Some(&settings::data_dir().join("backups"))); + ftp.quit(); + r + }) { + Ok(true) => self.with(id, |j| j.log("", format!("ShadowMountPlus manual.lst: added {remote}"))), + Ok(false) => self.with(id, |j| j.log("", "ShadowMountPlus manual.lst: already listed, nudged a rescan")), + Err(e) => self.with(id, |j| j.log("warn", format!("Couldn't update manual.lst ({e}); waiting for the periodic scan"))), + }; let started = Instant::now(); let mut last = Instant::now(); - let mounted = ps5::wait_for_mount(s, &title, &remote, Duration::from_secs(120), cancel, &mut |secs| { + let mounted = ps5::wait_for_mount(s, &title, &remote, Duration::from_secs(MOUNT_WAIT_SECS), cancel, &mut |secs| { self.progress(id, &mut last, started, |j| { - j.pct = (secs as f64 / 120.0 * 100.0).min(99.0); + j.pct = (secs as f64 / MOUNT_WAIT_SECS as f64 * 100.0).min(99.0); j.stages[3].meta = format!("Waiting for ShadowMountPlus · {secs}s"); }); })?; self.with(id, |j| { if mounted { + j.warnings.retain(|w| !w.starts_with("ShadowMountPlus hasn't mounted")); j.stages[3].meta = "Mounted by ShadowMountPlus".into(); j.log("ok", "ShadowMountPlus mounted the image. It's ready to play."); } else { j.stages[3].state = StageState::Warn; - j.stages[3].meta = "Not confirmed within 2 minutes".into(); + j.stages[3].meta = "Not confirmed within 5 minutes".into(); j.warnings.push( - "ShadowMountPlus hasn't mounted the image yet. It scans every 15 s; check the PS5, or restart ShadowMountPlus." + "ShadowMountPlus hasn't mounted the image yet. The game may still show up; use Check again, or restart ShadowMountPlus." .into(), ); - j.log("warn", "Mount not confirmed within 2 minutes"); + j.log("warn", "Mount not confirmed within 5 minutes"); } }); if mounted && s.delete_local_after_upload { @@ -918,6 +933,8 @@ mod e2e { assert!(cfg.contains("image_ro=OTHER.exfat") && cfg.contains("image_ro=PPSA00001.exfat"), "{cfg}"); let at = std::fs::read_to_string(ps5_root.join("data/shadowmount/autotune.ini")).unwrap(); assert_eq!(at, "image_sector=PPSA00001.ffpfsc:65536\n"); + let ml = std::fs::read_to_string(ps5_root.join("data/shadowmount/manual.lst")).unwrap(); + assert_eq!(ml, "/data/homebrew/PPSA00001.ffpfsc\n"); assert!(dump.join("ampr_emu.index").is_file(), "MkPFS should have generated the AMPR index"); // Same title again: image reused locally, upload skipped as identical. diff --git a/src-tauri/src/ps5.rs b/src-tauri/src/ps5.rs index 565fcde..d9071c9 100644 --- a/src-tauri/src/ps5.rs +++ b/src-tauri/src/ps5.rs @@ -167,6 +167,48 @@ pub fn write_hints(ftp: &mut Ftp, title_id: &str, backup_dir: &Path) -> Result<H Ok(res) } +/// manual.lst with `image` added, dropping other entries for the same title (same rules as +/// Game Compressor: exact path, or a basename of TITLE followed by end, '-' or '.'). +pub fn manual_list_with(content: &str, title_id: &str, image: &str) -> String { + let mut out: Vec<&str> = content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .filter(|l| { + if *l == image { + return false; + } + let base = l.rsplit('/').next().unwrap_or(l); + let same_title = base.len() >= 9 + && base[..9].eq_ignore_ascii_case(title_id) + && matches!(base.as_bytes().get(9), None | Some(b'-') | Some(b'.')); + l.starts_with('#') || !same_title + }) + .collect(); + out.push(image); + out.join("\n") + "\n" +} + +/// Adds the image to /data/shadowmount/manual.lst. Rewriting that file is what makes +/// ShadowMountPlus scan; a new file in the folder alone may not be picked up for minutes. +pub fn request_scan(ftp: &mut Ftp, title_id: &str, image: &str, backup_dir: Option<&Path>) -> Result<bool, String> { + let path = format!("{SMP_DIR}/manual.lst"); + let current = ftp.retr(&path)?; + let text = String::from_utf8_lossy(current.as_deref().unwrap_or_default()).into_owned(); + let next = manual_list_with(&text, title_id, image); + let changed = next.trim_end() != text.trim_end(); + if changed { + if let (Some(dir), Some(bytes)) = (backup_dir, ¤t) { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + std::fs::write(dir.join(format!("{stamp}-manual.lst")), bytes).map_err(|e| e.to_string())?; + } + } + // Rewrite even when unchanged: the replace itself is the scan trigger. + ftp.put_atomic(&path, next.as_bytes())?; + Ok(changed) +} + /// Polls `/user/app/<TITLE>/mount_img.lnk` until ShadowMountPlus records the image. pub fn wait_for_mount( s: &Settings, @@ -178,7 +220,15 @@ pub fn wait_for_mount( ) -> Result<bool, String> { let start = Instant::now(); let link = format!("/user/app/{title_id}/mount_img.lnk"); + let mut last_nudge = Instant::now(); while start.elapsed() < timeout { + if last_nudge.elapsed() >= Duration::from_secs(30) { + last_nudge = Instant::now(); + if let Ok(mut ftp) = connect(s) { + let _ = request_scan(&mut ftp, title_id, image_remote, None); + ftp.quit(); + } + } if cancel.load(std::sync::atomic::Ordering::Relaxed) { return Err("Cancelled".into()); } @@ -227,6 +277,17 @@ mod tests { assert!(upsert_hint(before, &["image_ro", "image_rw"], &names(), Some("image_ro=PPSA01234.exfat")).is_none()); } + #[test] + fn manual_list_replaces_same_title_entries() { + let before = "/data/homebrew/PPSA05678.ffpfsc\n/data/homebrew/PPSA09012-app\n\n/data/homebrew/PPSA090120.ffpfsc\n"; + let after = manual_list_with(before, "PPSA09012", "/data/homebrew/PPSA09012.ffpfsc"); + assert_eq!( + after, + "/data/homebrew/PPSA05678.ffpfsc\n/data/homebrew/PPSA090120.ffpfsc\n/data/homebrew/PPSA09012.ffpfsc\n" + ); + assert_eq!(manual_list_with("", "PPSA09012", "/x/PPSA09012.ffpfsc"), "/x/PPSA09012.ffpfsc\n"); + } + #[test] fn drops_rw_rule_that_would_conflict() { let before = "image_rw=PPSA01234.exfat\n"; diff --git a/src/lib/Focus.svelte b/src/lib/Focus.svelte index 11abd7e..f86059d 100644 --- a/src/lib/Focus.svelte +++ b/src/lib/Focus.svelte @@ -43,7 +43,7 @@ <div class="left"> <div class="kicker">Step {job.stage + 1} of 4 · {STAGE_NAMES[stageKey]}</div> {#if stageKey === "mount"} - <div class="big num">{clock(job.stageSecs)}<small>of 2:00</small></div> + <div class="big num">{clock(job.stageSecs)}<small>of 5:00</small></div> {:else} <div class="big num">{shown.toFixed(1)}<small>%</small></div> {/if} @@ -114,7 +114,14 @@ <div><dt>Saved</dt><dd class="num ok">{gb(saved)} · {savedPct}%</dd></div> <div><dt>On PS5</dt><dd class="mono path">{job.remotePath}</dd></div> </dl> + {#if job.stages[3].state === "warn"} + <p class="desc"> + The image is on the PS5 and listed in ShadowMountPlus's manual.lst, but the mount wasn't confirmed in time. + It often appears a little later; check the game list on the PS5. + </p> + {/if} <div class="actions"> + {#if job.stages[3].state === "warn"}<button class="btn primary" onclick={onResume}>Check again</button>{/if} <button class="btn" onclick={onReveal}>Show image</button> <button class="btn ghost" onclick={onRemove}>Clear from list</button> </div> diff --git a/tests/fake_ps5.py b/tests/fake_ps5.py index c93a0d6..3b73139 100644 --- a/tests/fake_ps5.py +++ b/tests/fake_ps5.py @@ -15,8 +15,13 @@ if not os.path.exists(cfg): def fake_smp(): hb = os.path.join(root, "data/homebrew") while True: + # Like the real one in practice: new images are only picked up once manual.lst lists them. + try: + listed = open(os.path.join(root, "data/shadowmount/manual.lst")).read().split() + except FileNotFoundError: + listed = [] for n in os.listdir(hb): - if n.endswith(".ffpfsc") and not n.startswith("."): + if n.endswith(".ffpfsc") and not n.startswith(".") and f"/data/homebrew/{n}" in listed: tid = n[:9] d = os.path.join(root, "user/app", tid); os.makedirs(d, exist_ok=True) lnk = os.path.join(d, "mount_img.lnk")