PatchDL 0.0.1: scan, resolve, download and install PS5 game patches

Standalone PS5 payload with an embedded web UI on :12880, no etaHEN.

- Scans installed titles and classifies the source (genuine install,
  ShadowMountPlus mount, preinstall, unknown) from the on-disk layout.
- Reads name, installed version and the Sony version.xml URL from the PS5
  app database (vendored SQLite).
- Resolves version.xml past nanoDNS via a raw DNS query, TLS pinned to the
  SCEI DNAS root.
- Filters patches to the newest one compatible with the current firmware.
- Downloads the patch and installs it through AppInstUtil (sysmodule loaded
  at runtime), gated to genuine installs with a package title-id match guard.
- Action-based UI filters and an on-screen startup notification with the URL.
This commit is contained in:
Knutwurst committed 2026-06-23 14:52:35 +02:00
1 parent 4a14c689e5
commit 01464ae88a
31 files changed
+273765 -365

No files matched your search

+4
View File
@@ -5,6 +5,10 @@ build/
node_modules/
gen/
*.elf
*.o
__pycache__/
*.pyc
.toolchains/
# AI process artifacts — keep local, never commit/push (specs, plans, design docs)
docs/superpowers/
+33 -5
View File
@@ -12,13 +12,38 @@ endif
PYTHON ?= python3
BIN := patchdl-ps5.elf
SRCS := src/main.c src/patchdl_assets.c src/patchdl_websrv.c
SRCS := src/main.c \
src/patchdl_assets.c \
src/patchdl_websrv.c \
src/patchdl_proc.c \
src/patchdl_fw.c \
src/patchdl_scan.c \
src/patchdl_appdb.c \
src/patchdl_net.c \
src/patchdl_resolve.c \
src/patchdl_verxml.c \
src/patchdl_install.c \
src/patchdl_notify.c
WEB_ASSETS := web/index.html web/styles.css web/app.js
GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS))
CFLAGS := -g -O2 -Wall -Werror -Isrc -I"$(ETAHEN_DEPS_DIR)/include" -DPATCHDL_HTTP_PORT=$(PATCHDL_HTTP_PORT)
LDADD := -L"$(ETAHEN_DEPS_DIR)/lib" -lmicrohttpd -lkernel_sys
SQLITE_DIR := vendor/sqlite
SQLITE_OBJ := $(SQLITE_DIR)/sqlite3.o
SQLITE_CFLAGS := -O2 -w \
-DSQLITE_THREADSAFE=0 \
-DSQLITE_OMIT_LOAD_EXTENSION \
-DSQLITE_OMIT_WAL \
-DSQLITE_DEFAULT_MEMSTATUS=0 \
-DSQLITE_OMIT_DEPRECATED
CFLAGS := -g -O2 -Wall -Werror -Isrc -I"$(ETAHEN_DEPS_DIR)/include" -I"$(SQLITE_DIR)" -DPATCHDL_HTTP_PORT=$(PATCHDL_HTTP_PORT)
LDADD := -L"$(ETAHEN_DEPS_DIR)/lib" -lmicrohttpd -lkernel_sys -lpthread
ifdef CURL_DIR
CFLAGS += -DPATCHDL_HAVE_CURL -I"$(CURL_DIR)/include"
LDADD += -L"$(CURL_DIR)/lib" -lcurl -lpsl -lssl -lcrypto -lzstd -lz
endif
all: $(BIN)
@@ -28,13 +53,16 @@ gen/web:
gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web
$(PYTHON) scripts/gen_asset_module.py --path $* $< > $@
$(BIN): $(SRCS) $(GEN_SRCS)
$(SQLITE_OBJ): $(SQLITE_DIR)/sqlite3.c
$(CC) $(SQLITE_CFLAGS) -c -o $@ $<
$(BIN): $(SRCS) $(GEN_SRCS) $(SQLITE_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(LDADD)
test: $(BIN)
$(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^
clean:
rm -rf $(BIN) gen
rm -rf $(BIN) gen $(SQLITE_OBJ)
.PHONY: all clean test
+66 -100
View File
@@ -1,111 +1,77 @@
# PatchDL
PatchDL is a planned standalone PS5 ELF payload for controlled official game
patch downloads.
A standalone PlayStation 5 ELF payload that downloads and installs official game
patches on your terms. It serves its own web UI and runs without etaHEN.
The target design is deny-by-default:
PatchDL is built for setups where nanoDNS blocks Sony's servers for the whole
console. It resolves the Sony patch CDN on its own path, so the rest of the
system stays offline and only the patches you pick get fetched.
- Sony domains stay blocked by nanoDNS for the normal PS5 system.
- PatchDL uses its own internal allowlist resolver for Sony CDN hosts.
- Games must be explicitly enabled before PatchDL checks or downloads updates.
- Patch selection is capped to the highest update compatible with the current
firmware.
- Install actions are source-aware: shadowmounted and unknown titles are never
installed by PatchDL.
by Knutwurst
## Source Classification
## What it does
PatchDL treats the title source as part of the safety policy:
- Scans installed titles and classifies each one: genuine install,
ShadowMountPlus mount, preinstall, or unknown.
- Reads the title name, installed version, and the Sony `version.xml` URL from
the PS5 app database.
- Fetches each title's `version.xml` from Sony's CDN past nanoDNS (a raw DNS
query to 1.1.1.1) and verifies TLS against the pinned SCEI DNAS root.
- Picks the newest patch compatible with the current firmware
(`system_ver <= firmware`), so an update never forces a firmware upgrade.
- Downloads the patch package and installs it through Sony's AppInstUtil
service.
## Safety model
Deny-by-default. A patch is installed only for a genuine install, and only when
the package's title id matches the installed game:
| Source | Check | Download | Install |
|-----------------------|-------|----------|---------|
| official | yes | yes | yes |
| shadowmount | yes | yes | no |
| preinstall / unknown | yes | no | no |
Two independent guards stop the wrong package being installed: the patch's title
id (read from its download URL) must match the game, and just before install the
real title id is read back from the package
(`sceAppInstUtilGetTitleIdFromPkg`) and checked again. A cross-region or
cross-title package is refused instead of installed as a phantom title.
## Build
Requires `ps5-payload-dev/sdk`. The network and install features also need the
prebuilt libcurl + OpenSSL from `ps5-payload-dev/pacbrew-repo` placed in the SDK
sysroot (`target/user/homebrew`); `scripts/build_ps5.sh` enables them
automatically when present. libmicrohttpd is vendored under `vendor/etahen`, and
SQLite is vendored under `vendor/sqlite`.
```sh
scripts/build_ps5.sh # produces patchdl-ps5.elf
```
## Deploy
This console uses the BD-JB autoloader with itsPLK's Payload Manager on port
8084 (not a 9021 elfldr). `scripts/deploy_ps5.sh` uploads the ELF named with its
version and launches it; the payload replaces any running instance itself.
```sh
PS5_HOST=<console-ip> scripts/deploy_ps5.sh
```
On start it shows an on-screen notification with the URL. Open the web UI at:
```text
official check/download/install allowed
external check/download/install allowed when detected as a real install
shadowmount check/download allowed, install blocked
unknown check allowed, download/install blocked
http://<console-ip>:12880/
```
The future ELF scanner should classify titles by app metadata plus mount table
inspection. Shadowmounts should be detected through `statfs()` / `getfsstat()`
and `nullfs` mount origins instead of trusting title IDs alone.
## Status
## Current Contents
```text
Makefile
PS5 payload build using ps5-payload-sdk and libmicrohttpd.
src/
Embedded web server entrypoint and API stubs.
vendor/etahen/
Vendored libmicrohttpd header and static library required for standalone
builds.
web/
Static web UI prototype for the future embedded PatchDL web server.
```
Open `web/index.html` directly for the mock UI, or serve `web/` from a local
HTTP server.
## Embedded Web Server
PatchDL is intended to run one self-contained ELF. It does not need a second
`websrv.elf` process. The payload starts its own libmicrohttpd server and serves
the UI from compiled-in assets.
Default port:
```text
http://PS5_IP:12880/
```
The port can be changed at build time:
```sh
make PATCHDL_HTTP_PORT=12881
```
or at runtime by passing a port as the first argument, if the loader supports
argv:
```sh
patchdl-ps5.elf 12881
```
## Building
PatchDL builds against `ps5-payload-dev/sdk` and reuses etaHEN's checked-in
`libmicrohttpd.a` plus header files. The required etaHEN build dependency is
vendored in this repository:
```text
vendor/etahen/include/microhttpd.h
vendor/etahen/lib/libmicrohttpd.a
```
If the SDK is unpacked into `.toolchains/ps5-payload-sdk/ps5-payload-sdk`, the
helper script sets the required environment automatically:
```sh
scripts/build_ps5.sh clean all
```
For a system-wide SDK install, set `PS5_PAYLOAD_SDK` yourself:
```sh
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
make clean all
```
Override paths and ports as needed:
```sh
make ETAHEN_DEPS_DIR="vendor/etahen" PATCHDL_HTTP_PORT=12881
```
Deploy with an ELF loader listening on the PS5:
```sh
PS5_HOST=192.168.178.90 PS5_PORT=9021 scripts/build_ps5.sh test
```
0.0.1, early. Title scan, version resolution, firmware-compatibility filtering,
download, and install work and have been verified on firmware 11.60. Open items:
the web UI marks a title "Installing…" but reads progress from the PS5's own
notifications rather than a percentage; config persistence and a download queue
are not built yet; disc-based games need the disc inserted for their patch to
apply (a normal Sony requirement).
+10
View File
@@ -27,4 +27,14 @@ else
fi
export PATH
# Enable the libcurl/OpenSSL network path automatically when those static
# libs have been installed into the SDK sysroot (the pacbrew homebrew prefix).
if [ -z "${CURL_DIR:-}" ]; then
HBREW="$PS5_PAYLOAD_SDK/target/user/homebrew"
if [ -f "$HBREW/include/curl/curl.h" ] && [ -f "$HBREW/lib/libcurl.a" ]; then
CURL_DIR="$HBREW"
export CURL_DIR
fi
fi
exec make -C "$ROOT_DIR" "$@"
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
# Deploy patchdl to the PS5 via the Payload Manager HTTP API (port 8084).
# The uploaded filename carries the version so it is identifiable in the
# Payload Manager UI. patchdl self-kills any running instance, so this is
# an idempotent redeploy.
#
# Usage: scripts/deploy_ps5.sh [PS5_HOST]
# PS5_HOST defaults to $PS5_HOST or ps5-slim.fritz.box
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
HOST=${1:-${PS5_HOST:-ps5-slim.fritz.box}}
PM_PORT=${PM_PORT:-8084}
HTTP_PORT=${PATCHDL_HTTP_PORT:-12880}
VERSION=$(sed -n 's/.*PATCHDL_VERSION[^"]*"\([^"]*\)".*/\1/p' "$ROOT_DIR/src/patchdl_version.h")
[ -n "$VERSION" ] || { echo "could not read PATCHDL_VERSION" >&2; exit 1; }
SRC_ELF="$ROOT_DIR/patchdl-ps5.elf"
[ -f "$SRC_ELF" ] || { echo "build first: $SRC_ELF missing" >&2; exit 1; }
UP_NAME="patchdl-ps5-v${VERSION}.elf"
TMP_ELF="$ROOT_DIR/$UP_NAME"
cp "$SRC_ELF" "$TMP_ELF"
echo "Uploading $UP_NAME to $HOST:$PM_PORT ..."
curl -fsS -m120 -X POST "http://$HOST:$PM_PORT/manage:upload?filename=$UP_NAME" \
-H 'Content-Type: application/octet-stream' --data-binary @"$TMP_ELF" >/dev/null
rm -f "$TMP_ELF"
PAYLOAD_PATH=$(curl -fsS -m15 "http://$HOST:$PM_PORT/list_payloads" | \
UP_NAME="$UP_NAME" python3 -c '
import os, sys, json
want = os.environ["UP_NAME"]
obj = json.load(sys.stdin)
paths = obj.get("payloads", obj) if isinstance(obj, dict) else obj
for p in paths:
if isinstance(p, str) and p.endswith(want):
print(p); break
')
[ -n "$PAYLOAD_PATH" ] || { echo "uploaded payload not found in list_payloads" >&2; exit 1; }
echo "Launching $PAYLOAD_PATH ..."
curl -fsS -m20 "http://$HOST:$PM_PORT/loadpayload:$PAYLOAD_PATH" >/dev/null
printf "Waiting for web server on :%s " "$HTTP_PORT"
i=0
while [ "$i" -lt 20 ]; do
if curl -fsS -m4 "http://$HOST:$HTTP_PORT/api/status" >/dev/null 2>&1; then
echo " up."
curl -fsS -m6 "http://$HOST:$HTTP_PORT/api/status"; echo
echo "UI: http://$HOST:$HTTP_PORT/"
exit 0
fi
printf "."
sleep 2
i=$((i + 1))
done
echo " timeout — server did not answer." >&2
exit 1
+14 -1
View File
@@ -3,12 +3,17 @@
#include <stdlib.h>
#include <unistd.h>
#include "patchdl_notify.h"
#include "patchdl_proc.h"
#include "patchdl_version.h"
#include "patchdl_websrv.h"
#ifndef PATCHDL_HTTP_PORT
#define PATCHDL_HTTP_PORT 12880
#endif
#define PATCHDL_PROC_NAME "patchdl.elf"
int
main(int argc, char** argv) {
unsigned short port = PATCHDL_HTTP_PORT;
@@ -22,13 +27,21 @@ main(int argc, char** argv) {
signal(SIGPIPE, SIG_IGN);
printf("PatchDL starting web UI on port %u\n", port);
/* Identify ourselves in the PS5 process list and replace any stale
instance so re-deploys are idempotent and the HTTP port is free. */
patchdl_proc_set_name(PATCHDL_PROC_NAME);
patchdl_proc_kill_others(PATCHDL_PROC_NAME);
printf("PatchDL v%s starting web UI on port %u\n", PATCHDL_VERSION, port);
if(patchdl_websrv_start(port)) {
puts("PatchDL web server failed to start");
return 1;
}
/* On-screen debug notification with the URL to open. */
patchdl_notify_startup(port);
while(1) {
sleep(60);
}
+124
View File
@@ -0,0 +1,124 @@
#include "patchdl_appdb.h"
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define APP_DB_URI \
"file:/system_data/priv/mms/app.db?immutable=1"
#define APPDB_MAX 1024
/* Extract a string value for `key` from a flat PS5 AppInfoJson blob.
Trailing whitespace (Sony pads VERSION_FILE_URI) is stripped. */
static void
json_str(const char *json, const char *key, char *out, size_t out_sz) {
char needle[48];
const char *p;
size_t len;
out[0] = '\0';
if ((size_t)snprintf(needle, sizeof(needle), "\"%s\"", key) >= sizeof(needle))
return;
p = strstr(json, needle);
if (!p) return;
p += strlen(needle);
while (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')
p++;
if (*p != '"') return;
p++;
/* Copy with minimal JSON unescaping — AppInfoJson escapes URL slashes
as "\/", so VERSION_FILE_URI arrives as "https:\/\/..." otherwise. */
len = 0;
while (*p && *p != '"' && len < out_sz - 1) {
if (*p == '\\' && p[1]) {
p++;
switch (*p) {
case 'n': out[len++] = '\n'; break;
case 't': out[len++] = '\t'; break;
case 'r': out[len++] = '\r'; break;
default: out[len++] = *p; break; /* \/ \\ \" -> literal */
}
p++;
continue;
}
out[len++] = *p++;
}
out[len] = '\0';
while (len > 0 && (out[len - 1] == ' ' || out[len - 1] == '\t'))
out[--len] = '\0';
}
int
patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count) {
sqlite3 *db = NULL;
sqlite3_stmt *stmt = NULL;
patchdl_appinfo_t *arr;
size_t n = 0;
*out = NULL;
*count = 0;
if (sqlite3_open_v2(APP_DB_URI, &db,
SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, NULL) != SQLITE_OK) {
if (db) sqlite3_close(db);
return -1;
}
if (sqlite3_prepare_v2(db,
"SELECT titleId, titleName, contentId, AppInfoJson "
"FROM tbl_contentinfo;", -1, &stmt, NULL) != SQLITE_OK) {
sqlite3_close(db);
return -1;
}
arr = calloc(APPDB_MAX, sizeof(*arr));
if (!arr) {
sqlite3_finalize(stmt);
sqlite3_close(db);
return -1;
}
while (sqlite3_step(stmt) == SQLITE_ROW && n < APPDB_MAX) {
patchdl_appinfo_t *e = &arr[n];
const char *tid = (const char *)sqlite3_column_text(stmt, 0);
const char *name = (const char *)sqlite3_column_text(stmt, 1);
const char *cid = (const char *)sqlite3_column_text(stmt, 2);
const char *json = (const char *)sqlite3_column_text(stmt, 3);
if (!tid) continue;
strncpy(e->title_id, tid, sizeof(e->title_id) - 1);
if (name) strncpy(e->title_name, name, sizeof(e->title_name) - 1);
if (cid) strncpy(e->content_id, cid, sizeof(e->content_id) - 1);
if (json) {
json_str(json, "CONTENT_VERSION", e->content_version,
sizeof(e->content_version));
json_str(json, "VERSION_FILE_URI", e->version_file_uri,
sizeof(e->version_file_uri));
}
n++;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
*out = arr;
*count = n;
return 0;
}
void
patchdl_appdb_free(patchdl_appinfo_t *arr) {
free(arr);
}
const patchdl_appinfo_t *
patchdl_appdb_find(const patchdl_appinfo_t *arr, size_t count,
const char *title_id) {
for (size_t i = 0; i < count; i++)
if (!strcmp(arr[i].title_id, title_id))
return &arr[i];
return NULL;
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <stddef.h>
typedef struct {
char title_id[16];
char title_name[128];
char content_id[64];
char content_version[16];
char version_file_uri[256]; /* Sony version.xml URL (UUID assigned) */
} patchdl_appinfo_t;
/* Read the PS5 app database (tbl_contentinfo) for installed-title metadata.
Returns 0 on success with a malloc'd array (caller frees with
patchdl_appdb_free). Requires the root-vnode swap + elevated authid to be
in effect (the caller arranges this). */
int patchdl_appdb_load(patchdl_appinfo_t **out, size_t *count);
void patchdl_appdb_free(patchdl_appinfo_t *arr);
const patchdl_appinfo_t *patchdl_appdb_find(const patchdl_appinfo_t *arr,
size_t count, const char *title_id);
+33
View File
@@ -0,0 +1,33 @@
#pragma once
/*
* SCEI DNAS Root 05 — the private root CA that signs Sony's PS5 download CDN
* (sgst/gst/gs2.prod.dl.playstation.net). Their version.xml is served over TLS
* with this root, which is NOT in any public CA bundle, so it is embedded here
* and passed to libcurl via CURLOPT_CAINFO_BLOB (no file written to disk).
* Public, non-secret certificate (valid 2004-2037).
*/
static const char PATCHDL_SCEI_DNAS_ROOT_PEM[] =
"-----BEGIN CERTIFICATE-----\n"
"MIID0jCCArqgAwIBAgIBADANBgkqhkiG9w0BAQUFADBUMQswCQYDVQQGEwJKUDEp\n"
"MCcGA1UEChMgU29ueSBDb21wdXRlciBFbnRlcnRhaW5tZW50IEluYy4xGjAYBgNV\n"
"BAMTEVNDRUkgRE5BUyBSb290IDA1MB4XDTA0MDcxMjA5MDExOVoXDTM3MTIwNjA5\n"
"MDExOVowVDELMAkGA1UEBhMCSlAxKTAnBgNVBAoTIFNvbnkgQ29tcHV0ZXIgRW50\n"
"ZXJ0YWlubWVudCBJbmMuMRowGAYDVQQDExFTQ0VJIEROQVMgUm9vdCAwNTCCASIw\n"
"DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANmPeza8PwCqlI7esOGIkoSESnIN\n"
"g72ZD3Ut63jy7SdothPIvGBqVZWYkIpqJYJd1I4Nh//IpXQCQL0PnJLrh9BBeowq\n"
"Muf5NNq3Us80Ihiu9CvNEAEO18g3OFV1TYdSwQ5zUsk33OUeI7h4aBPDVcZXYeHt\n"
"dbPLqe4K8igian5prrAD5S6h28t8aAm+qMWRo+bW25B/841XwDGBP7/IxZv8Yoio\n"
"rCo80CVYe6lGoU08eeqQiaHI5zAF281DWZSoVfLjJUEWmEnxqr8aOhszRGePi+Ei\n"
"7UQjHDuZX9rLhDI1zAND+BA259tn/iwOqVXe20OccJllHJcG4Ecmd98f5qMCAwEA\n"
"AaOBrjCBqzAdBgNVHQ4EFgQUxlahM1tPzoN3YgVEhm0gV7Wv2twwfAYDVR0jBHUw\n"
"c4AUxlahM1tPzoN3YgVEhm0gV7Wv2tyhWKRWMFQxCzAJBgNVBAYTAkpQMSkwJwYD\n"
"VQQKEyBTb255IENvbXB1dGVyIEVudGVydGFpbm1lbnQgSW5jLjEaMBgGA1UEAxMR\n"
"U0NFSSBETkFTIFJvb3QgMDWCAQAwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUF\n"
"AAOCAQEACZPihjwXA27wJ03tEKcHAeFLi8aBw2ysH4GwuH1dWb3UpuznWOB0iQT1\n"
"wQocnEFYCJx5XFEnj4aLWpSHLEq/sSO+my+aPoTEsy20ajF+YLYZm0bZxH50CJYh\n"
"rkET4C2aC0XvhGp9k1JQ1o0W6+cFT5LTlXapsq8Btt31t+XDPX7RqGV4WGekt3hM\n"
"T7xRc7JWXdAQijIrbYi8mtbM07KEGnPU6IT8C47+0mSurpwLOoWL1tPgo6ePpLNi\n"
"c4quUMgh9RXVjeTyXOMmyYdeUm2gt7qErvQONli+6Epmhm0A2khpIMHSpQjTE8gV\n"
"rZp42a6+zg1iYy2vFBOmiQ17GRUl0A==\n"
"-----END CERTIFICATE-----\n";
+46
View File
@@ -0,0 +1,46 @@
#include "patchdl_fw.h"
#include <ps5/kernel.h>
#include <stdio.h>
#include <string.h>
/* Layout per ps5-payload-dev/shsrv (verified on fw 11.60). The leading
`unknown1` field is why a naive {str,bin} struct reads garbage. */
typedef struct {
unsigned long unknown1;
char str_version[0x1c];
unsigned int bin_version;
unsigned long unknown2;
} sce_version_t;
/* Resolved from libkernel_sys at runtime on PS5 */
extern int sceKernelGetProsperoSystemSwVersion(sce_version_t *ver);
/* bin_version packs the firmware as hex digits read as decimal:
0x11600000 -> "11.60". */
static void
bin_to_str(uint32_t bin, char *out, size_t sz) {
snprintf(out, sz, "%x.%02x", (bin >> 24) & 0xff, (bin >> 16) & 0xff);
}
int
patchdl_fw_get(patchdl_fw_t *out) {
sce_version_t v;
if (!out) return -1;
memset(out, 0, sizeof(*out));
memset(&v, 0, sizeof(v));
if (sceKernelGetProsperoSystemSwVersion(&v) == 0 && v.bin_version) {
out->bin = v.bin_version;
bin_to_str(out->bin, out->str, sizeof(out->str));
return 0;
}
/* Fallback: kernel_get_fw_version reads the live FW build from the
kernel when the SCE call is unavailable. */
out->bin = kernel_get_fw_version();
if (out->bin)
bin_to_str(out->bin, out->str, sizeof(out->str));
return 0;
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <stdint.h>
typedef struct {
char str[0x1c]; /* e.g. "11.60" */
uint32_t bin; /* e.g. 0x11600000 */
} patchdl_fw_t;
int patchdl_fw_get(patchdl_fw_t *out);
+245
View File
@@ -0,0 +1,245 @@
#include "patchdl_install.h"
#include <ps5/kernel.h>
#include <pthread.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
/* AppInstUtil structs/signatures, reverse-engineered by the PS5 homebrew
scene (notmaj0r/CheatRunner, ps5-payload-dev/websrv). Sizes per CheatRunner:
CONTENTID=0x30, PLAYGOSCENARIOID=3, LANGUAGE=8, NUM_LANGUAGES=30, NUM_IDS=64.
The trailing `unknown` padding makes the playgo struct safely large. */
#define AI_CONTENTID_SIZE 0x30
#define AI_PLAYGOSCENARIOID_SIZE 3
#define AI_LANGUAGE_SIZE 8
#define AI_NUM_LANGUAGES 30
#define AI_NUM_IDS 64
typedef struct {
char content_id[AI_CONTENTID_SIZE];
int content_type;
int content_platform;
} ai_pkg_info_t;
typedef struct {
const char *uri;
const char *ex_uri;
const char *playgo_scenario_id;
const char *content_id;
const char *content_name;
const char *icon_url;
} ai_meta_info_t;
typedef struct {
char languages[AI_NUM_LANGUAGES][AI_LANGUAGE_SIZE];
char playgo_scenario_ids[AI_NUM_IDS][AI_PLAYGOSCENARIOID_SIZE];
char content_ids[AI_NUM_IDS][AI_CONTENTID_SIZE];
long unknown[810];
} ai_playgo_info_t;
/* Sysmodule IDs (from ps5-payload-dev/sdk crt/rtld_sprx.c). */
#define SYSMOD_IPMI 0x8000001d
#define SYSMOD_USERSERVICE 0x80000011
#define SYSMOD_SYSTEMSERVICE 0x80000010
#define SYSMOD_APPINSTUTIL 0x80000014
typedef int (*sysmod_load_fn)(unsigned int id);
typedef int (*ai_init_fn)(void);
typedef int (*ai_install_pkg_fn)(const char *path, ai_pkg_info_t *info);
typedef int (*ai_install_by_pkg_fn)(ai_meta_info_t *meta, ai_pkg_info_t *info,
ai_playgo_info_t *playgo);
typedef int (*ai_title_from_pkg_fn)(const char *path, char *title_id, int *is_app);
static ai_init_fn ai_initialize;
static ai_install_pkg_fn ai_install_pkg;
static ai_install_by_pkg_fn ai_install_by_package;
static ai_title_from_pkg_fn ai_title_from_pkg;
/* Resolve + initialize the AppInstUtil backend WITHOUT linking the sce libs
(that makes the ELF unloadable by the elfldr) and WITHOUT raw
sceKernelLoadStartModule (that wedged the process). Instead use the proper
sysmodule loader — the same path the SDK's rtld_sprx takes — then resolve
symbols via the kernel dynlib helpers. Runs in a detached thread; the HTTP
handler reports the stage and never blocks.
stage: 0 idle, 1 resolve loader, 2 load modules, 3 resolve symbols,
4 initialize, 5 ready, negative = failure at that step. */
static volatile int g_stage;
static int g_err;
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static intptr_t
dynsym(const char *module, const char *sym) {
uint32_t h = 0;
if (kernel_dynlib_handle(-1, module, &h) < 0)
return 0;
return kernel_dynlib_dlsym(-1, h, sym);
}
static void *
backend_init_thread(void *arg) {
(void)arg;
sysmod_load_fn sysmod_load;
g_stage = 1;
sysmod_load = (sysmod_load_fn)dynsym("libSceSysmodule.sprx",
"sceSysmoduleLoadModuleInternal");
if (!sysmod_load) { g_stage = -1; return NULL; }
g_stage = 2;
/* Dependencies first, then AppInstUtil. The sysmodule loader pulls in
further deps and is the non-wedging path. */
sysmod_load(SYSMOD_IPMI);
sysmod_load(SYSMOD_USERSERVICE);
sysmod_load(SYSMOD_SYSTEMSERVICE);
g_err = sysmod_load(SYSMOD_APPINSTUTIL);
if (g_err) { g_stage = -2; return NULL; }
g_stage = 3;
ai_initialize = (ai_init_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilInitialize");
ai_install_pkg = (ai_install_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilAppInstallPkg");
ai_install_by_package = (ai_install_by_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilInstallByPackage");
ai_title_from_pkg = (ai_title_from_pkg_fn)dynsym("libSceAppInstUtil.sprx",
"sceAppInstUtilGetTitleIdFromPkg");
if (!ai_initialize || !ai_install_pkg || !ai_install_by_package) {
g_stage = -3;
return NULL;
}
g_stage = 4;
g_err = ai_initialize();
if (g_err) { g_stage = -4; return NULL; }
g_stage = 5;
return NULL;
}
static void
backend_start(void) {
pthread_mutex_lock(&g_mtx);
if (g_stage == 0) {
pthread_t tid;
pthread_attr_t attr;
g_stage = 1;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&tid, &attr, backend_init_thread, NULL);
pthread_attr_destroy(&attr);
}
pthread_mutex_unlock(&g_mtx);
}
static const char *
stage_str(int s) {
switch (s) {
case 0: return "idle";
case 1: return "resolving sysmodule loader";
case 2: return "loading AppInstUtil module";
case 3: return "resolving AppInstUtil symbols";
case 4: return "initializing service";
case 5: return "ready";
case -1: return "sysmodule loader unavailable";
case -2: return "AppInstUtil module load failed";
case -3: return "AppInstUtil symbol resolution failed";
case -4: return "AppInstUtil init failed";
default: return "unknown";
}
}
int
patchdl_install_backend_check(char *msg, size_t msg_sz) {
int s;
backend_start();
s = g_stage;
snprintf(msg, msg_sz, "%s (rc=0x%08x)", stage_str(s), (unsigned)g_err);
return (s == 5) ? 0 : -1;
}
int
patchdl_install_local_pkg(const char *local_path, const char *expected_title_id,
char *msg, size_t msg_sz) {
char sdk_path[1024];
struct stat st;
int rc;
if (!local_path || !local_path[0]) {
snprintf(msg, msg_sz, "no package path");
return -1;
}
if (stat(local_path, &st) != 0) {
snprintf(msg, msg_sz, "package not downloaded");
return -1;
}
backend_start();
if (g_stage != 5) {
snprintf(msg, msg_sz, "install backend not ready: %s", stage_str(g_stage));
return -1;
}
/* The install service runs in its own sandbox that sees the user
partition as /user/data, not /data — remap so it can read the file. */
if (!strncmp(local_path, "/data/", 6))
snprintf(sdk_path, sizeof(sdk_path), "/user%s", local_path);
else
snprintf(sdk_path, sizeof(sdk_path), "%s", local_path);
/* GUARD: read the PKG's own title id and refuse if it does not match the
installed game. A cross-title/region package (e.g. a US PPSA03098 patch
on an EU PPSA03099 install) would otherwise be registered as a separate
phantom title instead of patching the game. */
if (ai_title_from_pkg && expected_title_id && expected_title_id[0]) {
char pkg_tid[48] = {0};
int is_app = 0;
if (ai_title_from_pkg(sdk_path, pkg_tid, &is_app) == 0 && pkg_tid[0] &&
strncmp(pkg_tid, expected_title_id, 9) != 0) {
snprintf(msg, msg_sz,
"refused: package is for %.12s, installed game is %.12s "
"(cross-title/region)", pkg_tid, expected_title_id);
return -1;
}
}
/* Primary: direct package install. */
{
ai_pkg_info_t pkg = {0};
rc = ai_install_pkg(sdk_path, &pkg);
if (rc == 0) {
snprintf(msg, msg_sz, "install started (AppInstallPkg)");
return 0;
}
}
/* Fallback: InstallByPackage with a file:// URI. */
{
char file_uri[1100];
ai_meta_info_t meta = {0};
ai_pkg_info_t pkg = {0};
ai_playgo_info_t playgo = {0};
int rc2;
snprintf(file_uri, sizeof(file_uri), "file://%s", sdk_path);
meta.uri = file_uri;
meta.ex_uri = "";
meta.playgo_scenario_id = "";
meta.content_id = "";
meta.content_name = "PatchDL";
meta.icon_url = "";
rc2 = ai_install_by_package(&meta, &pkg, &playgo);
if (rc2 == 0) {
snprintf(msg, msg_sz, "install started (InstallByPackage)");
return 0;
}
snprintf(msg, msg_sz,
"install rejected (AppInstallPkg=0x%08x, InstallByPackage=0x%08x)",
(unsigned)rc, (unsigned)rc2);
return rc2;
}
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <stddef.h>
/*
* Install a local PKG (a downloaded patch) via Sony's AppInstUtil service.
* `local_path` is the on-disk path under /data/patchdl/... The install runs
* asynchronously in the system installer queue once started; this returns 0
* when the install was accepted, non-zero (the SCE error code) otherwise, and
* writes a short human-readable status into `msg`.
*
* This MODIFIES the installed game. The caller must gate it to genuine
* (official) titles and obtain explicit user intent before calling.
*/
/* `expected_title_id` is the title id of the installed game the patch is for.
The PKG's own title id is read and must match, else the install is refused
(prevents a cross-region/cross-title package being installed as a new
phantom title). */
int patchdl_install_local_pkg(const char *local_path,
const char *expected_title_id,
char *msg, size_t msg_sz);
/* Verify the AppInstUtil backend can be loaded + resolved + initialized,
WITHOUT performing any install. Returns 0 if ready. Safe to call. */
int patchdl_install_backend_check(char *msg, size_t msg_sz);
+448
View File
@@ -0,0 +1,448 @@
#include "patchdl_net.h"
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#ifdef PATCHDL_HAVE_CURL
#include <curl/curl.h>
#include "patchdl_ca.h"
#endif
#define DNS_SERVER "1.1.1.1"
#define DNS_PORT 53
#define DNS_TIMEOUT_MS 3000
#ifdef PATCHDL_HAVE_CURL
static const char *ALLOWED_HOSTS[] = {
"sgst.prod.dl.playstation.net",
"gst.prod.dl.playstation.net",
"gs2.ww.prod.dl.playstation.net",
"prosperopatches.com",
NULL,
};
static int
host_allowed(const char *host) {
size_t hlen = strlen(host);
for (int i = 0; ALLOWED_HOSTS[i]; i++) {
if (!strcmp(host, ALLOWED_HOSTS[i]))
return 1;
size_t alen = strlen(ALLOWED_HOSTS[i]);
if (hlen > alen + 1 &&
host[hlen - alen - 1] == '.' &&
!strcmp(host + hlen - alen, ALLOWED_HOSTS[i]))
return 1;
}
return 0;
}
/* Extract hostname from https://host/path or http://host/path */
static int
url_host(const char *url, char *out, size_t out_sz) {
const char *p = strstr(url, "://");
const char *end;
size_t len;
if (!p) return -1;
p += 3;
end = strpbrk(p, "/:?");
len = end ? (size_t)(end - p) : strlen(p);
if (len == 0 || len >= out_sz) return -1;
memcpy(out, p, len);
out[len] = '\0';
return 0;
}
/* ---------- raw UDP DNS resolver ---------------------------------------- */
/* Diagnostics from the last dns_resolve() call (read by patchdl_net_diag).
step: 0=ok 1=socket 2=sendto 3=recv<12 4=no-answer. */
static int g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount;
static int
dns_encode_name(const char *host, uint8_t *buf, size_t bufsz) {
size_t pos = 0;
const char *p = host;
while (*p) {
const char *dot = strchr(p, '.');
size_t lablen = dot ? (size_t)(dot - p) : strlen(p);
if (lablen > 63 || pos + 1 + lablen + 1 >= bufsz) return -1;
buf[pos++] = (uint8_t)lablen;
memcpy(buf + pos, p, lablen);
pos += lablen;
p += lablen;
if (*p == '.') p++;
}
buf[pos++] = 0;
return (int)pos;
}
static int
dns_resolve(const char *host, char *ip_out, size_t ip_sz) {
int fd;
struct sockaddr_in srv;
uint8_t pkt[512], resp[512];
ssize_t n;
int name_len;
size_t pos;
struct timeval tv;
g_dns_step = 0; g_dns_errno = 0; g_dns_n = 0; g_dns_ancount = 0;
fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { g_dns_step = 1; g_dns_errno = errno; return -1; }
tv.tv_sec = DNS_TIMEOUT_MS / 1000;
tv.tv_usec = (DNS_TIMEOUT_MS % 1000) * 1000;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
memset(&srv, 0, sizeof(srv));
srv.sin_family = AF_INET;
srv.sin_port = htons(DNS_PORT);
srv.sin_addr.s_addr = inet_addr(DNS_SERVER);
memset(pkt, 0, sizeof(pkt));
pkt[0] = 0xDE; pkt[1] = 0xAD; /* ID */
pkt[2] = 0x01; pkt[3] = 0x00; /* standard query, RD */
pkt[4] = 0x00; pkt[5] = 0x01; /* QDCOUNT = 1 */
pos = 12;
name_len = dns_encode_name(host, pkt + pos, sizeof(pkt) - pos - 4);
if (name_len < 0) { close(fd); return -1; }
pos += (size_t)name_len;
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QTYPE = A */
pkt[pos++] = 0x00; pkt[pos++] = 0x01; /* QCLASS = IN */
if (sendto(fd, pkt, pos, 0,
(struct sockaddr *)&srv, sizeof(srv)) < 0) {
g_dns_step = 2; g_dns_errno = errno; close(fd); return -1;
}
n = recv(fd, resp, sizeof(resp), 0);
g_dns_n = (int)n;
if (n < 0) g_dns_errno = errno;
close(fd);
if (n < 12) { g_dns_step = 3; return -1; }
uint16_t ancount = (uint16_t)((resp[6] << 8) | resp[7]);
g_dns_ancount = ancount;
if (!ancount) { g_dns_step = 4; return -1; }
/* Skip header and question section */
pos = 12;
while (pos < (size_t)n) {
if (!resp[pos]) { pos++; break; }
if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; break; }
pos += 1 + resp[pos];
}
if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; }
pos += 4; /* QTYPE + QCLASS */
/* Walk every answer RR; the CDN returns a CNAME chain, so skip
non-A records and return the first A (type 1, rdlen 4). */
for (int a = 0; a < ancount && pos < (size_t)n; a++) {
if ((resp[pos] & 0xC0) == 0xC0) {
pos += 2;
} else {
while (pos < (size_t)n && resp[pos])
pos += 1 + resp[pos];
pos++;
}
if (pos + 10 > (size_t)n) break;
uint16_t type = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
pos += 6; /* CLASS + TTL */
uint16_t rdlen = (uint16_t)((resp[pos] << 8) | resp[pos + 1]); pos += 2;
if (type == 1 && rdlen == 4 && pos + 4 <= (size_t)n) {
snprintf(ip_out, ip_sz, "%u.%u.%u.%u",
resp[pos], resp[pos + 1], resp[pos + 2], resp[pos + 3]);
return 0;
}
pos += rdlen; /* skip this RR's data (e.g. a CNAME target) */
}
g_dns_step = 6; /* no A record among the answers */
return -1;
}
/* Cache resolved IPs by host. The batch fetch hits the same CDN host dozens
of times; a burst of identical raw-UDP queries gets dropped/rate-limited,
so resolve once (with retries) and reuse. */
static pthread_mutex_t dns_cache_mtx = PTHREAD_MUTEX_INITIALIZER;
static struct { char host[256]; char ip[INET_ADDRSTRLEN]; } dns_cache[64];
static int dns_cache_n;
static int
dns_lookup(const char *host, char *ip_out, size_t ip_sz) {
int rc = -1;
pthread_mutex_lock(&dns_cache_mtx);
for (int i = 0; i < dns_cache_n; i++) {
if (!strcmp(dns_cache[i].host, host)) {
strncpy(ip_out, dns_cache[i].ip, ip_sz - 1);
ip_out[ip_sz - 1] = '\0';
pthread_mutex_unlock(&dns_cache_mtx);
return 0;
}
}
pthread_mutex_unlock(&dns_cache_mtx);
for (int attempt = 0; attempt < 4 && rc; attempt++)
rc = dns_resolve(host, ip_out, ip_sz);
if (rc) return -1;
pthread_mutex_lock(&dns_cache_mtx);
if (dns_cache_n < (int)(sizeof(dns_cache) / sizeof(dns_cache[0]))) {
strncpy(dns_cache[dns_cache_n].host, host,
sizeof(dns_cache[0].host) - 1);
strncpy(dns_cache[dns_cache_n].ip, ip_out,
sizeof(dns_cache[0].ip) - 1);
dns_cache_n++;
}
pthread_mutex_unlock(&dns_cache_mtx);
return 0;
}
/* ---------- HTTP GET via curl ------------------------------------------- */
static size_t
write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
patchdl_buf_t *b = userdata;
size_t total = size * nmemb;
char *newp = realloc(b->data, b->size + total + 1);
if (!newp) return 0;
b->data = newp;
memcpy(b->data + b->size, ptr, total);
b->size += total;
b->data[b->size] = '\0';
return total;
}
int
patchdl_http_get(const char *url, patchdl_buf_t *out) {
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN];
char resolve_str[512];
struct curl_slist *resolve_list = NULL;
struct curl_blob ca_blob;
if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(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);
/* Also cover port 80 in case a redirect lands there */
char resolve_80[512];
snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip);
resolve_list = curl_slist_append(resolve_list, resolve_80);
memset(out, 0, sizeof(*out));
curl = curl_easy_init();
if (!curl) { curl_slist_free_all(resolve_list); return -1; }
/* Trust only the embedded SCEI DNAS Root (Sony's private CDN CA).
CAINFO_BLOB keeps the cert in memory — no file written anywhere. */
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
ca_blob.flags = CURL_BLOB_COPY;
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_CAINFO_BLOB, &ca_blob);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
/* Some Sony CDN edges still serve SHA-1-signed leaf certs, which
OpenSSL 3.x rejects at the default security level. Lower it so the
chain still verifies against the pinned SCEI root. */
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
res = curl_easy_perform(curl);
curl_easy_cleanup(curl);
curl_slist_free_all(resolve_list);
if (res != CURLE_OK) {
free(out->data);
out->data = NULL;
out->size = 0;
return -1;
}
return 0;
}
static size_t
file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
return fwrite(ptr, size, nmemb, (FILE *)userdata);
}
int
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
CURL *curl;
CURLcode res;
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
struct curl_slist *rl = NULL;
struct curl_blob ca_blob;
FILE *fp;
curl_off_t dl = 0;
if (bytes_out) *bytes_out = 0;
if (url_host(url, host, sizeof(host))) return -1;
if (!host_allowed(host)) return -1;
if (dns_lookup(host, ip, sizeof(ip))) return -1;
fp = fopen(dest_path, "wb");
if (!fp) return -1;
snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip);
rl = curl_slist_append(NULL, rs443);
snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip);
rl = curl_slist_append(rl, rs80);
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
ca_blob.flags = CURL_BLOB_COPY;
curl = curl_easy_init();
if (!curl) { fclose(fp); curl_slist_free_all(rl); return -1; }
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, file_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
/* No total timeout (patches can be large); abort only on a long stall. */
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
fclose(fp);
if (res != CURLE_OK) {
unlink(dest_path);
return -1;
}
if (bytes_out) *bytes_out = (long long)dl;
return 0;
}
void
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
char host[256] = {0}, ip[INET_ADDRSTRLEN] = {0};
int hostrc, allowrc, dnsrc;
long http_code = 0;
CURLcode res = CURLE_OK;
int did_curl = 0;
hostrc = url_host(url, host, sizeof(host));
allowrc = (hostrc == 0) ? host_allowed(host) : 0;
dnsrc = (hostrc == 0) ? dns_resolve(host, ip, sizeof(ip)) : -1;
if (hostrc == 0 && allowrc && dnsrc == 0) {
patchdl_buf_t buf;
memset(&buf, 0, sizeof(buf));
CURL *curl = curl_easy_init();
if (curl) {
char rs[512];
struct curl_slist *rl;
struct curl_blob ca_blob;
snprintf(rs, sizeof(rs), "%s:443:%s", host, ip);
rl = curl_slist_append(NULL, rs);
ca_blob.data = (void *)PATCHDL_SCEI_DNAS_ROOT_PEM;
ca_blob.len = strlen(PATCHDL_SCEI_DNAS_ROOT_PEM);
ca_blob.flags = CURL_BLOB_COPY;
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_RESOLVE, rl);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_SSL_CIPHER_LIST, "DEFAULT@SECLEVEL=0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
res = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
curl_slist_free_all(rl);
free(buf.data);
did_curl = 1;
}
}
snprintf(out_json, sz,
"{\"host\":\"%s\",\"host_ok\":%d,\"allowed\":%d,"
"\"dns_rc\":%d,\"ip\":\"%s\","
"\"dns_step\":%d,\"dns_errno\":%d,\"dns_recv\":%d,\"dns_ancount\":%d,"
"\"curl_ran\":%d,\"curl_rc\":%d,\"curl_err\":\"%s\",\"http_code\":%ld}",
host, hostrc == 0, allowrc, dnsrc, ip,
g_dns_step, g_dns_errno, g_dns_n, g_dns_ancount,
did_curl, (int)res,
did_curl ? curl_easy_strerror(res) : "n/a", http_code);
}
#else /* !PATCHDL_HAVE_CURL */
int
patchdl_http_get(const char *url, patchdl_buf_t *out) {
(void)url;
(void)out;
return -1; /* curl not available in this build; set CURL_DIR= to enable */
}
int
patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out) {
(void)url; (void)dest_path;
if (bytes_out) *bytes_out = 0;
return -1;
}
void
patchdl_net_diag(const char *url, char *out_json, size_t sz) {
(void)url;
snprintf(out_json, sz, "{\"error\":\"curl not built\"}");
}
#endif /* PATCHDL_HAVE_CURL */
patchdl_buf_t *
patchdl_buf_new(void) {
return calloc(1, sizeof(patchdl_buf_t));
}
void
patchdl_buf_free(patchdl_buf_t *b) {
if (!b) return;
free(b->data);
free(b);
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <stddef.h>
typedef struct {
char *data;
size_t size;
size_t cap;
} patchdl_buf_t;
patchdl_buf_t *patchdl_buf_new(void);
void patchdl_buf_free(patchdl_buf_t *b);
int patchdl_http_get(const char *url, patchdl_buf_t *out);
/* Stream a URL to a file on disk (for large PKG downloads). Returns 0 on
success and writes the byte count to *bytes_out. */
int patchdl_http_download(const char *url, const char *dest_path,
long long *bytes_out);
/* Diagnostic: run the GET pipeline for `url` and write a JSON report
(dns result/ip, curl code, http status, bytes) into `out_json`. */
void patchdl_net_diag(const char *url, char *out_json, size_t sz);
+71
View File
@@ -0,0 +1,71 @@
#include "patchdl_notify.h"
#include "patchdl_version.h"
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
/* Standard PS5/PS4 on-screen notification (same layout ftpsrv uses). */
typedef struct {
char useless1[45];
char message[3075];
} notify_request_t;
extern int sceKernelSendNotificationRequest(int, notify_request_t *,
unsigned long, int);
static void
notify(const char *fmt, ...) {
notify_request_t req;
va_list ap;
memset(&req, 0, sizeof(req));
va_start(ap, fmt);
vsnprintf(req.message, sizeof(req.message), fmt, ap);
va_end(ap);
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
puts(req.message);
}
/* First non-loopback IPv4 address of the console. */
static void
local_ip(char *out, size_t n) {
struct ifaddrs *ifa = NULL, *p;
out[0] = '\0';
if (getifaddrs(&ifa))
return;
for (p = ifa; p; p = p->ifa_next) {
char ip[INET_ADDRSTRLEN];
struct sockaddr_in *s;
if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET)
continue;
s = (struct sockaddr_in *)p->ifa_addr;
if (!inet_ntop(AF_INET, &s->sin_addr, ip, sizeof(ip)))
continue;
if (strcmp(ip, "127.0.0.1") && strncmp(ip, "0.", 2)) {
strncpy(out, ip, n - 1);
out[n - 1] = '\0';
break;
}
}
freeifaddrs(ifa);
}
void
patchdl_notify_startup(unsigned short port) {
char ip[INET_ADDRSTRLEN] = {0};
local_ip(ip, sizeof(ip));
if (ip[0])
notify("PatchDL %s by Knutwurst\nhttp://%s:%u/", PATCHDL_VERSION, ip, port);
else
notify("PatchDL %s by Knutwurst\nWeb UI on port %u", PATCHDL_VERSION, port);
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
/* Send an on-screen PS5 notification at startup showing the web UI URL
(with the console's LAN IP when resolvable). */
void patchdl_notify_startup(unsigned short port);
+65
View File
@@ -0,0 +1,65 @@
#include "patchdl_proc.h"
#include <signal.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/sysctl.h>
/* kinfo_proc field offsets for the FreeBSD 9-based PS5 kernel.
Same layout used by ps5-payload-dev/ftpsrv (verified on fw 11.60):
ki_pid at +72, ki_tdname (thread name) at +447, ki_structsize at +0. */
#define KINFO_OFF_STRUCTSIZE 0
#define KINFO_OFF_PID 72
#define KINFO_OFF_TDNAME 447
void
patchdl_proc_set_name(const char *name) {
/* tid -1 = current thread */
syscall(SYS_thr_set_name, -1, name);
}
static pid_t
find_pid(const char *name) {
int mib[4] = {1, 14, 8, 0}; /* CTL_KERN, KERN_PROC, KERN_PROC_ALL */
pid_t mypid = getpid();
pid_t pid = -1;
size_t buf_size;
uint8_t *buf;
if (sysctl(mib, 4, 0, &buf_size, 0, 0)) return -1;
if (!(buf = malloc(buf_size))) return -1;
if (sysctl(mib, 4, buf, &buf_size, 0, 0)) { free(buf); return -1; }
for (uint8_t *ptr = buf; ptr < buf + buf_size; ) {
int ki_structsize = *(int *)(ptr + KINFO_OFF_STRUCTSIZE);
pid_t ki_pid = *(pid_t *)(ptr + KINFO_OFF_PID);
char *ki_tdname = (char *)(ptr + KINFO_OFF_TDNAME);
if (ki_structsize <= 0) break; /* guard against malformed entries */
ptr += ki_structsize;
if (!strcmp(name, ki_tdname) && ki_pid != mypid)
pid = ki_pid;
}
free(buf);
return pid;
}
int
patchdl_proc_kill_others(const char *name) {
int killed = 0;
pid_t pid;
while ((pid = find_pid(name)) > 0) {
if (kill(pid, SIGKILL))
break;
killed++;
sleep(1); /* let the port + process slot free up */
}
return killed;
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
/* Set this process's (main thread) name as shown in the PS5 process list. */
void patchdl_proc_set_name(const char *name);
/* SIGKILL any other process whose name matches `name` (excluding self),
so a freshly launched payload replaces a stale instance cleanly.
Returns the number of processes killed. */
int patchdl_proc_kill_others(const char *name);
+64
View File
@@ -0,0 +1,64 @@
#include "patchdl_resolve.h"
#include <stdio.h>
#include <string.h>
#define DB_PATH "/data/patchdl/db/ps5_xml.tsv"
static int
lookup_tsv(const char *title_id, char *url_out, size_t url_sz) {
FILE *fp;
char line[1024];
fp = fopen(DB_PATH, "r");
if (!fp) return -1;
while (fgets(line, sizeof(line), fp)) {
char *tab = strchr(line, '\t');
if (!tab) continue;
*tab = '\0';
if (strcmp(line, title_id)) continue;
char *url = tab + 1;
size_t len = strlen(url);
while (len > 0 && (url[len - 1] == '\n' || url[len - 1] == '\r'))
url[--len] = '\0';
if (len == 0 || len >= url_sz) {
fclose(fp);
return -1;
}
memcpy(url_out, url, len + 1);
fclose(fp);
return 0;
}
fclose(fp);
return -1;
}
static int
lookup_prosperopatches(const char *title_id, char *url_out, size_t url_sz) {
/* TODO: implement live lookup once API endpoint is confirmed on-device.
prosperopatches.com serves Sony CDN index links for PS5 titles. */
(void)title_id;
(void)url_out;
(void)url_sz;
return -1;
}
int
patchdl_resolve_url(const char *title_id, char *url_out, size_t url_sz) {
if (!title_id) return -1;
/* Only PS5 native title IDs (PPSA/PPSE/PPSH/PPSJ/PPSC) */
if (strncmp(title_id, "PPSA", 4) &&
strncmp(title_id, "PPSE", 4) &&
strncmp(title_id, "PPSH", 4) &&
strncmp(title_id, "PPSJ", 4) &&
strncmp(title_id, "PPSC", 4))
return -1;
if (!lookup_tsv(title_id, url_out, url_sz))
return 0;
return lookup_prosperopatches(title_id, url_out, url_sz);
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <stddef.h>
int patchdl_resolve_url(const char *title_id, char *url_out, size_t url_sz);
+489
View File
@@ -0,0 +1,489 @@
#include "patchdl_scan.h"
#include "patchdl_appdb.h"
#include <ps5/kernel.h>
#include <dirent.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <unistd.h>
#define MAX_TITLES 512
static const char *SCAN_DIRS[] = {
"/user/app",
"/mnt/ext0/user/app",
"/mnt/ext1/user/app",
"/system_ex/app",
NULL,
};
/* ---------- param.json -------------------------------------------------- */
static int
json_str(const char *json, const char *key, char *out, size_t out_sz) {
char needle[80];
const char *p;
size_t len;
if ((size_t)snprintf(needle, sizeof(needle), "\"%s\"", key) >= sizeof(needle))
return -1;
p = strstr(json, needle);
if (!p) return -1;
p += strlen(needle);
while (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')
p++;
if (*p != '"') return -1;
p++;
for (len = 0; p[len] && p[len] != '"' && len < out_sz - 1; len++)
;
memcpy(out, p, len);
out[len] = '\0';
return 0;
}
/* Extract the title name for the default language out of the nested
localizedParameters block. PS5 param.json has no flat titleName key. */
static void
json_title_name(const char *json, char *out, size_t out_sz) {
char lang[16] = {0};
const char *p;
if (!json_str(json, "defaultLanguage", lang, sizeof(lang)) && lang[0]) {
char anchor[24];
if ((size_t)snprintf(anchor, sizeof(anchor), "\"%s\"", lang) <
sizeof(anchor)) {
p = strstr(json, anchor);
if (p) {
const char *tn = strstr(p, "\"titleName\"");
if (tn && !json_str(tn, "titleName", out, out_sz))
return;
}
}
}
/* Fallback: first titleName anywhere in the file */
json_str(json, "titleName", out, out_sz);
}
static int
try_param_json(const char *dir, patchdl_title_t *t) {
char path[PATH_MAX];
FILE *fp;
char buf[8192];
size_t n;
snprintf(path, sizeof(path), "%s/sce_sys/param.json", dir);
fp = fopen(path, "r");
if (!fp) return -1;
n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
if (!n) return -1;
buf[n] = '\0';
/* PS5 keys first, then PS4 fallbacks */
if (json_str(buf, "titleId", t->title_id, sizeof(t->title_id)) &&
json_str(buf, "TITLE_ID", t->title_id, sizeof(t->title_id)))
return -1;
json_str(buf, "contentId", t->content_id, sizeof(t->content_id));
json_str(buf, "contentVersion", t->installed_version, sizeof(t->installed_version));
if (!t->content_id[0])
json_str(buf, "CONTENT_ID", t->content_id, sizeof(t->content_id));
if (!t->installed_version[0])
json_str(buf, "APP_VER", t->installed_version, sizeof(t->installed_version));
json_title_name(buf, t->name, sizeof(t->name));
return 0;
}
/* ---------- param.sfo --------------------------------------------------- */
#pragma pack(push, 1)
typedef struct {
uint32_t magic; /* 0x46535000 = "\x00PSF" */
uint32_t version;
uint32_t key_table_start;
uint32_t data_table_start;
uint32_t num_entries;
} sfo_hdr_t;
typedef struct {
uint16_t key_offset;
uint16_t data_fmt;
uint32_t data_len;
uint32_t data_max_len;
uint32_t data_offset;
} sfo_entry_t;
#pragma pack(pop)
#define SFO_MAGIC 0x46535000u
#define SFO_FMT_STR 0x0204u
static int
sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
char *out, size_t out_sz) {
const sfo_hdr_t *h;
const sfo_entry_t *entries;
uint32_t i;
if (bufsz < sizeof(*h)) return -1;
h = (const sfo_hdr_t *)buf;
if (h->magic != SFO_MAGIC || h->num_entries > 256) return -1;
entries = (const sfo_entry_t *)(buf + sizeof(*h));
for (i = 0; i < h->num_entries; i++) {
size_t key_off = h->key_table_start + entries[i].key_offset;
size_t val_off = h->data_table_start + entries[i].data_offset;
if (key_off >= bufsz || val_off >= bufsz) continue;
const char *k = (const char *)(buf + key_off);
if (strcmp(k, key)) continue;
if (entries[i].data_fmt != SFO_FMT_STR) return -1;
size_t len = entries[i].data_len;
if (val_off + len > bufsz) return -1;
if (len >= out_sz) len = out_sz - 1;
memcpy(out, buf + val_off, len);
out[len] = '\0';
return 0;
}
return -1;
}
static int
try_param_sfo(const char *dir, patchdl_title_t *t) {
char path[PATH_MAX];
FILE *fp;
uint8_t buf[8192];
size_t n;
struct stat st;
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", dir);
fp = fopen(path, "rb");
if (!fp) return -1;
if (fstat(fileno(fp), &st) || (size_t)st.st_size > sizeof(buf)) {
fclose(fp);
return -1;
}
n = fread(buf, 1, (size_t)st.st_size, fp);
fclose(fp);
if (n != (size_t)st.st_size) return -1;
if (sfo_get(buf, n, "TITLE_ID", t->title_id, sizeof(t->title_id)))
return -1;
sfo_get(buf, n, "CONTENT_ID", t->content_id, sizeof(t->content_id));
sfo_get(buf, n, "APP_VER", t->installed_version, sizeof(t->installed_version));
sfo_get(buf, n, "TITLE", t->name, sizeof(t->name));
if (!t->name[0])
sfo_get(buf, n, "TITLE_00", t->name, sizeof(t->name));
return 0;
}
/* ---------- install-type classification (file-based) -------------------- */
static int
path_exists(const char *dir, const char *rel) {
char p[PATH_MAX];
struct stat st;
snprintf(p, sizeof(p), "%s/%s", dir, rel);
return stat(p, &st) == 0;
}
/* Read up to sz-1 bytes of dir/rel into buf (NUL-terminated). 0 on success. */
static int
read_small_file(const char *dir, const char *rel, char *buf, size_t sz) {
char p[PATH_MAX];
FILE *fp;
size_t n;
snprintf(p, sizeof(p), "%s/%s", dir, rel);
fp = fopen(p, "rb");
if (!fp) return -1;
n = fread(buf, 1, sz - 1, fp);
fclose(fp);
buf[n] = '\0';
return 0;
}
/* Keep only actual games. Skip PS5 system apps (NPXS), the Payload Manager
fake app (PLDM) and ELF-loader homebrew (PSPS). */
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;
return 1;
}
/* ---------- directory scanner ------------------------------------------- */
/*
* Distinguish genuine installs from ShadowMountPlus mounts by on-disk layout
* under /user/app/<TID>/ (verified on fw 11.60):
* - mount.lnk / mount_img.lnk + full sce_sys/ -> ShadowMountPlus mount
* - app.pkg (no mount.lnk) -> genuine install; app.json
* with CDN piece URLs means a not-downloaded preinstall stub, local
* URLs mean a real install
* - app.json with "fake":true -> homebrew fake (skip)
*/
static int
scan_one(const char *base, const char *name, patchdl_title_t *t) {
char dir[PATH_MAX];
char appjson[4096];
int has_mountlnk, has_app_pkg, has_paramjson, is_fake = 0, is_cdn = 0;
snprintf(dir, sizeof(dir), "%s/%s", base, name);
memset(t, 0, sizeof(*t));
strncpy(t->source_path, dir, sizeof(t->source_path) - 1);
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
if (!is_game_title(t->title_id))
return -1;
has_mountlnk = path_exists(dir, "mount.lnk") ||
path_exists(dir, "mount_img.lnk");
has_app_pkg = path_exists(dir, "app.pkg");
has_paramjson = path_exists(dir, "sce_sys/param.json");
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)
return -1;
if (has_mountlnk) {
/* metadata lives in the mounted sce_sys (param.json, or param.sfo
for PS4 titles) */
if (try_param_json(dir, t))
try_param_sfo(dir, t);
t->source_type = PATCHDL_SOURCE_SHADOWMOUNT;
} else if (has_app_pkg) {
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN
: PATCHDL_SOURCE_OFFICIAL;
} else if (has_paramjson || path_exists(dir, "sce_sys/param.sfo")) {
if (try_param_json(dir, t)) /* genuine game currently mounted */
try_param_sfo(dir, t);
t->source_type = PATCHDL_SOURCE_OFFICIAL;
} else {
return -1; /* empty / leftover directory */
}
if (!t->title_id[0])
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
if (!t->name[0])
strncpy(t->name, t->title_id, sizeof(t->name) - 1);
return 0;
}
static int
already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) {
for (size_t i = 0; i < cnt; i++)
if (!strcmp(arr[i].title_id, title_id))
return 1;
return 0;
}
static void
scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap) {
DIR *d;
struct dirent *de;
d = opendir(base);
if (!d) return;
while ((de = readdir(d))) {
if (de->d_name[0] == '.') continue;
if (*cnt >= cap) break;
if (scan_one(base, de->d_name, &arr[*cnt]) != 0)
continue;
if (already_seen(arr, *cnt, arr[*cnt].title_id))
continue; /* dedupe a title already found in an earlier base */
(*cnt)++;
}
closedir(d);
}
/* ---------- public API -------------------------------------------------- */
const char *
patchdl_source_str(patchdl_source_t src) {
switch (src) {
case PATCHDL_SOURCE_OFFICIAL: return "official";
case PATCHDL_SOURCE_EXTERNAL: return "external";
case PATCHDL_SOURCE_SHADOWMOUNT: return "shadowmount";
default: return "unknown";
}
}
/* 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. */
static void
merge_appdb(patchdl_title_t *arr, size_t cnt) {
patchdl_appinfo_t *info = NULL;
size_t n = 0;
if (patchdl_appdb_load(&info, &n))
return;
for (size_t i = 0; i < cnt; i++) {
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
if (!a) continue;
if (a->title_name[0])
strncpy(arr[i].name, a->title_name, sizeof(arr[i].name) - 1);
if (a->content_version[0])
strncpy(arr[i].installed_version, a->content_version,
sizeof(arr[i].installed_version) - 1);
if (a->content_id[0] && !arr[i].content_id[0])
strncpy(arr[i].content_id, a->content_id,
sizeof(arr[i].content_id) - 1);
if (a->version_file_uri[0])
strncpy(arr[i].version_file_uri, a->version_file_uri,
sizeof(arr[i].version_file_uri) - 1);
}
patchdl_appdb_free(info);
}
int
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
patchdl_title_t *arr;
size_t cnt = 0;
pid_t pid = getpid();
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0;
arr = calloc(MAX_TITLES, sizeof(*arr));
if (!arr) return -1;
/* Elevate so privileged paths (the app.db under /system_data) are
readable; same authid ftpsrv uses. No-op without kernel R/W. */
kernel_set_ucred_authid(pid, 0x4801000000000013L);
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
}
for (int i = 0; SCAN_DIRS[i]; i++)
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES);
merge_appdb(arr, cnt);
if (using_vswap)
kernel_set_proc_rootdir(pid, saved_root);
*titles_out = arr;
*count_out = cnt;
return 0;
}
void
patchdl_scan_free(patchdl_title_t *titles, size_t count) {
(void)count;
free(titles);
}
/* ---------- diagnostic dump --------------------------------------------- */
static char *
dbg_append(char *buf, size_t *len, size_t *cap, const char *s) {
size_t sl = strlen(s);
if (*len + sl + 1 > *cap) {
size_t nc = (*cap + sl + 1) * 2;
char *p = realloc(buf, nc);
if (!p) return buf;
buf = p;
*cap = nc;
}
memcpy(buf + *len, s, sl + 1);
*len += sl;
return buf;
}
char *
patchdl_scan_debug_json(void) {
char *buf = NULL;
size_t len = 0, cap = 0;
char tmp[2048];
pid_t pid = getpid();
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0;
struct statfs *mounts = NULL;
int nmounts;
/* getmntinfo returns the global mount table — no root-vnode swap needed.
Must NOT free the returned buffer (libc-managed). */
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
buf = dbg_append(buf, &len, &cap, "{\"mounts\":[");
for (int i = 0; i < nmounts; i++) {
snprintf(tmp, sizeof(tmp),
"%s{\"from\":\"%s\",\"on\":\"%s\",\"type\":\"%s\",\"flags\":\"0x%lx\"}",
i ? "," : "",
mounts[i].f_mntfromname, mounts[i].f_mntonname,
mounts[i].f_fstypename, (unsigned long)mounts[i].f_flags);
buf = dbg_append(buf, &len, &cap, tmp);
}
buf = dbg_append(buf, &len, &cap, "],\"bases\":[");
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
}
for (int i = 0; SCAN_DIRS[i]; i++) {
DIR *d = opendir(SCAN_DIRS[i]);
snprintf(tmp, sizeof(tmp), "%s{\"base\":\"%s\",\"exists\":%s,\"entries\":[",
i ? "," : "", SCAN_DIRS[i], d ? "true" : "false");
buf = dbg_append(buf, &len, &cap, tmp);
if (d) {
struct dirent *de;
int first = 1;
while ((de = readdir(d))) {
char dir[PATH_MAX];
if (de->d_name[0] == '.') continue;
snprintf(dir, sizeof(dir), "%s/%s", SCAN_DIRS[i], de->d_name);
snprintf(tmp, sizeof(tmp),
"%s{\"name\":\"%s\",\"pj\":%s,\"pkg\":%s,\"lnk\":%s}",
first ? "" : ",", de->d_name,
path_exists(dir, "sce_sys/param.json") ? "true" : "false",
path_exists(dir, "app.pkg") ? "true" : "false",
(path_exists(dir, "mount.lnk") ||
path_exists(dir, "mount_img.lnk")) ? "true" : "false");
buf = dbg_append(buf, &len, &cap, tmp);
first = 0;
}
closedir(d);
}
buf = dbg_append(buf, &len, &cap, "]}");
}
if (using_vswap)
kernel_set_proc_rootdir(pid, saved_root);
buf = dbg_append(buf, &len, &cap, "]}");
return buf;
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
typedef enum {
PATCHDL_SOURCE_OFFICIAL = 0,
PATCHDL_SOURCE_EXTERNAL = 1,
PATCHDL_SOURCE_SHADOWMOUNT = 2,
PATCHDL_SOURCE_UNKNOWN = 3,
} patchdl_source_t;
typedef struct {
char title_id[32];
char content_id[64];
char name[128];
char installed_version[16];
char source_path[256];
char mount_from[256];
char version_file_uri[256]; /* from app.db, for version.xml */
patchdl_source_t source_type;
/* populated by background verxml fetch — guarded by websrv mutex */
char compatible_version[16];
char latest_version[16];
char latest_required_fw[16];
char patch_url[512]; /* delta_url of the compatible patch */
char patch_title_id[16]; /* title id embedded in patch_url */
int verxml_done;
} patchdl_title_t;
int patchdl_scan(patchdl_title_t **titles_out, size_t *count_out);
void patchdl_scan_free(patchdl_title_t *titles, size_t count);
const char *patchdl_source_str(patchdl_source_t src);
/* Diagnostic: malloc'd JSON dump of the mount table + scan-base directory
listings. Caller frees. */
char *patchdl_scan_debug_json(void);
+3
View File
@@ -0,0 +1,3 @@
#pragma once
#define PATCHDL_VERSION "0.0.1"
+141
View File
@@ -0,0 +1,141 @@
#include "patchdl_verxml.h"
#include "patchdl_net.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Convert packed fw bin to "11.60" string */
static void
fw_bin_to_str(uint32_t bin, char *out, size_t sz) {
/* Packed firmware: hex digits read as decimal (0x10200006 -> "10.20"),
same scheme as patchdl_fw. */
snprintf(out, sz, "%x.%02x",
(bin >> 24) & 0xff,
(bin >> 16) & 0xff);
}
static uint32_t
parse_hex(const char *s) {
uint32_t v = 0;
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X'))
sscanf(s + 2, "%x", &v);
else
sscanf(s, "%u", &v);
return v;
}
/* Extract attribute value from within a <package ...> tag span.
tag_start points to '<', tag_end points past '>'. */
static int
attr_val(const char *tag_start, const char *tag_end, const char *attr,
char *out, size_t out_sz) {
char needle[64];
const char *p;
size_t len;
if ((size_t)snprintf(needle, sizeof(needle), " %s=\"", attr) >= sizeof(needle))
return -1;
p = tag_start;
while (p < tag_end) {
p = strstr(p, needle);
if (!p || p >= tag_end) return -1;
p += strlen(needle);
for (len = 0; p + len < tag_end && p[len] != '"' && len < out_sz - 1; len++)
;
memcpy(out, p, len);
out[len] = '\0';
return 0;
}
return -1;
}
/* version strings are zero-padded (e.g. "01.041.000") — strcmp orders correctly */
static int
ver_gt(const char *a, const char *b) {
return strcmp(a, b) > 0;
}
/* Extract the first PS4/PS5 title id token ([A-Z]{4}[0-9]{5}, e.g. PPSA03098)
from a string such as a delta_url. Used to detect cross-title patches. */
static void
extract_title_id(const char *s, char *out, size_t sz) {
out[0] = '\0';
if (sz < 10 || !s) return;
for (const char *p = s; p[0] && p[8]; p++) {
int ok = 1;
for (int i = 0; i < 4 && ok; i++)
if (p[i] < 'A' || p[i] > 'Z') ok = 0;
for (int i = 4; i < 9 && ok; i++)
if (p[i] < '0' || p[i] > '9') ok = 0;
if (ok) {
memcpy(out, p, 9);
out[9] = '\0';
return;
}
}
}
static void
parse_packages(const char *xml, uint32_t fw_bin, patchdl_verinfo_t *out) {
const char *p = xml;
while ((p = strstr(p, "<package "))) {
const char *tag_end = strchr(p, '>');
if (!tag_end) break;
tag_end++;
char ver[16] = {0};
char sver[16] = {0};
char durl[512] = {0};
/* PS5 version.xml uses content_ver; PS4 uses version. */
if (attr_val(p, tag_end, "content_ver", ver, sizeof(ver)))
attr_val(p, tag_end, "version", ver, sizeof(ver));
attr_val(p, tag_end, "system_ver", sver, sizeof(sver));
attr_val(p, tag_end, "delta_url", durl, sizeof(durl));
if (ver[0] && sver[0]) {
uint32_t pkg_sver = parse_hex(sver);
/* Track latest overall */
if (!out->latest_version[0] || ver_gt(ver, out->latest_version)) {
strncpy(out->latest_version, ver, sizeof(out->latest_version) - 1);
fw_bin_to_str(pkg_sver, out->latest_required_fw,
sizeof(out->latest_required_fw));
}
/* Track latest compatible + its patch URL */
if (pkg_sver <= fw_bin) {
if (!out->compatible_version[0] ||
ver_gt(ver, out->compatible_version)) {
strncpy(out->compatible_version, ver,
sizeof(out->compatible_version) - 1);
strncpy(out->compatible_url, durl,
sizeof(out->compatible_url) - 1);
extract_title_id(durl, out->compatible_title,
sizeof(out->compatible_title));
}
}
}
p = tag_end;
}
}
int
patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) {
patchdl_buf_t buf;
if (!url || !out) return -1;
memset(out, 0, sizeof(*out));
if (patchdl_http_get(url, &buf)) return -1;
if (!buf.data || !buf.size) { free(buf.data); return -1; }
parse_packages(buf.data, fw_bin, out);
free(buf.data);
return (out->latest_version[0]) ? 0 : -1;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <stdint.h>
typedef struct {
char compatible_version[16]; /* highest pkg with system_ver <= fw_bin, or "" */
char latest_version[16]; /* highest pkg overall, or "" */
char latest_required_fw[16]; /* fw str for latest pkg, e.g. "11.60", or "" */
char compatible_url[512]; /* delta_url of the chosen compatible pkg */
char compatible_title[16]; /* title id embedded in that delta_url */
} patchdl_verinfo_t;
int patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out);
+489 -227
View File
@@ -1,23 +1,29 @@
#include "patchdl_websrv.h"
#include "patchdl_assets.h"
#include "patchdl_fw.h"
#include "patchdl_scan.h"
#include "patchdl_install.h"
#include "patchdl_net.h"
#include "patchdl_resolve.h"
#include "patchdl_verxml.h"
#include <microhttpd.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static struct MHD_Daemon* web_daemon;
static struct MHD_Daemon *web_daemon;
static const char status_json[] =
"{"
"\"firmware\":\"11.60\","
"\"firmware_build\":\"0x11600000\","
"\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":64218,"
"\"download_dir\":\"/mnt/usb0/patches\""
"}";
static patchdl_fw_t g_fw;
static patchdl_title_t *g_titles;
static size_t g_title_count;
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
static char g_status_json[512];
static char *g_debug_json; /* built once at startup */
static const char config_json[] =
"{"
@@ -35,270 +41,526 @@ static const char config_json[] =
"\"sgst.prod.dl.playstation.net\","
"\"gst.prod.dl.playstation.net\","
"\"gs2.ww.prod.dl.playstation.net\""
"]"
"}";
"]}";
static const char titles_json[] =
"["
"{"
"\"title_id\":\"PPSA01628_00\","
"\"name\":\"Call of Duty Black Ops Cold War\","
"\"content_id\":\"UP0002-PPSA01628_00-CODCWTHEGAME0001\","
"\"installed_version\":\"01.032.000\","
"\"compatible_version\":\"01.041.000\","
"\"latest_version\":\"01.041.000\","
"\"latest_required_fw\":\"11.60\","
"\"source_type\":\"official\","
"\"source_path\":\"/system_ex/app/PPSA01628_00\","
"\"mount_from\":\"/dev/ssd0.system_ex\","
"\"enabled\":true,"
"\"mode\":\"latest_compatible\","
"\"queued\":false,"
"\"status\":\"available\""
"},"
"{"
"\"title_id\":\"PPSA90001_00\","
"\"name\":\"Shadowmounted Test Title\","
"\"content_id\":\"UP0000-PPSA90001_00-SHADOWMOUNT0001\","
"\"installed_version\":\"01.000.000\","
"\"compatible_version\":\"01.006.000\","
"\"latest_version\":\"01.009.000\","
"\"latest_required_fw\":\"12.00\","
"\"source_type\":\"shadowmount\","
"\"source_path\":\"/system_ex/app/PPSA90001_00\","
"\"mount_from\":\"/mnt/usb0/itemzflow/Shadowmounted Test Title\","
"\"enabled\":true,"
"\"mode\":\"download_only\","
"\"queued\":false,"
"\"status\":\"available\""
"},"
"{"
"\"title_id\":\"PPSA08329_00\","
"\"name\":\"Example Future Game\","
"\"content_id\":\"EP0000-PPSA08329_00-FUTUREPATCH00001\","
"\"installed_version\":\"01.000.000\","
"\"compatible_version\":null,"
"\"latest_version\":\"01.012.000\","
"\"latest_required_fw\":\"12.50\","
"\"source_type\":\"unknown\","
"\"source_path\":\"/system_ex/app/PPSA08329_00\","
"\"mount_from\":\"\","
"\"enabled\":false,"
"\"mode\":\"disabled\","
"\"queued\":false,"
"\"status\":\"blocked\""
"}"
"]";
static const char downloads_json[] = "[]";
static const char downloads_json[] =
"[]";
/* ---------- JSON builder ------------------------------------------------ */
typedef struct {
char *buf;
size_t len;
size_t cap;
} jbuf_t;
static void
jbuf_ensure(jbuf_t *j, size_t extra) {
if (!j->buf || j->len + extra + 1 > j->cap) {
size_t newcap = (j->cap + extra + 1) * 2;
if (newcap < 1024) newcap = 1024;
char *p = realloc(j->buf, newcap);
if (!p) return;
j->buf = p;
j->cap = newcap;
}
}
static void
jbuf_append(jbuf_t *j, const char *s) {
size_t slen = strlen(s);
jbuf_ensure(j, slen);
if (!j->buf) return;
memcpy(j->buf + j->len, s, slen + 1);
j->len += slen;
}
static void
jbuf_appendf(jbuf_t *j, const char *fmt, ...) {
char tmp[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(tmp, sizeof(tmp), fmt, ap);
va_end(ap);
jbuf_append(j, tmp);
}
static void
jbuf_append_str(jbuf_t *j, const char *s) {
jbuf_append(j, "\"");
for (const char *p = s; *p; p++) {
if (*p == '"') jbuf_append(j, "\\\"");
else if (*p == '\\') jbuf_append(j, "\\\\");
else if (*p == '\n') jbuf_append(j, "\\n");
else if (*p == '\r') jbuf_append(j, "\\r");
else if (*p == '\t') jbuf_append(j, "\\t");
else { char c[2] = {*p, '\0'}; jbuf_append(j, c); }
}
jbuf_append(j, "\"");
}
static void
jbuf_append_ver_or_null(jbuf_t *j, const char *ver) {
if (ver[0])
jbuf_append_str(j, ver);
else
jbuf_append(j, "null");
}
/* ---------- HTTP helpers ------------------------------------------------ */
static enum MHD_Result
queue_buffer(struct MHD_Connection* conn, unsigned int status,
const char* mime, const void* data, size_t size) {
struct MHD_Response* response;
enum MHD_Result result;
queue_buffer(struct MHD_Connection *conn, unsigned int status,
const char *mime, const void *data, size_t size,
enum MHD_ResponseMemoryMode mm) {
struct MHD_Response *resp;
enum MHD_Result ret;
response = MHD_create_response_from_buffer(size, (void*)data,
MHD_RESPMEM_PERSISTENT);
if(!response) {
return MHD_NO;
}
resp = MHD_create_response_from_buffer(size, (void *)data, mm);
if (!resp) return MHD_NO;
MHD_add_response_header(response, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
"*");
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
"no-store");
if(mime) {
MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, mime);
}
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
if (mime)
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE, mime);
result = MHD_queue_response(conn, status, response);
MHD_destroy_response(response);
return result;
ret = MHD_queue_response(conn, status, resp);
MHD_destroy_response(resp);
return ret;
}
static enum MHD_Result
queue_text(struct MHD_Connection* conn, unsigned int status, const char* text) {
return queue_buffer(conn, status, "text/plain; charset=utf-8", text,
strlen(text));
queue_text(struct MHD_Connection *conn, unsigned int status, const char *text) {
return queue_buffer(conn, status, "text/plain; charset=utf-8",
text, strlen(text), MHD_RESPMEM_PERSISTENT);
}
static enum MHD_Result
queue_json(struct MHD_Connection* conn, unsigned int status, const char* json) {
return queue_buffer(conn, status, "application/json", json, strlen(json));
queue_json(struct MHD_Connection *conn, unsigned int status, const char *json) {
return queue_buffer(conn, status, "application/json",
json, strlen(json), MHD_RESPMEM_PERSISTENT);
}
/* Takes ownership of json — MHD will free() it */
static enum MHD_Result
queue_json_owned(struct MHD_Connection *conn, unsigned int status, char *json) {
if (!json) return queue_text(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, "oom");
return queue_buffer(conn, status, "application/json",
json, strlen(json), MHD_RESPMEM_MUST_FREE);
}
static enum MHD_Result
queue_asset(struct MHD_Connection* conn, const char* url) {
const patchdl_asset_t* asset = patchdl_asset_find(url);
if(!asset) {
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
return queue_buffer(conn, MHD_HTTP_OK, asset->mime, asset->data,
asset->size);
queue_asset(struct MHD_Connection *conn, const char *url) {
const patchdl_asset_t *asset = patchdl_asset_find(url);
if (!asset) return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
return queue_buffer(conn, MHD_HTTP_OK, asset->mime,
asset->data, asset->size, MHD_RESPMEM_PERSISTENT);
}
static const char*
source_type_for_title(const char* title_id) {
if(!title_id) {
return "unknown";
}
/* ---------- status JSON ------------------------------------------------- */
if(!strcmp(title_id, "PPSA90001_00")) {
return "shadowmount";
}
if(!strcmp(title_id, "PPSA08329_00")) {
return "unknown";
}
return "official";
static void
rebuild_status_json(void) {
snprintf(g_status_json, sizeof(g_status_json),
"{\"firmware\":\"%s\","
"\"firmware_build\":\"0x%08x\","
"\"dns_guard\":\"Active\","
"\"resolver\":\"Internal allowlist\","
"\"free_space_mb\":0,"
"\"download_dir\":\"/mnt/usb0/patches\"}",
g_fw.str, g_fw.bin);
}
/* ---------- titles JSON (built per request under mutex) ----------------- */
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";
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)
return "up_to_date";
return "available";
}
if (t->latest_version[0]) 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_UNKNOWN: return "disabled";
default: return "latest_compatible";
}
}
static char *
build_titles_json(void) {
jbuf_t j = {0};
pthread_mutex_lock(&g_mutex);
jbuf_append(&j, "[");
for (size_t i = 0; i < g_title_count; i++) {
const patchdl_title_t *t = &g_titles[i];
if (i > 0) jbuf_append(&j, ",");
jbuf_append(&j, "{");
jbuf_append(&j, "\"title_id\":"); jbuf_append_str(&j, t->title_id);
jbuf_append(&j, ",\"name\":"); jbuf_append_str(&j, t->name);
jbuf_append(&j, ",\"content_id\":"); jbuf_append_str(&j, t->content_id);
jbuf_append(&j, ",\"installed_version\":");
jbuf_append_ver_or_null(&j, t->installed_version);
jbuf_append(&j, ",\"compatible_version\":");
jbuf_append_ver_or_null(&j, t->compatible_version);
jbuf_append(&j, ",\"latest_version\":");
jbuf_append_ver_or_null(&j, t->latest_version);
jbuf_append(&j, ",\"latest_required_fw\":");
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, ",\"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\":");
jbuf_append_ver_or_null(&j, t->version_file_uri);
jbuf_append(&j, ",\"patch_title_id\":");
jbuf_append_ver_or_null(&j, t->patch_title_id);
/* The patch package's title must match the game; a mismatch means a
cross-region/title package that must NOT be installed. */
jbuf_appendf(&j, ",\"patch_title_match\":%s",
(!t->patch_title_id[0] ||
!strncmp(t->patch_title_id, t->title_id, 9)) ? "true" : "false");
jbuf_appendf(&j, ",\"enabled\":%s",
t->source_type == PATCHDL_SOURCE_UNKNOWN ? "false" : "true");
jbuf_append(&j, ",\"mode\":");
jbuf_append_str(&j, title_mode_str(t->source_type));
jbuf_append(&j, ",\"queued\":false");
jbuf_append(&j, ",\"status\":");
jbuf_append_str(&j, title_status_str(t));
jbuf_append(&j, "}");
}
jbuf_append(&j, "]");
pthread_mutex_unlock(&g_mutex);
return j.buf; /* caller owns; use queue_json_owned */
}
/* ---------- verxml background fetch ------------------------------------ */
static void *
verxml_fetch_thread(void *arg) {
(void)arg;
for (size_t i = 0; i < g_title_count; i++) {
patchdl_title_t *t = &g_titles[i];
char url[512] = {0};
patchdl_verinfo_t info;
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])
patchdl_verxml_query(t->version_file_uri, g_fw.bin, &info);
else if (!patchdl_resolve_url(t->title_id, url, sizeof(url)))
patchdl_verxml_query(url, g_fw.bin, &info);
pthread_mutex_lock(&g_mutex);
strncpy(t->compatible_version, info.compatible_version,
sizeof(t->compatible_version) - 1);
strncpy(t->latest_version, info.latest_version,
sizeof(t->latest_version) - 1);
strncpy(t->latest_required_fw, info.latest_required_fw,
sizeof(t->latest_required_fw) - 1);
strncpy(t->patch_url, info.compatible_url,
sizeof(t->patch_url) - 1);
strncpy(t->patch_title_id, info.compatible_title,
sizeof(t->patch_title_id) - 1);
t->verxml_done = 1;
pthread_mutex_unlock(&g_mutex);
}
return NULL;
}
/* ---------- title action helper ---------------------------------------- */
/* Snapshot the fields an action needs, under the lock. Returns 0 if found. */
static int
get_title_action_info(const char *title_id, patchdl_source_t *src,
char *patch_url, size_t url_sz,
char *patch_title_id, size_t pt_sz) {
int found = 0;
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++) {
if (!strcmp(g_titles[i].title_id, title_id)) {
*src = g_titles[i].source_type;
strncpy(patch_url, g_titles[i].patch_url, url_sz - 1);
patch_url[url_sz - 1] = '\0';
strncpy(patch_title_id, g_titles[i].patch_title_id, pt_sz - 1);
patch_title_id[pt_sz - 1] = '\0';
found = 1;
break;
}
}
pthread_mutex_unlock(&g_mutex);
return found;
}
#define PATCHDL_DL_DIR "/data/patchdl"
/* Local on-disk path a title's patch downloads to / installs from. */
static void
title_pkg_path(const char *title_id, const char *patch_url,
char *out, size_t out_sz) {
const char *base = strrchr(patch_url, '/');
base = base ? base + 1 : "patch.pkg";
snprintf(out, out_sz, "%s/%s/%s", PATCHDL_DL_DIR, title_id, base);
}
static enum MHD_Result
do_download(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url) {
char dir[256], dest[320], resp[640];
long long bytes = 0;
if (src == PATCHDL_SOURCE_UNKNOWN)
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"source_unknown\"}");
if (!patch_url[0])
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
/* /data/patchdl/<title_id>/<pkg-basename> — homebrew data dir, not system */
mkdir(PATCHDL_DL_DIR, 0777);
snprintf(dir, sizeof(dir), "%s/%s", PATCHDL_DL_DIR, title_id);
mkdir(dir, 0777);
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
if (patchdl_http_download(patch_url, dest, &bytes)) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"download_failed\"}");
return queue_json_owned(conn, MHD_HTTP_BAD_GATEWAY, strdup(resp));
}
/* shadowmount: download allowed, install is not (per source policy). */
snprintf(resp, sizeof(resp),
"{\"ok\":true,\"downloaded\":true,\"bytes\":%lld,\"path\":\"%s\","
"\"install_allowed\":%s}",
bytes, dest,
src == PATCHDL_SOURCE_SHADOWMOUNT ? "false" : "true");
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
}
static enum MHD_Result
do_install(struct MHD_Connection *conn, const char *title_id,
patchdl_source_t src, const char *patch_url,
const char *patch_title_id) {
char dest[320], msg[256], resp[640];
int rc;
/* Source policy: only genuine installs may be patched in place.
Shadowmounts are download-only; unknown/preinstall 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\"}");
if (!patch_url[0])
return queue_json(conn, MHD_HTTP_CONFLICT,
"{\"ok\":false,\"reason\":\"no_compatible_patch\"}");
/* GUARD (app layer): the patch package's title id must match the game.
A cross-title/region patch would install as a phantom title. */
if (patch_title_id[0] && strncmp(patch_title_id, title_id, 9) != 0) {
snprintf(resp, sizeof(resp),
"{\"ok\":false,\"reason\":\"patch_title_mismatch\","
"\"patch_title_id\":\"%.15s\",\"title_id\":\"%.15s\"}",
patch_title_id, title_id);
return queue_json_owned(conn, MHD_HTTP_CONFLICT, strdup(resp));
}
title_pkg_path(title_id, patch_url, dest, sizeof(dest));
/* GUARD (authoritative): patchdl_install reads the PKG's real title id and
refuses if it does not match `title_id`. */
rc = patchdl_install_local_pkg(dest, title_id, msg, sizeof(msg));
snprintf(resp, sizeof(resp),
"{\"ok\":%s,\"rc\":%d,\"message\":\"%s\",\"path\":\"%s\"}",
rc == 0 ? "true" : "false", rc, msg, dest);
return queue_json_owned(conn, rc == 0 ? MHD_HTTP_OK : MHD_HTTP_BAD_GATEWAY,
strdup(resp));
}
/* ---------- request routing -------------------------------------------- */
static int
parse_title_action(const char* url, char* title_id, size_t title_id_size,
char* action, size_t action_size) {
const char* prefix = "/api/titles/";
const char* start;
const char* slash;
size_t len;
parse_title_action(const char *url, char *title_id, size_t tid_sz,
char *action, size_t act_sz) {
const char *prefix = "/api/titles/";
const char *start, *slash;
size_t len;
if(strncmp(url, prefix, strlen(prefix))) {
return -1;
}
if (strncmp(url, prefix, strlen(prefix))) return -1;
start = url + strlen(prefix);
slash = strchr(start, '/');
if (!slash || slash == start || !slash[1]) return -1;
start = url + strlen(prefix);
slash = strchr(start, '/');
if(!slash || slash == start || !slash[1]) {
return -1;
}
len = (size_t)(slash - start);
if (len >= tid_sz) return -1;
memcpy(title_id, start, len);
title_id[len] = '\0';
len = slash - start;
if(len >= title_id_size) {
return -1;
}
memcpy(title_id, start, len);
title_id[len] = 0;
len = strlen(slash + 1);
if(len >= action_size) {
return -1;
}
memcpy(action, slash + 1, len + 1);
return 0;
len = strlen(slash + 1);
if (len >= act_sz) return -1;
memcpy(action, slash + 1, len + 1);
return 0;
}
static enum MHD_Result
handle_title_action(struct MHD_Connection* conn, const char* url) {
char title_id[32];
char action[24];
const char* source_type;
handle_title_action(struct MHD_Connection *conn, const char *url) {
char title_id[32];
char action[24];
char patch_url[512] = {0};
char patch_title_id[16] = {0};
patchdl_source_t src = PATCHDL_SOURCE_UNKNOWN;
if (parse_title_action(url, title_id, sizeof(title_id),
action, sizeof(action)))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
if (!get_title_action_info(title_id, &src, patch_url, sizeof(patch_url),
patch_title_id, sizeof(patch_title_id)))
return queue_text(conn, MHD_HTTP_NOT_FOUND, "unknown title");
if (!strcmp(action, "download"))
return do_download(conn, title_id, src, patch_url);
if (!strcmp(action, "check"))
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
if (!strcmp(action, "install"))
return do_install(conn, title_id, src, patch_url, patch_title_id);
if(parse_title_action(url, title_id, sizeof(title_id), action,
sizeof(action))) {
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
source_type = source_type_for_title(title_id);
if(!strcmp(action, "download")) {
if(!strcmp(source_type, "unknown")) {
return queue_json(conn, MHD_HTTP_FORBIDDEN,
"{\"ok\":false,\"reason\":\"source_unknown\"}");
}
if(!strcmp(source_type, "shadowmount")) {
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,"
"\"source_type\":\"shadowmount\","
"\"install_allowed\":false}");
}
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,"
"\"install_allowed\":true}");
}
if(!strcmp(action, "check")) {
return queue_json(conn, MHD_HTTP_ACCEPTED,
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
}
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
static enum MHD_Result
on_request(void* cls, struct MHD_Connection* conn, const char* url,
const char* method, const char* version, const char* upload_data,
size_t* upload_data_size, void** con_cls) {
(void)cls;
(void)version;
(void)upload_data;
(void)con_cls;
on_request(void *cls, struct MHD_Connection *conn, const char *url,
const char *method, const char *version, const char *upload_data,
size_t *upload_data_size, void **con_cls) {
(void)cls; (void)version; (void)upload_data; (void)con_cls;
if(!strcmp(method, MHD_HTTP_METHOD_OPTIONS)) {
return queue_text(conn, MHD_HTTP_NO_CONTENT, "");
}
if (!strcmp(method, MHD_HTTP_METHOD_OPTIONS))
return queue_text(conn, MHD_HTTP_NO_CONTENT, "");
if(!strcmp(method, MHD_HTTP_METHOD_POST)) {
if(*upload_data_size) {
*upload_data_size = 0;
return MHD_YES;
if (!strcmp(method, MHD_HTTP_METHOD_POST)) {
if (*upload_data_size) { *upload_data_size = 0; return MHD_YES; }
if (!strcmp(url, "/api/config"))
return queue_json(conn, MHD_HTTP_OK, config_json);
if (!strncmp(url, "/api/titles/", 12))
return handle_title_action(conn, url);
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
if(!strcmp(url, "/api/config")) {
return queue_json(conn, MHD_HTTP_OK, config_json);
if (strcmp(method, MHD_HTTP_METHOD_GET) &&
strcmp(method, MHD_HTTP_METHOD_HEAD))
return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed");
if (!strcmp(url, "/api/status"))
return queue_json(conn, MHD_HTTP_OK, g_status_json);
if (!strcmp(url, "/api/config"))
return queue_json(conn, MHD_HTTP_OK, config_json);
if (!strcmp(url, "/api/titles"))
return queue_json_owned(conn, MHD_HTTP_OK, build_titles_json());
if (!strcmp(url, "/api/downloads"))
return queue_json(conn, MHD_HTTP_OK, downloads_json);
if (!strcmp(url, "/api/debug"))
return queue_json(conn, MHD_HTTP_OK,
g_debug_json ? g_debug_json : "{}");
if (!strcmp(url, "/api/installcheck")) {
char m[256], r[320];
int rc = patchdl_install_backend_check(m, sizeof(m));
snprintf(r, sizeof(r), "{\"ready\":%s,\"message\":\"%s\"}",
rc == 0 ? "true" : "false", m);
return queue_json_owned(conn, MHD_HTTP_OK, strdup(r));
}
if(!strncmp(url, "/api/titles/", 12)) {
return handle_title_action(conn, url);
if (!strcmp(url, "/api/netcheck")) {
char diag[1024] = "{\"error\":\"no title with version_file_uri\"}";
pthread_mutex_lock(&g_mutex);
for (size_t i = 0; i < g_title_count; i++) {
if (g_titles[i].version_file_uri[0]) {
patchdl_net_diag(g_titles[i].version_file_uri, diag, sizeof(diag));
break;
}
}
pthread_mutex_unlock(&g_mutex);
return queue_json(conn, MHD_HTTP_OK, diag);
}
return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found");
}
if(strcmp(method, MHD_HTTP_METHOD_GET) &&
strcmp(method, MHD_HTTP_METHOD_HEAD)) {
return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed");
}
if(!strcmp(url, "/api/status")) {
return queue_json(conn, MHD_HTTP_OK, status_json);
}
if(!strcmp(url, "/api/config")) {
return queue_json(conn, MHD_HTTP_OK, config_json);
}
if(!strcmp(url, "/api/titles")) {
return queue_json(conn, MHD_HTTP_OK, titles_json);
}
if(!strcmp(url, "/api/downloads")) {
return queue_json(conn, MHD_HTTP_OK, downloads_json);
}
return queue_asset(conn, url);
return queue_asset(conn, url);
}
/* ---------- lifecycle -------------------------------------------------- */
int
patchdl_websrv_start(unsigned short port) {
if(web_daemon) {
pthread_t tid;
pthread_attr_t attr;
if (web_daemon) return 0;
/* Collect real FW version and installed titles synchronously.
The web UI is reachable only after this completes. */
patchdl_fw_get(&g_fw);
rebuild_status_json();
if (patchdl_scan(&g_titles, &g_title_count))
g_title_count = 0;
/* Build the diagnostic dump now, while single-threaded — the root-vnode
swap it performs is unsafe once MHD worker threads are running. */
g_debug_json = patchdl_scan_debug_json();
web_daemon = MHD_start_daemon(
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
port, NULL, NULL, &on_request, NULL,
MHD_OPTION_END);
if (!web_daemon) {
patchdl_scan_free(g_titles, g_title_count);
g_titles = NULL;
g_title_count = 0;
return -1;
}
/* Start background verxml fetch — detached, runs until complete */
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&tid, &attr, verxml_fetch_thread, NULL);
pthread_attr_destroy(&attr);
return 0;
}
web_daemon = MHD_start_daemon(MHD_USE_INTERNAL_POLLING_THREAD |
MHD_USE_THREAD_PER_CONNECTION,
port, NULL, NULL, &on_request, NULL,
MHD_OPTION_END);
return web_daemon ? 0 : -1;
}
void
patchdl_websrv_stop(void) {
if(web_daemon) {
MHD_stop_daemon(web_daemon);
web_daemon = 0;
}
if (web_daemon) {
MHD_stop_daemon(web_daemon);
web_daemon = NULL;
}
pthread_mutex_lock(&g_mutex);
patchdl_scan_free(g_titles, g_title_count);
g_titles = NULL;
g_title_count = 0;
pthread_mutex_unlock(&g_mutex);
}
+257679
View File
File diff suppressed because it is too large. Load diff
+13425
View File
File diff suppressed because it is too large. Load diff
+103 -27
View File
@@ -284,13 +284,26 @@ function renderGames() {
visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
}
// One mutually-exclusive "what can I do with this title" bucket per game.
// updatable : a newer compatible patch exists AND this source may be installed
// uptodate : already on the newest compatible patch
// needsfw : a newer patch exists but needs a newer firmware than installed
// blocked : source can't be safely patched (shadowmount / preinstall / unknown)
// checking : version lookup still running
function gameCategory(game) {
if (game.installing) return "updatable"; // keep visible while the patch installs
if (game.status === "checking") return "checking";
if (game.patch_title_match === false) return "blocked"; // cross-region/title patch
if (!sourcePolicy(game).allow_install) return "blocked";
if (game.status === "available") return "updatable";
if (game.status === "incompatible_fw") return "needsfw";
return "uptodate";
}
function matchesFilter(game) {
if (state.filter === "enabled") return game.enabled;
if (state.filter === "available") return game.status === "available";
if (state.filter === "shadowmount") return sourceType(game) === "shadowmount";
if (state.filter === "blocked") return game.status === "blocked";
if (state.filter === "queued") return game.queued || game.status === "queued";
return true;
if (state.filter === "all") return true;
if (state.filter === "queued") return Boolean(game.queued) || game.status === "queued";
return gameCategory(game) === state.filter;
}
function matchesQuery(game) {
@@ -376,6 +389,10 @@ function createGameCard(game) {
<svg><use href="#icon-download"></use></svg>
Download
</button>
<button class="row-button" data-action="install" ${(!isInstallAllowed(game) || game.installing) ? "disabled" : ""} title="${escapeHtml(installTitle(game))}">
<svg><use href="#icon-download"></use></svg>
${game.installing ? "Installing…" : "Install"}
</button>
`;
actions.querySelectorAll("button").forEach((button) => {
@@ -397,10 +414,14 @@ function versionBox(label, value) {
}
function statusPill(game) {
if (game.status === "blocked") return `<span class="pill blocked">${escapeHtml(blockedLabel(game))}</span>`;
if (game.status === "queued") return `<span class="pill warn">In Queue</span>`;
if (game.status === "available") return `<span class="pill ok">Update available</span>`;
return `<span class="pill">Current</span>`;
if (game.installing) return `<span class="pill warn">Installing…</span>`;
const cat = gameCategory(game);
if (cat === "checking") return `<span class="pill">Checking…</span>`;
if (game.queued || game.status === "queued") return `<span class="pill warn">In queue</span>`;
if (cat === "blocked") return `<span class="pill blocked">${escapeHtml(blockedLabel(game))}</span>`;
if (cat === "updatable") return `<span class="pill ok">Update available</span>`;
if (cat === "needsfw") return `<span class="pill warn">Needs FW ${escapeHtml(game.latest_required_fw || "")}</span>`;
return `<span class="pill">Up to date</span>`;
}
function sourcePill(game) {
@@ -425,7 +446,10 @@ function sourceDetail(game) {
}
function blockedLabel(game) {
if (sourceType(game) === "unknown") return "Unknown source";
if (game.patch_title_match === false) return "Region mismatch";
const src = sourceType(game);
if (src === "shadowmount") return "Shadowmount";
if (src === "unknown") return "Preinstall";
if (!game.compatible_version) return "FW blocked";
return "Blocked";
}
@@ -504,30 +528,50 @@ async function runTitleAction(titleId, action) {
showToast(downloadTitle(game));
return;
}
try {
await postJson(API.action(titleId, action), {});
} catch (error) {
// Demo mode keeps the UI interactive before the ELF API exists.
if (action === "install" && !isInstallAllowed(game)) {
showToast(installTitle(game));
return;
}
if (action === "download" && game.compatible_version) {
const detail = isInstallBlocked(game)
? "Download only - install blocked by source policy"
: "Waiting to start";
if (action === "download" || action === "install") {
showToast(action === "download"
? `Downloading ${game.title_id} ${game.compatible_version}...`
: `Installing ${game.title_id} ${game.compatible_version}...`);
}
let result = null;
try {
result = await postJson(API.action(titleId, action), {});
} catch (error) {
const why = reasonText(error);
state.logs.push(`[${timeNow()}] ${action} ${game.title_id} blocked: ${why}`);
showToast(`${game.title_id}: ${why}`);
renderGames();
renderLogs();
return;
}
if (action === "download") {
const mb = result && result.bytes ? (result.bytes / (1024 * 1024)).toFixed(1) : "?";
game.queued = true;
game.status = "queued";
if (!state.downloads.some((item) => item.title_id === titleId)) {
state.downloads.push({
title_id: game.title_id,
name: game.name,
version: game.compatible_version,
progress: 0,
detail,
progress: 100,
detail: result && result.install_allowed ? "Downloaded - ready to install" : "Downloaded (install blocked)",
});
}
state.logs.push(`[${timeNow()}] Queued ${game.title_id} ${game.compatible_version} (${sourceType(game)}, install=${isInstallBlocked(game) ? "blocked" : "allowed"})`);
showToast(`${game.title_id} was added to the queue.`);
state.logs.push(`[${timeNow()}] Downloaded ${game.title_id} ${game.compatible_version} (${mb} MB) -> ${result ? result.path : "?"}`);
showToast(`${game.title_id}: downloaded ${mb} MB.`);
} else if (action === "install") {
// rc=0 means the Sony installer accepted the job; it now runs in the PS5
// background. Mark the card so it's clearly visible the patch is installing.
game.installing = true;
game.status = "installing";
state.logs.push(`[${timeNow()}] Install started for ${game.title_id} ${game.compatible_version} — running in PS5 background`);
showToast(`${game.name}: installing update — progress shows in your PS5 notifications.`);
} else {
state.logs.push(`[${timeNow()}] Check requested for ${game.title_id}`);
showToast(`${game.title_id} check queued.`);
@@ -561,6 +605,20 @@ function isDownloadAllowed(game) {
function isInstallBlocked(game) {
return !sourcePolicy(game).allow_install;
}
function isInstallAllowed(game) {
return Boolean(
game.enabled &&
game.compatible_version &&
game.patch_title_match !== false &&
sourcePolicy(game).allow_install
);
}
function installTitle(game) {
if (game.patch_title_match === false) return "Patch is for a different title/region — install blocked.";
if (isInstallBlocked(game)) return "Install blocked for this source.";
if (!game.compatible_version) return "No compatible update to install.";
return "Install the downloaded patch (modifies the game).";
}
function sourcePolicy(game) {
return sourcePolicyForType(sourceType(game));
@@ -586,8 +644,26 @@ async function postJson(url, body) {
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json().catch(() => ({}));
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const err = new Error(data.reason || data.message || `HTTP ${response.status}`);
err.body = data;
err.status = response.status;
throw err;
}
return data;
}
const REASON_TEXT = {
patch_title_mismatch: "Patch is for a different title/region — install blocked.",
install_not_allowed_for_source: "Install blocked for this source.",
source_unknown: "Source unknown — blocked.",
no_compatible_patch: "No compatible patch available.",
download_failed: "Download failed.",
};
function reasonText(error) {
const r = error && error.body && error.body.reason;
return (r && REASON_TEXT[r]) || (error && error.message) || "failed";
}
function initials(name) {
+5 -5
View File
@@ -53,7 +53,7 @@
<div class="brand-mark">PD</div>
<div>
<strong>PatchDL</strong>
<span>PS5 Patch Control</span>
<span>by Knutwurst · v0.0.1</span>
</div>
</div>
@@ -125,10 +125,10 @@
<div class="segmented" role="tablist" aria-label="Filter">
<button class="is-selected" data-filter="all">All</button>
<button data-filter="enabled">Enabled</button>
<button data-filter="available">Updates</button>
<button data-filter="shadowmount">Shadow</button>
<button data-filter="blocked">Blocked</button>
<button data-filter="updatable">Updatable</button>
<button data-filter="uptodate">Up to date</button>
<button data-filter="needsfw">Needs FW</button>
<button data-filter="blocked">Can't update</button>
<button data-filter="queued">Queue</button>
</div>
</section>