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.
This commit is contained in:
Knutwurst committed 2026-10-15 21:31:00 +02:00
1 parent bc716339ba
commit 91cfb2cd02
3 files changed
+138 -32

No files matched your search

+48 -5
View File
@@ -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,
+80 -27
View File
@@ -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(`<span>Looking for a patch for this firmware\u2026</span>`);
}
if (regionLockedUpdate(game)) {
const master = (game.patch_storage_title_id || "").slice(0, 9);
parts.push(`<span class="card-note">Sony serves this update from another region's package${master ? ` (${escapeHtml(master)})` : ""}. The PS5 refuses to install it from homebrew.</span>`);
}
const job = state.downloads.find((d) => d.title_id === game.title_id);
if (job && job.state === "error" && Number(job.rc) === -3)
parts.push(`<span class="card-note is-warn">${noSpaceText(job.need_bytes, job.free_bytes)}</span>`);
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 `<span class="pill">Not supported</span>`;
if (game.patch_title_match === false) return `<span class="pill blocked">Title mismatch</span>`;
if (game.manual_version || hasOlderPatch(game)) return `<span class="pill prospero">Via Prospero</span>`;
if (regionLockedUpdate(game)) return `<span class="pill blocked">Region patch \u00b7 can't install</span>`;
if (game.status === "available" && !isInstallAllowed(game))
return `<span class="pill warn">Download only</span>`;
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
@@ -577,7 +595,7 @@ function statusPill(game) {
}
function storagePill(game) {
return hasSharedStorage(game) ? `<span class="pill warn">Shared master</span>` : "";
return hasSharedStorage(game) && !regionLockedUpdate(game) ? `<span class="pill warn">Shared master</span>` : "";
}
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
? `<div class="guide-qr">${qrSvg(matrix)}<div>
<strong>Easier on your phone</strong>
<span>Scan this with your phone camera and continue there. Copying a link works much better on a phone. This screen follows along.</span>
<code>${escapeHtml(link)}</code>
</div></div>`
: "";
const wrap = document.createElement("div");
wrap.className = "guide-backdrop";
wrap.innerHTML = `
<section class="guide" role="dialog" aria-modal="true" aria-labelledby="guideTitle">
const head = `
<header class="guide-head">
<div>
<p class="eyebrow">Old FW patch</p>
@@ -1405,7 +1431,26 @@ function openGuide(game, fromLink = false) {
<p class="guide-game">${escapeHtml(game.name)}</p>
</div>
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
</header>
</header>`;
let body;
if (onConsole) {
body = matrix
? `<div class="guide-handoff">${qrSvg(matrix)}
<p>Scan this code with your phone, or open this link on your phone:</p>
<code>${escapeHtml(link)}</code>
</div>`
: `<p class="guide-why">Open PatchDL on your phone (this console's IP address, port ${escapeHtml(String(state.status.http_port || 12880))}) and pick the game there.</p>`;
body += `<div class="guide-status" role="status" aria-live="polite"></div>`;
} else {
const handoff = matrix
? `<div class="guide-qr">${qrSvg(matrix)}<div>
<strong>Easier on your phone</strong>
<span>Scan this with your phone camera and continue there. This screen follows along.</span>
<code>${escapeHtml(link)}</code>
</div></div>`
: "";
body = `
<p class="guide-why">Sony only offers ${escapeHtml(game.latest_version || "the newest patch")}, which needs FW ${escapeHtml(game.latest_required_fw || "?")}. Prospero Patches still lists <b>${ver}</b>, which runs on FW ${escapeHtml(game.prospero_required_fw || "?")}.</p>
${handoff}
<ol class="guide-steps">
@@ -1427,27 +1472,33 @@ function openGuide(game, fromLink = false) {
</div>
<div class="guide-status" role="status" aria-live="polite"></div>
</li>
</ol>
</section>`;
</ol>`;
}
const wrap = document.createElement("div");
wrap.className = "guide-backdrop";
wrap.innerHTML = `<section class="guide${onConsole ? " is-handoff" : ""}" role="dialog" aria-modal="true" aria-labelledby="guideTitle">${head}${body}</section>`;
document.body.appendChild(wrap);
guide.el = wrap;
const input = wrap.querySelector(".guide-input");
const tryInput = () => {
const url = extractLink(input.value);
if (url && url !== guide.lastUrl && /\.(pkg|crc|json)$/i.test(url)) guideSubmit(url, false);
};
input.addEventListener("focus", () => input.select()); // a paste then replaces the old link
input.addEventListener("input", tryInput);
input.addEventListener("paste", () => setTimeout(tryInput, 0));
wrap.querySelector(".guide-start").addEventListener("click",
() => guideSubmit(extractLink(input.value), false));
if (input) {
const tryInput = () => {
const url = extractLink(input.value);
if (url && url !== guide.lastUrl && /\.(pkg|crc|json)$/i.test(url)) guideSubmit(url, false);
};
input.addEventListener("focus", () => input.select()); // a paste then replaces the old link
input.addEventListener("input", tryInput);
input.addEventListener("paste", () => setTimeout(tryInput, 0));
wrap.querySelector(".guide-start").addEventListener("click",
() => guideSubmit(extractLink(input.value), false));
}
wrap.querySelector(".guide-close").addEventListener("click", closeGuide);
wrap.addEventListener("click", (e) => { if (e.target === wrap) closeGuide(); });
guide.onKey = (e) => { if (e.key === "Escape") closeGuide(); };
document.addEventListener("keydown", guide.onKey);
guideStatus("idle", matrix ? "Waiting for the link…" : "");
guideStatus("idle", matrix ? "Waiting for the link from your phone…" : "");
guide.timer = setInterval(guidePoll, 2000);
(fromLink ? wrap.querySelector(".guide-open") : wrap.querySelector(".guide-close")).focus();
}
@@ -1503,7 +1554,9 @@ async function guideSubmit(url, force) {
if (!guide.el) return;
start.disabled = false;
const b = (error && error.body) || {};
if (b.reason === "size_mismatch") {
if (b.reason === "no_space") {
guideStatus("error", escapeHtml(noSpaceText(b.need_bytes, b.free_bytes)));
} else if (b.reason === "size_mismatch") {
guideStatus("warn",
`This link is ${formatBytes(b.found_bytes)} in ${Number(b.pieces) || "?"} pieces, but ${escapeHtml(game.prospero_version)} should be about ${escapeHtml(b.expected || "?")}. It is probably a different version.`,
{ label: "Download anyway", run: () => guideSubmit(url, true) });
+10
View File
@@ -395,6 +395,8 @@ h2 { font-size: 18px; line-height: 1.2; }
color: var(--muted);
}
.versions b { color: var(--ink); font-weight: 700; }
.card-note { flex-basis: 100%; line-height: 1.45; }
.card-note.is-warn { color: var(--amber); }
.prospero-line a { color: var(--blue); text-decoration: none; }
.prospero-line a:hover { text-decoration: underline; }
@@ -681,6 +683,14 @@ pre {
.guide-qr span { color: var(--muted); line-height: 1.5; }
.guide-qr code { overflow-wrap: anywhere; color: var(--blue-dark); font-size: 13px; }
/* Console view: the PS5 can't copy links, so it only hands over to a phone. */
.guide.is-handoff { width: min(560px, 100%); justify-self: center; text-align: center; }
.guide.is-handoff .guide-head { text-align: left; }
.guide-handoff { display: grid; justify-items: center; gap: 12px; }
.guide-handoff .qr { width: min(420px, 100%); height: auto; aspect-ratio: 1; border-radius: 8px; }
.guide-handoff p { margin: 0; color: var(--muted); line-height: 1.5; }
.guide-handoff code { overflow-wrap: anywhere; color: var(--blue-dark); font-size: 16px; }
.guide-steps { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; counter-reset: step; }
.guide-steps li {
display: grid;