mirror of
https://github.com/unicxrn/prospack.git
synced 2026-10-17 12:01:31 +02:00
Trigger ShadowMountPlus scans
This commit is contained in:
1 parent
2fe5076ef9
commit
b80c3d3117
5 files changed
+104
-10
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user