mirror of
https://github.com/elripalda/psgfx.git
synced 2026-10-17 17:00:42 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96496e1054 | ||
|
|
d612f0dc38 | ||
|
|
e1c6edb7bb |
No files matched your search
@@ -8,7 +8,7 @@
|
||||
|
||||
PSGFX gives your PS5 homebrew and games new home-screen art, and lets you arrange the home screen itself: hide tiles like the PlayStation Store, rename anything, move apps between Games and Media, and lock your games in the order you want. Pick an app, drop in any image, and click **Apply**. PSGFX resizes and converts it to the exact format the console expects, uploads it to the right folder, and keeps a backup of the original so you can undo it with one click.
|
||||
|
||||
It works through [PS5 Upload](https://github.com/phantomptr/ps5upload)'s payload, which is already running on your console. There's nothing to install on the PS5 or on your PC: PSGFX is a single `.exe`.
|
||||
It works through [PS5 Upload](https://github.com/phantomptr/ps5upload): keep the PS5 Upload app open on your PC, connected to your console, and PSGFX does its work through it. There's nothing else to install: PSGFX is a single `.exe`.
|
||||
|
||||

|
||||
|
||||
@@ -30,16 +30,18 @@ It works through [PS5 Upload](https://github.com/phantomptr/ps5upload)'s payload
|
||||
## Requirements
|
||||
|
||||
- A jailbroken PS5 with **[PS5 Upload](https://github.com/phantomptr/ps5upload)**'s payload running (the same setup you use to upload homebrew).
|
||||
- **The PS5 Upload app, version 6 or newer, open on the same PC and connected to your PS5.** PSGFX 1.2 and later go through it; PSGFX 1.1 and older only work with PS5 Upload 5.x.
|
||||
- A Windows PC on the same network.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Download `PSGFX.exe` from the [latest release](../../releases/latest) and double-click it. PSGFX opens in your browser.
|
||||
2. Enter your PS5's IP address (the same one PS5 Upload uses) and click **Connect**.
|
||||
3. Choose a homebrew app or an installed game from the list.
|
||||
4. Drop an image onto the preview's tile (**icon**) or background (**background**), or use the **Choose image** buttons.
|
||||
5. Close that app or game on the PS5, then click **Apply to PS5**.
|
||||
6. Restart the PS5 to see the new art on the home screen.
|
||||
2. Open PS5 Upload and make sure it's connected to your PS5.
|
||||
3. Enter your PS5's IP address (the same one PS5 Upload uses) and click **Connect**.
|
||||
4. Choose a homebrew app or an installed game from the list.
|
||||
5. Drop an image onto the preview's tile (**icon**) or background (**background**), or use the **Choose image** buttons.
|
||||
6. Close that app or game on the PS5, then click **Apply to PS5**.
|
||||
7. Restart the PS5 to see the new art on the home screen.
|
||||
|
||||
To undo, select the app and click **Restore original art**.
|
||||
|
||||
@@ -50,7 +52,7 @@ To undo, select the app and click **Restore original art**.
|
||||
3. Drag games into any order, or use **A–Z** / **Z–A**. Tick **Lock order** to keep it that way after you play other games.
|
||||
4. Click **Apply to PS5**, optionally saving the layout as a preset, then restart the PS5.
|
||||
|
||||
PSGFX edits the console's home-screen database (`/system_data/priv/mms/app.db`) through PS5 Upload's built-in FTP server, which it starts for you. Backups and presets are kept in the **PSGFX Layout** folder next to `PSGFX.exe`. If the home screen ever looks wrong, restore a backup, or use Safe Mode → **Rebuild Database**. App Library always comes back on its own; the system adds it regardless of the database.
|
||||
PSGFX edits the console's home-screen database (`/system_data/priv/mms/app.db`) through PS5 Upload. It uploads the new database to `/data/psgfx` and has PS5 Upload's console shell copy it into place, then reads it back to check it. Backups and presets are kept in the **PSGFX Layout** folder next to `PSGFX.exe`. If the home screen ever looks wrong, restore a backup, or use Safe Mode → **Rebuild Database**. App Library always comes back on its own; the system adds it regardless of the database.
|
||||
|
||||

|
||||
|
||||
@@ -80,18 +82,20 @@ On Windows, run `./build.sh` from an [MSYS2](https://www.msys2.org/) MinGW shell
|
||||
|
||||
### How it works
|
||||
|
||||
PSGFX runs a small local web server (127.0.0.1 only) and opens your browser to it. It talks to PS5 Upload's payload over its FTX2 protocol on ports 9113 and 9114. It uses the payload's existing commands to list apps, read files, upload them (BLAKE3-verified), copy, and back up. Homebrew art is written to the app's `sce_sys` folder and to the home-screen metadata folder. Game art is written to `/user/appmeta/<title id>` in the same size and format as the original files. When the home-screen database (`app.db`) shows the game's tile art coming from somewhere PSGFX can't write, such as inside the game's own package, PSGFX writes the image to `/user/appmeta/<title id>/psgfx_*` and points the database at it. It keeps the original paths in `psgfx_original.json` for **Restore original art** and backs up the database first, as Home layout does. Backgrounds are encoded as BC7 DDS where the console uses DDS.
|
||||
PSGFX runs a small local web server (127.0.0.1 only) and opens your browser to it. PS5 Upload 6.0 replaced its old protocol (FTX2, ports 9113 and 9114) with AVA1, an encrypted connection on port 9120 that the console only opens to an app it has paired with. So PSGFX doesn't talk to the console itself: it sends each command to the PS5 Upload app's local engine (`POST http://127.0.0.1:19113/api/mgmt/call`), which runs it over its paired connection. It uses the payload's existing commands to list apps, read and write files, and back up. Homebrew art is written to the app's `sce_sys` folder and to the home-screen metadata folder. Game art is written to `/user/appmeta/<title id>` in the same size and format as the original files. When the home-screen database (`app.db`) shows the game's tile art coming from somewhere PSGFX can't write, such as inside the game's own package, PSGFX writes the image to `/user/appmeta/<title id>/psgfx_*` and points the database at it. It keeps the original paths in `psgfx_original.json` for **Restore original art** and backs up the database first, as Home layout does. Backgrounds are encoded as BC7 DDS where the console uses DDS.
|
||||
|
||||
`tools/mock_ps5.py` is a stand-in for the payload that serves a folder on your computer as if it were a PS5. Use it to develop without a console:
|
||||
`tools/mock_engine.py` is a stand-in for the PS5 Upload engine and console that serves a folder on your computer as if it were a PS5. Use it to develop without a console:
|
||||
|
||||
```bash
|
||||
python3 tools/mock_ps5.py ./fake-ps5 39114 39113 &
|
||||
PS5AS_HOST=127.0.0.1 PS5AS_MGMT_PORT=39114 PS5AS_XFER_PORT=39113 ./build/psgfx
|
||||
python3 tools/mock_engine.py ./fake-ps5 39200 &
|
||||
PS5AS_ENGINE=127.0.0.1:39200 ./build/psgfx
|
||||
```
|
||||
|
||||
If PS5 Upload's engine runs somewhere else (a NAS or Docker), add `"engine": "host:port"` to `psgfx.json`.
|
||||
|
||||
## Credits
|
||||
|
||||
- [PS5 Upload](https://github.com/phantomptr/ps5upload) by phantomptr, whose payload PSGFX talks to
|
||||
- [PS5 Upload](https://github.com/phantomptr/ps5upload) by phantomptr, whose app and payload PSGFX works through
|
||||
- [bc7enc](https://github.com/richgel999/bc7enc) by Rich Geldreich, [stb](https://github.com/nothings/stb) by Sean Barrett, [BLAKE3](https://github.com/BLAKE3-team/BLAKE3), [JSON for Modern C++](https://github.com/nlohmann/json) by Niels Lohmann, and the [Sora](https://github.com/sora-xor/sora-font) typeface. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
## License
|
||||
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
PSGFX 1.1
|
||||
PSGFX 1.2
|
||||
Custom icons and backgrounds for your PS5 homebrew and games, and your home-screen layout.
|
||||
|
||||
BEFORE YOU START
|
||||
- PS5 Upload's payload must be running on your PS5 (the same setup you use to upload homebrew).
|
||||
- The PS5 Upload app (version 6 or newer) must be open on this PC and connected
|
||||
to your PS5. PSGFX does its work through it.
|
||||
- Your PC and PS5 must be on the same network.
|
||||
|
||||
STEPS
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
// Minimal FTP client, used only for the home-screen database.
|
||||
//
|
||||
// PS5 Upload's own file commands only write under /data, /user and USB drives, but the
|
||||
// home screen lives in /system_data/priv/mms/app.db. The payload can start its built-in
|
||||
// FTP server on request (management frame FTP_START), and that server can reach the
|
||||
// whole filesystem, so the Home layout feature goes through it.
|
||||
#pragma once
|
||||
#include "ps5client.h"
|
||||
#include "lib/json.hpp"
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ftp {
|
||||
|
||||
using ps5::Error;
|
||||
|
||||
constexpr uint16_t FTP_START = 224, FTP_START_ACK = 225;
|
||||
constexpr int DEFAULT_PORT = 2122;
|
||||
|
||||
// Ask PS5 Upload's payload to start its FTP server; returns the port it listens on.
|
||||
inline int start_server(ps5::Client &c) {
|
||||
std::string body = nlohmann::json{{"port", DEFAULT_PORT}, {"root", "/"}, {"readonly", false}}.dump();
|
||||
auto j = nlohmann::json::parse(c.call_str(FTP_START, body, FTP_START_ACK, 15000));
|
||||
std::string err = j.value("error", "");
|
||||
if (j.value("ok", false) || err == "already_running") return j.value("port", DEFAULT_PORT) ? j.value("port", DEFAULT_PORT) : DEFAULT_PORT;
|
||||
if (err == "stopping") throw Error("PS5 Upload's file server is still shutting down. Try again in a few seconds.");
|
||||
throw Error("PS5 Upload couldn't start its file server (" + err + ").");
|
||||
}
|
||||
|
||||
class Session {
|
||||
public:
|
||||
Session(const std::string &host, int port) : host_(host) {
|
||||
ctl_ = dial(host, port);
|
||||
expect(reply(), 2, "connect");
|
||||
cmd("USER anonymous"); int code = reply();
|
||||
if (code / 100 == 3) { cmd("PASS anonymous"); code = reply(); }
|
||||
if (code / 100 != 2) throw Error("The PS5's file server refused the login (" + last_ + ")");
|
||||
cmd("TYPE I"); expect(reply(), 2, "binary mode");
|
||||
}
|
||||
~Session() { if (ctl_ != SOCK_BAD) { try { cmd("QUIT"); } catch (...) {} sock_close(ctl_); } }
|
||||
Session(const Session &) = delete; Session &operator=(const Session &) = delete;
|
||||
|
||||
std::vector<uint8_t> get(const std::string &path) {
|
||||
sock_t d = pasv();
|
||||
cmd("RETR " + path);
|
||||
int code = reply();
|
||||
if (code / 100 != 1) { sock_close(d); throw Error("Couldn't read " + path + " (" + last_ + ")"); }
|
||||
std::vector<uint8_t> out; char buf[1 << 16];
|
||||
for (;;) { int n = recv(d, buf, sizeof buf, 0); if (n <= 0) break; out.insert(out.end(), buf, buf + n); }
|
||||
sock_close(d);
|
||||
expect(reply(), 2, "read " + path);
|
||||
return out;
|
||||
}
|
||||
|
||||
void put(const std::string &path, const std::vector<uint8_t> &data) {
|
||||
sock_t d = pasv();
|
||||
cmd("STOR " + path);
|
||||
int code = reply();
|
||||
if (code / 100 != 1) { sock_close(d); throw Error("Couldn't write " + path + " (" + last_ + ")"); }
|
||||
size_t off = 0;
|
||||
while (off < data.size()) {
|
||||
int n = ::send(d, (const char *)data.data() + off, (int)std::min<size_t>(data.size() - off, 1 << 20), 0);
|
||||
if (n <= 0) { sock_close(d); throw Error("Upload of " + path + " was interrupted"); }
|
||||
off += (size_t)n;
|
||||
}
|
||||
sock_close(d);
|
||||
expect(reply(), 2, "write " + path);
|
||||
}
|
||||
|
||||
std::vector<std::string> list(const std::string &dir) {
|
||||
sock_t d = pasv();
|
||||
cmd("NLST " + dir);
|
||||
int code = reply();
|
||||
std::vector<std::string> names;
|
||||
if (code / 100 != 1) { sock_close(d); return names; }
|
||||
std::string all; char buf[8192];
|
||||
for (;;) { int n = recv(d, buf, sizeof buf, 0); if (n <= 0) break; all.append(buf, n); }
|
||||
sock_close(d);
|
||||
reply();
|
||||
size_t p = 0;
|
||||
while (p < all.size()) {
|
||||
size_t e = all.find('\n', p); if (e == std::string::npos) e = all.size();
|
||||
std::string line = all.substr(p, e - p);
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) line.pop_back();
|
||||
if (!line.empty()) { size_t s = line.find_last_of('/'); names.push_back(s == std::string::npos ? line : line.substr(s + 1)); }
|
||||
p = e + 1;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
bool exists(const std::string &path) {
|
||||
size_t s = path.find_last_of('/');
|
||||
std::string dir = s == 0 ? "/" : path.substr(0, s), name = path.substr(s + 1);
|
||||
for (auto &n : list(dir)) if (n == name) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void mkdirs(const std::string &path) {
|
||||
std::string cur;
|
||||
size_t p = 1;
|
||||
while (p <= path.size()) {
|
||||
size_t e = path.find('/', p); if (e == std::string::npos) e = path.size();
|
||||
cur += "/" + path.substr(p, e - p);
|
||||
cmd("MKD " + cur); reply(); // "already exists" is fine
|
||||
p = e + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::string host_, last_, pending_;
|
||||
sock_t ctl_ = SOCK_BAD;
|
||||
|
||||
static sock_t dial(const std::string &host, int port) {
|
||||
addrinfo hints{}, *res = nullptr;
|
||||
hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM;
|
||||
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) != 0 || !res) throw Error("Can't resolve " + host);
|
||||
sock_t s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||||
#ifdef _WIN32
|
||||
DWORD t = 30000;
|
||||
#else
|
||||
timeval t{30, 0};
|
||||
#endif
|
||||
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char *)&t, sizeof t);
|
||||
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char *)&t, sizeof t);
|
||||
if (s == SOCK_BAD || connect(s, res->ai_addr, (int)res->ai_addrlen) != 0) {
|
||||
freeaddrinfo(res); if (s != SOCK_BAD) sock_close(s);
|
||||
throw Error("Can't reach the PS5's file server at " + host + ":" + std::to_string(port));
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
return s;
|
||||
}
|
||||
void cmd(const std::string &c) {
|
||||
std::string line = c + "\r\n";
|
||||
if (::send(ctl_, line.data(), (int)line.size(), 0) <= 0) throw Error("Lost the connection to the PS5's file server");
|
||||
}
|
||||
std::string read_line() {
|
||||
for (;;) {
|
||||
size_t e = pending_.find('\n');
|
||||
if (e != std::string::npos) { std::string l = pending_.substr(0, e); pending_.erase(0, e + 1); if (!l.empty() && l.back() == '\r') l.pop_back(); return l; }
|
||||
char buf[1024]; int n = recv(ctl_, buf, sizeof buf, 0);
|
||||
if (n <= 0) throw Error("The PS5's file server stopped responding");
|
||||
pending_.append(buf, n);
|
||||
}
|
||||
}
|
||||
// Reads one (possibly multi-line) reply; returns its code.
|
||||
int reply() {
|
||||
std::string l = read_line();
|
||||
if (l.size() >= 4 && l[3] == '-') {
|
||||
std::string code = l.substr(0, 3);
|
||||
for (;;) { std::string m = read_line(); if (m.size() >= 4 && m.compare(0, 3, code) == 0 && m[3] == ' ') { l = m; break; } }
|
||||
}
|
||||
last_ = l;
|
||||
return l.size() >= 3 ? atoi(l.substr(0, 3).c_str()) : 0;
|
||||
}
|
||||
void expect(int code, int cls, const std::string &what) {
|
||||
if (code / 100 != cls) throw Error("The PS5's file server refused to " + what + " (" + last_ + ")");
|
||||
}
|
||||
// Passive data connection. Connects to the same host as the control channel.
|
||||
sock_t pasv() {
|
||||
cmd("PASV");
|
||||
expect(reply(), 2, "open a data connection");
|
||||
int a, b, c, d, p1, p2;
|
||||
size_t o = last_.find('(');
|
||||
if (o == std::string::npos || sscanf(last_.c_str() + o, "(%d,%d,%d,%d,%d,%d)", &a, &b, &c, &d, &p1, &p2) != 6)
|
||||
throw Error("Unexpected reply from the PS5's file server: " + last_);
|
||||
return dial(host_, p1 * 256 + p2);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ftp
|
||||
+81
-68
@@ -1,14 +1,14 @@
|
||||
// PSGFX - change the home-screen icon and background of PS5 homebrew and installed games,
|
||||
// through PS5 Upload's payload.
|
||||
// through PS5 Upload.
|
||||
// Runs a tiny local web server and opens the UI in the default browser.
|
||||
// Talks to PS5 Upload's resident payload (ports 9114 / 9113); nothing is installed on the PS5.
|
||||
// Talks to the PS5 through the PS5 Upload app's local engine (see ps5client.h); nothing is
|
||||
// installed on the PS5.
|
||||
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#define STB_IMAGE_RESIZE_IMPLEMENTATION
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#include "imaging.h"
|
||||
#include "ps5client.h"
|
||||
#include "ftp.h"
|
||||
#include "layout.h"
|
||||
#include "lib/json.hpp"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
#include <shellapi.h>
|
||||
#include <tlhelp32.h>
|
||||
#endif
|
||||
#define STUDIO_VERSION "1.1"
|
||||
#define STUDIO_VERSION "1.2"
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
@@ -43,6 +43,7 @@ struct Staged { // the user's image, decoded; converted per targ
|
||||
};
|
||||
static std::mutex g_mu;
|
||||
static std::string g_ip;
|
||||
static std::string g_engine; // PS5 Upload's engine, "host:port"; empty = 127.0.0.1:19113
|
||||
static std::map<std::string, Staged> g_staged; // "icon" | "background"
|
||||
static std::map<std::string, std::vector<uint8_t>> g_icon_cache;
|
||||
static std::string g_config_path = "psgfx.json";
|
||||
@@ -53,18 +54,24 @@ static void load_config() {
|
||||
std::ifstream f(g_config_path);
|
||||
if (!f && !g_legacy_config_path.empty()) f.open(g_legacy_config_path);
|
||||
if (!f) return;
|
||||
try { json j; f >> j; g_ip = j.value("ip", ""); } catch (...) {}
|
||||
try { json j; f >> j; g_ip = j.value("ip", ""); g_engine = j.value("engine", ""); } catch (...) {}
|
||||
}
|
||||
static void save_config() {
|
||||
std::ofstream f(g_config_path);
|
||||
f << json{{"ip", g_ip}}.dump(2);
|
||||
json j = {{"ip", g_ip}};
|
||||
if (!g_engine.empty()) j["engine"] = g_engine;
|
||||
f << j.dump(2);
|
||||
}
|
||||
|
||||
static ps5::Client client() {
|
||||
ps5::Client c; c.host = g_ip;
|
||||
if (const char *e = getenv("PS5AS_HOST")) c.host = e; // test hooks (mock PS5)
|
||||
if (const char *e = getenv("PS5AS_MGMT_PORT")) c.mgmt_port = atoi(e);
|
||||
if (const char *e = getenv("PS5AS_XFER_PORT")) c.xfer_port = atoi(e);
|
||||
std::string eng = g_engine;
|
||||
if (const char *e = getenv("PS5AS_ENGINE")) eng = e; // test hook (tools/mock_engine.py)
|
||||
if (!eng.empty()) {
|
||||
size_t colon = eng.rfind(':');
|
||||
c.engine_host = eng.substr(0, colon);
|
||||
if (colon != std::string::npos) c.engine_port = atoi(eng.c_str() + colon + 1);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -80,17 +87,26 @@ static bool mounted_image(const std::string &src) {
|
||||
// ─────────────────────────────── PS5 file helpers ─────────────────────
|
||||
static json list_dir(ps5::Client &c, const std::string &dir) {
|
||||
try {
|
||||
json j = json::parse(c.call_str(ps5::FS_LIST_DIR, json{{"path", dir}, {"limit", 4096}}.dump(), ps5::FS_LIST_DIR_ACK));
|
||||
return j.value("entries", json::array());
|
||||
json all = json::array();
|
||||
for (uint64_t off = 0;;) { // the console answers at most 256 entries a call
|
||||
json j = json::parse(c.call_str(ps5::FS_LIST, json{{"path", dir}, {"offset", off}, {"limit", 256}}.dump()));
|
||||
auto e = j.value("entries", json::array());
|
||||
for (auto &x : e) all.push_back(x);
|
||||
if (!j.value("truncated", false) || e.empty()) break;
|
||||
off += e.size();
|
||||
}
|
||||
return all;
|
||||
} catch (...) { return json::array(); }
|
||||
}
|
||||
static bool exists(ps5::Client &c, const std::string &dir, const std::string &name) {
|
||||
for (auto &e : list_dir(c, dir)) if (e.value("name", "") == name) return true;
|
||||
return false;
|
||||
}
|
||||
// FS_COPY's "overwrite" must be a number: the payload reads it as an integer field.
|
||||
static void copy_file(ps5::Client &c, const std::string &from, const std::string &to, bool overwrite) {
|
||||
c.call(ps5::FS_COPY, json{{"from", from}, {"to", to}, {"overwrite", overwrite ? 1 : 0}}.dump(), ps5::FS_COPY_ACK, 30000);
|
||||
// Art files are small, so a copy is a read and a write (PS5 Upload's own copy runs as a job).
|
||||
static void copy_file(ps5::Client &c, const std::string &from, const std::string &to) {
|
||||
auto data = c.read_file(from, 16u << 20);
|
||||
try { c.call(ps5::FS_MKDIR, json{{"path", to.substr(0, to.find_last_of('/'))}}.dump()); } catch (...) {}
|
||||
c.upload(to, data);
|
||||
}
|
||||
|
||||
// Title name from a PS4 param.sfo.
|
||||
@@ -124,13 +140,13 @@ static std::string meta_title(ps5::Client &c, const std::string &id) {
|
||||
}
|
||||
|
||||
// ─────────────────────────────── home layout ──────────────────────────
|
||||
// Edits the home-screen database through PS5 Upload's built-in FTP server (see ftp.h).
|
||||
// Edits the home-screen database, /system_data/priv/mms/app.db. PS5 Upload reads it directly,
|
||||
// but only writes under /data, /user and drives, so a new database goes to /data/psgfx first
|
||||
// and PS5 Upload's console shell copies it into place (see push_db).
|
||||
// Every apply re-downloads the database, saves it to PSGFX Layout/backups first, edits
|
||||
// a copy in memory, checks it, then uploads it.
|
||||
namespace fs = std::filesystem;
|
||||
static std::mutex g_layout_mu; // one layout operation at a time
|
||||
static std::mutex g_ftp_mu;
|
||||
static std::unique_ptr<ftp::Session> g_ftp;
|
||||
static std::string g_layout_user;
|
||||
static std::map<std::string, std::vector<uint8_t>> g_tile_img_cache;
|
||||
|
||||
@@ -147,25 +163,6 @@ static std::string now_stamp(const char *fmt) {
|
||||
char b[64]; strftime(b, sizeof b, fmt, &l); return b;
|
||||
}
|
||||
|
||||
// Runs fn with a live FTP session, reconnecting once if the old one has gone stale.
|
||||
template <class F> static auto with_ftp(F fn) {
|
||||
std::lock_guard<std::mutex> l(g_ftp_mu);
|
||||
for (int attempt = 0;; attempt++) {
|
||||
try {
|
||||
if (!g_ftp) {
|
||||
auto c = client();
|
||||
int port = ftp::start_server(c);
|
||||
if (const char *e = getenv("PS5AS_FTP_PORT")) port = atoi(e); // test hook
|
||||
g_ftp = std::make_unique<ftp::Session>(c.host, port);
|
||||
}
|
||||
return fn(*g_ftp);
|
||||
} catch (const ps5::Error &) {
|
||||
g_ftp.reset();
|
||||
if (attempt) throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> read_local(const fs::path &p) {
|
||||
std::ifstream f(p, std::ios::binary);
|
||||
return std::vector<uint8_t>((std::istreambuf_iterator<char>(f)), {});
|
||||
@@ -196,15 +193,33 @@ static json layout_state(const std::vector<uint8_t> &db_bytes, std::vector<std::
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> pull_db(std::vector<std::string> *warn = nullptr) {
|
||||
return with_ftp([&](ftp::Session &f) {
|
||||
if (warn)
|
||||
for (auto &n : f.list(layout::DB_DIR))
|
||||
if (n == "app.db-wal" || n == "app.db-journal")
|
||||
warn->push_back("The console has unsaved home-screen changes (" + n + "). For the most accurate result, restart the PS5 and reconnect.");
|
||||
auto b = f.get(layout::DB_PATH);
|
||||
if (b.empty()) throw ps5::Error("The PS5 sent an empty home-screen database.");
|
||||
return b;
|
||||
});
|
||||
auto c = client();
|
||||
if (warn)
|
||||
for (auto &e : list_dir(c, layout::DB_DIR)) {
|
||||
std::string n = e.value("name", "");
|
||||
if ((n == "app.db-wal" || n == "app.db-journal") && e.value("size", 0) > 0)
|
||||
warn->push_back("The console has unsaved home-screen changes (" + n + "). For the most accurate result, restart the PS5 and reconnect.");
|
||||
}
|
||||
auto b = c.read_file(layout::DB_PATH);
|
||||
if (b.empty()) throw ps5::Error("The PS5 sent an empty home-screen database.");
|
||||
return b;
|
||||
}
|
||||
|
||||
// Puts a new database in place: upload to /data/psgfx, copy over app.db with PS5 Upload's
|
||||
// shell `cp` (which keeps the file, so the system's open handle sees the new contents), then
|
||||
// read it back to be sure.
|
||||
static void push_db(const std::vector<uint8_t> &db) {
|
||||
auto c = client();
|
||||
const std::string tmp = "/data/psgfx/app.db.upload";
|
||||
try { c.call(ps5::FS_MKDIR, json{{"path", "/data/psgfx"}}.dump()); } catch (...) {}
|
||||
c.upload(tmp, db);
|
||||
json r = c.shell("cp " + tmp + " " + layout::DB_PATH);
|
||||
if (r.value("exit_code", -1) != 0)
|
||||
throw ps5::Error("The PS5 didn't accept the new home-screen database: " +
|
||||
(r.contains("stdout") ? r.value("stdout", "") : r.dump()) + " Nothing was changed.");
|
||||
try { c.remove(tmp); } catch (...) {}
|
||||
if (c.read_file(layout::DB_PATH) != db)
|
||||
throw ps5::Error("The home-screen database on the PS5 doesn't match what PSGFX sent. Restore a backup from the Backups list.");
|
||||
}
|
||||
|
||||
static json layout_load(const std::string &user) {
|
||||
@@ -229,7 +244,7 @@ static std::vector<uint8_t> tile_image(const std::string &path) {
|
||||
}
|
||||
std::vector<uint8_t> out;
|
||||
try {
|
||||
auto b = with_ftp([&](ftp::Session &f) { return f.get(path); });
|
||||
auto b = client().read_file(path);
|
||||
img::Image im;
|
||||
if (ext == "dds") { if (img::from_bc7_dds(b, im)) out = img::to_png(img::resize(im, 960, 540)); }
|
||||
else out = std::move(b);
|
||||
@@ -248,7 +263,7 @@ static json layout_apply(const json &req) {
|
||||
auto info = layout::analyze(db, g_layout_user);
|
||||
layout::apply(db, info, req, log);
|
||||
auto out = db.serialize();
|
||||
with_ftp([&](ftp::Session &f) { f.put(layout::DB_PATH, out); return 0; });
|
||||
push_db(out);
|
||||
log.push_back("Uploaded. Restart the PS5 to see the new home screen.");
|
||||
return {{"ok", true}, {"log", log}, {"backup", backup}, {"layout", layout_state(out)}};
|
||||
}
|
||||
@@ -270,7 +285,7 @@ static json layout_restore(const std::string &id) {
|
||||
auto db = read_local(data_path("backups") / fs::u8path(id) / "app.db");
|
||||
if (db.empty()) throw ps5::Error("That backup is missing its database file.");
|
||||
{ layout::DB check(db); } // throws if it isn't a healthy database
|
||||
with_ftp([&](ftp::Session &f) { f.put(layout::DB_PATH, db); return 0; });
|
||||
push_db(db);
|
||||
return {{"ok", true}, {"layout", layout_state(db)}};
|
||||
}
|
||||
|
||||
@@ -296,7 +311,7 @@ static json list_apps() {
|
||||
auto c = client();
|
||||
std::map<std::string, std::string> src, name;
|
||||
std::set<std::string> mounted;
|
||||
json reg = json::parse(c.call_str(ps5::APP_LIST_REGISTERED, "", ps5::APP_LIST_REGISTERED_ACK));
|
||||
json reg = json::parse(c.call_str(ps5::APP_LIST, "{}", 120000)); // 10-75 s on firmware 13.60
|
||||
for (auto &a : reg.value("apps", json::array())) {
|
||||
std::string id = a.value("title_id", "");
|
||||
src[id] = a.value("src", "");
|
||||
@@ -307,7 +322,7 @@ static json list_apps() {
|
||||
bool have_db = false;
|
||||
std::set<std::string> in_db;
|
||||
try { // Sony's app list with real names (newer PS5 Upload payloads)
|
||||
json db = json::parse(c.call_str(ps5::APPDB_QUERY, "{}", ps5::APPDB_QUERY_ACK));
|
||||
json db = json::parse(c.call_str(ps5::APP_DB_QUERY, "{}", 120000));
|
||||
for (auto &a : db.value("apps", json::array())) {
|
||||
std::string id = a.value("title_id", ""), n = a.value("name", "");
|
||||
if (id.empty()) continue;
|
||||
@@ -422,7 +437,7 @@ static std::vector<Target> game_targets(ps5::Client &c, const std::string &id, c
|
||||
: (ln.rfind("pic0", 0) == 0 || ln.rfind("pic1", 0) == 0);
|
||||
if (!want) continue;
|
||||
std::vector<uint8_t> h;
|
||||
try { h = c.call(ps5::FS_READ, json{{"path", appmeta(id) + "/" + name}, {"offset", 0}, {"limit", 160}}.dump(), ps5::FS_READ_ACK); }
|
||||
try { h = c.read(appmeta(id) + "/" + name, 0, 160); }
|
||||
catch (...) { continue; }
|
||||
Target t; t.name = name; t.dds = dds;
|
||||
if (png && h.size() >= 24 && memcmp(h.data(), "\x89PNG", 4) == 0) {
|
||||
@@ -447,8 +462,8 @@ static std::vector<Target> game_targets(ps5::Client &c, const std::string &id, c
|
||||
// Keep the first original as <name>.bak; mark files we create with <name>.added.
|
||||
static void backup_once(ps5::Client &c, const std::string &dir, const std::string &name, json &log) {
|
||||
if (exists(c, dir, name + ".bak") || exists(c, dir, name + ".added")) return;
|
||||
if (exists(c, dir, name)) { copy_file(c, dir + "/" + name, dir + "/" + name + ".bak", false); log.push_back("Backed up " + name); }
|
||||
else c.call(ps5::FS_WRITE_BYTES, json{{"path", dir + "/" + name + ".added"}, {"bytes", "MQ=="}}.dump(), ps5::FS_WRITE_BYTES_ACK);
|
||||
if (exists(c, dir, name)) { copy_file(c, dir + "/" + name, dir + "/" + name + ".bak"); log.push_back("Backed up " + name); }
|
||||
else c.upload(dir + "/" + name + ".added", {'1'});
|
||||
}
|
||||
|
||||
// Homebrew: copy the app's sce_sys art to where the home screen reads it, then ask PS5 Upload
|
||||
@@ -465,12 +480,12 @@ static void sync_home(ps5::Client &c, const std::string &id, const std::string &
|
||||
std::vector<std::string> dests = {appmeta(id) + "/" + name, "/user/app/" + id + "/sce_sys/" + name};
|
||||
if (name == "icon0.png") dests.push_back("/user/app/" + id + "/icon0.png");
|
||||
for (auto &d : dests) {
|
||||
try { copy_file(c, dir + "/" + name, d, true); }
|
||||
try { copy_file(c, dir + "/" + name, d); }
|
||||
catch (const std::exception &e) { log.push_back("Couldn't update " + d + ": " + e.what()); }
|
||||
}
|
||||
}
|
||||
try {
|
||||
c.call_str(ps5::APP_REGISTER, json{{"src_path", src}}.dump(), ps5::APP_REGISTER_ACK, 60000);
|
||||
c.call_str(ps5::APP_REGISTER, json{{"src_path", src}}.dump(), 60000);
|
||||
log.push_back("Refreshed the home screen. If the old art still shows, restart the PS5.");
|
||||
} catch (const std::exception &e) {
|
||||
std::string m = e.what();
|
||||
@@ -506,9 +521,7 @@ static bool probe_art(const std::vector<uint8_t> &h, Target &t) {
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> read_head(ps5::Client &c, const std::string &path) {
|
||||
try { return c.call(ps5::FS_READ, json{{"path", path}, {"offset", 0}, {"limit", 160}}.dump(), ps5::FS_READ_ACK); }
|
||||
catch (...) {}
|
||||
try { return with_ftp([&](ftp::Session &f) { return f.get(path); }); } // outside /user and /data
|
||||
try { return c.read(path, 0, 160); }
|
||||
catch (...) { return {}; }
|
||||
}
|
||||
|
||||
@@ -532,7 +545,7 @@ static void upload_db(const std::vector<uint8_t> &orig, layout::DB &db, const st
|
||||
std::string backup = make_backup(orig, label);
|
||||
auto out = db.serialize();
|
||||
{ layout::DB check(out); } // throws if the edit broke it; nothing is uploaded then
|
||||
with_ftp([&](ftp::Session &f) { f.put(layout::DB_PATH, out); return 0; });
|
||||
push_db(out);
|
||||
{ std::lock_guard<std::mutex> l(g_mu); g_tile_img_cache.clear(); }
|
||||
log.push_back("Home-screen database updated (backup " + backup + ")");
|
||||
}
|
||||
@@ -594,7 +607,7 @@ static std::set<std::string> game_apply_db(ps5::Client &c, const std::string &id
|
||||
std::string o = originals.dump(2);
|
||||
c.upload(dir + "/" + ORIGINALS, std::vector<uint8_t>(o.begin(), o.end()));
|
||||
upload_db(orig, db, "Before new art for " + id, log);
|
||||
for (auto &p : replaced) try { c.call(ps5::FS_DELETE, json{{"path", p}}.dump(), ps5::FS_DELETE_ACK); } catch (...) {}
|
||||
for (auto &p : replaced) try { c.remove(p); } catch (...) {}
|
||||
return done;
|
||||
}
|
||||
|
||||
@@ -614,7 +627,7 @@ static bool game_restore_db(ps5::Client &c, const std::string &id, json &log) {
|
||||
}
|
||||
for (auto &e : list_dir(c, appmeta(id))) {
|
||||
std::string n = e.value("name", "");
|
||||
if (n.rfind("psgfx_", 0) == 0) try { c.call(ps5::FS_DELETE, json{{"path", appmeta(id) + "/" + n}}.dump(), ps5::FS_DELETE_ACK); } catch (...) {}
|
||||
if (n.rfind("psgfx_", 0) == 0) try { c.remove(appmeta(id) + "/" + n); } catch (...) {}
|
||||
}
|
||||
log.push_back("Pointed the home screen back at the game's own art");
|
||||
return true;
|
||||
@@ -688,15 +701,15 @@ static json do_restore(const std::string &id, const std::string &src, std::strin
|
||||
for (auto &name : names) {
|
||||
const std::string path = dir + "/" + name;
|
||||
if (exists(c, dir, name + ".bak")) {
|
||||
copy_file(c, path + ".bak", path, true);
|
||||
c.call(ps5::FS_DELETE, json{{"path", path + ".bak"}}.dump(), ps5::FS_DELETE_ACK);
|
||||
copy_file(c, path + ".bak", path);
|
||||
c.remove(path + ".bak");
|
||||
log.push_back("Restored " + name);
|
||||
} else {
|
||||
try { c.call(ps5::FS_DELETE, json{{"path", path}}.dump(), ps5::FS_DELETE_ACK); } catch (...) {}
|
||||
c.call(ps5::FS_DELETE, json{{"path", path + ".added"}}.dump(), ps5::FS_DELETE_ACK);
|
||||
try { c.remove(path); } catch (...) {}
|
||||
c.remove(path + ".added");
|
||||
if (kind_of_app != "game") // registering copied it here too, and never deletes
|
||||
for (const std::string © : {appmeta(id) + "/" + name, "/user/app/" + id + "/sce_sys/" + name})
|
||||
try { c.call(ps5::FS_DELETE, json{{"path", copy}}.dump(), ps5::FS_DELETE_ACK); } catch (...) {}
|
||||
try { c.remove(copy); } catch (...) {}
|
||||
log.push_back("Removed " + name + " (it wasn't there originally)");
|
||||
}
|
||||
}
|
||||
@@ -719,11 +732,11 @@ static void rename_homebrew_param(ps5::Client &c, const std::string &id, const s
|
||||
for (auto &[lang, v] : j["localizedParameters"].items())
|
||||
if (v.is_object() && v.contains("titleName")) { v["titleName"] = name; n++; }
|
||||
if (!n) { log.push_back("This app's param.json has no name to update; only the home screen was renamed."); return; }
|
||||
if (!exists(c, dir, "param.json.orig")) copy_file(c, dir + "/param.json", dir + "/param.json.orig", false);
|
||||
if (!exists(c, dir, "param.json.orig")) copy_file(c, dir + "/param.json", dir + "/param.json.orig");
|
||||
std::string out = j.dump(2);
|
||||
c.upload(dir + "/param.json", std::vector<uint8_t>(out.begin(), out.end()));
|
||||
for (auto &d : {appmeta(id) + "/param.json", "/user/app/" + id + "/sce_sys/param.json"})
|
||||
try { if (exists(c, d.substr(0, d.find_last_of('/')), "param.json")) copy_file(c, dir + "/param.json", d, true); } catch (...) {}
|
||||
try { if (exists(c, d.substr(0, d.find_last_of('/')), "param.json")) copy_file(c, dir + "/param.json", d); } catch (...) {}
|
||||
log.push_back("Updated the name in the app's param.json");
|
||||
} catch (const std::exception &e) {
|
||||
log.push_back(std::string("Couldn't update the app's param.json (") + e.what() + "). Changing its art later may bring back the old name.");
|
||||
|
||||
+134
-145
@@ -1,15 +1,17 @@
|
||||
// Minimal client for PS5 Upload's resident payload (FTX2 protocol).
|
||||
// port 9114: management (JSON request -> ACK)
|
||||
// port 9113: transfers (BeginTx -> StreamShard -> CommitTx)
|
||||
// Frame = 28-byte little-endian header + body.
|
||||
// Minimal client for the PS5, through the PS5 Upload app on this PC.
|
||||
//
|
||||
// PS5 Upload 6.0 replaced its old protocol (FTX2, ports 9113/9114) with AVA1: one encrypted
|
||||
// port (9120) that the console only opens to a paired app. Rather than pair a second app,
|
||||
// PSGFX hands its calls to PS5 Upload's local engine (http://127.0.0.1:19113), which already
|
||||
// holds the paired session: POST /api/mgmt/call runs one management call on the console, with
|
||||
// the same JSON bodies the old protocol used. So PS5 Upload has to be open on this PC.
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <random>
|
||||
#include "lib/blake3.h"
|
||||
#include "lib/json.hpp"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
@@ -32,180 +34,167 @@ typedef int sock_t;
|
||||
|
||||
namespace ps5 {
|
||||
|
||||
enum Frame : uint16_t {
|
||||
ERROR_ = 3,
|
||||
BEGIN_TX = 10, BEGIN_TX_ACK = 11, COMMIT_TX = 14, COMMIT_TX_ACK = 15,
|
||||
ABORT_TX = 16, ABORT_TX_ACK = 17,
|
||||
STREAM_SHARD = 30, SHARD_ACK = 31,
|
||||
FS_LIST_DIR = 36, FS_LIST_DIR_ACK = 37,
|
||||
FS_DELETE = 40, FS_DELETE_ACK = 41,
|
||||
FS_READ = 48, FS_READ_ACK = 49,
|
||||
FS_COPY = 50, FS_COPY_ACK = 51,
|
||||
APP_REGISTER = 56, APP_REGISTER_ACK = 57,
|
||||
APP_LIST_REGISTERED = 62, APP_LIST_REGISTERED_ACK = 63,
|
||||
APPDB_QUERY = 120, APPDB_QUERY_ACK = 121,
|
||||
FS_WRITE_BYTES = 130, FS_WRITE_BYTES_ACK = 131,
|
||||
};
|
||||
// AVA1 management methods (PS5 Upload's protocol/ava1 schema) and the labels its errors carry.
|
||||
struct Method { uint16_t id; const char *label; };
|
||||
constexpr Method FS_LIST{33, "FS_LIST_DIR"}, FS_MKDIR{35, "FS_MKDIR"}, FS_READ{38, "FS_READ"},
|
||||
FS_WRITE{39, "FS_WRITE_BYTES"}, APP_REGISTER{48, "APP_REGISTER"},
|
||||
APP_LIST{51, "APP_LIST_REGISTERED"}, APP_DB_QUERY{56, "APP_DB_QUERY"},
|
||||
SHELL_EXEC{87, "SHELL_EXEC"};
|
||||
constexpr uint8_t JOB_DELETE = 1;
|
||||
|
||||
struct Error : std::runtime_error { using std::runtime_error::runtime_error; };
|
||||
|
||||
inline void put16(uint8_t *p, uint16_t v) { p[0] = v & 255; p[1] = v >> 8; }
|
||||
inline void put32(uint8_t *p, uint32_t v) { for (int i = 0; i < 4; i++) p[i] = (uint8_t)(v >> (8 * i)); }
|
||||
inline void put64(uint8_t *p, uint64_t v) { for (int i = 0; i < 8; i++) p[i] = (uint8_t)(v >> (8 * i)); }
|
||||
inline uint64_t get64(const uint8_t *p) { uint64_t v = 0; for (int i = 7; i >= 0; i--) v = (v << 8) | p[i]; return v; }
|
||||
inline uint32_t get32(const uint8_t *p) { return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24; }
|
||||
|
||||
class Conn {
|
||||
public:
|
||||
Conn(const std::string &host, int port, int timeout_ms) {
|
||||
addrinfo hints{}, *res = nullptr;
|
||||
hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM;
|
||||
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) != 0 || !res)
|
||||
throw Error("Can't resolve " + host);
|
||||
s_ = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||||
if (s_ == SOCK_BAD) { freeaddrinfo(res); throw Error("socket() failed"); }
|
||||
set_timeout(timeout_ms);
|
||||
int one = 1; setsockopt(s_, IPPROTO_TCP, TCP_NODELAY, (const char *)&one, sizeof one);
|
||||
if (connect(s_, res->ai_addr, (int)res->ai_addrlen) != 0) {
|
||||
freeaddrinfo(res); sock_close(s_); s_ = SOCK_BAD;
|
||||
throw Error("Can't reach the PS5 at " + host + ":" + std::to_string(port) +
|
||||
" - is PS5 Upload's payload running?");
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
inline std::string b64_encode(const uint8_t *d, size_t n) {
|
||||
static const char *T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string o; o.reserve((n + 2) / 3 * 4);
|
||||
for (size_t i = 0; i < n; i += 3) {
|
||||
uint32_t v = (uint32_t)d[i] << 16 | (i + 1 < n ? (uint32_t)d[i + 1] << 8 : 0) | (i + 2 < n ? d[i + 2] : 0);
|
||||
o += T[v >> 18 & 63]; o += T[v >> 12 & 63];
|
||||
o += i + 1 < n ? T[v >> 6 & 63] : '='; o += i + 2 < n ? T[v & 63] : '=';
|
||||
}
|
||||
~Conn() { if (s_ != SOCK_BAD) sock_close(s_); }
|
||||
Conn(const Conn &) = delete; Conn &operator=(const Conn &) = delete;
|
||||
return o;
|
||||
}
|
||||
inline std::string b64_encode(const std::string &s) { return b64_encode((const uint8_t *)s.data(), s.size()); }
|
||||
inline std::vector<uint8_t> b64_decode(const std::string &s) {
|
||||
std::vector<uint8_t> o; o.reserve(s.size() / 4 * 3);
|
||||
uint32_t v = 0; int bits = 0;
|
||||
for (char ch : s) {
|
||||
int x = ch >= 'A' && ch <= 'Z' ? ch - 'A' : ch >= 'a' && ch <= 'z' ? ch - 'a' + 26
|
||||
: ch >= '0' && ch <= '9' ? ch - '0' + 52 : ch == '+' ? 62 : ch == '/' ? 63 : -1;
|
||||
if (x < 0) continue;
|
||||
v = v << 6 | (uint32_t)x; bits += 6;
|
||||
if (bits >= 8) { bits -= 8; o.push_back((uint8_t)(v >> bits)); }
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
void set_timeout(int ms) {
|
||||
// One HTTP/1.1 request to the local engine (Connection: close). Returns (status, body).
|
||||
inline std::pair<int, std::string> http(const std::string &host, int port, const std::string &method,
|
||||
const std::string &path, const std::string &body, int timeout_ms) {
|
||||
addrinfo hints{}, *res = nullptr;
|
||||
hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM;
|
||||
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) != 0 || !res)
|
||||
throw Error("Can't resolve " + host);
|
||||
sock_t s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||||
if (s == SOCK_BAD) { freeaddrinfo(res); throw Error("socket() failed"); }
|
||||
#ifdef _WIN32
|
||||
DWORD t = (DWORD)ms;
|
||||
DWORD t = (DWORD)timeout_ms;
|
||||
#else
|
||||
timeval t{ms / 1000, (ms % 1000) * 1000};
|
||||
timeval t{timeout_ms / 1000, (timeout_ms % 1000) * 1000};
|
||||
#endif
|
||||
setsockopt(s_, SOL_SOCKET, SO_RCVTIMEO, (const char *)&t, sizeof t);
|
||||
setsockopt(s_, SOL_SOCKET, SO_SNDTIMEO, (const char *)&t, sizeof t);
|
||||
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char *)&t, sizeof t);
|
||||
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char *)&t, sizeof t);
|
||||
if (connect(s, res->ai_addr, (int)res->ai_addrlen) != 0) {
|
||||
freeaddrinfo(res); sock_close(s);
|
||||
throw Error("PSGFX talks to your PS5 through the PS5 Upload app, and it isn't running on this PC. "
|
||||
"Open PS5 Upload (version 6 or newer), make sure it's connected to your PS5, then click Connect again.");
|
||||
}
|
||||
|
||||
void send_frame(uint16_t type, const uint8_t *a, size_t alen, const uint8_t *b = nullptr, size_t blen = 0) {
|
||||
uint8_t h[28] = {0};
|
||||
put32(h, 0x32585446u); // "FTX2"
|
||||
put16(h + 4, 1); // version
|
||||
put16(h + 6, type);
|
||||
put32(h + 8, 0); // flags
|
||||
put64(h + 12, (uint64_t)(alen + blen));
|
||||
put64(h + 20, 0); // trace id 0 = untracked fast path
|
||||
send_all(h, 28);
|
||||
if (alen) send_all(a, alen);
|
||||
if (blen) send_all(b, blen);
|
||||
freeaddrinfo(res);
|
||||
std::string req = method + " " + path + " HTTP/1.1\r\nHost: " + host + ":" + std::to_string(port) +
|
||||
"\r\nContent-Type: application/json\r\nContent-Length: " + std::to_string(body.size()) +
|
||||
"\r\nConnection: close\r\n\r\n" + body;
|
||||
for (size_t off = 0; off < req.size();) {
|
||||
int w = ::send(s, req.data() + off, (int)std::min<size_t>(req.size() - off, 1 << 20), 0);
|
||||
if (w <= 0) { sock_close(s); throw Error("The connection to PS5 Upload dropped."); }
|
||||
off += (size_t)w;
|
||||
}
|
||||
|
||||
// Returns (frame_type, body). Throws on transport errors.
|
||||
std::pair<uint16_t, std::vector<uint8_t>> recv_frame() {
|
||||
uint8_t h[28];
|
||||
recv_all(h, 28);
|
||||
if (get32(h) != 0x32585446u) throw Error("Unexpected reply from the PS5 (not PS5 Upload's payload?)");
|
||||
uint16_t type = (uint16_t)(h[6] | h[7] << 8);
|
||||
uint64_t len = get64(h + 12);
|
||||
if (len > (256ull << 20)) throw Error("Reply too large");
|
||||
std::vector<uint8_t> body((size_t)len);
|
||||
if (len) recv_all(body.data(), (size_t)len);
|
||||
return {type, std::move(body)};
|
||||
std::string resp; char buf[1 << 16];
|
||||
for (;;) {
|
||||
int r = ::recv(s, buf, sizeof buf, 0);
|
||||
if (r == 0) break;
|
||||
if (r < 0) { sock_close(s); throw Error("PS5 Upload didn't answer in time."); }
|
||||
resp.append(buf, (size_t)r);
|
||||
}
|
||||
|
||||
private:
|
||||
sock_t s_ = SOCK_BAD;
|
||||
void send_all(const uint8_t *p, size_t n) {
|
||||
while (n) {
|
||||
int w = ::send(s_, (const char *)p, (int)std::min<size_t>(n, 1 << 20), 0);
|
||||
if (w <= 0) throw Error("Connection to the PS5 dropped while sending");
|
||||
p += w; n -= (size_t)w;
|
||||
sock_close(s);
|
||||
size_t he = resp.find("\r\n\r\n");
|
||||
if (resp.compare(0, 5, "HTTP/") != 0 || he == std::string::npos) throw Error("Unexpected reply from PS5 Upload.");
|
||||
int status = atoi(resp.c_str() + resp.find(' ') + 1);
|
||||
std::string head = resp.substr(0, he), out = resp.substr(he + 4);
|
||||
for (auto &ch : head) ch = (char)tolower((unsigned char)ch);
|
||||
if (head.find("transfer-encoding: chunked") != std::string::npos) {
|
||||
std::string d; size_t p = 0;
|
||||
for (;;) {
|
||||
size_t e = out.find("\r\n", p);
|
||||
if (e == std::string::npos) break;
|
||||
size_t n = strtoull(out.c_str() + p, nullptr, 16);
|
||||
if (n == 0 || e + 2 + n > out.size()) break;
|
||||
d.append(out, e + 2, n);
|
||||
p = e + 2 + n + 2;
|
||||
}
|
||||
out.swap(d);
|
||||
}
|
||||
void recv_all(uint8_t *p, size_t n) {
|
||||
while (n) {
|
||||
int r = ::recv(s_, (char *)p, (int)std::min<size_t>(n, 1 << 20), 0);
|
||||
if (r <= 0) throw Error("The PS5 didn't answer in time");
|
||||
p += r; n -= (size_t)r;
|
||||
}
|
||||
}
|
||||
};
|
||||
return {status, out};
|
||||
}
|
||||
|
||||
struct Client {
|
||||
std::string host;
|
||||
int mgmt_port = 9114, xfer_port = 9113;
|
||||
std::string host; // the PS5's IP
|
||||
std::string engine_host = "127.0.0.1"; int engine_port = 19113;
|
||||
|
||||
// One management round-trip. `expect` is the ACK frame type.
|
||||
std::vector<uint8_t> call(uint16_t type, const std::string &json, uint16_t expect, int timeout_ms = 15000) {
|
||||
Conn c(host, mgmt_port, timeout_ms);
|
||||
c.send_frame(type, (const uint8_t *)json.data(), json.size());
|
||||
auto [ft, body] = c.recv_frame();
|
||||
if (ft == ERROR_) throw Error(std::string(body.begin(), body.end()));
|
||||
if (ft != expect) throw Error("Unexpected reply type " + std::to_string(ft));
|
||||
return body;
|
||||
// One management call on the console. Throws ps5::Error with a readable message.
|
||||
std::vector<uint8_t> call(Method m, const std::string &body, int timeout_ms = 15000) {
|
||||
return proxy({{"op", "call"}, {"addr", host}, {"method", m.id}, {"label", m.label},
|
||||
{"body_b64", b64_encode(body)}, {"timeout_ms", timeout_ms}}, m.label, timeout_ms);
|
||||
}
|
||||
std::string call_str(uint16_t type, const std::string &json, uint16_t expect, int timeout_ms = 15000) {
|
||||
auto b = call(type, json, expect, timeout_ms);
|
||||
std::string call_str(Method m, const std::string &body, int timeout_ms = 15000) {
|
||||
auto b = call(m, body, timeout_ms);
|
||||
return std::string(b.begin(), b.end());
|
||||
}
|
||||
|
||||
// Whole file (<= 64 MiB) in one transaction with a single BLAKE3-verified shard.
|
||||
void upload(const std::string &dest, const std::vector<uint8_t> &data) {
|
||||
uint8_t tx[16];
|
||||
std::random_device rd; for (auto &b : tx) b = (uint8_t)rd();
|
||||
Conn c(host, xfer_port, 60000);
|
||||
|
||||
std::string manifest = "{\"dest_root\":\"" + json_escape(dest) + "\",\"file_count\":1,\"total_bytes\":" +
|
||||
std::to_string(data.size()) + ",\"total_shards\":1}";
|
||||
std::vector<uint8_t> begin(24);
|
||||
memcpy(begin.data(), tx, 16); put32(&begin[16], 1); put32(&begin[20], 0);
|
||||
begin.insert(begin.end(), manifest.begin(), manifest.end());
|
||||
c.send_frame(BEGIN_TX, begin.data(), begin.size());
|
||||
expect_ack(c, BEGIN_TX_ACK, "start upload");
|
||||
|
||||
uint8_t sh[64] = {0};
|
||||
memcpy(sh, tx, 16); put64(sh + 16, 1);
|
||||
blake3_hasher hs; blake3_hasher_init(&hs);
|
||||
if (!data.empty()) blake3_hasher_update(&hs, data.data(), data.size());
|
||||
blake3_hasher_finalize(&hs, sh + 24, 32);
|
||||
put32(sh + 56, 1); put32(sh + 60, 0);
|
||||
c.send_frame(STREAM_SHARD, sh, 64, data.data(), data.size());
|
||||
expect_ack(c, SHARD_ACK, "send data");
|
||||
|
||||
uint8_t commit[24] = {0};
|
||||
memcpy(commit, tx, 16);
|
||||
c.send_frame(COMMIT_TX, commit, 24);
|
||||
expect_ack(c, COMMIT_TX_ACK, "finish upload");
|
||||
void remove(const std::string &path) {
|
||||
std::string body = nlohmann::json{{"path", path}}.dump();
|
||||
proxy({{"op", "run_job"}, {"addr", host}, {"job_op", JOB_DELETE}, {"label", "FS_DELETE"},
|
||||
{"body_b64", b64_encode(body)}, {"op_id", 0}, {"subject", path}, {"deadline_ms", 30000}}, "FS_DELETE", 30000);
|
||||
}
|
||||
|
||||
// Read a whole file in <= 2 MiB pieces.
|
||||
// Whole file, up to 16 MiB (PS5 Upload sends it in pieces and commits it once).
|
||||
void upload(const std::string &dest, const std::vector<uint8_t> &data) {
|
||||
if (data.size() > (16u << 20)) throw Error("That file is too large to send (over 16 MB).");
|
||||
call(FS_WRITE, nlohmann::json{{"path", dest}, {"bytes", b64_encode(data.data(), data.size())},
|
||||
{"mode", "overwrite"}, {"large", true}}.dump(), 60000);
|
||||
}
|
||||
|
||||
// Up to `limit` bytes from `offset`. "unsafe" lets reads reach /system_data (app.db); the
|
||||
// payload still refuses anything outside its readable roots.
|
||||
std::vector<uint8_t> read(const std::string &path, uint64_t offset, uint64_t limit, int timeout_ms = 20000) {
|
||||
return call(FS_READ, nlohmann::json{{"path", path}, {"offset", offset}, {"limit", limit}, {"unsafe", true}}.dump(), timeout_ms);
|
||||
}
|
||||
std::vector<uint8_t> read_file(const std::string &path, size_t max_total = 64u << 20) {
|
||||
std::vector<uint8_t> out;
|
||||
const size_t piece = 2u << 20; // PS5 Upload caps one fs.read at 2 MiB
|
||||
for (;;) {
|
||||
auto part = call(FS_READ, "{\"path\":\"" + json_escape(path) + "\",\"offset\":" + std::to_string(out.size()) +
|
||||
",\"limit\":2097152}", FS_READ_ACK, 20000);
|
||||
if (part.empty()) break;
|
||||
auto part = read(path, out.size(), piece, 60000);
|
||||
out.insert(out.end(), part.begin(), part.end());
|
||||
if (part.size() < 2097152 || out.size() >= max_total) break;
|
||||
if (part.size() < piece || out.size() >= max_total) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::string json_escape(const std::string &s) {
|
||||
std::string o;
|
||||
for (char ch : s) {
|
||||
if (ch == '"' || ch == '\\') { o += '\\'; o += ch; }
|
||||
else if ((unsigned char)ch < 0x20) { char b[8]; snprintf(b, sizeof b, "\\u%04x", ch); o += b; }
|
||||
else o += ch;
|
||||
}
|
||||
return o;
|
||||
// PS5 Upload's console shell (its own built-in commands; there is no real shell on a PS5).
|
||||
nlohmann::json shell(const std::string &cmd, int timeout_ms = 30000) {
|
||||
return nlohmann::json::parse(call_str(SHELL_EXEC, nlohmann::json{{"cmd", cmd}, {"cwd", "/"}}.dump(), timeout_ms));
|
||||
}
|
||||
|
||||
private:
|
||||
static void expect_ack(Conn &c, uint16_t want, const char *what) {
|
||||
auto [ft, body] = c.recv_frame();
|
||||
if (ft == ERROR_) throw Error(std::string("PS5 refused to ") + what + ": " + std::string(body.begin(), body.end()));
|
||||
if (ft != want) throw Error(std::string("Unexpected reply during ") + what);
|
||||
std::vector<uint8_t> proxy(const nlohmann::json &req, const std::string &label, int timeout_ms) {
|
||||
auto [status, body] = http(engine_host, engine_port, "POST", "/api/mgmt/call", req.dump(), timeout_ms + 10000);
|
||||
if (status == 404) throw Error("This version of PS5 Upload is too old for PSGFX. Update PS5 Upload to version 6 or newer.");
|
||||
nlohmann::json j;
|
||||
try { j = nlohmann::json::parse(body); }
|
||||
catch (...) { throw Error("PS5 Upload refused the request (HTTP " + std::to_string(status) + "): " + body.substr(0, 300)); }
|
||||
if (j.value("ok", false)) return b64_decode(j["result"].value("body_b64", ""));
|
||||
auto e = j.value("error", nlohmann::json::object());
|
||||
std::string cause = e.value("cause", e.value("message", std::string("unknown error")));
|
||||
if (cause.find("not_paired") != std::string::npos)
|
||||
throw Error("Your PS5 hasn't accepted PS5 Upload yet. Pair them on PS5 Upload's Connection screen, then try again.");
|
||||
if (cause.find("helper_not_ava1") != std::string::npos)
|
||||
throw Error("PS5 Upload's payload isn't running on the PS5 at " + host + " (or it's an old version). "
|
||||
"Send it from PS5 Upload's Connection screen, then try again.");
|
||||
if (cause.find("ava1_unreachable") != std::string::npos || cause.find("did not answer") != std::string::npos)
|
||||
throw Error("Can't reach the PS5 at " + host + ". Check the IP, and that PS5 Upload can see the console.");
|
||||
throw Error(label + ": " + cause);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ps5
|
||||
} // namespace ps5
|
||||
+2
-2
@@ -176,7 +176,7 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div class="brand"><img src="/wordmark.png" alt="PSGFX"><span>1.1</span></div>
|
||||
<div class="brand"><img src="/wordmark.png" alt="PSGFX"><span>1.2</span></div>
|
||||
<nav class="views" aria-label="View"><button data-v="art" aria-pressed="true">Art</button><button data-v="layout" aria-pressed="false">Home layout</button></nav>
|
||||
<form class="connect" id="cf">
|
||||
<span class="dot" id="dot" aria-hidden="true"></span>
|
||||
@@ -194,7 +194,7 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
|
||||
<main>
|
||||
<section class="welcome" id="welcome">
|
||||
<h1>New art for your homebrew and games.</h1>
|
||||
<p>Make sure PS5 Upload's payload is running on your console, then enter your PS5's IP above. Pick an app or game, drop images onto the preview, and apply.</p>
|
||||
<p>Open the PS5 Upload app on this PC (version 6 or newer) and connect it to your console, then enter your PS5's IP above. Pick an app or game, drop images onto the preview, and apply.</p>
|
||||
</section>
|
||||
|
||||
<section id="editor" class="hidden" aria-label="Artwork editor">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""File-backed stand-in for the PS5 Upload app's local engine and the console behind it
|
||||
(development only), following the engine's POST /api/mgmt/call and the payload's runtime.c,
|
||||
register.c, mgmt_fs.c and shell builtins.
|
||||
Usage: python3 tools/mock_engine.py ROOT PORT (set MOCK_FW13=1 to mimic firmware 12/13 register behaviour,
|
||||
MOCK_NOT_PAIRED=1 for a console that hasn't accepted PS5 Upload)
|
||||
Paths map under ROOT. Writes are allowed under /data, /user and /mnt; reads also under /system*."""
|
||||
import base64, json, os, shutil, sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
ROOT = sys.argv[1]; PORT = int(sys.argv[2])
|
||||
WRITABLE = ("/data/", "/user/", "/mnt/")
|
||||
READABLE = WRITABLE + ("/system/", "/system_data/", "/system_ex/")
|
||||
|
||||
def P(p): return os.path.join(ROOT, p.lstrip("/"))
|
||||
def writable(p): return p.startswith(WRITABLE) and ".." not in p
|
||||
def readable(p, unsafe): return (p.startswith(READABLE if unsafe else WRITABLE)) and ".." not in p
|
||||
|
||||
class Refused(Exception): pass
|
||||
|
||||
def call(method, label, j):
|
||||
if method == 51: # app.list
|
||||
apps = []
|
||||
base = P("/user/app")
|
||||
for tid in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
||||
lnk = os.path.join(base, tid, "mount.lnk"); src = open(lnk).read() if os.path.exists(lnk) else ""
|
||||
name = tid
|
||||
pj = os.path.join(base, tid, "sce_sys", "param.json")
|
||||
if os.path.exists(pj):
|
||||
name = json.load(open(pj))["localizedParameters"]["en-US"]["titleName"]
|
||||
apps.append({"title_id": tid, "title_name": name, "src": src, "image_backed": False})
|
||||
return json.dumps({"apps": apps}).encode()
|
||||
if method == 56: # app.db_query - names from app.db (mocked by ROOT/appdb.json)
|
||||
dbp = os.path.join(ROOT, "appdb.json") # {"apps":[{"title_id":..,"app_id":..,"name":..}]}
|
||||
if os.environ.get("MOCK_NO_APPDB") or not os.path.exists(dbp): raise Refused("unsupported_in_mock")
|
||||
return open(dbp, "rb").read()
|
||||
if method == 38: # fs.read (the engine loops to the limit, capped at 2 MiB like LEGACY_READ_MAX)
|
||||
p = j.get("path", "")
|
||||
if not readable(p, j.get("unsafe", False)): raise Refused("fs_read_path_not_allowed")
|
||||
if not os.path.isfile(P(p)): raise Refused("fs_read_stat_failed")
|
||||
with open(P(p), "rb") as f: f.seek(j.get("offset", 0)); return f.read(min(j.get("limit", 2 << 20), 2 << 20))
|
||||
if method == 33: # fs.list, at most 256 a call
|
||||
p = j.get("path", "")
|
||||
if not os.path.isdir(P(p)): raise Refused("fs_list_dir_opendir_errno_2")
|
||||
names = sorted(os.listdir(P(p))); off = j.get("offset", 0); lim = min(j.get("limit", 256), 256)
|
||||
ents = [{"name": n, "kind": "dir" if os.path.isdir(os.path.join(P(p), n)) else "file",
|
||||
"size": os.path.getsize(os.path.join(P(p), n)), "mtime": 0} for n in names[off:off + lim]]
|
||||
return json.dumps({"path": p, "entries": ents, "returned": len(ents), "truncated": off + lim < len(names)}).encode()
|
||||
if method == 35: # fs.mkdir
|
||||
p = j.get("path", "")
|
||||
if not writable(p): raise Refused("path_unsafe")
|
||||
os.makedirs(P(p), exist_ok=True); return b""
|
||||
if method == 39: # fs.write: whole file, replaced atomically; the folder must exist
|
||||
p = j.get("path", "")
|
||||
if not writable(p): raise Refused("path_unsafe")
|
||||
data = base64.b64decode(j["bytes"])
|
||||
if len(data) > (16 << 20 if j.get("large") else 256 << 10): raise Refused("too_large")
|
||||
if not os.path.isdir(os.path.dirname(P(p))): raise Refused("open_failed")
|
||||
if j.get("mode") == "create" and os.path.exists(P(p)): raise Refused("exists")
|
||||
open(P(p) + ".tmp", "wb").write(data); os.replace(P(p) + ".tmp", P(p))
|
||||
print("WROTE", p, len(data), flush=True)
|
||||
return json.dumps({"ok": True, "size": len(data)}).encode()
|
||||
if method == 87: # shell.exec - only `cp SRC DST`, which (like the payload's) has no path policy
|
||||
a = j.get("cmd", "").split()
|
||||
if len(a) != 3 or a[0] != "cp": return json.dumps({"exit_code": 127, "timed_out": False, "stdout": "unsupported in mock\n"}).encode()
|
||||
if not os.path.isfile(P(a[1])): return json.dumps({"exit_code": 1, "timed_out": False, "stdout": "cp: %s: No such file or directory\n" % a[1]}).encode()
|
||||
with open(P(a[1]), "rb") as f, open(P(a[2]), "r+b" if os.path.exists(P(a[2])) else "wb") as g:
|
||||
g.truncate(0); g.write(f.read())
|
||||
print("CP", a[1], a[2], flush=True)
|
||||
return json.dumps({"exit_code": 0, "timed_out": False, "stdout": ""}).encode()
|
||||
if method == 48: # app.register - stage sce_sys like register.c
|
||||
src = j.get("src_path", "").rstrip("/")
|
||||
pj = json.load(open(P(src + "/sce_sys/param.json"))); tid = pj["titleId"]
|
||||
ua = P("/user/app/" + tid); meta = P("/user/appmeta/" + tid)
|
||||
os.makedirs(ua, exist_ok=True); os.makedirs(meta, exist_ok=True)
|
||||
shutil.copytree(P(src + "/sce_sys"), ua + "/sce_sys", dirs_exist_ok=True)
|
||||
if os.path.exists(P(src + "/sce_sys/icon0.png")): shutil.copyfile(P(src + "/sce_sys/icon0.png"), ua + "/icon0.png")
|
||||
for n in os.listdir(P(src + "/sce_sys")):
|
||||
if n == "param.json" or n.lower().endswith((".png", ".dds", ".at9")):
|
||||
shutil.copyfile(P(src + "/sce_sys/" + n), os.path.join(meta, n))
|
||||
open(ua + "/mount.lnk", "w").write(src)
|
||||
print("REGISTER", tid, "from", src, flush=True)
|
||||
if os.environ.get("MOCK_FW13"): raise Refused("register_install_api_unavailable")
|
||||
return json.dumps({"title_id": tid, "title_name": tid, "used_nullfs": True}).encode()
|
||||
raise Refused("unsupported_in_mock")
|
||||
|
||||
def job(op, j):
|
||||
if op == 1: # job.run DELETE
|
||||
p = j.get("path", "")
|
||||
if not writable(p): raise Refused("fs_delete_path_not_allowed")
|
||||
try: os.remove(P(p))
|
||||
except FileNotFoundError: raise Refused("fs_delete_failed")
|
||||
print("DELETED", p, flush=True)
|
||||
return json.dumps({"ok": True}).encode()
|
||||
raise Refused("unsupported_in_mock")
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def do_POST(self):
|
||||
if self.path != "/api/mgmt/call":
|
||||
self.send_response(404); self.end_headers(); return
|
||||
req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
label = req.get("label", "")
|
||||
try:
|
||||
if os.environ.get("MOCK_NOT_PAIRED"): raise Refused("not_paired")
|
||||
body = base64.b64decode(req.get("body_b64", ""))
|
||||
j = json.loads(body) if body[:1] == b"{" else {}
|
||||
out = call(req["method"], label, j) if req["op"] == "call" else job(req["job_op"], j)
|
||||
r = {"ok": True, "result": {"kind": "bytes", "body_b64": base64.b64encode(out).decode()}}
|
||||
except Refused as e:
|
||||
r = {"ok": False, "error": {"kind": "mgmt", "label": label, "status": 7, "cause": str(e)}}
|
||||
b = json.dumps(r).encode()
|
||||
self.send_response(200); self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
|
||||
|
||||
print("mock engine up", flush=True)
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""File-backed stand-in for PS5 Upload's payload (development only), following its runtime.c + register.c.
|
||||
Usage: python3 tools/mock_ps5.py ROOT MGMT_PORT XFER_PORT (set MOCK_FW13=1 to mimic firmware 12/13 register behaviour)
|
||||
Serves management (FTX2) and transfer frames on two ports; paths map under ROOT."""
|
||||
import base64, json, os, shutil, socket, struct, sys, threading
|
||||
|
||||
ROOT = sys.argv[1]; MPORT = int(sys.argv[2]); XPORT = int(sys.argv[3])
|
||||
ALLOWED = ("/data/", "/user/", "/mnt/")
|
||||
txs = {}
|
||||
ftp_started = []
|
||||
|
||||
def P(p): return os.path.join(ROOT, p.lstrip("/"))
|
||||
def allowed(p): return p.startswith(ALLOWED) and ".." not in p
|
||||
|
||||
def recv_exact(s, n):
|
||||
b = b""
|
||||
while len(b) < n:
|
||||
c = s.recv(n - len(b))
|
||||
if not c: raise EOFError
|
||||
b += c
|
||||
return b
|
||||
|
||||
def send(s, t, body=b""):
|
||||
if isinstance(body, str): body = body.encode()
|
||||
s.sendall(struct.pack("<IHHIQQ", 0x32585446, 1, t, 0, len(body), 0) + body)
|
||||
|
||||
def err(s, m): send(s, 3, m)
|
||||
|
||||
def handle(s, t, body):
|
||||
j = {}
|
||||
if body[:1] == b"{":
|
||||
try: j = json.loads(body)
|
||||
except Exception: pass
|
||||
if t == 224: # FTP_START - the payload's built-in FTP server (needs: pip install pyftpdlib)
|
||||
port = int(j.get("port") or 2122)
|
||||
if not ftp_started:
|
||||
from pyftpdlib.authorizers import DummyAuthorizer
|
||||
from pyftpdlib.handlers import FTPHandler
|
||||
from pyftpdlib.servers import FTPServer
|
||||
import logging; logging.disable(logging.CRITICAL)
|
||||
auth = DummyAuthorizer(); auth.add_anonymous(ROOT, perm="elradfmwM")
|
||||
h = FTPHandler; h.authorizer = auth
|
||||
srv = FTPServer(("127.0.0.1", port), h)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
ftp_started.append(port)
|
||||
return send(s, 225, json.dumps({"ok": True, "port": port, "root": "/"}))
|
||||
return send(s, 225, json.dumps({"ok": False, "error": "already_running", "port": ftp_started[0]}))
|
||||
if t == 62: # APP_LIST_REGISTERED
|
||||
apps = []
|
||||
base = P("/user/app")
|
||||
for tid in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
||||
lnk = os.path.join(base, tid, "mount.lnk"); src = open(lnk).read() if os.path.exists(lnk) else ""
|
||||
name = tid
|
||||
pj = os.path.join(base, tid, "sce_sys", "param.json")
|
||||
if os.path.exists(pj):
|
||||
name = json.load(open(pj))["localizedParameters"]["en-US"]["titleName"]
|
||||
apps.append({"title_id": tid, "title_name": name, "src": src, "image_backed": False})
|
||||
return send(s, 63, json.dumps({"apps": apps}))
|
||||
if t == 120: # APPDB_QUERY - names from app.db (mocked by ROOT/appdb.json)
|
||||
dbp = os.path.join(ROOT, "appdb.json") # {"apps":[{"title_id":..,"app_id":..,"name":..}]}
|
||||
if os.environ.get("MOCK_NO_APPDB") or not os.path.exists(dbp): return err(s, "unsupported_in_mock")
|
||||
return send(s, 121, open(dbp).read())
|
||||
if t == 48: # FS_READ
|
||||
p = j.get("path", "")
|
||||
if not allowed(p): return err(s, "fs_read_path_not_allowed")
|
||||
if not os.path.isfile(P(p)): return err(s, "fs_read_stat_failed")
|
||||
off = j.get("offset", 0); lim = min(j.get("limit", 2 << 20) or (2 << 20), 2 << 20)
|
||||
with open(P(p), "rb") as f: f.seek(off); return send(s, 49, f.read(lim))
|
||||
if t == 36: # FS_LIST_DIR
|
||||
p = j.get("path", "")
|
||||
if not os.path.isdir(P(p)): return err(s, "fs_list_dir_opendir_errno_2")
|
||||
ents = [{"name": n, "kind": "dir" if os.path.isdir(os.path.join(P(p), n)) else "file",
|
||||
"size": os.path.getsize(os.path.join(P(p), n)), "mtime": 0} for n in sorted(os.listdir(P(p)))]
|
||||
return send(s, 37, json.dumps({"path": p, "entries": ents, "truncated": False}))
|
||||
if t == 50: # FS_COPY
|
||||
a, b = j.get("from", ""), j.get("to", "")
|
||||
if not (allowed(a) and allowed(b)): return err(s, "fs_copy_path_not_allowed")
|
||||
if not os.path.exists(P(a)): return err(s, "fs_copy_source_missing")
|
||||
ow = j.get("overwrite", 0)
|
||||
if os.path.exists(P(b)) and not (isinstance(ow, int) and not isinstance(ow, bool) and ow != 0): return err(s, "fs_copy_dest_exists")
|
||||
shutil.copyfile(P(a), P(b)); return send(s, 51)
|
||||
if t == 40: # FS_DELETE
|
||||
p = j.get("path", "")
|
||||
if not allowed(p): return err(s, "fs_delete_path_not_allowed")
|
||||
try: os.remove(P(p))
|
||||
except FileNotFoundError: return err(s, "fs_delete_failed")
|
||||
return send(s, 41)
|
||||
if t == 130: # FS_WRITE_BYTES
|
||||
p = j.get("path", "")
|
||||
if not allowed(p): return send(s, 131, '{"ok":false,"err":"path_unsafe"}')
|
||||
data = base64.b64decode(j["bytes"])
|
||||
open(P(p), "wb").write(data); return send(s, 131, '{"ok":true,"size":%d}' % len(data))
|
||||
if t == 56: # APP_REGISTER - stage sce_sys like register.c
|
||||
src = j.get("src_path", "").rstrip("/")
|
||||
pj = json.load(open(P(src + "/sce_sys/param.json"))); tid = pj["titleId"]
|
||||
ua = P("/user/app/" + tid); meta = P("/user/appmeta/" + tid)
|
||||
os.makedirs(ua, exist_ok=True); os.makedirs(meta, exist_ok=True)
|
||||
shutil.copytree(P(src + "/sce_sys"), ua + "/sce_sys", dirs_exist_ok=True)
|
||||
if os.path.exists(P(src + "/sce_sys/icon0.png")): shutil.copyfile(P(src + "/sce_sys/icon0.png"), ua + "/icon0.png")
|
||||
for n in os.listdir(P(src + "/sce_sys")):
|
||||
if n == "param.json" or n.lower().endswith((".png", ".dds", ".at9")):
|
||||
shutil.copyfile(P(src + "/sce_sys/" + n), os.path.join(meta, n))
|
||||
open(ua + "/mount.lnk", "w").write(src)
|
||||
print("REGISTER", tid, "from", src, flush=True)
|
||||
if os.environ.get("MOCK_FW13"): return err(s, "register_install_api_unavailable")
|
||||
return send(s, 57, json.dumps({"title_id": tid, "title_name": tid, "used_nullfs": True}))
|
||||
if t == 10: # BEGIN_TX
|
||||
tx = body[:16]; man = json.loads(body[24:])
|
||||
txs[tx] = {"dest": man["dest_root"], "data": b""}; return send(s, 11, '{"accepted":true,"last_acked_shard":0}')
|
||||
if t == 30: # STREAM_SHARD
|
||||
tx = body[:16]; txs[tx]["data"] += body[64:]
|
||||
return send(s, 31, tx + struct.pack("<Q", 1) + bytes([1]) + bytes(7) + struct.pack("<QQ", len(body) - 64, 1))
|
||||
if t == 14: # COMMIT_TX
|
||||
tx = body[:16]; d = txs.pop(tx)
|
||||
if not allowed(d["dest"]): return err(s, "path_not_allowed")
|
||||
os.makedirs(os.path.dirname(P(d["dest"])), exist_ok=True)
|
||||
open(P(d["dest"]), "wb").write(d["data"]); print("WROTE", d["dest"], len(d["data"]), flush=True)
|
||||
return send(s, 15, '{"committed":true}')
|
||||
return err(s, "unsupported_in_mock")
|
||||
|
||||
def serve_conn(s):
|
||||
try:
|
||||
while True:
|
||||
h = recv_exact(s, 28)
|
||||
magic, ver, t, flags, blen, trace = struct.unpack("<IHHIQQ", h)
|
||||
handle(s, t, recv_exact(s, blen) if blen else b"")
|
||||
except (EOFError, ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def listen(port):
|
||||
ls = socket.socket(); ls.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
ls.bind(("127.0.0.1", port)); ls.listen(16)
|
||||
while True:
|
||||
c, _ = ls.accept(); threading.Thread(target=serve_conn, args=(c,), daemon=True).start()
|
||||
|
||||
threading.Thread(target=listen, args=(XPORT,), daemon=True).start()
|
||||
print("mock ps5 up", flush=True)
|
||||
listen(MPORT)
|
||||
Reference in new issue
Block a user