18 Commits
Author SHA1 Message Date
Knutwurst 70863fb54e Show the newest version and its firmware on every card again
Since the version line rework, "Newest … · needs FW …" only appeared when
the newest patch was newer than the installed one, so up-to-date games,
fake packages and Shadowmount titles lost the firmware information that
0.0.8 showed. It is back whenever Sony reports a newest version, unless
the Update entry already names the same version.
2026-10-15 22:16:20 +02:00
Knutwurst b115c0fd4b Drop leftovers of the "shared master can't install" assumption
/api/pkgmeta now calls a package installable when its embedded title id
matches the installed game, which is what AppInstallPkg goes by, instead
of flagging every shared-master package. The unused UI message for the
old refusal is gone, and web/README.md describes the current policy:
Shadowmount, unknown and fake titles get neither download nor install,
and shared-master packages install like any other.
2026-10-15 22:05:44 +02:00
Knutwurst 7ae09ce571 Allow installing shared-master patches again
The UI hid Install for games whose patch pieces live under another
region's title id. That block predates the switch to AppInstallPkg on the
manifest-assembled package, which carries the installed game's own
content id; Dead Island 2 (EU disc, US master pieces) updated fine that
way. The server has a branch for exactly this case, so the UI now offers
Install again and only keeps the "Shared master" label.
2026-10-15 21:58:06 +02:00
Knutwurst 1739096aa1 Treat Shadowmount titles like fake packages: no download
A mounted image can't take a patch, so a download leads nowhere. The
server now refuses downloads and Prospero links for Shadowmount titles
and skips the Prospero lookup for them; the UI lists them under Not
supported without a button, the same as fake packages.
2026-10-15 21:53:46 +02:00
Knutwurst 25c2adc4bf Bump version to 0.0.9 2026-10-15 21:48:47 +02:00
Knutwurst 36c7c02ebb README: new screenshots with the old-firmware flow on TV and phone 2026-10-15 21:48:47 +02:00
Knutwurst 4379ac3f59 List download-only titles under Not supported, soften the human-check hint
Shadowmount titles can't take a patch, so they no longer sit under Updates.
They move to Not supported and keep their download: Download for Sony
updates, Old FW download for patches from Prospero. Fake packages stay
without any download.

Prospero's human check usually passes without the user noticing, so the
guide now only asks to confirm it if one shows up.
2026-10-15 21:48:47 +02:00
Knutwurst 91cfb2cd02 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.
2026-10-15 21:31:00 +02:00
Knutwurst bc716339ba README: mention patches for older firmware and the Prospero lookup 2026-10-15 21:21:51 +02:00
Knutwurst fbe984da87 Offer the newest patch that fits the firmware via Prospero Patches
Sony's version.xml only lists the newest patch. When that one needs a
newer firmware, PatchDL now asks Prospero Patches for the version history
and offers the newest patch that still runs on the console. Prospero only
hands out download links after a human check, so a guide with a QR code
moves the user to a phone, where they copy the SC link. PatchDL derives
Sony's manifest from that link and downloads like an official update.

The game list now groups by what the user can do: Updates, Up to date,
Not supported. Update all read a nonexistent state.games and did nothing;
it now uses state.titles.
2026-10-15 21:21:51 +02:00
Knutwurst f251e2aa98 README: show 0.0.8 with game icons and a library screenshot
New screenshots for the hero, games, queue, settings and phone views,
all with game icons. Add a library shot sorted A to Z, mention the
Needs FW label and the package check in the highlights, and answer
which games PatchDL updates in the FAQ.
2026-10-15 11:17:01 +02:00
Knutwurst f6b0bba49a Show game icons and label the firmware a patch needs
- The scan opens each game's icon0.png (app.db icon0Info first, then
  /user/appmeta and the title's sce_sys) while it can still see the
  whole filesystem, and keeps the fd. GET /api/icon/<title_id> serves
  it with a one-day cache. Cards show the icon and fall back to the
  initials; loaded icons are reused across re-renders.
- "Latest x · FW y" read like the console firmware. It now says
  "needs FW y".
2026-10-15 11:08:49 +02:00
Knutwurst 0efa0312d5 Bump version to 0.0.8 2026-10-15 11:00:31 +02:00
Knutwurst ca5977f872 Show the latest official version for fake packages
The update check only reads Sony's version.xml, so it is safe for fake
packages too. Their cards now show the latest version and the firmware
it needs. Download and install stay blocked.
2026-10-15 10:58:42 +02:00
Knutwurst 04d27a9fd5 List only games, keep fake packages visible, sort by name
- Scan only PPSA and CUSA title ids. Homebrew with made-up ids (the
  PatchDL tile, loaders, file managers) no longer shows up as a game.
- Installed fake packages carry "fake": true in app.json, the same
  marker homebrew uses, so they vanished from the list. Titles with an
  app.pkg now stay listed and count as fake when either the marker or
  the package header says so.
- Sort the game list by name.
- "Updatable" only holds titles PatchDL can install. Download-only
  titles (shadowmounts, shared-master patches) with an update move to
  "Can't update" and show "Update · download only".
2026-10-15 10:53:29 +02:00
Knutwurst 9253235a0d Classify installs by package signature, block fake packages
The scanner treated every title with an app.pkg as official unless its
app.json mentioned a URL. That let fake packages through as official, so
PatchDL offered and tried to install Sony patches on them, while disc and
store installs whose app.json lists CDN pieces ended up as unknown.

Read the app.pkg header instead. A PS5 finalized image (\x7FFIH) is
retail when byte 0x05 has bit 0x80 set; a PS4 container (\x7FCNT) is
retail when bit 0 of byte 4 is set. Retail installs count as official,
fake-signed ones as the new "fake" source, unreadable headers stay
unknown.

Fake packages are listed with a "Not supported" label. PatchDL skips
their update check, and the server refuses download, install and the
enable toggle for them. The titles JSON reports pkg_sign for diagnostics.
2026-10-15 10:21:36 +02:00
Knutwurst 664ad3d4fd Show "Needs FW" when a newer patch exceeds the console firmware
A title whose installed version matched the newest compatible patch was
reported as up to date even when Sony offered a newer patch that needs a
higher firmware. It now reports incompatible_fw, so the UI shows
"Needs FW x.xx". The UI drops stale job flags for that state too.

