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=.exfat` in `config.ini`. Both files are backed up first to
- `~/.local/share/ps5-packer/backups/` and replaced atomically. Packer then polls
- `/user/app//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//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 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 {
+ 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//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 {
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 @@
Step {job.stage + 1} of 4 · {STAGE_NAMES[stageKey]}
{#if stageKey === "mount"}
-
{clock(job.stageSecs)}of 2:00
+
{clock(job.stageSecs)}of 5:00
{:else}
{shown.toFixed(1)}%
{/if}
@@ -114,7 +114,14 @@
Saved
{gb(saved)} · {savedPct}%
On PS5
{job.remotePath}
+ {#if job.stages[3].state === "warn"}
+
+ 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.
+
+ {/if}
+ {#if job.stages[3].state === "warn"}{/if}
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")