From 91cfb2cd028753ffe4d414464b84fd2e92dd1fda Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:31:00 +0200 Subject: [PATCH] Flag region patches, check free space, slim the console guide Disc and store installs whose update Sony serves from another region's master package can't be installed from homebrew; Sony's installer refused them in testing. They now sit under Not supported with a short reason instead of offering a download that leads nowhere. Before preallocating a package, PatchDL now checks that it fits on /data with 2 GB to spare and reports how much it needs. The Prospero link check runs the same test, so the phone gets the answer right away. The PS5 browser can't copy links, so on the console the old-FW guide only shows a large QR code and the link for the phone. --- src/patchdl_websrv.c | 53 +++++++++++++++++++-- web/app.js | 107 ++++++++++++++++++++++++++++++++----------- web/styles.css | 10 ++++ 3 files changed, 138 insertions(+), 32 deletions(-) diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 6e939e4..30edf52 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -109,7 +109,9 @@ typedef struct dl_job { int pieces_failed; int inflight; int fd; /* O_RDWR dest fd, -1 until admit */ - int rc; /* 0 ok, -1 net/io, -2 verify */ + int rc; /* 0 ok, -1 net/io, -2 verify, -3 no space */ + long long need_bytes; /* rc -3: bytes the package still needed */ + long long free_bytes; /* rc -3: bytes free on /data at the time */ int last_curl_rc; /* CURLcode from the most recent piece */ long last_http_code; /* HTTP status from the most recent piece */ struct dl_job *next; @@ -640,6 +642,28 @@ data_free_mb(void) { return 0; } +/* Headroom kept free on /data so a download never fills the console's + internal storage to the last byte. */ +#define PATCHDL_SPACE_MARGIN (2LL * 1024 * 1024 * 1024) + +/* Bytes still to be written for a package of `total` bytes at `path`: a + partial from an earlier run already occupies part of it. */ +static long long +space_needed(const char *path, long long total) { + struct stat st; + long long have = 0; + if (stat(path, &st) == 0) have = (long long)st.st_blocks * 512; + return total > have ? total - have : 0; +} + +/* 1 if `need` bytes plus the margin fit; *free_out gets the free bytes. */ +static int +space_ok(long long need, long long *free_out) { + long long free_b = data_free_mb() * 1024LL * 1024LL; + if (free_out) *free_out = free_b; + return need + PATCHDL_SPACE_MARGIN <= free_b; +} + /* Built fresh per request so free space stays live (a 60+ GB download moves it a lot). g_fw is read-only after startup, so this is thread-safe. */ /* The console's LAN address, so a page opened on the PS5 itself (127.0.0.1) @@ -950,6 +974,9 @@ build_downloads_json(void) { jbuf_appendf(&j, ",\"bytes\":%lld,\"total_bytes\":%lld", bytes, total); jbuf_appendf(&j, ",\"rc\":%d,\"last_curl_rc\":%d,\"last_http_code\":%ld", job->rc, job->last_curl_rc, job->last_http_code); + if (job->rc == -3) + jbuf_appendf(&j, ",\"need_bytes\":%lld,\"free_bytes\":%lld", + job->need_bytes, job->free_bytes); jbuf_append(&j, "}"); } jbuf_append(&j, "]"); @@ -1500,8 +1527,8 @@ admit_next(void) { dl_job_t *q = NULL; patchdl_manifest_t mf; char murl[768], dest[320], dir[288]; - int is_manifest, fd = -1, ok = 0; - long long total = 0, old_size = 0; + int is_manifest, fd = -1, ok = 0, no_space = 0; + long long total = 0, old_size = 0, need = 0, free_b = 0; dl_pstate_t *ps = NULL; unsigned char *bitmap = NULL; @@ -1526,7 +1553,9 @@ admit_next(void) { if (is_manifest) { if (patchdl_fetch_manifest(murl, &mf) == 0 && mf.count > 0) { total = mf.total; - fd = open(dest, O_RDWR | O_CREAT, 0666); + need = space_needed(dest, total); + if (!space_ok(need, &free_b)) no_space = 1; + else fd = open(dest, O_RDWR | O_CREAT, 0666); if (fd >= 0) { struct stat st; if (fstat(fd, &st) == 0) old_size = (long long)st.st_size; @@ -1563,7 +1592,12 @@ admit_next(void) { free(ps); free(bitmap); if (q->state == JOB_CANCELLING) finalize_cancel_locked(q); - else { q->state = JOB_FAILED; q->rc = -1; } + else { + q->state = JOB_FAILED; + q->rc = no_space ? -3 : -1; + q->need_bytes = need; + q->free_bytes = free_b; + } pthread_cond_broadcast(&g_pool.cv); return; } @@ -2125,6 +2159,15 @@ handle_download_url(struct MHD_Connection *conn, const char *body) { found = mf.total; pieces = mf.count; patchdl_manifest_free(&mf); + { + long long free_b; + if (!space_ok(found, &free_b)) { + snprintf(resp, sizeof resp, + "{\"ok\":false,\"reason\":\"no_space\"," + "\"need_bytes\":%lld,\"free_bytes\":%lld}", found, free_b); + return queue_json_owned(conn, 507, strdup(resp)); + } + } if (size_mismatch(tid, ver, found, expect, sizeof expect) && !json_get_bool(body, "force", 0)) { snprintf(resp, sizeof resp, diff --git a/web/app.js b/web/app.js index 9ef8c1d..7bb29c8 100644 --- a/web/app.js +++ b/web/app.js @@ -357,11 +357,20 @@ function gameCategory(game) { return "blocked"; if (game.downloading || game.resumable || game.downloaded || game.installing || game._jobState) return "updates"; + if (regionLockedUpdate(game)) return "blocked"; if (game.status === "checking" || game.status === "available" || hasOlderPatch(game)) return "updates"; return "uptodate"; } +// A disc or store install whose update Sony serves from another region's +// master package. Sony's installer rejected exactly this kind of install in +// testing, so offering the download would only lead nowhere. +function regionLockedUpdate(game) { + return game.status === "available" && hasSharedStorage(game) && + Boolean(sourcePolicy(game).allow_install); +} + // Sony's newest patch needs newer firmware, but Prospero knows an older one // that runs here and is newer than what is installed. function hasOlderPatch(game) { @@ -510,6 +519,7 @@ function primaryButton(game) { if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true }; if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." }; if (game.patch_title_match === false) return null; + if (regionLockedUpdate(game) && !game.downloading && !game.resumable && !game.downloaded) return null; if (canGetOlderPatch(game)) return { label: "Old FW patch", action: "guide", variant: "update", hint: `Get ${game.prospero_version} from Prospero Patches, the newest patch that runs on this firmware.` }; if (game.resumable && isDownloadAllowed(game)) @@ -553,6 +563,13 @@ function versionsHtml(game) { } else if (game.status === "incompatible_fw" && !game.prospero_done) { parts.push(`Looking for a patch for this firmware\u2026`); } + if (regionLockedUpdate(game)) { + const master = (game.patch_storage_title_id || "").slice(0, 9); + parts.push(`Sony serves this update from another region's package${master ? ` (${escapeHtml(master)})` : ""}. The PS5 refuses to install it from homebrew.`); + } + const job = state.downloads.find((d) => d.title_id === game.title_id); + if (job && job.state === "error" && Number(job.rc) === -3) + parts.push(`${noSpaceText(job.need_bytes, job.free_bytes)}`); if (game.latest_version && game.latest_version !== offered && game.latest_version > (game.installed_version || "")) { const fw = game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : ""; @@ -567,6 +584,7 @@ function statusPill(game) { if (sourceType(game) === "fake") return `Not supported`; if (game.patch_title_match === false) return `Title mismatch`; if (game.manual_version || hasOlderPatch(game)) return `Via Prospero`; + if (regionLockedUpdate(game)) return `Region patch \u00b7 can't install`; if (game.status === "available" && !isInstallAllowed(game)) return `Download only`; if (game.status === "available") return `Update available`; @@ -577,7 +595,7 @@ function statusPill(game) { } function storagePill(game) { - return hasSharedStorage(game) ? `Shared master` : ""; + return hasSharedStorage(game) && !regionLockedUpdate(game) ? `Shared master` : ""; } function sourcePill(game) { @@ -654,6 +672,7 @@ function reconcileFromJobs(jobs) { g._localDownloading = false; // the pool now tracks it g._jobState = j.state; if (j.state === "active" || j.state === "queued") { + g._spaceWarned = false; g.downloading = true; g.resumable = false; g._wasActive = true; @@ -677,7 +696,10 @@ function reconcileFromJobs(jobs) { // network failure. Reflect that so primaryButton shows "Resume" and the // red Cancel stays available to delete the kept partial. const bytes = Number(j.bytes) || 0; - if (g._wasActive) { + if (Number(j.rc) === -3) { + if (!g._spaceWarned) showToast(`${g.name}: ${noSpaceText(j.need_bytes, j.free_bytes)}`); + g._spaceWarned = true; + } else if (g._wasActive) { showToast(`${g.name}: download failed${bytes > 0 ? " — partial kept, press Resume to continue" : "."}`); } g._wasActive = false; @@ -1110,10 +1132,16 @@ const REASON_TEXT = { url_title_mismatch: "That link belongs to a different game.", url_not_allowed: "Only Sony CDN links (https) are accepted.", url_invalid: "The link contains unexpected characters.", + no_space: "Not enough free space on the PS5.", manifest_unavailable: "The PS5 could not load the package list from Sony. Copy the SC link again and retry.", download_exists: "A download already exists for this game. Cancel it first.", source_not_supported: "Downloads are not supported for this source.", }; +// The server keeps 2 GB free on /data on top of the package itself. +function noSpaceText(need, free) { + return `Not enough space on the PS5: needs ${formatBytes(need)} plus 2 GB headroom, ${formatBytes(free)} free.`; +} + function reasonText(error) { const r = error && error.body && error.body.reason; return (r && REASON_TEXT[r]) || (error && error.message) || "failed"; @@ -1378,26 +1406,24 @@ function clearGuideHash() { history.replaceState(null, "", location.pathname + location.search); } +// The PS5 browser can't copy links, so on the console the guide only hands +// over to a phone. Opened via the home-screen tile the page runs on 127.0.0.1. +function isConsoleBrowser() { + const h = location.hostname; + return /PlayStation/i.test(navigator.userAgent) || h === "127.0.0.1" || h === "localhost"; +} + function openGuide(game, fromLink = false) { closeGuide(); Object.assign(guide, { game, fromLink, started: false, busy: false, lastUrl: "", returnFocus: document.activeElement }); const ver = escapeHtml(game.prospero_version); const size = game.prospero_size ? ` (${escapeHtml(game.prospero_size)})` : ""; + const onConsole = !fromLink && isConsoleBrowser(); const link = fromLink ? null : phoneLink(game); const matrix = link ? qrEncode(link) : null; - const handoff = matrix - ? `
${escapeHtml(link)}
- Old FW patch
@@ -1405,7 +1431,26 @@ function openGuide(game, fromLink = false) {${escapeHtml(game.name)}
Scan this code with your phone, or open this link on your phone:
+${escapeHtml(link)}
+ Open PatchDL on your phone (this console's IP address, port ${escapeHtml(String(state.status.http_port || 12880))}) and pick the game there.
`; + body += ``; + } else { + const handoff = matrix + ? `${escapeHtml(link)}
+ Sony only offers ${escapeHtml(game.latest_version || "the newest patch")}, which needs FW ${escapeHtml(game.latest_required_fw || "?")}. Prospero Patches still lists ${ver}, which runs on FW ${escapeHtml(game.prospero_required_fw || "?")}.
${handoff}