Fixes the Final Fantasy VII Rebirth case in #4.
2026-10-15 10:12:20 +02:00
Knutwurst 9209408c40 README: list firmware 1.00 to 13.60 as supported
SDK 0.44 ships kernel offsets for every firmware from 1.00 to 13.60,
and the AppInstUtil calls and app.db table PatchDL uses exist across
that range. Tested on 11.60 and 13.60.
2026-10-14 19:42:58 +02:00
24 changed files with 1680 additions and 103 deletions

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ SRCS := src/main.c \
src/patchdl_scan.c \
src/patchdl_appdb.c \
src/patchdl_net.c \
src/patchdl_prospero.c \
src/patchdl_resolve.c \
src/patchdl_verxml.c \
src/patchdl_install.c \
+22 -12
View File
@@ -1,7 +1,7 @@
<h1 align="center">PatchDL</h1>
<p align="center">
<img src="docs/images/hero.jpg" alt="PatchDL in a desktop browser and on a phone, downloading a game update" width="900">
<img src="docs/images/hero.jpg" alt="PatchDL in a desktop browser and on a phone, fetching a patch that runs on an older firmware" width="900">
</p>
<p align="center">
@@ -13,7 +13,7 @@
<p align="center">
<img src="https://img.shields.io/github/v/release/knutwurst/ps5-patchdl?style=for-the-badge&label=release&color=22c55e" alt="Latest release">
<img src="https://img.shields.io/github/downloads/knutwurst/ps5-patchdl/total?style=for-the-badge&color=22c55e" alt="Downloads">
<img src="https://img.shields.io/badge/FW-11.60%20%C2%B7%2013.60-22c55e?style=for-the-badge" alt="Firmware 11.60 and 13.60">
<img src="https://img.shields.io/badge/FW-1.00%E2%80%9313.60-22c55e?style=for-the-badge" alt="Firmware 1.00 to 13.60">
<img src="https://img.shields.io/badge/UI-any%20browser-22c55e?style=for-the-badge" alt="Web UI in any browser">
<img src="https://img.shields.io/github/license/knutwurst/ps5-patchdl?style=for-the-badge&color=3a3a3a" alt="License">
</p>
@@ -28,11 +28,12 @@
## Highlights
- **Your whole library at a glance.** Every installed game shows up with its version and the newest update Sony offers for it.
- **Your whole library at a glance.** Every installed game shows up with its icon, its version and the newest update Sony offers for it, sorted A to Z.
- **Update all.** One click queues every game that has an update. PatchDL works through the queue and installs each update as soon as it lands.
- **Fast downloads.** Up to 16 parallel connections per update. Change the number in Settings while a download runs.
- **Survives reboots.** Pause, resume, or lose power: PatchDL saves progress after every finished piece and continues where it stopped.
- **Made for your firmware.** You only see updates your console can run, so no game ends up asking for newer system software.
- **Made for your firmware.** You only see updates your console can run. When Sony's newest patch needs a newer firmware, PatchDL finds the newest one that still runs on yours and helps you get it: scan a QR code with your phone, copy one link, and the PS5 does the rest.
- **Knows where each game came from.** PatchDL checks every game's package and recognizes disc and PlayStation Store installs. It only ever patches games Sony signed.
- **Checked against Sony's checksums.** Turn on verification and PatchDL compares every piece with the SHA-256 Sony publishes for it.
- **A tile on your home screen.** One toggle adds PatchDL to the PS5 home screen. Tap it and the UI opens in the console's browser.
- **Live progress.** A banner at the top shows the current game, speed, time left and where you are in the queue.
@@ -41,12 +42,15 @@
<table>
<tr>
<td align="center" width="50%"><img src="docs/images/games.jpg" width="420" alt="Games view with a running download"><br><sub><b>Games</b> &nbsp;·&nbsp; every game, its version and the update waiting for it</sub></td>
<td align="center" width="50%"><img src="docs/images/queue.jpg" width="420" alt="Updating view with two queued updates"><br><sub><b>Updating</b> &nbsp;·&nbsp; the queue, next to the download that's running</sub></td>
<td align="center" width="50%"><img src="docs/images/games.jpg" width="420" alt="Games view with a running download"><br><sub><b>Games</b> &nbsp;·&nbsp; every update waiting for you, one click away</sub></td>
<td align="center" width="50%"><img src="docs/images/library.jpg" width="420" alt="Full library with game icons, sorted by name"><br><sub><b>Library</b> &nbsp;·&nbsp; icons, versions and firmware needs, A to Z</sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="docs/images/oldfw.jpg" width="420" alt="QR code on the TV and the step-by-step guide on a phone"><br><sub><b>Older firmware</b> &nbsp;·&nbsp; scan on the TV, paste one link on your phone</sub></td>
<td align="center" width="50%"><img src="docs/images/settings.jpg" width="420" alt="Settings view"><br><sub><b>Settings</b> &nbsp;·&nbsp; changes apply right away, no restart</sub></td>
<td align="center" width="50%"><img src="docs/images/tile-homescreen.jpg" width="420" alt="PatchDL tile on the PS5 home screen"><br><sub><b>Home screen</b> &nbsp;·&nbsp; the PatchDL tile on a real PS5</sub></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="docs/images/tile-homescreen.jpg" width="420" alt="PatchDL tile on the PS5 home screen"><br><sub><b>Home screen</b> &nbsp;·&nbsp; the PatchDL tile on a real PS5</sub></td>
</tr>
</table>
@@ -55,7 +59,7 @@
Start an update from the couch with your phone, check on it from your laptop, or open it on the TV. The UI adapts to whatever screen it's on.
<p align="center">
<img src="docs/images/phones.jpg" alt="PatchDL on a phone: game list with a download, and Settings" width="560">
<img src="docs/images/phones.jpg" alt="PatchDL on a phone: game list and Settings" width="560">
</p>
## Get started
@@ -65,15 +69,21 @@ Start an update from the couch with your phone, check on it from your laptop, or
3. A notification on the TV shows the address, for example `http://192.168.0.42:12880/`. Open it in any browser on your network.
4. Optional: switch on **Home-screen shortcut** in Settings to get the PatchDL tile.
**You need** a PS5 that can load ELF payloads. PatchDL runs on firmware 11.60 and 13.60.
**You need** a PS5 that can load ELF payloads. PatchDL runs on firmware 1.00 to 13.60 (tested on 11.60 and 13.60).
## Good to know
**Does PatchDL download games?**
No. It fetches updates for games that are already installed on your console. Nothing else.
**Which games does PatchDL update?**
Games you installed from disc or from the PlayStation Store. PatchDL reads each game's package before it offers anything, and it leaves every other title alone.
**Where do the updates come from?**
From Sony's official update servers, the same files your console downloads on its own. PatchDL only talks to a short list of PlayStation CDN hosts.
From Sony's official update servers, the same files your console downloads on its own. PatchDL downloads only from a short list of PlayStation CDN hosts. To find older patches it also asks [Prospero Patches](https://prosperopatches.com/) which versions exist for a game. That lookup sends the game's title ID and nothing else.
**Can I get an older patch that runs on my firmware?**
Yes. Sony still hosts older patches, but its servers only tell your console about the newest one. When that one needs a newer firmware, PatchDL looks up the version history on [Prospero Patches](https://prosperopatches.com/) and lists the game under *Updates* with an **Old FW patch** button. Tap it and scan the QR code with your phone. Open the game on Prospero, pick the version PatchDL names (confirm the human check if one shows up) and copy the **SC** link. Paste it on your phone, and the PS5 downloads the patch from Sony and handles it like any other update.
**Does it change the updates?**
No. Each package stays exactly as Sony published it, and the console's own installer applies it.
@@ -102,7 +112,7 @@ PatchDL downloads official, unmodified game updates that Sony publishes for game
scripts/build_ps5.sh # produces patchdl-ps5.elf
```
You need the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk) with `libcurl` and `OpenSSL` from its [pacbrew-repo](https://github.com/ps5-payload-dev/pacbrew-repo). `libmicrohttpd` and SQLite are vendored. GitHub Actions builds every push; tagged builds become releases.
You need the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk), version 0.44 or newer for firmware 1.00 to 13.60, with `libcurl` and `OpenSSL` from its [pacbrew-repo](https://github.com/ps5-payload-dev/pacbrew-repo). `libmicrohttpd` and SQLite are vendored. GitHub Actions builds every push; tagged builds become releases.
To try a build on your own console:
@@ -114,7 +124,7 @@ PS5_HOST=<console-ip> scripts/deploy_ps5.sh
## Credits
PatchDL is written in C and builds on [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/), [libcurl](https://curl.se/), [OpenSSL](https://www.openssl.org/), [SQLite](https://sqlite.org/) and the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk). Thanks to everyone behind them.
PatchDL is written in C and builds on [libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/), [libcurl](https://curl.se/), [OpenSSL](https://www.openssl.org/), [SQLite](https://sqlite.org/) and the [ps5-payload-dev SDK](https://github.com/ps5-payload-dev/sdk). The version history behind older patches comes from [Prospero Patches](https://prosperopatches.com/). Thanks to everyone behind them.
## License
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 197 KiB

+19
View File
@@ -115,6 +115,25 @@ patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
}
sqlite3_finalize(stmt);
/* Icon paths in a separate query: if a firmware lacks the icon0Info
column, only the icons go missing, not the whole title list. */
if (sqlite3_prepare_v2(db,
"SELECT titleId, icon0Info FROM tbl_contentinfo "
"WHERE icon0Info IS NOT NULL AND icon0Info != '';",
-1, &stmt, NULL) == SQLITE_OK) {
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *tid = (const char *)sqlite3_column_text(stmt, 0);
const char *icon = (const char *)sqlite3_column_text(stmt, 1);
patchdl_appinfo_t *e;
if (!tid || !icon) continue;
e = (patchdl_appinfo_t *)patchdl_appdb_find(arr, n, tid);
if (!e || e->icon_path[0]) continue;
strncpy(e->icon_path, icon, sizeof(e->icon_path) - 1);
e->icon_path[strcspn(e->icon_path, "?")] = '\0';
}
sqlite3_finalize(stmt);
}
sqlite3_close(db);
*out = arr;
+1
View File
@@ -17,6 +17,7 @@ typedef struct {
char content_id[64];
char content_version[16];
char version_file_uri[256]; /* Sony version.xml URL (UUID assigned) */
char icon_path[256]; /* icon0Info without its "?ts=" suffix, or "" */
} patchdl_appinfo_t;
/* Read the PS5 app database (tbl_contentinfo) for installed-title metadata.
+84
View File
@@ -353,6 +353,90 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) {
return 0;
}
/* ---------- external-host HTTP (no Sony CA, no allowlist) -------------- */
static int
http_external(const char *url, const char *method,
const char *content_type, const char *body,
patchdl_buf_t *out) {
#ifndef PATCHDL_HAVE_CURL
(void)url; (void)method; (void)content_type; (void)body; (void)out;
return -1;
#else
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN];
char resolve_str[512];
struct curl_slist *resolve_list = NULL;
struct curl_slist *headers = NULL;
if (url_host(url, host, sizeof(host))) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
snprintf(resolve_str, sizeof(resolve_str), "%s:443:%s", host, ip);
resolve_list = curl_slist_append(NULL, resolve_str);
{
size_t caller_max = out->max;
memset(out, 0, sizeof(*out));
out->max = caller_max;
}
curl = curl_easy_init();
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve_list);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, out);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT,
"Mozilla/5.0 (PlayStation; PatchDL)");
if (body) {
if (content_type) {
char ct[256];
snprintf(ct, sizeof(ct), "Content-Type: %s", content_type);
headers = curl_slist_append(headers, ct);
}
headers = curl_slist_append(headers, "Accept: application/json");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
}
res = curl_easy_perform(curl);
curl_easy_cleanup(curl);
curl_slist_free_all(resolve_list);
curl_slist_free_all(headers);
if (res != CURLE_OK) {
free(out->data);
out->data = NULL;
out->size = 0;
return -1;
}
return 0;
#endif
}
int
patchdl_http_get_external(const char *url, patchdl_buf_t *out) {
return http_external(url, "GET", NULL, NULL, out);
}
int
patchdl_http_post_external(const char *url, const char *content_type,
const char *body, patchdl_buf_t *out) {
return http_external(url, "POST", content_type, body, out);
}
/* Write sink: tees the body to the file and, when verifying, into a running
SHA-256. curl always calls with size==1, so nmemb is the byte count. */
typedef struct {
+8
View File
@@ -29,6 +29,13 @@ void patchdl_net_global_cleanup(void);
int patchdl_http_get(const char *url, patchdl_buf_t *out);
/* External-host variants: no Sony CA pinning, no Sony host allowlist.
SSL peer verification is disabled (PS5 has no system CA store; these
are used for public metadata only, never for sensitive data). */
int patchdl_http_get_external(const char *url, patchdl_buf_t *out);
int patchdl_http_post_external(const char *url, const char *content_type,
const char *body, patchdl_buf_t *out);
/* ---- parallel piece download (used by the connection pool) ------------- */
/* One piece of a split manifest package. `url` is heap-allocated. */
@@ -51,6 +58,7 @@ typedef struct {
int patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out);
void patchdl_manifest_free(patchdl_manifest_t *m);
/* Live state shared with one in-flight piece download. The worker owns these;
the curl callbacks read `abort` (set elsewhere) and publish progress into
`bytes_slot` (single-writer per worker slot). */
+157
View File
@@ -0,0 +1,157 @@
/* SPDX-License-Identifier: GPL-3.0-or-later
* Copyright (C) 2026 Knutwurst
*
* PatchDL is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version. See the LICENSE file in the project root for details.
*/
#include "patchdl_prospero.h"
#include "patchdl_net.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define PROSPERO_BASE "https://prosperopatches.com"
#define PROSPERO_API PROSPERO_BASE "/api/internal"
#define BUF_MAX (512 * 1024)
/* Prospero firmware strings use the same hex-digits-as-decimal encoding
as Sony's system_ver: "10.20.00.06" packs to 0x10200006. Parse with
%x so the hex bytes compare correctly against g_fw.bin. */
static uint32_t
fw_str_to_bin(const char *s) {
unsigned a = 0, b = 0, c = 0, d = 0;
sscanf(s, "%x.%x.%x.%x", &a, &b, &c, &d);
return (a << 24) | (b << 16) | (c << 8) | d;
}
static void
fw_bin_to_display(uint32_t bin, char *out, size_t sz) {
snprintf(out, sz, "%x.%02x", (bin >> 24) & 0xff, (bin >> 16) & 0xff);
}
/* Extract the first occurrence of `data-key="<hex>"` from HTML. */
static int
extract_data_key(const char *html, char *out, size_t out_sz) {
const char *needle = "data-key=\"";
const char *p = strstr(html, needle);
if (!p) return -1;
p += strlen(needle);
size_t len = 0;
while (p[len] && p[len] != '"' && len < out_sz - 1)
len++;
if (!len || p[len] != '"') return -1;
memcpy(out, p, len);
out[len] = '\0';
return 0;
}
/* Find the next JSON string value for a given key inside a JSON object
fragment. Crude but sufficient for the predictable Prospero response. */
static const char *
json_str_val(const char *json, const char *key, char *out, size_t out_sz) {
char needle[64];
snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *p = strstr(json, needle);
if (!p) return NULL;
p += strlen(needle);
while (*p == ' ' || *p == ':') p++;
if (*p != '"') return NULL;
p++;
size_t len = 0;
while (p[len] && p[len] != '"' && len < out_sz - 1)
len++;
memcpy(out, p, len);
out[len] = '\0';
return p + len + 1;
}
/* Check if a JSON fragment contains "success":true */
static int
json_success(const char *json) {
const char *p = strstr(json, "\"success\"");
if (!p) return 0;
p += 9;
while (*p == ' ' || *p == ':') p++;
return !strncmp(p, "true", 4);
}
int
patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
patchdl_prospero_result_t *out) {
patchdl_buf_t buf;
char url[256], key[128], body[256];
char best_ver[16] = {0}, best_size[16] = {0};
uint32_t best_fw = 0;
if (!title_id || !out) return -1;
memset(out, 0, sizeof(*out));
/* Step 1: fetch the title page to get the data-key. */
memset(&buf, 0, sizeof(buf));
buf.max = BUF_MAX;
snprintf(url, sizeof(url), "%s/%.16s", PROSPERO_BASE, title_id);
if (patchdl_http_get_external(url, &buf)) return -1;
if (!buf.data) return -1;
if (extract_data_key(buf.data, key, sizeof(key))) {
free(buf.data);
return -1;
}
free(buf.data);
/* Step 2: POST loadpatches. The API expects JSON in the body but
application/x-www-form-urlencoded as Content-Type — this is how
the website and every known third-party client calls it. */
snprintf(body, sizeof(body),
"{\"titleid\":\"%.16s\",\"key\":\"%s\"}", title_id, key);
memset(&buf, 0, sizeof(buf));
buf.max = BUF_MAX;
if (patchdl_http_post_external(
PROSPERO_API "/loadpatches",
"application/x-www-form-urlencoded; charset=UTF-8",
body, &buf)) return -1;
if (!buf.data) return -1;
if (!json_success(buf.data)) {
free(buf.data);
return -1;
}
/* Walk the patches array. Each entry looks like:
{"content_ver":"01.003.000","required_firmware":"07.01.01.00",...}
Find the newest version whose firmware requirement is met. */
const char *cursor = buf.data;
while ((cursor = strstr(cursor, "\"content_ver\""))) {
char ver[16] = {0}, fw[32] = {0}, size[16] = {0};
json_str_val(cursor, "content_ver", ver, sizeof(ver));
json_str_val(cursor, "filesize", size, sizeof(size));
json_str_val(cursor, "required_firmware", fw, sizeof(fw));
if (ver[0] && fw[0]) {
uint32_t pkg_fw = fw_str_to_bin(fw);
if (pkg_fw <= fw_bin) {
if (!best_ver[0] || strcmp(ver, best_ver) > 0) {
strncpy(best_ver, ver, sizeof(best_ver) - 1);
memcpy(best_size, size, sizeof(best_size));
best_fw = pkg_fw;
}
}
}
cursor++;
}
free(buf.data);
if (best_ver[0]) {
strncpy(out->version, best_ver, sizeof(out->version) - 1);
fw_bin_to_display(best_fw, out->required_fw, sizeof(out->required_fw));
memcpy(out->size, best_size, sizeof(out->size));
out->found = 1;
}
return 0;
}
+27
View File
@@ -0,0 +1,27 @@
/* SPDX-License-Identifier: GPL-3.0-or-later
* Copyright (C) 2026 Knutwurst
*
* PatchDL is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version. See the LICENSE file in the project root for details.
*/
#pragma once
#include <stdint.h>
typedef struct {
char version[16]; /* e.g. "01.003.000" */
char required_fw[16]; /* PatchDL display format, e.g. "7.01" */
char size[16]; /* Prospero's label, e.g. "71.7 GB" (GiB, rounded up) */
int found;
} patchdl_prospero_result_t;
/* Query prosperopatches.com for the newest patch of `title_id` whose
firmware requirement is <= fw_bin. Returns 0 on success (result may
still have found==0 if no compatible patch exists), -1 on network or
parse failure. Best-effort: a failure means the UI shows no Prospero
data, nothing breaks. */
int patchdl_prospero_query(const char *title_id, uint32_t fw_bin,
patchdl_prospero_result_t *out);
+107 -15
View File
@@ -13,6 +13,7 @@
#include <ps5/kernel.h>
#include <dirent.h>
#include <fcntl.h>
#include <limits.h>
#include <stdatomic.h>
#include <stdint.h>
@@ -237,13 +238,42 @@ read_small_file(const char *dir, const char *rel, char *buf, size_t sz) {
return 0;
}
/* Keep only actual games. Skip PS5 system apps (NPXS), the Payload Manager
fake app (PLDM) and ELF-loader homebrew (PSPS). */
/* Tell a Sony-signed package from a debug/fake-signed one by its header.
PS5 finalized image "\x7FFIH": byte 0x05 is 0x80 for retail, 0x00 for
debug (matches Sony's CDN patches, which start 7F 46 49 48 01 80).
PS4 container "\x7FCNT": bit 0 of byte 4 is set for retail, clear for a
fake package (same test Itemzflow uses). */
static patchdl_pkg_sign_t
pkg_sign_state(const char *dir) {
char p[PATH_MAX];
unsigned char h[8];
FILE *fp;
size_t n;
snprintf(p, sizeof(p), "%s/app.pkg", dir);
fp = fopen(p, "rb");
if (!fp) return PATCHDL_PKG_UNREAD;
n = fread(h, 1, sizeof(h), fp);
fclose(fp);
if (n < sizeof(h)) return PATCHDL_PKG_UNREAD;
if (!memcmp(h, "\x7f" "FIH", 4))
return (h[5] & 0x80) ? PATCHDL_PKG_RETAIL : PATCHDL_PKG_FAKE;
if (!memcmp(h, "\x7f" "CNT", 4))
return (h[4] & 0x01) ? PATCHDL_PKG_RETAIL : PATCHDL_PKG_FAKE;
return PATCHDL_PKG_UNREAD;
}
/* Keep only actual games: PS5 (PPSA) and PS4 (CUSA) title ids followed by
five digits. System apps (NPXS) and homebrew with made-up ids (PatchDL's
own tile PTDL00001, loaders, file managers) have no Sony updates. */
static int
is_game_title(const char *title_id) {
if (!strncmp(title_id, "NPXS", 4)) return 0;
if (!strncmp(title_id, "PLDM", 4)) return 0;
if (!strncmp(title_id, "PSPS", 4)) return 0;
if (strncmp(title_id, "PPSA", 4) && strncmp(title_id, "CUSA", 4))
return 0;
for (int i = 4; i < 9; i++)
if (title_id[i] < '0' || title_id[i] > '9')
return 0;
return 1;
}
@@ -270,19 +300,22 @@ mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts)
* Classify each /user/app/<TID> (verified on fw 11.60):
* - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus
* mount (authoritative; mount.lnk is only a fallback hint)
* - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece
* URLs means a not-downloaded preinstall stub, local URLs a real install
* - app.json with "fake":true -> homebrew fake (skip)
* - app.pkg (no shadow mount) -> its header decides: Sony-signed is a
* genuine install (store or disc), debug/fake-signed is a fake package
* (listed, but never checked, downloaded or patched), unreadable is unknown
* - app.json with "fake":true -> fake package if it has an app.pkg,
* otherwise homebrew (skip)
*/
static int
scan_one(const char *base, const char *name, patchdl_title_t *t,
const struct statfs *mounts, int nmounts) {
char dir[PATH_MAX];
char appjson[4096];
int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0, is_cdn = 0;
int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0;
snprintf(dir, sizeof(dir), "%s/%s", base, name);
memset(t, 0, sizeof(*t));
t->icon_fd = -1;
strncpy(t->source_path, dir, sizeof(t->source_path) - 1);
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
@@ -298,10 +331,10 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
if (!read_small_file(dir, "app.json", appjson, sizeof(appjson))) {
if (strstr(appjson, "\"fake\"") && strstr(appjson, "true"))
is_fake = 1;
if (strstr(appjson, "http"))
is_cdn = 1; /* piece URLs point at the Sony CDN -> preinstall */
}
if (is_fake)
/* "fake": true marks both homebrew and installed fake packages. Only
homebrew (no app.pkg) drops out; a fake package stays in the list. */
if (is_fake && !has_app_pkg)
return -1;
/* app.pkg is the on-disk package of a genuine install; ShadowMountPlus
@@ -309,8 +342,15 @@ scan_one(const char *base, const char *name, patchdl_title_t *t,
app.pkg is the reliable genuine-vs-shadow discriminator — independent of
whether the shadow image is currently mounted. */
if (has_app_pkg) {
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN /* CDN pkg = preinstall */
: PATCHDL_SOURCE_OFFICIAL;
/* app.json piece URLs are no signal: disc and store installs carry
CDN URLs there too. The package header is. */
t->pkg_sign = pkg_sign_state(dir);
/* The installer's own fake marker wins over the header: block when
either says fake. */
t->source_type = (is_fake || t->pkg_sign == PATCHDL_PKG_FAKE)
? PATCHDL_SOURCE_FAKE
: t->pkg_sign == PATCHDL_PKG_RETAIL ? PATCHDL_SOURCE_OFFICIAL
: PATCHDL_SOURCE_UNKNOWN;
} else if (is_shadow || has_mountlnk || has_paramjson ||
path_exists(dir, "sce_sys/param.sfo")) {
if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */
@@ -365,10 +405,55 @@ patchdl_source_str(patchdl_source_t src) {
case PATCHDL_SOURCE_OFFICIAL: return "official";
case PATCHDL_SOURCE_EXTERNAL: return "external";
case PATCHDL_SOURCE_SHADOWMOUNT: return "shadowmount";
case PATCHDL_SOURCE_FAKE: return "fake";
default: return "unknown";
}
}
/* Open a title's icon0.png while the scan still sees the whole filesystem.
The web server can't reach these paths later, but an fd opened now stays
readable. Tries the app.db path first, then the usual install locations.
Returns an fd to a non-empty PNG of sane size, or -1. */
#define ICON_MAX_BYTES (4 * 1024 * 1024)
static int
open_png(const char *path) {
unsigned char magic[8];
struct stat st;
int fd;
if (!path || path[0] != '/') return -1;
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return -1;
if (fstat(fd, &st) || !S_ISREG(st.st_mode) ||
st.st_size < 8 || st.st_size > ICON_MAX_BYTES ||
pread(fd, magic, 8, 0) != 8 ||
memcmp(magic, "\x89PNG\r\n\x1a\n", 8)) {
close(fd);
return -1;
}
return fd;
}
static int
open_icon(const patchdl_title_t *t, const char *db_path) {
static const char *const candidates[] = {
"/user/appmeta/%s/icon0.png",
"/user/appmeta/external/%s/icon0.png",
"%s/sce_sys/icon0.png", /* source_path, e.g. a shadowmount */
NULL,
};
char path[PATH_MAX];
int fd = open_png(db_path);
for (int i = 0; fd < 0 && candidates[i]; i++) {
snprintf(path, sizeof(path), candidates[i],
i < 2 ? t->title_id : t->source_path);
fd = open_png(path);
}
return fd;
}
/* Overlay app.db metadata (real title name, installed version, version.xml
URL) onto the scanned titles. Genuine installs have no readable param.json,
so this is the only source of their name/version. */
@@ -396,6 +481,11 @@ merge_appdb(patchdl_title_t *arr, size_t cnt) {
sizeof(arr[i].version_file_uri) - 1);
}
for (size_t i = 0; i < cnt; i++) {
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
arr[i].icon_fd = open_icon(&arr[i], a ? a->icon_path : NULL);
}
patchdl_appdb_free(info);
}
@@ -460,7 +550,9 @@ patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
void
patchdl_scan_free(patchdl_title_t *titles, size_t count) {
(void)count;
for (size_t i = 0; titles && i < count; i++)
if (titles[i].icon_fd >= 0)
close(titles[i].icon_fd);
free(titles);
}
+20
View File
@@ -17,8 +17,17 @@ typedef enum {
PATCHDL_SOURCE_EXTERNAL = 1,
PATCHDL_SOURCE_SHADOWMOUNT = 2,
PATCHDL_SOURCE_UNKNOWN = 3,
PATCHDL_SOURCE_FAKE = 4, /* debug/fake-signed package: not supported */
} patchdl_source_t;
/* Signing state read from the installed app.pkg header. */
typedef enum {
PATCHDL_PKG_NONE = 0, /* no app.pkg */
PATCHDL_PKG_RETAIL = 1,
PATCHDL_PKG_FAKE = 2,
PATCHDL_PKG_UNREAD = 3, /* app.pkg present but header unreadable/unknown */
} patchdl_pkg_sign_t;
typedef struct {
char title_id[32];
char content_id[64];
@@ -28,6 +37,8 @@ typedef struct {
char mount_from[256];
char version_file_uri[256]; /* from app.db, for version.xml */
patchdl_source_t source_type;
patchdl_pkg_sign_t pkg_sign; /* app.pkg header verdict, for diagnostics */
int icon_fd; /* open icon0.png, or -1; see patchdl_scan */
/* populated by background verxml fetch — guarded by websrv mutex */
char compatible_version[16];
char latest_version[16];
@@ -37,6 +48,15 @@ typedef struct {
char patch_title_id[16]; /* target title id from version.xml */
char patch_storage_title_id[16]; /* title id embedded in delta_url */
int verxml_done;
/* Prospero Patches lookup (best compatible patch from version history) */
char prospero_version[16];
char prospero_required_fw[16];
char prospero_size[16];
int prospero_done;
/* Manifest URL derived from a link the user pasted from Prospero. When
set it replaces patch_url for download and install. */
char manual_url[512];
char manual_version[16];
int enabled; /* user policy, persisted in config.json */
int resumable; /* a partial download is on disk */
long long partial_bytes; /* size of that partial, for the UI */
+1 -1
View File
@@ -9,4 +9,4 @@
#pragma once
#define PATCHDL_VERSION "0.0.7"
#define PATCHDL_VERSION "0.0.9"
+466 -23
View File
@@ -16,16 +16,21 @@
#include "patchdl_resolve.h"
#include "patchdl_tile.h"
#include "patchdl_verxml.h"
#include "patchdl_prospero.h"
#include "patchdl_version.h"
#include <arpa/inet.h>
#include <microhttpd.h>
#include <netinet/in.h>
#include <pthread.h>
#include <dirent.h>
#include <fcntl.h>
#include <ctype.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <unistd.h>
@@ -104,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;
@@ -135,8 +142,9 @@ static const char config_tail_json[] =
"\"source_policy\":{"
"\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
"\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true},"
"\"shadowmount\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":false},"
"\"unknown\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false}"
"\"shadowmount\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false},"
"\"unknown\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false},"
"\"fake\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false}"
"},"
"\"cdn_allowlist\":["
"\"sgst.prod.dl.playstation.net\","
@@ -331,6 +339,44 @@ queue_json_owned(struct MHD_Connection *conn, unsigned int status, char *json) {
json, strlen(json), MHD_RESPMEM_MUST_FREE);
}
/* Serve a title's icon from the fd the scan opened. Icons don't change while
PatchDL runs, so the browser may cache them; the card list re-renders on
every poll and would otherwise refetch all of them. */
static enum MHD_Result
queue_icon(struct MHD_Connection *conn, const char *title_id) {
struct MHD_Response *resp;
enum MHD_Result ret;
struct stat st;
unsigned char *buf;
int fd = -1;
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, title_id)) {
fd = g_titles[i].icon_fd;
break;
}
pthread_mutex_unlock(&g_mutex);
if (fd < 0 || fstat(fd, &st) || st.st_size <= 0)
return queue_text(conn, MHD_HTTP_NOT_FOUND, "no icon");
buf = malloc((size_t)st.st_size);
if (!buf) return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
if (pread(fd, buf, (size_t)st.st_size, 0) != st.st_size) {
free(buf);
return queue_text(conn, MHD_HTTP_NOT_FOUND, "no icon");
}
resp = MHD_create_response_from_buffer((size_t)st.st_size, buf,
MHD_RESPMEM_MUST_FREE);
if (!resp) { free(buf); return MHD_NO; }
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE, "image/png");
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "max-age=86400");
ret = MHD_queue_response(conn, MHD_HTTP_OK, resp);
MHD_destroy_response(resp);
return ret;
}
static enum MHD_Result
queue_asset(struct MHD_Connection *conn, const char *url) {
const patchdl_asset_t *asset = patchdl_asset_find(url);
@@ -392,13 +438,11 @@ static const char *INSTALL_URI_HOSTS[] = {
INSTALL_URI_HOSTS (or a subdomain). Rejects file://, http://, redirects
to other hosts (libcurl enforces via host_allowed at fetch time). */
static int
install_uri_safe(const char *uri) {
const char *host_start, *end;
cdn_host_ok(const char *host_start) {
const char *end;
size_t hlen;
char host[128];
if (!uri || strncmp(uri, "https://", 8) != 0) return 0;
host_start = uri + 8;
end = strpbrk(host_start, "/:?");
hlen = end ? (size_t)(end - host_start) : strlen(host_start);
if (hlen == 0 || hlen >= sizeof(host)) return 0;
@@ -415,6 +459,11 @@ install_uri_safe(const char *uri) {
return 0;
}
static int
install_uri_safe(const char *uri) {
return uri && !strncmp(uri, "https://", 8) && cdn_host_ok(uri + 8);
}
typedef struct {
int fd;
uint64_t start;
@@ -593,12 +642,58 @@ 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)
can hand a phone a reachable link. Connecting a UDP socket sends nothing;
it only makes the kernel pick the outgoing interface. */
static void
lan_ipv4(char *out, size_t sz) {
struct sockaddr_in a;
socklen_t len = sizeof a;
int s = socket(AF_INET, SOCK_DGRAM, 0);
out[0] = '\0';
if (s < 0) return;
memset(&a, 0, sizeof a);
a.sin_family = AF_INET;
a.sin_port = htons(53);
a.sin_addr.s_addr = htonl(0x01010101);
if (connect(s, (struct sockaddr *)&a, sizeof a) == 0 &&
getsockname(s, (struct sockaddr *)&a, &len) == 0 &&
!inet_ntop(AF_INET, &a.sin_addr, out, (socklen_t)sz))
out[0] = '\0';
close(s);
}
static char *
build_status_json(void) {
char ip[INET_ADDRSTRLEN];
char *out = malloc(512);
if (!out) return NULL;
lan_ipv4(ip, sizeof ip);
snprintf(out, 512,
"{\"firmware\":\"%s\","
"\"firmware_build\":\"0x%08x\","
@@ -606,8 +701,11 @@ build_status_json(void) {
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":%lld,"
"\"download_dir\":\"/data/patchdl (internal)\","
"\"lan_ip\":\"%s\","
"\"http_port\":%d,"
"\"version\":\"%s\"}",
g_fw.str, g_fw.bin, data_free_mb(), PATCHDL_VERSION);
g_fw.str, g_fw.bin, data_free_mb(), ip, PATCHDL_HTTP_PORT,
PATCHDL_VERSION);
return out;
}
@@ -721,24 +819,40 @@ load_config(void) {
static const char *
title_status_str(const patchdl_title_t *t) {
if (!t->verxml_done) return "checking";
if (t->source_type == PATCHDL_SOURCE_UNKNOWN) return "blocked";
/* A mounted image can't take a patch, so neither download nor install
would lead anywhere; treat it like a fake package. */
if (t->source_type == PATCHDL_SOURCE_UNKNOWN ||
t->source_type == PATCHDL_SOURCE_FAKE ||
t->source_type == PATCHDL_SOURCE_SHADOWMOUNT) return "blocked";
if (t->compatible_version[0]) {
/* Versions are zero-padded (01.000.011), so strcmp orders them.
A compatible patch newer than what's installed = update available. */
if (t->installed_version[0] &&
strcmp(t->compatible_version, t->installed_version) <= 0)
strcmp(t->compatible_version, t->installed_version) <= 0) {
/* Nothing installable, but a newer patch exists that needs a
newer firmware: say so instead of claiming up to date. */
if (t->latest_version[0] &&
strcmp(t->latest_version, t->installed_version) > 0)
return "incompatible_fw";
return "up_to_date";
}
return "available";
}
if (t->latest_version[0]) return "incompatible_fw";
if (t->latest_version[0]) {
if (t->installed_version[0] &&
strcmp(t->latest_version, t->installed_version) <= 0)
return "up_to_date";
return "incompatible_fw";
}
return "no_update";
}
static const char *
title_mode_str(patchdl_source_t src) {
switch (src) {
case PATCHDL_SOURCE_SHADOWMOUNT: return "download_only";
case PATCHDL_SOURCE_SHADOWMOUNT: return "disabled";
case PATCHDL_SOURCE_UNKNOWN: return "disabled";
case PATCHDL_SOURCE_FAKE: return "disabled";
default: return "latest_compatible";
}
}
@@ -767,6 +881,12 @@ build_titles_json(void) {
jbuf_append_ver_or_null(&j, t->latest_required_fw);
jbuf_append(&j, ",\"source_type\":");
jbuf_append_str(&j, patchdl_source_str(t->source_type));
jbuf_append(&j, t->icon_fd >= 0 ? ",\"has_icon\":true" : ",\"has_icon\":false");
jbuf_append(&j, ",\"pkg_sign\":");
jbuf_append_str(&j, t->pkg_sign == PATCHDL_PKG_RETAIL ? "retail"
: t->pkg_sign == PATCHDL_PKG_FAKE ? "fake"
: t->pkg_sign == PATCHDL_PKG_UNREAD ? "unreadable"
: "none");
jbuf_append(&j, ",\"source_path\":"); jbuf_append_str(&j, t->source_path);
jbuf_append(&j, ",\"mount_from\":"); jbuf_append_str(&j, t->mount_from);
jbuf_append(&j, ",\"version_file_uri\":");
@@ -784,6 +904,16 @@ build_titles_json(void) {
jbuf_appendf(&j, ",\"patch_title_match\":%s",
(!t->patch_title_id[0] ||
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
jbuf_append(&j, ",\"prospero_version\":");
jbuf_append_ver_or_null(&j, t->prospero_version);
jbuf_append(&j, ",\"prospero_required_fw\":");
jbuf_append_ver_or_null(&j, t->prospero_required_fw);
jbuf_append(&j, ",\"prospero_size\":");
jbuf_append_ver_or_null(&j, t->prospero_size);
jbuf_appendf(&j, ",\"prospero_done\":%s",
t->prospero_done ? "true" : "false");
jbuf_append(&j, ",\"manual_version\":");
jbuf_append_ver_or_null(&j, t->manual_url[0] ? t->manual_version : "");
jbuf_appendf(&j, ",\"enabled\":%s", t->enabled ? "true" : "false");
jbuf_appendf(&j, ",\"resumable\":%s", t->resumable ? "true" : "false");
jbuf_appendf(&j, ",\"partial_bytes\":%lld", t->partial_bytes);
@@ -847,6 +977,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, "]");
@@ -899,6 +1032,7 @@ verxml_fetch_thread(void *arg) {
if (g_verxml_stop) break; /* shutdown: stop before the next blocking query */
memset(&info, 0, sizeof(info));
/* The version.xml URL comes straight from the app.db (UUID included);
fall back to the resolver only if the app.db had no entry. */
if (t->version_file_uri[0])
@@ -928,6 +1062,29 @@ verxml_fetch_thread(void *arg) {
if (up_to_date && info.compatible_url[0])
cleanup_installed_download(t->title_id, info.compatible_url);
/* When the latest patch needs newer firmware and no compatible
version came from Sony, ask Prospero Patches for the full
version history. */
if (g_verxml_stop) break;
if (!strcmp(title_status_str(t), "incompatible_fw") &&
t->source_type != PATCHDL_SOURCE_FAKE &&
t->source_type != PATCHDL_SOURCE_UNKNOWN) {
patchdl_prospero_result_t pp;
if (!patchdl_prospero_query(t->title_id, g_fw.bin, &pp) &&
pp.found && strcmp(pp.version, t->installed_version) > 0) {
pthread_mutex_lock(&g_mutex);
strncpy(t->prospero_version, pp.version,
sizeof(t->prospero_version) - 1);
strncpy(t->prospero_required_fw, pp.required_fw,
sizeof(t->prospero_required_fw) - 1);
strncpy(t->prospero_size, pp.size, sizeof(t->prospero_size) - 1);
pthread_mutex_unlock(&g_mutex);
}
pthread_mutex_lock(&g_mutex);
t->prospero_done = 1;
pthread_mutex_unlock(&g_mutex);
}
}
return NULL;
}
@@ -965,6 +1122,15 @@ get_title_action_info(const char *title_id, patchdl_source_t *src,
name[name_sz - 1] = '\0';
strncpy(version, g_titles[i].compatible_version, ver_sz - 1);
version[ver_sz - 1] = '\0';
if (g_titles[i].manual_url[0]) {
/* A pasted Prospero link wins: it is the title's own package
(checked on submit), so no delta and no shared master. */
snprintf(patch_url, url_sz, "%s", g_titles[i].manual_url);
delta_url[0] = '\0';
snprintf(patch_title_id, pt_sz, "%.9s", title_id);
snprintf(patch_storage_title_id, pst_sz, "%.9s", title_id);
snprintf(version, ver_sz, "%s", g_titles[i].manual_version);
}
*enabled = g_titles[i].enabled;
found = 1;
break;
@@ -983,6 +1149,8 @@ set_title_enabled(struct MHD_Connection *conn, const char *title_id, int en) {
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++) {
if (!strcmp(g_titles[i].title_id, title_id)) {
/* Fake packages stay off; the toggle can't switch them on. */
if (g_titles[i].source_type == PATCHDL_SOURCE_FAKE) en = 0;
g_titles[i].enabled = en;
found = 1;
break;
@@ -1028,6 +1196,7 @@ url_is_manifest(const char *url) {
return n > 5 && !strcmp(url + n - 5, ".json");
}
/* ---------- resume sidecar + pool helpers ------------------------------- */
static void
@@ -1063,6 +1232,79 @@ remove_title_dir(const char *title_id) {
rmdir(dir);
}
/* ---------- user-supplied patch link (Prospero) ------------------------ */
static void
manual_source_path(const char *title_id, char *out, size_t sz) {
snprintf(out, sz, "%s/%s/manual.json", PATCHDL_DL_DIR, title_id);
}
/* Persist the pasted link next to the download so install and resume still
find it after a restart. Cancel deletes the directory, and with it this. */
static void
save_manual_source(const char *title_id, const char *url, const char *version) {
char dir[288], path[320];
FILE *f;
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
mkdir(PATCHDL_DL_DIR, 0777);
mkdir(dir, 0777);
manual_source_path(title_id, path, sizeof path);
if ((f = fopen(path, "w"))) {
fprintf(f, "{\"url\":\"%s\",\"version\":\"%s\"}\n", url, version);
fclose(f);
}
}
static void
set_manual_source(const char *title_id, const char *url, const char *version) {
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, title_id)) {
snprintf(g_titles[i].manual_url, sizeof g_titles[i].manual_url, "%s", url);
snprintf(g_titles[i].manual_version, sizeof g_titles[i].manual_version,
"%s", version);
break;
}
pthread_mutex_unlock(&g_mutex);
}
static void
clear_manual_source(const char *title_id) {
char path[320], dir[288];
set_manual_source(title_id, "", "");
manual_source_path(title_id, path, sizeof path);
unlink(path);
snprintf(dir, sizeof dir, "%s/%s", PATCHDL_DL_DIR, title_id);
rmdir(dir); /* fails while the pkg is still there; the cancel path removes it */
}
/* Startup, single-threaded. A link whose version is already installed is
spent: drop it together with its package. */
static void
load_manual_sources(void) {
for (size_t i = 0; i < g_title_count; i++) {
patchdl_title_t *t = &g_titles[i];
char path[320], buf[1024];
FILE *f;
size_t n;
if (!path_segment_safe(t->title_id)) continue;
manual_source_path(t->title_id, path, sizeof path);
if (!(f = fopen(path, "r"))) continue;
n = fread(buf, 1, sizeof buf - 1, f);
fclose(f);
buf[n] = '\0';
json_get_str(buf, "url", t->manual_url, sizeof t->manual_url);
json_get_str(buf, "version", t->manual_version, sizeof t->manual_version);
if (!url_is_manifest(t->manual_url) || !t->manual_version[0] ||
(t->installed_version[0] &&
strcmp(t->installed_version, t->manual_version) >= 0)) {
t->manual_url[0] = t->manual_version[0] = '\0';
remove_title_dir(t->title_id);
}
}
}
/* Mirror the resumable flag into g_titles (read by /api/titles). Takes g_mutex,
so it must be called with the POOL lock NOT held (lock order: g_mutex first). */
static void
@@ -1288,8 +1530,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;
@@ -1314,7 +1556,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;
@@ -1351,7 +1595,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;
}
@@ -1537,6 +1786,12 @@ do_download(struct MHD_Connection *conn, const char *title_id,
if (src == PATCHDL_SOURCE_UNKNOWN)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"source_unknown\"}");
if (src == PATCHDL_SOURCE_FAKE)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"source_fake\"}");
if (src == PATCHDL_SOURCE_SHADOWMOUNT)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"source_shadowmount\"}");
if (!patch_url[0])
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
@@ -1686,6 +1941,7 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) {
remove_title_dir(title_id);
set_title_resumable(title_id, 0, 0);
}
clear_manual_source(title_id);
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"cancelled\":true}");
}
@@ -1702,7 +1958,7 @@ do_install(struct MHD_Connection *conn, const char *title_id,
"{\"ok\":false,\"reason\":\"title_disabled\"}");
/* Source policy: only genuine installs may be patched in place.
Shadowmounts are download-only; unknown/preinstall are blocked. */
Shadowmounts are download-only; unknown and fake packages are blocked. */
if (src != PATCHDL_SOURCE_OFFICIAL && src != PATCHDL_SOURCE_EXTERNAL)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"install_not_allowed_for_source\"}");
@@ -1759,6 +2015,180 @@ do_install(struct MHD_Connection *conn, const char *title_id,
strdup(resp));
}
/* Turn a link copied from Prospero's "Additional Files" into the URL of
Sony's package manifest. Sony keeps the manifest ("<name>.json") in the same
folder as the package's "<name>_sc.pkg" piece and "<name>.crc" file, and the
manifest lists every piece with its offset and SHA-256, so the download then
runs exactly like an official one. Piece links ("<name>_<n>.pkg") live in a
different folder and can't lead to the manifest. Returns NULL on success,
else a reason code for the UI. */
static const char *
manifest_from_link(const char *tid, const char *in, char *canon, size_t sz) {
static const char *const ends[] = { "_sc.pkg", ".crc", ".json", NULL };
char tag[16];
const char *p, *rest, *base, *stem_end = NULL;
size_t n = strlen(in);
/* The URL ends up unescaped in JSON sidecars and responses, and dot
segments could walk the path into another title's folder. */
for (p = in; *p; p++)
if (!isalnum((unsigned char)*p) && !strchr("-._~:/", *p))
return "url_invalid";
if (strstr(in, "..")) return "url_invalid";
if (!strncmp(in, "https://", 8)) rest = in + 8;
else if (!strncmp(in, "http://", 7)) rest = in + 7;
else return "url_not_allowed";
if (!cdn_host_ok(rest)) return "url_not_allowed";
for (int i = 0; ends[i]; i++) {
size_t el = strlen(ends[i]);
if (n > el && !strcmp(in + n - el, ends[i])) { stem_end = in + n - el; break; }
}
if (!stem_end) return "not_an_sc_link";
base = strrchr(in, '/') + 1; /* the scheme guarantees a '/' */
if (stem_end <= base) return "not_an_sc_link";
/* Sony keeps a title's files under ".../<TID>_00/..." and names them
"<publisher>-<TID>_00-<label>..."; both must name this title. */
snprintf(tag, sizeof tag, "/%.9s_", tid);
if (!strstr(in, tag)) return "url_title_mismatch";
tag[0] = '-';
if (!strstr(base, tag)) return "url_title_mismatch";
/* Manifests are fetched over https with the pinned Sony root. */
if (sizeof("https://") - 1 + (size_t)(stem_end - rest) + sizeof(".json") > sz)
return "url_too_long";
snprintf(canon, sz, "https://%.*s.json", (int)(stem_end - rest), rest);
return NULL;
}
/* Prospero labels sizes in GiB ("71.7 GB"), about 0.1 above the piece sum.
Returns 1 when `found` bytes clearly don't match the size Prospero lists
for `ver`, i.e. the user most likely copied a link of another version.
Versions of (nearly) equal size can't be told apart this way. */
static int
size_mismatch(const char *tid, const char *ver, long long found,
char *label, size_t lsz) {
char lbl[16] = {0}, pver[16] = {0}, unit[8] = {0};
double v, unit_bytes, expect, diff, tol;
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++)
if (!strcmp(g_titles[i].title_id, tid)) {
memcpy(lbl, g_titles[i].prospero_size, sizeof lbl - 1);
memcpy(pver, g_titles[i].prospero_version, sizeof pver - 1);
break;
}
pthread_mutex_unlock(&g_mutex);
for (char *c = lbl; *c; c++) /* external text, echoed into JSON */
if (!isalnum((unsigned char)*c) && *c != '.' && *c != ' ') *c = '?';
snprintf(label, lsz, "%s", lbl);
if (strcmp(pver, ver) || sscanf(lbl, "%lf %7s", &v, unit) != 2) return 0;
if (!strcmp(unit, "TB")) unit_bytes = 1099511627776.0;
else if (!strcmp(unit, "GB")) unit_bytes = 1073741824.0;
else if (!strcmp(unit, "MB")) unit_bytes = 1048576.0;
else return 0;
expect = v * unit_bytes;
diff = (double)found - expect;
if (diff < 0) diff = -diff;
tol = expect * 0.02;
if (tol < 0.15 * unit_bytes) tol = 0.15 * unit_bytes;
return diff > tol;
}
/* Queue a patch from an SC/CRC link copied off Prospero Patches. The link must
point at this title's own folder on Sony's CDN, so a pasted URL can't put
another game's package on this one. */
static enum MHD_Result
handle_download_url(struct MHD_Connection *conn, const char *body) {
char tid[32], in[768], ver[16], canon[512], state[320], resp[192];
char expect[16];
patchdl_manifest_t mf;
char purl[512], durl[512], ptid[16], pstid[16];
char cid[64], tname[128], tv[16];
const char *p, *why;
struct stat st;
int en = 0, busy = 0, pieces;
long long found;
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
json_get_str(body, "title_id", tid, sizeof tid);
json_get_str(body, "url", in, sizeof in);
json_get_str(body, "version", ver, sizeof ver);
if (!tid[0] || !in[0] || !ver[0])
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"title_id, url and version required\"}");
if (!path_segment_safe(tid) || strlen(tid) < 9)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"title_id_unsafe\"}");
for (p = ver; *p; p++)
if (!isdigit((unsigned char)*p) && *p != '.')
return queue_json(conn, MHD_HTTP_BAD_REQUEST,
"{\"ok\":false,\"reason\":\"version_invalid\"}");
if ((why = manifest_from_link(tid, in, canon, sizeof canon))) {
snprintf(resp, sizeof resp, "{\"ok\":false,\"reason\":\"%s\"}", why);
return queue_json_owned(conn, MHD_HTTP_BAD_REQUEST, strdup(resp));
}
if (!get_title_action_info(tid, &src, purl, sizeof purl, durl, sizeof durl,
ptid, sizeof ptid, pstid, sizeof pstid,
cid, sizeof cid, tname, sizeof tname,
tv, sizeof tv, &en))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
if (!en)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"title_disabled\"}");
if (src == PATCHDL_SOURCE_UNKNOWN || src == PATCHDL_SOURCE_FAKE ||
src == PATCHDL_SOURCE_SHADOWMOUNT)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"source_not_supported\"}");
/* Different versions share the same file name, so never mix a new link
into an existing download or partial: the user cancels it first. */
pthread_mutex_lock(&g_pool.mtx);
for (dl_job_t *j = g_pool.jobs; j; j = j->next)
if (!strcmp(j->title_id, tid)) busy = 1;
pthread_mutex_unlock(&g_pool.mtx);
title_state_path(tid, state, sizeof state);
if (busy || stat(state, &st) == 0)
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"download_exists\","
"\"hint\":\"cancel the current download first\"}");
/* Load the manifest here rather than only at admit: a dead or wrong link
then fails with a reason the user sees right away, and the total can be
checked against the size Prospero lists for the version. */
if (patchdl_fetch_manifest(canon, &mf) != 0)
return queue_json(conn, MHD_HTTP_BAD_GATEWAY,
"{\"ok\":false,\"reason\":\"manifest_unavailable\"}");
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,
"{\"ok\":false,\"reason\":\"size_mismatch\","
"\"found_bytes\":%lld,\"pieces\":%d,\"expected\":\"%s\"}",
found, pieces, expect);
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
}
save_manual_source(tid, canon, ver);
set_manual_source(tid, canon, ver);
return do_download(conn, tid, src, canon, "", tname, ver, en);
}
/* ---------- request routing -------------------------------------------- */
static int
@@ -2046,6 +2476,8 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
if (!strcmp(url, "/api/download_url"))
return handle_download_url(conn, body);
if (!strcmp(url, "/api/install_tile")) {
char msg[256], resp[320];
int rc = patchdl_tile_install_if_needed(msg, sizeof(msg));
@@ -2071,6 +2503,12 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
if (!strcmp(url, "/api/titles"))
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
if (!strncmp(url, "/api/icon/", 10)) {
if (!path_segment_safe(url + 10))
return queue_text(conn, MHD_HTTP_FORBIDDEN, "forbidden");
return queue_icon(conn, url + 10);
}
if (!strcmp(url, "/api/downloads"))
return queue_json_owned(conn, MHD_HTTP_OK, build_downloads_json());
@@ -2209,7 +2647,9 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
}
/* Read-only: report the .pkg's embedded content id / title id vs the target
(installed app) ids, to expose the cross-region linkage. No install. */
(installed app) ids. AppInstallPkg installs by the id inside the package,
so it lands on the game only when they match; where the CDN stores the
pieces (storage_title_id) doesn't matter. No install. */
if (!strncmp(url, "/api/pkgmeta/", 13)) {
const char *tid = url + 13;
char purl[768] = {0}, durl_[512] = {0};
@@ -2232,13 +2672,11 @@ on_request(void *cls, struct MHD_Connection *conn, const char *url,
snprintf(resp, sizeof resp,
"{\"ok\":%s,\"pkg_content_id\":\"%s\",\"pkg_title_id\":\"%s\","
"\"is_app\":%s,\"target_content_id\":\"%s\",\"target_title_id\":\"%s\","
"\"storage_title_id\":\"%s\",\"installable\":%s,\"install_plan\":\"%s\","
"\"msg\":\"%s\"}",
"\"storage_title_id\":\"%s\",\"installable\":%s,"
"\"install_plan\":\"appinstallpkg\",\"msg\":\"%s\"}",
rc == 0 ? "true" : "false", ecid, etid, is_app ? "true" : "false",
cid, tid, psti,
(psti[0] && strncmp(psti, tid, 9) != 0) ? "false" : "true",
(psti[0] && strncmp(psti, tid, 9) != 0) ?
"cross_region_storage_unsupported" : "installbypackage",
(rc == 0 && !strncmp(etid, tid, 9)) ? "true" : "false",
msg);
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
@@ -2307,6 +2745,10 @@ patchdl_websrv_start(unsigned short port) {
for (size_t i = 0; i < g_title_count; i++)
g_titles[i].enabled = (g_titles[i].source_type != PATCHDL_SOURCE_UNKNOWN);
load_config();
/* A saved config must not switch a fake package back on. */
for (size_t i = 0; i < g_title_count; i++)
if (g_titles[i].source_type == PATCHDL_SOURCE_FAKE)
g_titles[i].enabled = 0;
/* If the user opted into a home-screen tile, refresh it now. The helper
is stat-guarded — when nothing changed on disk it's a no-op, so the
@@ -2319,6 +2761,7 @@ patchdl_websrv_start(unsigned short port) {
/* Flag titles that have a partial download on disk so the UI can offer
Resume after a reboot. */
load_manual_sources();
detect_resumable_partials();
/* Build the diagnostic dump now, while single-threaded — the root-vnode
+12 -10
View File
@@ -39,8 +39,9 @@ The UI assumes deny-by-default behavior:
"source_policy": {
"official": { "allow_check": true, "allow_download": true, "allow_install": true },
"external": { "allow_check": true, "allow_download": true, "allow_install": true },
"shadowmount": { "allow_check": true, "allow_download": true, "allow_install": false },
"unknown": { "allow_check": true, "allow_download": false, "allow_install": false }
"shadowmount": { "allow_check": true, "allow_download": false, "allow_install": false },
"unknown": { "allow_check": true, "allow_download": false, "allow_install": false },
"fake": { "allow_check": true, "allow_download": false, "allow_install": false }
},
"cdn_allowlist": [
"sgst.prod.dl.playstation.net",
@@ -70,7 +71,7 @@ Per-title source fields:
"source_path": "/system_ex/app/PPSA90001_00",
"mount_from": "/mnt/usb0/itemzflow/Shadowmounted Test Title",
"enabled": true,
"mode": "download_only"
"mode": "disabled"
}
```
@@ -83,16 +84,17 @@ shadowmount
unknown
```
The frontend treats `shadowmount` as download-only and `unknown` as blocked for
downloads and installs. Backend code should enforce the same policy even if a
client sends a forged request.
The frontend lists `shadowmount`, `unknown` and `fake` titles under *Not
supported* and offers neither download nor install for them. The backend refuses
both as well, even if a client sends a forged request.
For PS5 game updates, the backend may turn a Sony `manifest_url` into a merged
local `.pkg` by downloading all manifest pieces. The `delta_url` `*-DP.pkg` is
not shown as a separate user action because it can bootstrap the storage/master
title instead of the installed regional target.
If `patch_storage_match` is false, the UI keeps download/verify available but
does not offer install or auto-install. Those shared-master packages are signed
for a different storage title id and cannot be retargeted by standalone
AppInstUtil on firmware 11.60.
If `patch_storage_match` is false, the CDN stores the patch pieces under another
regional (master) title id. That is fine: the merged `.pkg` assembled from the
manifest carries the installed title's own content id, and AppInstUtil
`AppInstallPkg` installs it onto that title. The UI only adds a "Shared master"
label.
+623 -36
View File
@@ -7,11 +7,15 @@ const API = {
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
};
// title_id -> loaded <img>, so re-rendered cards show icons instantly.
const iconCache = new Map();
const SOURCE_TYPES = {
official: { label: "Official", className: "ok", installAllowed: true, description: "Native console installation" },
external: { label: "External", className: "external", installAllowed: true, description: "Real external installation" },
shadowmount: { label: "Shadowmount", className: "shadow", installAllowed: false, description: "Download only" },
shadowmount: { label: "Shadowmount", className: "shadow", installAllowed: false, description: "Not supported" },
unknown: { label: "Unknown", className: "blocked", installAllowed: false, description: "Blocked" },
fake: { label: "Fake package", className: "blocked", installAllowed: false, description: "Not supported" },
};
const fallback = {
@@ -22,6 +26,8 @@ const fallback = {
resolver: "Internal allowlist",
free_space_mb: 771916,
download_dir: "/data/patchdl (internal)",
lan_ip: "192.168.178.90",
http_port: 12880,
},
config: {
default_policy: "deny",
@@ -34,8 +40,9 @@ const fallback = {
source_policy: {
official: { allow_check: true, allow_download: true, allow_install: true },
external: { allow_check: true, allow_download: true, allow_install: true },
shadowmount: { allow_check: true, allow_download: true, allow_install: false },
shadowmount: { allow_check: true, allow_download: false, allow_install: false },
unknown: { allow_check: true, allow_download: false, allow_install: false },
fake: { allow_check: true, allow_download: false, allow_install: false },
},
cdn_allowlist: [
"sgst.prod.dl.playstation.net",
@@ -69,7 +76,17 @@ const fallback = {
latest_version: "01.009.000", latest_required_fw: "12.00",
source_type: "shadowmount", source_path: "/system_ex/app/PPSA90001_00",
patch_storage_match: true,
mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "available",
mount_from: "/mnt/usb0/itemzflow/Shadowmounted Test Title", enabled: true, status: "blocked",
},
{
title_id: "PPSA03396", name: "The Last of Us Part I",
content_id: "UP9000-PPSA03396_00-THELASTOFUSPART1",
installed_version: "01.000.000", compatible_version: null,
latest_version: "02.002.000", latest_required_fw: "10.20",
prospero_version: "01.003.000", prospero_required_fw: "7.01", prospero_size: "71.7 GB", prospero_done: true,
source_type: "official", source_path: "/system_ex/app/PPSA03396",
patch_storage_match: true,
mount_from: "/dev/ssd0.system_ex", enabled: true, status: "incompatible_fw",
},
],
downloads: [],
@@ -83,7 +100,7 @@ let state = {
downloads: fallback.downloads,
logs: fallback.logs,
view: "games",
filter: "updatable",
filter: "updates",
query: "",
usingFallback: false,
};
@@ -98,7 +115,8 @@ const els = {};
document.addEventListener("DOMContentLoaded", () => {
bindElements();
bindEvents();
loadInitialData();
loadInitialData().then(openGuideFromHash);
window.addEventListener("hashchange", openGuideFromHash);
});
function bindElements() {
@@ -202,7 +220,9 @@ async function loadInitialData() {
const prev = new Map(state.titles.map((g) => [g.title_id, g]));
titles.forEach((g) => {
const old = prev.get(g.title_id);
if (old && g.status !== "up_to_date") {
// Neither state has a patch left to fetch, so drop stale job flags.
if (old && g.status !== "up_to_date" &&
(g.status !== "incompatible_fw" || g.manual_version)) {
if (old.downloaded) g.downloaded = true;
if (old.installing) g.installing = true;
if (old._autoInstalled) g._autoInstalled = true;
@@ -308,7 +328,14 @@ function renderSettings() {
function renderGames() {
renderGlobalStatus();
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
const counts = { updates: 0, uptodate: 0, blocked: 0, all: state.titles.length };
state.titles.forEach((g) => { counts[gameCategory(g)]++; });
document.querySelectorAll(".segmented button").forEach((b) => {
const c = b.querySelector(".chip-count");
if (c) c.textContent = String(counts[b.dataset.filter] ?? "");
});
const visible = state.titles.filter(matchesFilter).filter(matchesQuery)
.sort((a, b) => (a.name || a.title_id).localeCompare(b.name || b.title_id, undefined, { sensitivity: "base", numeric: true }));
els.gameGrid.replaceChildren();
if (!visible.length) {
@@ -321,23 +348,28 @@ function renderGames() {
visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
}
// Mutually-exclusive bucket per game for the filter chips.
// One bucket per game for the filter chips, answering a single question: is
// there something to get for this console? Where the patch comes from (Sony,
// Prospero) and whether it installs or only downloads is shown on the card.
function gameCategory(game) {
// Updating is the queue — jobs waiting for a pool slot. An actively
// downloading game stays under Updatable so you don't lose sight of it
// while drilling into the queue; same for paused (the user knows where
// it is and can resume from the card) and installing.
if (game._jobState === "queued") return "updating";
// checking is transient (version lookup still running); keep it visible under
// Updatable rather than letting it fall out of every specific filter.
if (game.status === "checking") return "updatable";
if (game.patch_title_match === false) return "blocked";
if (game.status === "available" && isDownloadAllowed(game)) return "updatable";
if (!sourcePolicy(game).allow_install && !sourcePolicy(game).allow_download) return "blocked";
if (game.status === "incompatible_fw") return "needsfw";
const policy = sourcePolicy(game);
// Not supported = PatchDL can't install it (fake packages, mounted images).
if (game.patch_title_match === false || !policy.allow_install)
return "blocked";
if (game.downloading || game.resumable || game.downloaded || game.installing || game._jobState)
return "updates";
if (game.status === "checking" || game.status === "available" || hasOlderPatch(game))
return "updates";
return "uptodate";
}
// 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) {
return game.status === "incompatible_fw" && Boolean(game.prospero_version) &&
game.prospero_version > (game.installed_version || "");
}
function matchesFilter(game) {
if (state.filter === "all") return true;
return gameCategory(game) === state.filter;
@@ -366,6 +398,20 @@ function createGameCard(game) {
const cover = document.createElement("div");
cover.className = "cover";
cover.textContent = initials(game.name);
if (game.has_icon) {
// Cards re-render on every poll. Reuse an icon that already loaded so
// it doesn't flash back to the initials; a failed icon keeps them.
const loaded = iconCache.get(game.title_id);
if (loaded) {
cover.replaceChildren(loaded.cloneNode());
} else {
const img = new Image();
img.alt = "";
img.decoding = "async";
img.onload = () => { iconCache.set(game.title_id, img); cover.replaceChildren(img.cloneNode()); };
img.src = `/api/icon/${encodeURIComponent(game.title_id)}`;
}
}
lead.appendChild(cover);
if (sourcePolicy(game).allow_install) lead.appendChild(buildToggle(game));
@@ -385,11 +431,7 @@ function createGameCard(game) {
${storagePill(game)}
${sourcePill(game)}
</div>
<div class="versions">
<span>Installed <b>${escapeHtml(game.installed_version || "--")}</b></span>
<span>Compatible <b>${escapeHtml(game.compatible_version || "None")}</b></span>
<span>Latest <b>${escapeHtml(game.latest_version || "--")}</b>${game.latest_required_fw ? ` · FW ${escapeHtml(game.latest_required_fw)}` : ""}</span>
</div>
<div class="versions">${versionsHtml(game)}</div>
`;
const actions = document.createElement("div");
@@ -469,12 +511,15 @@ 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 (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))
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
if (game.downloaded && game.status === "available" && isInstallAllowed(game))
const hasPatch = game.status === "available" || Boolean(game.manual_version);
if (game.downloaded && hasPatch && isInstallAllowed(game))
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
if (game.downloaded && game.status === "available") return null;
if (game.status !== "available") return null;
if (game.downloaded && hasPatch) return null;
if (!hasPatch) return null;
if (!isDownloadAllowed(game)) return null;
return state.config.install_after_download && isInstallAllowed(game)
? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." }
@@ -490,18 +535,52 @@ function stopButton(game) {
return null;
}
// Installed -> what PatchDL can get for this console -> newest overall with
// the firmware it needs (left out when it's the same as the offered update).
function versionsHtml(game) {
const v = (x) => `<b>${escapeHtml(x)}</b>`;
const parts = [`<span>Installed ${v(game.installed_version || "--")}</span>`];
let offered = "";
if (game.manual_version) {
offered = game.manual_version;
parts.push(`<span>Update ${v(offered)} · from Prospero</span>`);
} else if (game.status === "available" && game.compatible_version) {
offered = game.compatible_version;
parts.push(`<span>Update ${v(offered)}</span>`);
} else if (hasOlderPatch(game)) {
offered = game.prospero_version;
const fw = game.prospero_required_fw ? ` · needs FW ${escapeHtml(game.prospero_required_fw)}` : "";
parts.push(`<span class="prospero-line">Update ${v(offered)}${fw} · <a href="https://prosperopatches.com/${encodeURIComponent(game.title_id)}" target="_blank" rel="noopener">Prospero</a></span>`);
} else if (game.status === "incompatible_fw" && !game.prospero_done) {
parts.push(`<span>Looking for a patch for this firmware\u2026</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) {
const fw = game.latest_required_fw ? ` · needs FW ${escapeHtml(game.latest_required_fw)}` : "";
parts.push(`<span>Newest ${v(game.latest_version)}${fw}</span>`);
}
return parts.join("");
}
function statusPill(game) {
if (game.installing) return `<span class="pill warn">Installing…</span>`;
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
if (game.status === "blocked" || !sourcePolicy(game).allow_install)
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 (game.status === "available") return `<span class="pill ok">Update available</span>`;
if (game.status === "incompatible_fw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`;
if (game.status === "incompatible_fw")
return `<span class="pill">Newest needs FW ${escapeHtml(game.latest_required_fw || "?")}</span>`;
if (game.status === "up_to_date") return `<span class="pill">Up to date</span>`;
return `<span class="pill">No patch info</span>`;
}
function storagePill(game) {
return hasSharedStorage(game) ? `<span class="pill warn">Shared master</span>` : "";
return hasSharedStorage(game) && sourcePolicy(game).allow_install
? `<span class="pill warn">Shared master</span>` : "";
}
function sourcePill(game) {
@@ -578,6 +657,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;
@@ -601,7 +681,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;
@@ -835,7 +918,7 @@ async function saveConfig() {
// is on, the per-job auto-install pipeline kicks in once each download
// finishes — no further client action needed.
async function updateAll() {
const targets = state.games.filter((g) =>
const targets = state.titles.filter((g) =>
g.status === "available" && isInstallAllowed(g) &&
!g.downloading && !g.downloaded);
if (!targets.length) {
@@ -925,6 +1008,7 @@ async function cancelDownload(titleId) {
game.downloaded = false;
game.resumable = false;
game.partial_bytes = 0;
game.manual_version = null; // the server drops the pasted link on cancel
}
state.downloads = state.downloads.filter((i) => i.title_id !== titleId);
state.logs.push(`[${timeNow()}] Download cancelled / deleted: ${titleId}`);
@@ -941,6 +1025,7 @@ async function runTitleAction(titleId, action) {
else if (action === "install") await doInstall(game);
else if (action === "pause") await doPause(game);
else if (action === "cancel") await cancelDownload(titleId);
else if (action === "guide") openGuide(game);
}
// Pause only sends the signal; the in-flight doDownload() request returns its
@@ -978,7 +1063,7 @@ function hasSharedStorage(game) {
function isDownloadAllowed(game) {
return Boolean(
game.enabled !== false &&
game.compatible_version &&
(game.compatible_version || game.manual_version) &&
game.patch_title_match !== false &&
sourcePolicy(game).allow_download
);
@@ -986,9 +1071,8 @@ function isDownloadAllowed(game) {
function isInstallAllowed(game) {
return Boolean(
game.enabled !== false &&
game.compatible_version &&
(game.compatible_version || game.manual_version) &&
game.patch_title_match !== false &&
!hasSharedStorage(game) &&
sourcePolicy(game).allow_install
);
}
@@ -1017,9 +1101,10 @@ async function postJson(url, body) {
const REASON_TEXT = {
patch_title_mismatch: "Patch metadata targets a different title - install blocked.",
cross_region_storage_unsupported: "Patch bytes are signed for a shared master title; this standalone installer cannot retarget them.",
install_not_allowed_for_source: "Install blocked for this source.",
source_unknown: "Source unknown — blocked.",
source_fake: "Fake packages are not supported.",
source_shadowmount: "Shadowmount titles are not supported.",
no_compatible_patch: "No compatible patch available.",
download_failed: "Download failed.",
download_in_progress: "Another download is already running.",
@@ -1027,12 +1112,514 @@ const REASON_TEXT = {
title_disabled: "This title is disabled.",
download_paused: "Download paused.",
not_downloading: "Nothing is downloading for this title.",
not_an_sc_link: "That is not the SC link. Under Additional Files, copy the link of the SC button.",
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";
}
/* ---------------- QR code (byte mode, ECC level M, versions 1-6) ----------------
Enough for ~100 bytes, i.e. any PatchDL link. Follows ISO/IEC 18004; the
structure mirrors Project Nayuki's reference encoder. */
const QR_ECC_M = [0, 10, 16, 26, 18, 24, 16]; // ECC codewords per block
const QR_BLOCKS_M = [0, 1, 1, 1, 2, 2, 4]; // number of blocks
function qrRawModules(ver) {
let n = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const align = Math.floor(ver / 7) + 2;
n -= (25 * align - 10) * align - 55;
}
return n;
}
function qrDataCodewords(ver) {
return Math.floor(qrRawModules(ver) / 8) - QR_ECC_M[ver] * QR_BLOCKS_M[ver];
}
function qrGfMul(x, y) {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
function qrRsDivisor(degree) {
const r = new Array(degree - 1).fill(0);
r.push(1);
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < r.length; j++) {
r[j] = qrGfMul(r[j], root);
if (j + 1 < r.length) r[j] ^= r[j + 1];
}
root = qrGfMul(root, 0x02);
}
return r;
}
function qrRsRemainder(data, divisor) {
const r = divisor.map(() => 0);
for (const b of data) {
const factor = b ^ r.shift();
r.push(0);
divisor.forEach((coef, i) => { r[i] ^= qrGfMul(coef, factor); });
}
return r;
}
// Returns a square boolean matrix (true = dark), or null if text is too long.
function qrEncode(text) {
const bytes = Array.from(new TextEncoder().encode(text));
let ver = 1;
while (ver <= 6 && 4 + 8 + bytes.length * 8 > qrDataCodewords(ver) * 8) ver++;
if (ver > 6) return null;
// Data bit stream: byte mode, 8-bit length, payload, terminator, padding.
const bits = [];
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
push(0x4, 4);
push(bytes.length, 8);
bytes.forEach((b) => push(b, 8));
const cap = qrDataCodewords(ver) * 8;
push(0, Math.min(4, cap - bits.length));
push(0, (8 - (bits.length % 8)) % 8);
for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) push(pad, 8);
const data = [];
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
// Split into blocks, add Reed-Solomon ECC, interleave.
const numBlocks = QR_BLOCKS_M[ver];
const eccLen = QR_ECC_M[ver];
const rawCodewords = Math.floor(qrRawModules(ver) / 8);
const numShort = numBlocks - (rawCodewords % numBlocks);
const shortLen = Math.floor(rawCodewords / numBlocks);
const divisor = qrRsDivisor(eccLen);
const blocks = [];
for (let i = 0, k = 0; i < numBlocks; i++) {
const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
k += dat.length;
const ecc = qrRsRemainder(dat, divisor);
if (i < numShort) dat.push(0);
blocks.push(dat.concat(ecc));
}
const codewords = [];
for (let i = 0; i < blocks[0].length; i++)
blocks.forEach((blk, j) => {
if (i !== shortLen - eccLen || j >= numShort) codewords.push(blk[i]);
});
// Function patterns.
const size = ver * 4 + 17;
const mod = Array.from({ length: size }, () => new Array(size).fill(false));
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; };
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
[[3, 3], [size - 4, 3], [3, size - 4]].forEach(([cx, cy]) => {
for (let dy = -4; dy <= 4; dy++)
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx, y = cy + dy;
if (x < 0 || x >= size || y < 0 || y >= size) continue;
const d = Math.max(Math.abs(dx), Math.abs(dy));
set(x, y, d !== 2 && d !== 4);
}
});
if (ver >= 2) {
const pos = [6, size - 7]; // versions 2-6 have one alignment pattern
const c = pos[1];
for (let dy = -2; dy <= 2; dy++)
for (let dx = -2; dx <= 2; dx++)
set(c + dx, c + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
const drawFormat = (mask) => {
const data5 = (0 << 3) | mask; // ECC level M = 0b00
let rem = data5;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const f = ((data5 << 10) | rem) ^ 0x5412;
const bit = (i) => ((f >>> i) & 1) === 1;
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
set(8, 7, bit(6));
set(8, 8, bit(7));
set(7, 8, bit(8));
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
set(8, size - 8, true);
};
drawFormat(0); // reserve the format areas before placing data
// Data placement: two-column zigzag from the bottom right.
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++)
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
if (!fn[y][x] && i < codewords.length * 8) {
mod[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1;
i++;
}
}
}
const maskFn = [
(x, y) => (x + y) % 2 === 0,
(x, y) => y % 2 === 0,
(x) => x % 3 === 0,
(x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
];
const applyMask = (m) => {
for (let y = 0; y < size; y++)
for (let x = 0; x < size; x++)
if (!fn[y][x] && maskFn[m](x, y)) mod[y][x] = !mod[y][x];
};
// Simplified penalty (runs, 2x2 blocks, balance) to pick a readable mask.
const penalty = () => {
let p = 0, dark = 0;
for (let a = 0; a < size; a++) {
let runR = 1, runC = 1;
for (let b = 0; b < size; b++) {
if (mod[a][b]) dark++;
if (b > 0) {
runR = mod[a][b] === mod[a][b - 1] ? runR + 1 : 1;
runC = mod[b][a] === mod[b - 1][a] ? runC + 1 : 1;
if (runR === 5) p += 3; else if (runR > 5) p++;
if (runC === 5) p += 3; else if (runC > 5) p++;
}
if (a > 0 && b > 0) {
const c = mod[a][b];
if (c === mod[a - 1][b] && c === mod[a][b - 1] && c === mod[a - 1][b - 1]) p += 3;
}
}
}
const total = size * size;
p += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10;
return p;
};
let best = 0, bestScore = Infinity;
for (let m = 0; m < 8; m++) {
applyMask(m);
drawFormat(m);
const score = penalty();
if (score < bestScore) { best = m; bestScore = score; }
applyMask(m); // XOR again to undo
}
applyMask(best);
drawFormat(best);
return mod;
}
// SVG markup for a QR matrix, dark on white with the required 4-module margin.
function qrSvg(matrix) {
const n = matrix.length, q = 4, s = n + q * 2;
let d = "";
matrix.forEach((row, y) => row.forEach((dark, x) => {
if (dark) d += `M${x + q},${y + q}h1v1h-1z`;
}));
return `<svg xmlns="http://www.w3.org/2000/svg" class="qr" viewBox="0 0 ${s} ${s}" shape-rendering="crispEdges" role="img" aria-label="QR code"><rect width="${s}" height="${s}" fill="#fff" stroke="none"/><path d="${d}" fill="#000" stroke="none"/></svg>`;
}
/* ---------------- older patch guide (Prospero) ---------------- */
// Sony's version.xml only lists the newest patch. When that needs newer
// firmware, Prospero Patches still has older ones, but its links sit behind a
// human check, so the user copies one link and PatchDL does the rest. Copying
// is awkward in the PS5 browser, hence the QR hand-off to a phone.
const guide = { el: null, game: null, fromLink: false, started: false, busy: false,
lastUrl: "", timer: null, returnFocus: null, onKey: null };
function canGetOlderPatch(game) {
return hasOlderPatch(game) &&
!game.manual_version && !game.downloading && !game.downloaded &&
!game.resumable && !game.installing &&
isDownloadAllowed({ ...game, manual_version: game.prospero_version });
}
// A link a phone can open. A page opened on the PS5 itself runs on 127.0.0.1,
// so the server reports its LAN address for this.
function phoneLink(game) {
const h = location.hostname;
let base = location.origin;
if (!h || h === "localhost" || h === "127.0.0.1" || h === "[::1]") {
if (!state.status.lan_ip) return null;
base = `http://${state.status.lan_ip}:${state.status.http_port || location.port || 12880}`;
}
return `${base}/#get/${encodeURIComponent(game.title_id)}`;
}
function openGuideFromHash() {
const m = location.hash.match(/^#get\/([A-Za-z0-9_.-]+)$/);
if (!m) return;
const game = state.titles.find((g) => g.title_id === m[1]);
if (!game) { showToast("That game is not installed on this PS5."); clearGuideHash(); return; }
if (!canGetOlderPatch(game)) {
showToast(game.manual_version || game.downloading || game.downloaded
? `${game.name}: the older patch is already on its way.`
: `${game.name}: there is no older patch to fetch.`);
clearGuideHash();
return;
}
openGuide(game, true);
}
function clearGuideHash() {
if (location.hash.startsWith("#get/"))
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 head = `
<header class="guide-head">
<div>
<p class="eyebrow">Old FW patch</p>
<h2 id="guideTitle">Get ${ver}</h2>
<p class="guide-game">${escapeHtml(game.name)}</p>
</div>
<button class="icon-button guide-close" type="button" aria-label="Close">×</button>
</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">
<li>
<div class="guide-step-text"><strong>Open Prospero Patches</strong>
<span>Tap version <b>${ver}</b>${size}. If a human check shows up, confirm it.</span></div>
<a class="row-button is-update guide-open" href="https://prosperopatches.com/${encodeURIComponent(game.title_id)}" target="_blank" rel="noopener">Open Prospero</a>
</li>
<li>
<div class="guide-step-text"><strong>Copy the SC link</strong>
<span>Under <b>Additional Files</b>, press and hold the <b>SC</b> button and choose <i>Copy Link</i>. PatchDL takes the full piece list from it.</span></div>
</li>
<li>
<div class="guide-step-text"><strong>Paste it here</strong>
<span>The download starts on your PS5 as soon as the link is in.</span></div>
<div class="guide-paste">
<input class="guide-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="https://…_sc.pkg" aria-label="SC link">
<button class="row-button guide-start" type="button">Start</button>
</div>
<div class="guide-status" role="status" aria-live="polite"></div>
</li>
</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");
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 from your phone…" : "");
guide.timer = setInterval(guidePoll, 2000);
(fromLink ? wrap.querySelector(".guide-open") : wrap.querySelector(".guide-close")).focus();
}
function closeGuide() {
if (!guide.el) return;
clearInterval(guide.timer);
document.removeEventListener("keydown", guide.onKey);
guide.el.remove();
guide.el = null;
guide.game = null;
clearGuideHash();
if (guide.returnFocus && document.contains(guide.returnFocus)) guide.returnFocus.focus();
}
// The last link in the field wins, so pasting a new link next to a rejected
// one (instead of over it) still picks up the new one.
function extractLink(text) {
const all = String(text || "").match(/https?:\/\/(?:(?!https?:\/\/)[^\s"'<>])+/g) || [];
const pkgs = all.filter((u) => /\.(pkg|crc|json)$/i.test(u));
return (pkgs.length ? pkgs[pkgs.length - 1] : all[all.length - 1]) || "";
}
function guideStatus(kind, message, action) {
if (!guide.el) return;
const box = guide.el.querySelector(".guide-status");
box.innerHTML = message ? `<p class="guide-msg is-${kind}">${message}</p>` : "";
if (action) {
const b = document.createElement("button");
b.type = "button";
b.className = "row-button is-ghost guide-action";
b.textContent = action.label;
b.addEventListener("click", action.run);
box.appendChild(b);
}
}
async function guideSubmit(url, force) {
const game = guide.game;
if (!game || guide.busy) return;
if (!url) { guideStatus("error", "Paste the SC link from Prospero first."); return; }
guide.busy = true;
guide.lastUrl = url;
const start = guide.el.querySelector(".guide-start");
start.disabled = true;
guideStatus("busy", "Checking the link on your PS5…");
try {
await postJson("/api/download_url", {
title_id: game.title_id, url, version: game.prospero_version, force,
});
} catch (error) {
guide.busy = false;
if (!guide.el) return;
start.disabled = false;
const b = (error && error.body) || {};
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) });
} else {
guideStatus("error", escapeHtml(reasonText(error)) + (b.detail ? ` (${escapeHtml(b.detail)})` : ""));
}
state.logs.push(`[${timeNow()}] Prospero link for ${game.title_id} refused: ${reasonText(error)}`);
renderLogs();
return;
}
guide.busy = false;
manualDownloadStarted(game);
if (!guide.fromLink) {
showToast(`${game.name}: download of ${game.prospero_version} started.`);
closeGuide();
return;
}
guide.started = true;
guideStatus("ok", "Download started on your PS5.");
}
// Card state for a download that the server has accepted (from this page or
// from a phone); the pool's job list takes over from here.
function manualDownloadStarted(game) {
game.manual_version = game.prospero_version;
game.downloading = true;
game._localDownloading = true;
game._localSince = Date.now();
game._autoInstalled = false;
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
state.downloads.push({ title_id: game.title_id, name: game.name, version: game.manual_version,
state: "queued", progress: 0, bytes: 0, total_bytes: 0 });
state.logs.push(`[${timeNow()}] Download queued (Prospero): ${game.title_id} ${game.manual_version}`);
renderGames(); renderLogs();
startDownloadPolling();
}
async function guidePoll() {
const game = guide.game;
if (!game) return;
let jobs;
try {
const r = await fetch(API.downloads, { cache: "no-store" });
if (!r.ok) return;
jobs = await r.json();
} catch (error) {
return;
}
if (guide.game !== game) return;
const j = (jobs || []).find((x) => x.title_id === game.title_id);
if (!j) return;
if (!guide.started) {
// Only a job that is actually running means a link arrived from elsewhere;
// an old failed or paused entry does not.
if (j.state !== "queued" && j.state !== "active") return;
manualDownloadStarted(game);
if (!guide.fromLink) {
showToast(`${game.name}: link received, download of ${game.prospero_version} started.`);
closeGuide();
return;
}
guide.started = true;
}
const total = Number(j.total_bytes) || 0;
const pct = total ? Math.min(100, Math.round((Number(j.bytes) || 0) * 100 / total)) : 0;
const note = {
queued: "Queued on your PS5…",
active: "Downloading on your PS5. You can close this page.",
paused: "Paused on your PS5.",
done: state.config.install_after_download
? "Downloaded. Your PS5 installs it now."
: "Downloaded. Press Install on the game in PatchDL.",
error: "The download stopped. Resume it from the game in PatchDL.",
}[j.state] || "";
guideStatus(j.state === "error" ? "error" : "ok",
`${note}<span class="guide-progress"><span class="progress"><i style="width:${pct}%"></i></span>` +
`<span>${total ? `${formatBytes(j.bytes)} / ${formatBytes(total)} · ` : ""}${pct}%</span></span>`);
}
/* ---------------- formatting ---------------- */
function initials(name) {
+4 -6
View File
@@ -132,12 +132,10 @@
<input id="searchInput" type="search" placeholder="Search title, Title ID, or Content ID" />
</div>
<div class="segmented" role="group" aria-label="Filter">
<button class="is-selected" data-filter="updatable" aria-pressed="true">Updatable</button>
<button data-filter="updating" aria-pressed="false">Updating</button>
<button data-filter="uptodate" aria-pressed="false">Up to date</button>
<button data-filter="needsfw" aria-pressed="false">Needs FW</button>
<button data-filter="blocked" aria-pressed="false">Can't update</button>
<button data-filter="all" aria-pressed="false">All</button>
<button class="is-selected" data-filter="updates" aria-pressed="true">Updates <span class="chip-count"></span></button>
<button data-filter="uptodate" aria-pressed="false">Up to date <span class="chip-count"></span></button>
<button data-filter="blocked" aria-pressed="false">Not supported <span class="chip-count"></span></button>
<button data-filter="all" aria-pressed="false">All <span class="chip-count"></span></button>
</div>
</div>
+128
View File
@@ -338,6 +338,8 @@ h2 { font-size: 18px; line-height: 1.2; }
white-space: nowrap;
}
.segmented button.is-selected { background: var(--surface); color: var(--ink); }
.chip-count { margin-left: 4px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.chip-count:empty { display: none; }
/* ---------------- game cards ---------------- */
@@ -374,7 +376,9 @@ h2 { font-size: 18px; line-height: 1.2; }
border-radius: 10px;
font-size: 14px;
font-weight: 800;
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card-body { min-width: 0; }
.card-body h3 { margin: 0 0 6px; overflow-wrap: anywhere; font-size: 16px; }
@@ -391,6 +395,10 @@ 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; }
.card-actions {
display: flex;
@@ -416,6 +424,7 @@ h2 { font-size: 18px; line-height: 1.2; }
.pill.warn { border-color: rgba(242, 184, 75, 0.38); background: var(--amber-soft); color: var(--amber); }
.pill.external { border-color: rgba(88, 166, 255, 0.34); background: var(--blue-soft); color: var(--blue); }
.pill.shadow { border-color: rgba(181, 146, 255, 0.34); background: rgba(181, 146, 255, 0.14); color: #c7b1ff; }
.pill.prospero { border-color: rgba(64, 201, 198, 0.38); background: rgba(64, 201, 198, 0.14); color: #6fe0dc; }
.pill.blocked { border-color: rgba(255, 107, 107, 0.34); background: var(--red-soft); color: var(--red); }
.pill.live { border-color: rgba(56, 193, 114, 0.5); background: var(--green-soft); color: var(--green-dark); }
.pill.live::before {
@@ -627,6 +636,125 @@ pre {
}
.toast.is-visible { opacity: 1; transform: translate(-50%, 0); }
/* ---------------- older patch guide (Prospero) ---------------- */
.guide-backdrop {
position: fixed;
inset: 0;
z-index: 15; /* below the toast (20) */
display: grid;
grid-template-columns: minmax(0, 560px); /* an auto track would grow to the content */
justify-content: center;
align-content: center;
padding: 24px 16px;
overflow-y: auto;
background: rgba(5, 7, 9, 0.72);
}
.guide {
display: grid;
gap: 16px;
padding: 22px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
box-shadow: var(--shadow);
}
/* minmax(0, 1fr): without it an <input>'s intrinsic width pushes the tracks wider than the sheet */
.guide, .guide-steps, .guide-steps li { grid-template-columns: minmax(0, 1fr); }
.guide-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.guide-head > div { min-width: 0; }
.guide-head h2 { margin: 0; font-size: 22px; }
.guide-game { margin: 4px 0 0; color: var(--muted); }
.guide-close { flex: none; width: 40px; padding: 0; font-size: 22px; line-height: 1; }
.guide-why { margin: 0; color: var(--muted); line-height: 1.55; }
.guide-why b { color: var(--ink); }
.guide-qr {
display: flex;
align-items: center;
gap: 16px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
}
.guide-qr .qr { flex: none; width: 148px; height: 148px; border-radius: 6px; }
.guide-qr > div { display: grid; gap: 6px; min-width: 0; }
.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;
gap: 10px;
padding: 14px 14px 14px 52px;
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius);
counter-increment: step;
}
.guide-steps li::before {
content: counter(step);
position: absolute;
left: 14px;
top: 14px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--green-soft);
color: var(--green-dark);
font-weight: 800;
font-size: 13px;
}
.guide-step-text { display: grid; gap: 4px; }
.guide-step-text span { color: var(--muted); line-height: 1.5; }
.guide-step-text b { color: var(--ink); }
.guide-open { width: auto; justify-self: start; padding: 0 18px; text-decoration: none; }
.guide-paste { display: flex; gap: 8px; }
.guide-input {
flex: 1;
min-width: 0;
width: 100%;
height: 44px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--ink);
font-size: 16px; /* below 16px iOS zooms the page on focus */
}
.guide-input::placeholder { color: var(--muted); }
.guide-input:focus { outline: none; border-color: var(--blue); }
.guide-start { width: auto; flex: none; min-height: 44px; padding: 0 18px; }
.guide-status:empty { display: none; }
.guide-status { display: grid; gap: 8px; }
.guide-msg { margin: 0; line-height: 1.5; }
.guide-msg.is-idle, .guide-msg.is-busy { color: var(--muted); }
.guide-msg.is-ok { color: var(--green-dark); }
.guide-msg.is-warn { color: var(--amber-dark); }
.guide-msg.is-error { color: var(--red); }
.guide-action { width: auto; justify-self: start; padding: 0 16px; }
.guide-progress { display: grid; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 13px; }
@media (max-width: 720px) {
/* Already on a phone: the hand-off QR has nothing to add. */
.guide-qr { display: none; }
.guide-backdrop { align-content: start; padding: 12px; }
.guide { padding: 16px; }
}
/* ---------------- responsive ---------------- */
@media (max-width: 1024px) {