Trigger ShadowMountPlus scans

This commit is contained in:
unicxrn committed 2026-10-10 21:07:09 +02:00
1 parent 2fe5076ef9
commit b80c3d3117
5 files changed
+104 -10

No files matched your search

+6 -2
View File
@@ -24,8 +24,12 @@ deletes the dump.
4. **Mount.** The upload writes the same hints Game Compressor writes:
`image_sector=<TITLE>.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
+23 -6
View File
@@ -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.
+61
View File
@@ -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, &current) {
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";
+8 -1
View File
@@ -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>
+6 -1
View File
@@ -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")