6 Commits
Author SHA1 Message Date
Claude 96496e1054 Read files in 2 MiB pieces
PS5 Upload caps one fs.read at 2 MiB, so an 8 MiB ask came back short and
read_file stopped there: app.db over 2 MiB arrived truncated ("database
disk image is malformed"), and so did large backgrounds. The mock now
applies the same cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019sEKffq7xSxkEwHu87bM8X
2026-10-11 16:52:04 +00:00
Claude d612f0dc38 Give the app list and app database query up to 2 minutes
Over AVA1 they take 10-75 s on firmware 13.60.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019sEKffq7xSxkEwHu87bM8X
2026-10-11 16:30:10 +00:00
Claude e1c6edb7bb Work with PS5 Upload 6.x and 7.x (AVA1)
PS5 Upload 6.0 removed the FTX2 protocol (ports 9113/9114) that PSGFX
spoke, and 6.8.2 removed the built-in FTP server Home layout used, so
PSGFX could no longer reach the console ("Can't reach the PS5 at
...:9114", issue #5).

PSGFX now sends each command to the PS5 Upload app's local engine
(POST 127.0.0.1:19113/api/mgmt/call), which runs it over its own paired
AVA1 session with the same JSON bodies as before. app.db is read
directly and written by uploading to /data/psgfx and copying it into
place with PS5 Upload's console shell, then read back to verify.

tools/mock_engine.py replaces the FTX2 mock. Version 1.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019sEKffq7xSxkEwHu87bM8X
2026-10-11 16:28:07 +00:00
Claude 4f059dc32b Treat games mounted from an image as games, not homebrew
PS5 Upload lists ShadowMount+ (/mnt/shadowmnt) and its own image mounts as
registered apps with a src folder, so PSGFX took them for homebrew and tried
to upload art into the image's sce_sys, which is read-only or nearly full
(preflight_insufficient_space). Those now use the installed-game path
(appmeta + app.db), including on the server side if the UI sends homebrew.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJYARztgmsQF7j7jtSCpZg
2026-10-03 20:27:05 +00:00
Claude a1fc21a34f Rename from the Art view; fix art for installed games
Installed games: the home screen draws tiles from the icon0Info/pic0Info
paths in app.db, which for many games point inside the read-only game
package, so replacing files in /user/appmeta changed nothing or was refused.
PSGFX now writes the image to /user/appmeta/<id>/psgfx_* and points the
database at it (backup first, originals kept for Restore). Games whose art
really lives in appmeta keep the in-place replace.

Art view: a name field renames the tile through the same app.db edit Home
layout uses; homebrew param.json is updated too so re-registering keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJYARztgmsQF7j7jtSCpZg
2026-10-03 20:16:45 +00:00
Claude a5894f49f9 Import PSGFX 1.1 source (Home layout tab)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJYARztgmsQF7j7jtSCpZg
2026-10-03 20:08:04 +00:00
13 changed files with 285539 additions and 360 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ psgfx.json
ps5-art-studio.json
__pycache__/
fake-ps5/
PSGFX Layout/
+31 -13
View File
@@ -6,9 +6,9 @@
<a href="../../releases/latest"><b>Download PSGFX for Windows</b></a>
</p>
PSGFX gives your PS5 homebrew and games new home-screen art. 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.
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`.
![Previewing a new icon and background before applying](docs/screenshot-preview.png)
@@ -18,26 +18,42 @@ It works through [PS5 Upload](https://github.com/phantomptr/ps5upload)'s payload
- **Custom backgrounds.** Any 16:9 image becomes the backdrop behind the app on the home screen. Homebrew gets full 4K backgrounds.
- **Homebrew and installed games.** Homebrew apps registered with PS5 Upload show up with their icons, and so do your installed PS4 and PS5 games, with their real names.
- **Live preview.** A replica of the PS5 home screen shows your art before anything touches the console. Drop an image on the tile to set the icon, or anywhere else to set the background.
- **Rename from the Art view.** Change a homebrew app's or game's home-screen name right next to its art. For homebrew, PSGFX also updates the name in the app's `param.json` so changing its art later keeps the new name.
- **One-click restore.** The first time you change something, PSGFX saves the original next to it. **Restore original art** puts it back exactly.
- **Clean list.** System apps and leftovers are tucked away under *Show other items*.
- **Home layout (new in 1.1).** Hide any tile (PlayStation Store, Game Library, PS Plus, media apps), rename games and apps, move apps between the Games and Media tabs, and reorder your games by dragging or with A–Z / Z–A.
- **Lock order.** Keeps your arrangement in place even after you play other games.
- **Layout presets.** Save a layout when you apply it and re-apply it in one click after a firmware update or database rebuild resets the home screen.
- **Home-screen backups.** PSGFX saves the home-screen database before every layout change, plus a copy of the very first one it read. Restore any of them from the Backups dialog.
- **No setup.** A single portable Windows app that opens in your browser. It remembers your PS5's IP address.
## 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**.
### Home layout
1. Connect, then click **Home layout** at the top.
2. Pick a tile in the strip under the preview. Choose **Games**, **Media** or **Hidden**, or type a new name.
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. 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.
![Homebrew app with its current background](docs/screenshot-homebrew.png)
## Tips
@@ -51,7 +67,7 @@ To undo, select the app and click **Restore original art**.
## Build from source
PSGFX is plain C++17 with no external dependencies. Everything it needs is in `src/lib`.
PSGFX is plain C++17 with no external dependencies. Everything it needs is in `src/lib` (including the SQLite amalgamation used for Home layout).
```bash
# Linux / WSL: produces build/PSGFX.exe
@@ -66,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. 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
Regular → Executable
+5 -2
View File
@@ -8,13 +8,15 @@ cd "$(dirname "$0")"
mkdir -p build
BLAKE3_FLAGS="-O2 -DBLAKE3_NO_SSE2 -DBLAKE3_NO_SSE41 -DBLAKE3_NO_AVX2 -DBLAKE3_NO_AVX512"
SQLITE_FLAGS="-O2 -DSQLITE_THREADSAFE=1 -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_DEFAULT_MEMSTATUS=0"
SOURCES="src/main.cpp src/ui.cpp src/assets_embed.cpp src/lib/bc7enc.cpp src/lib/bc7decomp.cpp"
if [[ "${1:-}" == "linux" ]]; then
for f in src/lib/blake3.c src/lib/blake3_dispatch.c src/lib/blake3_portable.c; do
gcc $BLAKE3_FLAGS -c "$f" -o "build/$(basename "${f%.c}").o"
done
g++ -O2 -std=c++17 -pthread $SOURCES build/blake3*.o -o build/psgfx
[[ -f build/sqlite3.o ]] || gcc $SQLITE_FLAGS -c src/lib/sqlite3.c -o build/sqlite3.o
g++ -O2 -std=c++17 -pthread $SOURCES build/blake3*.o build/sqlite3.o -o build/psgfx
echo "Built build/psgfx"
exit 0
fi
@@ -23,8 +25,9 @@ if command -v x86_64-w64-mingw32-g++ >/dev/null; then P=x86_64-w64-mingw32-; els
for f in src/lib/blake3.c src/lib/blake3_dispatch.c src/lib/blake3_portable.c; do
${P}gcc $BLAKE3_FLAGS -c "$f" -o "build/$(basename "${f%.c}").o"
done
[[ -f build/sqlite3_win.o ]] || ${P}gcc $SQLITE_FLAGS -c src/lib/sqlite3.c -o build/sqlite3_win.o
(cd src && ${P}windres psgfx.rc -O coff -o ../build/psgfx_res.o)
${P}g++ -O2 -std=c++17 -static -static-libgcc -static-libstdc++ -pthread \
$SOURCES build/blake3*.o build/psgfx_res.o -lws2_32 -lshell32 -o build/PSGFX.exe
$SOURCES build/blake3*.o build/sqlite3_win.o build/psgfx_res.o -lws2_32 -lshell32 -o build/PSGFX.exe
${P}strip build/PSGFX.exe
echo "Built build/PSGFX.exe"
+37 -26
View File
@@ -1,26 +1,37 @@
PSGFX 1.0
Custom icons and backgrounds for your PS5 homebrew and games.
BEFORE YOU START
- PS5 Upload's payload must be running on your PS5 (the same setup you use to upload homebrew).
- Your PC and PS5 must be on the same network.
STEPS
1. Double-click PSGFX.exe. PSGFX opens in your browser.
(If Windows SmartScreen warns, click "More info" > "Run anyway".
If the firewall asks, allow it on private networks.)
2. Type your PS5's IP address and click Connect.
3. Pick a homebrew app or an installed game from the list.
4. Drop an image on the preview's tile (icon) or background (background),
or use the "Choose image" buttons. Any PNG or JPEG works.
5. Close that app or game on the PS5, then click "Apply to PS5".
6. Restart the PS5 to see the new art.
UNDO
- Select the app and click "Restore original art".
TIPS
- Best results: icons 512x512 or larger, backgrounds 3840x2160.
- A game update may bring back that game's original art. Just apply yours again.
- Your PS5 IP is saved in psgfx.json next to PSGFX.exe.
- To quit, close the black console window.
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. Double-click PSGFX.exe. PSGFX opens in your browser.
(If Windows SmartScreen warns, click "More info" > "Run anyway".
If the firewall asks, allow it on private networks.)
2. Type your PS5's IP address and click Connect.
3. Pick a homebrew app or an installed game from the list.
4. Drop an image on the preview's tile (icon) or background (background),
or use the "Choose image" buttons. Any PNG or JPEG works.
5. Close that app or game on the PS5, then click "Apply to PS5".
6. Restart the PS5 to see the new art.
UNDO
- Select the app and click "Restore original art".
HOME LAYOUT (new in 1.1)
1. Connect, then click "Home layout" at the top.
2. Select a tile: choose Games, Media or Hidden, or rename it.
3. Drag games into order or use A-Z / Z-A. Tick "Lock order" to keep it.
4. Click "Apply to PS5" (optionally save it as a preset), then restart the PS5.
- Backups and presets live in the "PSGFX Layout" folder next to PSGFX.exe.
Use "Backups" to undo, or Safe Mode > Rebuild Database as a last resort.
- After a firmware update, open "Presets", load your layout and apply it again.
TIPS
- Best results: icons 512x512 or larger, backgrounds 3840x2160.
- A game update may bring back that game's original art. Just apply yours again.
- Your PS5 IP is saved in psgfx.json next to PSGFX.exe.
- To quit, close the black console window.
+361
View File
@@ -0,0 +1,361 @@
// Home layout: hide, rename, move between Games/Media and reorder home-screen tiles by
// editing the console's home-screen database, /system_data/priv/mms/app.db.
//
// Schema (per console user <u>):
// tbl_iconinfo_<u> one row per tile: titleId, titleName, dispLocation, visible,
// lastAccessIndex/Time, lastPlayed*, recentActivity*
// tbl_concepticoninfo_<u> per-concept name/art: conceptName, icon0Info, pic0Info
// tbl_group_<u> groupName 'MyHome': which system tiles (PS Plus, Store,
// Game Library) appear on the Games row
// tbl_contentinfo, tbl_conceptmetadata global copies of name/location/recency
// tbl_version counters: access_index, played_index
// dispLocation is a bit field: 0x02 = Games home, 0x04 = Media home. Only those two
// bits are ever changed. The home screen sorts by play history, so ordering sets every
// recency field consistently.
#pragma once
#include "lib/sqlite3.h"
#include "lib/json.hpp"
#include <algorithm>
#include <cstring>
#include <ctime>
#include <map>
#include <set>
#include <stdexcept>
#include <string>
#include <variant>
#include <vector>
namespace layout {
using json = nlohmann::json;
struct Error : std::runtime_error { using std::runtime_error::runtime_error; };
const char *const DB_PATH = "/system_data/priv/mms/app.db";
const char *const DB_DIR = "/system_data/priv/mms";
const char *const LIBRARY = "NPXS40071";
constexpr int64_t BIT_GAMES = 0x02, BIT_MEDIA = 0x04;
// Disc placeholders and similar internal tiles; never shown.
static const std::set<std::string> kInternal = {"NPXS40140", "NPXS40144", "NPXS40145", "NPXS40148",
"NPXS40149", "NPXS40150", "NPXS40205", "NPXS40106", "NPXS40063"};
using Val = std::variant<std::nullptr_t, int64_t, std::string>;
// An app.db loaded into memory. Nothing touches a file until serialize().
class DB {
public:
explicit DB(const std::vector<uint8_t> &data) {
if (sqlite3_open(":memory:", &db_) != SQLITE_OK) throw Error("Couldn't start SQLite");
auto *buf = (unsigned char *)sqlite3_malloc64(data.size());
if (!buf) throw Error("Out of memory");
memcpy(buf, data.data(), data.size());
if (sqlite3_deserialize(db_, "main", buf, (sqlite3_int64)data.size(), (sqlite3_int64)data.size(),
SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE) != SQLITE_OK)
throw Error("That isn't a readable home-screen database");
std::string ok = scalar_str("PRAGMA integrity_check");
if (ok != "ok") throw Error("The home-screen database failed its integrity check: " + ok);
}
~DB() { if (db_) sqlite3_close(db_); }
DB(const DB &) = delete; DB &operator=(const DB &) = delete;
std::vector<uint8_t> serialize() {
sqlite3_int64 n = 0;
unsigned char *p = sqlite3_serialize(db_, "main", &n, 0);
if (!p) throw Error("Couldn't save the database");
std::vector<uint8_t> out(p, p + n);
sqlite3_free(p);
return out;
}
void exec(const std::string &sql, const std::vector<Val> &args = {}) {
sqlite3_stmt *st = prepare(sql, args);
int rc = sqlite3_step(st);
sqlite3_finalize(st);
if (rc != SQLITE_DONE && rc != SQLITE_ROW) throw Error(std::string("Database error: ") + sqlite3_errmsg(db_));
}
// Runs a query, calling fn(stmt) for every row.
template <class F> void each(const std::string &sql, const std::vector<Val> &args, F fn) {
sqlite3_stmt *st = prepare(sql, args);
int rc;
while ((rc = sqlite3_step(st)) == SQLITE_ROW) fn(st);
sqlite3_finalize(st);
if (rc != SQLITE_DONE) throw Error(std::string("Database error: ") + sqlite3_errmsg(db_));
}
std::string scalar_str(const std::string &sql, const std::vector<Val> &args = {}) {
std::string out;
each(sql, args, [&](sqlite3_stmt *st) { out = text(st, 0); });
return out;
}
int64_t scalar_int(const std::string &sql, const std::vector<Val> &args = {}) {
int64_t out = 0;
each(sql, args, [&](sqlite3_stmt *st) { out = sqlite3_column_int64(st, 0); });
return out;
}
static std::string text(sqlite3_stmt *st, int i) {
const unsigned char *t = sqlite3_column_text(st, i);
return t ? std::string((const char *)t) : std::string();
}
static bool null(sqlite3_stmt *st, int i) { return sqlite3_column_type(st, i) == SQLITE_NULL; }
private:
sqlite3 *db_ = nullptr;
sqlite3_stmt *prepare(const std::string &sql, const std::vector<Val> &args) {
sqlite3_stmt *st = nullptr;
if (sqlite3_prepare_v2(db_, sql.c_str(), -1, &st, nullptr) != SQLITE_OK)
throw Error(std::string("Database error: ") + sqlite3_errmsg(db_));
for (size_t i = 0; i < args.size(); i++) {
int k = (int)i + 1;
if (auto *n = std::get_if<int64_t>(&args[i])) sqlite3_bind_int64(st, k, *n);
else if (auto *s = std::get_if<std::string>(&args[i])) sqlite3_bind_text(st, k, s->c_str(), (int)s->size(), SQLITE_TRANSIENT);
else sqlite3_bind_null(st, k);
}
return st;
}
};
inline std::string q(const std::string &id) {
std::string o = "\"";
for (char c : id) { if (c == '"') o += '"'; o += c; }
return o + "\"";
}
inline std::string strip_query(const std::string &p) { size_t i = p.find('?'); return i == std::string::npos ? p : p.substr(0, i); }
struct Tile {
std::string id, name, concept, kind, where, icon, bg;
int64_t disp = 0, home_idx = 0, access = 0;
bool visible = false, home = false;
};
struct Info {
std::string user, icon_t, concept_t, group_t;
std::vector<std::string> users, warnings;
std::vector<Tile> tiles;
std::vector<std::pair<std::string, int64_t>> ghosts; // MyHome rows for titles not on the console
std::map<std::string, std::set<std::string>> cols;
bool has_content = false, has_meta = false, can_order = false;
int64_t access_index = 0, played_index = 0;
const Tile *find(const std::string &id) const {
for (auto &t : tiles) if (t.id == id) return &t;
return nullptr;
}
json to_json() const {
json ts = json::array();
for (auto &t : tiles)
ts.push_back({{"id", t.id}, {"name", t.name}, {"kind", t.kind}, {"where", t.where}, {"visible", t.visible},
{"home", t.home}, {"homeIdx", t.home_idx}, {"access", t.access}, {"icon", t.icon}, {"bg", t.bg}});
return {{"user", user}, {"users", users}, {"tiles", ts}, {"warnings", warnings}, {"canOrder", can_order}};
}
};
inline Info analyze(DB &db, const std::string &want_user = "") {
Info info;
std::set<std::string> tables;
db.each("SELECT name FROM sqlite_master WHERE type='table'", {}, [&](sqlite3_stmt *st) { tables.insert(DB::text(st, 0)); });
int best = -1;
for (auto &t : tables) {
if (t.rfind("tbl_iconinfo_", 0) != 0) continue;
std::string u = t.substr(13);
info.users.push_back(u);
int n = (int)db.scalar_int("SELECT count(*) FROM " + q(t));
if (u == want_user) { info.user = u; best = 1 << 30; }
else if (want_user.empty() && n > best) { best = n; info.user = u; }
}
if (info.user.empty()) throw Error("This console's home-screen database has a layout PSGFX doesn't know yet (no tbl_iconinfo_ tables).");
std::sort(info.users.begin(), info.users.end());
info.icon_t = "tbl_iconinfo_" + info.user;
info.concept_t = tables.count("tbl_concepticoninfo_" + info.user) ? "tbl_concepticoninfo_" + info.user : "";
info.group_t = tables.count("tbl_group_" + info.user) ? "tbl_group_" + info.user : "";
info.has_content = tables.count("tbl_contentinfo") > 0;
info.has_meta = tables.count("tbl_conceptmetadata") > 0;
if (info.users.size() > 1)
info.warnings.push_back("This console has " + std::to_string(info.users.size()) + " user profiles. You're editing the one with the most tiles; switch with the profile menu.");
if (tables.count("tbl_version")) {
info.access_index = db.scalar_int("SELECT status FROM tbl_version WHERE category='access_index'");
info.played_index = db.scalar_int("SELECT status FROM tbl_version WHERE category='played_index'");
}
for (auto &t : tables)
db.each("SELECT name FROM pragma_table_info(?)", {t}, [&](sqlite3_stmt *st) { info.cols[t].insert(DB::text(st, 0)); });
struct Concept { std::string name, icon, bg; };
std::map<std::string, Concept> concepts;
if (!info.concept_t.empty())
db.each("SELECT localConceptId, conceptName, icon0Info, pic0Info FROM " + q(info.concept_t), {}, [&](sqlite3_stmt *st) {
concepts[DB::text(st, 0)] = {DB::text(st, 1), strip_query(DB::text(st, 2)), strip_query(DB::text(st, 3))};
});
std::map<std::string, int64_t> home;
if (!info.group_t.empty())
db.each("SELECT itemId, idx FROM " + q(info.group_t) + " WHERE groupName='MyHome' ORDER BY idx", {}, [&](sqlite3_stmt *st) {
home[DB::text(st, 0)] = sqlite3_column_int64(st, 1);
});
std::set<std::string> seen;
db.each("SELECT titleId, titleName, localConceptId, dispLocation, visible, lastAccessIndex, installStatus FROM " + q(info.icon_t), {},
[&](sqlite3_stmt *st) {
Tile t;
t.id = DB::text(st, 0);
if (t.id.empty() || kInternal.count(t.id)) return;
seen.insert(t.id);
t.name = DB::text(st, 1); t.concept = DB::text(st, 2);
t.disp = sqlite3_column_int64(st, 3); t.visible = sqlite3_column_int64(st, 4) == 1;
t.access = sqlite3_column_int64(st, 5);
int64_t install = sqlite3_column_int64(st, 6);
auto c = concepts.find(t.concept);
if (c != concepts.end()) { if (!c->second.name.empty()) t.name = c->second.name; t.icon = c->second.icon; t.bg = c->second.bg; }
t.kind = t.id.rfind("NPXS", 0) == 0 ? "system" : "game";
auto h = home.find(t.id); // the console only honours MyHome for its own system tiles
if (h != home.end() && t.kind == "system") { t.home = true; t.home_idx = h->second; }
if (t.home) t.where = "games";
else if (!t.visible) t.where = "hidden";
else if (t.disp & BIT_GAMES) t.where = "games";
else if (t.disp & BIT_MEDIA) t.where = "media";
else t.where = "hidden";
if (t.where == "hidden" && install != 2) return; // uninstalled leftovers
info.tiles.push_back(t);
});
for (auto &[id, idx] : home) if (!seen.count(id)) info.ghosts.push_back({id, idx});
std::sort(info.ghosts.begin(), info.ghosts.end(), [](auto &a, auto &b) { return a.second < b.second; });
std::stable_sort(info.tiles.begin(), info.tiles.end(), [](const Tile &a, const Tile &b) { return a.access > b.access; });
info.can_order = info.access_index > 0;
return info;
}
inline std::string fmt_time(time_t t) {
char b[32]; tm g{};
#ifdef _WIN32
gmtime_s(&g, &t);
#else
gmtime_r(&t, &g);
#endif
strftime(b, sizeof b, "%Y-%m-%d %H:%M:%S.000", &g);
return b;
}
inline void insert_before_library(std::vector<std::string> &v, const std::string &id) {
if (std::find(v.begin(), v.end(), id) != v.end()) return;
auto it = std::find(v.begin(), v.end(), LIBRARY);
v.insert(it, id);
}
// req: {edits: {id: {where?, name?}}, recent: [ids], recentChanged, lock}
inline void apply(DB &db, const Info &info, const json &req, std::vector<std::string> &log) {
db.exec("BEGIN");
try {
struct Target { std::string table, key; bool by_concept; };
std::vector<Target> targets = {{info.icon_t, "titleId", false}};
if (!info.concept_t.empty()) targets.push_back({info.concept_t, "localConceptId", true});
if (info.has_content) targets.push_back({"tbl_contentinfo", "titleId", false});
if (info.has_meta) targets.push_back({"tbl_conceptmetadata", "localConceptId", true});
auto key_of = [](const Tile &t, const Target &tg) { return tg.by_concept ? t.concept : t.id; };
auto has = [&](const std::string &table, const std::string &col) {
auto it = info.cols.find(table); return it != info.cols.end() && it->second.count(col) > 0;
};
std::vector<std::string> home_order;
for (auto &t : info.tiles) if (t.home) home_order.push_back(t.id);
std::sort(home_order.begin(), home_order.end(), [&](auto &a, auto &b) { return info.find(a)->home_idx < info.find(b)->home_idx; });
bool home_changed = false;
const json edits = req.value("edits", json::object());
for (auto &[id, e] : edits.items()) {
const Tile *t = info.find(id);
if (!t || !e.is_object()) continue;
std::vector<std::string> did;
std::string where = e.value("where", "");
if (where == "hidden") {
if (t->home) { home_order.erase(std::remove(home_order.begin(), home_order.end(), id), home_order.end()); home_changed = true; }
db.exec("UPDATE " + q(info.icon_t) + " SET visible=0 WHERE titleId=?", {id});
did.push_back("hidden");
} else if (where == "games" || where == "media") {
int64_t set = where == "games" ? BIT_GAMES : BIT_MEDIA, clr = where == "games" ? BIT_MEDIA : BIT_GAMES;
for (auto &tg : targets)
if (has(tg.table, "dispLocation"))
db.exec("UPDATE " + q(tg.table) + " SET dispLocation=((dispLocation | ?) & ~?) WHERE " + q(tg.key) + "=?", {set, clr, key_of(*t, tg)});
db.exec("UPDATE " + q(info.icon_t) + " SET visible=1 WHERE titleId=?", {id});
if (t->kind == "system" && where == "games" && !t->home) { insert_before_library(home_order, id); home_changed = true; }
did.push_back("shown on " + where);
}
if (e.contains("name") && e["name"].is_string()) {
std::string n = e["name"].get<std::string>();
while (!n.empty() && n.back() == ' ') n.pop_back();
if (!n.empty()) {
db.exec("UPDATE " + q(info.icon_t) + " SET titleName=? WHERE titleId=?", {n, id});
if (!info.concept_t.empty()) {
std::string set = "conceptName=?";
std::vector<Val> args = {n};
if (has(info.concept_t, "primaryTitleName")) { set += ", primaryTitleName=?"; args.push_back(n); }
if (has(info.concept_t, "ucmConceptName")) { set += ", ucmConceptName=CASE WHEN ucmConceptName IS NULL THEN NULL ELSE ? END"; args.push_back(n); }
args.push_back(t->concept);
db.exec("UPDATE " + q(info.concept_t) + " SET " + set + " WHERE localConceptId=?", args);
}
if (info.has_content) db.exec("UPDATE tbl_contentinfo SET titleName=? WHERE titleId=?", {n, id});
if (info.has_meta) db.exec("UPDATE tbl_conceptmetadata SET conceptName=? WHERE localConceptId=?", {n, t->concept});
did.push_back("renamed to \"" + n + "\"");
}
}
if (!did.empty()) {
std::string s; for (auto &d : did) s += (s.empty() ? "" : ", ") + d;
log.push_back(t->name + ": " + s);
}
}
if (home_changed && !info.group_t.empty()) {
std::vector<std::string> fin = home_order;
for (auto &g : info.ghosts) {
if (!fin.empty() && fin.front() == LIBRARY) fin.push_back(g.first);
else insert_before_library(fin, g.first);
}
db.exec("DELETE FROM " + q(info.group_t) + " WHERE groupName='MyHome'");
for (size_t i = 0; i < fin.size(); i++)
db.exec("INSERT INTO " + q(info.group_t) + " (itemId, groupName, type, idx, lastModifiedDate) VALUES (?, 'MyHome', 'title', ?, NULL)",
{fin[i], (int64_t)i + 1});
}
std::vector<std::string> recent;
const json recent_in = req.value("recent", json::array());
for (auto &x : recent_in) if (x.is_string()) recent.push_back(x.get<std::string>());
if (req.value("recentChanged", false) && info.can_order && !recent.empty()) {
bool lock = req.value("lock", false);
int64_t n = (int64_t)recent.size();
time_t base = time(nullptr);
int64_t acc = info.access_index + n, played = info.played_index + n;
if (lock) { base = 4102401600; acc = 1000000000; played = 1000000000; } // 2099-12-31 12:00 UTC
static const char *always[] = {"lastAccessTime", "recentActivityDatePlayedOrInstalled", "lastInteractedTime"};
static const char *played_only[] = {"lastPlayedDate", "lastPlayedDateOnConsole", "recentActivityDatePlayedOrPurchased"};
std::string first;
for (int64_t i = 0; i < n; i++) {
const Tile *t = info.find(recent[(size_t)i]);
if (!t) continue;
if (first.size() < 120) first += (first.empty() ? "" : " > ") + t->name;
std::string when = fmt_time(base - i * 60);
for (auto &tg : targets) {
std::string set; std::vector<Val> args;
auto add = [&](const std::string &s, Val v) { set += (set.empty() ? "" : ", ") + s; args.push_back(std::move(v)); };
if (has(tg.table, "lastAccessIndex")) add("lastAccessIndex=?", acc - i);
if (has(tg.table, "lastPlayedIndex")) add("lastPlayedIndex=CASE WHEN lastPlayedIndex IS NULL THEN NULL ELSE ? END", played - i);
for (auto c : always) if (has(tg.table, c)) add(q(c) + "=?", when);
for (auto c : played_only) if (has(tg.table, c)) add(q(c) + "=CASE WHEN " + q(c) + " IS NULL THEN NULL ELSE ? END", when);
if (set.empty()) continue;
args.push_back(key_of(*t, tg));
db.exec("UPDATE " + q(tg.table) + " SET " + set + " WHERE " + q(tg.key) + "=?", args);
}
}
if (!lock) {
db.exec("UPDATE tbl_version SET status=? WHERE category='access_index'", {acc});
db.exec("UPDATE tbl_version SET status=? WHERE category='played_index'", {played});
}
log.push_back(std::string(lock ? "Order locked" : "Order set") + " for " + std::to_string(n) + " tiles: " + first + (n > 4 ? " > ..." : ""));
}
db.exec("COMMIT");
} catch (...) {
try { db.exec("ROLLBACK"); } catch (...) {}
throw;
}
std::string ok = db.scalar_str("PRAGMA integrity_check");
if (ok != "ok") throw Error("The edited database failed its integrity check, so nothing was uploaded: " + ok);
}
} // namespace layout
+269649
View File
File diff suppressed because it is too large. Load diff
+14349
View File
File diff suppressed because it is too large. Load diff
+458 -36
View File
@@ -1,13 +1,15 @@
// 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 "layout.h"
#include "lib/json.hpp"
#include <atomic>
@@ -18,11 +20,14 @@
#include <set>
#include <algorithm>
#include <sstream>
#include <chrono>
#include <filesystem>
#include <memory>
#ifdef _WIN32
#include <shellapi.h>
#include <tlhelp32.h>
#endif
#define STUDIO_VERSION "1.0"
#define STUDIO_VERSION "1.2"
using json = nlohmann::json;
@@ -38,48 +43,70 @@ 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";
static std::string g_legacy_config_path; // pre-1.0 name, read once if present
static std::string g_data_dir = "PSGFX Layout"; // home-layout backups and presets, next to the exe
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;
}
static std::string lower(std::string s) { for (auto &ch : s) ch = (char)tolower((unsigned char)ch); return s; }
static bool ends_with(const std::string &s, const std::string &t) { return s.size() >= t.size() && s.compare(s.size() - t.size(), t.size(), t) == 0; }
static std::string appmeta(const std::string &id) { return "/user/appmeta/" + id; }
// Games PS5 Upload runs from a mounted image (ShadowMount+, or its own mounts). Their sce_sys is
// inside the image, read-only or nearly full, so their art is changed like an installed game's.
static bool mounted_image(const std::string &src) {
return src.rfind("/mnt/shadowmnt/", 0) == 0 || src.rfind("/mnt/ps5upload/", 0) == 0;
}
// ─────────────────────────────── 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.
@@ -112,27 +139,195 @@ static std::string meta_title(ps5::Client &c, const std::string &id) {
return "";
}
// ─────────────────────────────── home layout ──────────────────────────
// 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::string g_layout_user;
static std::map<std::string, std::vector<uint8_t>> g_tile_img_cache;
static fs::path data_path(const std::string &sub) { fs::path p = fs::path(g_data_dir) / sub; fs::create_directories(p); return p; }
static std::string now_stamp(const char *fmt) {
auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
tm l{};
#ifdef _WIN32
localtime_s(&l, &t);
#else
localtime_r(&t, &l);
#endif
char b[64]; strftime(b, sizeof b, fmt, &l); return b;
}
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)), {});
}
static void write_local(const fs::path &p, const std::vector<uint8_t> &d) {
std::ofstream f(p, std::ios::binary); f.write((const char *)d.data(), (std::streamsize)d.size());
if (!f) throw ps5::Error("Couldn't save " + p.u8string());
}
static std::string make_backup(const std::vector<uint8_t> &db, const std::string &label) {
std::string id = now_stamp("%Y-%m-%d_%H%M%S");
fs::path dir = data_path("backups") / id;
for (int n = 2; fs::exists(dir); n++) dir = data_path("backups") / (id + "_" + std::to_string(n));
fs::create_directories(dir);
write_local(dir / "app.db", db);
json m = {{"id", dir.filename().u8string()}, {"label", label}, {"ps5", g_ip}, {"created", now_stamp("%Y-%m-%d %H:%M:%S")}};
std::ofstream(dir / "backup.json") << m.dump(2);
return dir.filename().u8string();
}
static json layout_state(const std::vector<uint8_t> &db_bytes, std::vector<std::string> extra_warn = {}) {
layout::DB db(db_bytes);
auto info = layout::analyze(db, g_layout_user);
json j = info.to_json();
for (auto &w : extra_warn) j["warnings"].push_back(w);
j["ok"] = true;
return j;
}
static std::vector<uint8_t> pull_db(std::vector<std::string> *warn = nullptr) {
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) {
std::lock_guard<std::mutex> l(g_layout_mu);
g_layout_user = user;
{ std::lock_guard<std::mutex> c(g_mu); g_tile_img_cache.clear(); }
std::vector<std::string> warn;
auto db = pull_db(&warn);
fs::path marker = data_path("backups") / (".original-" + g_ip);
if (!fs::exists(marker)) { make_backup(db, "Original - first time PSGFX read this console"); std::ofstream(marker) << "1"; }
return layout_state(db, warn);
}
static std::vector<uint8_t> tile_image(const std::string &path) {
if (path.empty() || path[0] != '/' || path.find("..") != std::string::npos) return {};
std::string ext = lower(path.substr(path.find_last_of('.') + 1));
if (ext != "png" && ext != "dds" && ext != "jpg") return {};
{
std::lock_guard<std::mutex> l(g_mu);
auto it = g_tile_img_cache.find(path);
if (it != g_tile_img_cache.end()) return it->second;
}
std::vector<uint8_t> out;
try {
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);
} catch (...) {}
std::lock_guard<std::mutex> l(g_mu);
g_tile_img_cache[path] = out;
return out;
}
static json layout_apply(const json &req) {
std::lock_guard<std::mutex> l(g_layout_mu);
auto orig = pull_db();
std::string backup = make_backup(orig, "Before changes");
std::vector<std::string> log = {"Backup saved: " + backup};
layout::DB db(orig);
auto info = layout::analyze(db, g_layout_user);
layout::apply(db, info, req, log);
auto out = db.serialize();
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)}};
}
static json layout_backups() {
json out = json::array();
std::vector<fs::path> dirs;
for (auto &e : fs::directory_iterator(data_path("backups"))) if (e.is_directory()) dirs.push_back(e.path());
std::sort(dirs.rbegin(), dirs.rend());
for (auto &d : dirs) {
try { std::ifstream f(d / "backup.json"); json m; f >> m; out.push_back(m); } catch (...) {}
}
return {{"ok", true}, {"backups", out}};
}
static json layout_restore(const std::string &id) {
std::lock_guard<std::mutex> l(g_layout_mu);
if (id.empty() || id.find_first_of("/\\") != std::string::npos || id.find("..") != std::string::npos) throw ps5::Error("Unknown backup.");
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
push_db(db);
return {{"ok", true}, {"layout", layout_state(db)}};
}
static std::string preset_file(std::string name) {
std::string o;
for (char c : name) o += (isalnum((unsigned char)c) || c == ' ' || c == '-' || c == '_') ? c : '_';
while (!o.empty() && o.back() == ' ') o.pop_back();
if (o.empty()) throw ps5::Error("Give the preset a name.");
return o;
}
static json layout_presets() {
json names = json::array();
for (auto &e : fs::directory_iterator(data_path("presets")))
if (e.path().extension() == ".json") names.push_back(e.path().stem().u8string());
return {{"ok", true}, {"presets", names}};
}
// ─────────────────────────────── actions ──────────────────────────────
// Homebrew registered by PS5 Upload (we know its folder) + installed games (edited in
// /user/appmeta/<id>, where the home screen reads its art). System apps and nameless
// leftovers are marked hidden so the list stays clean.
// Homebrew registered by PS5 Upload (we know its folder) + installed games (in Sony's app
// list or with a /user/appmeta/<id> folder). System apps and nameless leftovers are
// marked hidden so the list stays clean.
static json list_apps() {
auto c = client();
std::map<std::string, std::string> src, name;
json reg = json::parse(c.call_str(ps5::APP_LIST_REGISTERED, "", ps5::APP_LIST_REGISTERED_ACK));
std::set<std::string> mounted;
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", "");
if (a.value("image_backed", false) || mounted_image(src[id])) mounted.insert(id);
std::string n = a.value("title_name", "");
if (n != id) name[id] = n;
}
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;
have_db = true;
in_db.insert(id);
if (!src.count(id)) src[id] = "";
if (!n.empty() && !name.count(id)) name[id] = n;
}
@@ -142,8 +337,8 @@ static json list_apps() {
json out = json::array();
for (auto &[id, s] : src) {
std::string kind = !s.empty() ? "homebrew"
: (metas.count(id) && id.rfind("NPXS", 0) != 0) ? "game" : "other";
std::string kind = mounted.count(id) ? "game" : !s.empty() ? "homebrew"
: ((metas.count(id) || in_db.count(id)) && id.rfind("NPXS", 0) != 0) ? "game" : "other";
std::string n = name.count(id) ? name[id] : "";
if (n.empty() && kind != "other" && !have_db) n = meta_title(c, id);
bool hidden = kind == "other" || (kind == "game" && n.empty());
@@ -233,7 +428,8 @@ static std::vector<Target> game_targets(ps5::Client &c, const std::string &id, c
std::vector<Target> out;
for (auto &e : list_dir(c, appmeta(id))) {
std::string name = e.value("name", ""), ln = lower(name);
if (e.value("kind", "") != "file") continue;
std::string ek = e.value("kind", "");
if (ek != "file") { if (ek == "link") seen.push_back(name + " (link)"); continue; }
seen.push_back(name);
bool png = ends_with(ln, ".png"), dds = ends_with(ln, ".dds");
if (!png && !dds) continue;
@@ -241,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) {
@@ -266,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
@@ -284,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();
@@ -300,7 +496,145 @@ static void sync_home(ps5::Client &c, const std::string &id, const std::string &
}
}
static json do_apply(const std::string &id, const std::string &src, const std::string &kind_of_app) {
// ── Installed games: the art the home screen actually draws ──
// The home screen draws each tile from the paths in its app.db concept row (icon0Info,
// pic0Info). For many installed games those point into the game's own read-only package,
// not /user/appmeta, so replacing files in the game's art folder can't change what's shown.
// For those, PSGFX writes the new image to /user/appmeta/<id>/psgfx_<name>_<time> and points
// the database at it. The original paths are kept in psgfx_original.json in that folder for
// Restore, and the database is backed up first, as Home layout does.
static const char *const ORIGINALS = "psgfx_original.json";
static std::string art_column(const std::string &kind) { return kind == "icon" ? "icon0Info" : "pic0Info"; }
// Size and format from an image's first bytes; false if it isn't a PNG or DDS we can match.
static bool probe_art(const std::vector<uint8_t> &h, Target &t) {
if (h.size() >= 24 && memcmp(h.data(), "\x89PNG", 4) == 0) {
t.dds = false;
t.w = (int)(h[16] << 24 | h[17] << 16 | h[18] << 8 | h[19]);
t.h = (int)(h[20] << 24 | h[21] << 16 | h[22] << 8 | h[23]);
} else if (h.size() >= 20 && memcmp(h.data(), "DDS ", 4) == 0) {
t.dds = true; t.h = (int)ps5::get32(&h[12]); t.w = (int)ps5::get32(&h[16]);
} else {
return false;
}
return t.w >= 16 && t.h >= 16 && t.w <= 8192 && t.h <= 8192 && (!t.dds || (t.w % 4 == 0 && t.h % 4 == 0));
}
static std::vector<uint8_t> read_head(ps5::Client &c, const std::string &path) {
try { return c.read(path, 0, 160); }
catch (...) { return {}; }
}
// Sets an art column for this title in every table that has it (all user profiles).
static void set_art_column(layout::DB &db, const layout::Info &info, const std::string &id, const std::string &concept,
const std::string &col, const std::string &value) {
for (auto &[table, cols] : info.cols) {
if (!cols.count(col)) continue;
if (cols.count("localConceptId") && !concept.empty())
db.exec("UPDATE " + layout::q(table) + " SET " + layout::q(col) + "=? WHERE localConceptId=?", {value, concept});
else if (cols.count("titleId"))
db.exec("UPDATE " + layout::q(table) + " SET " + layout::q(col) + "=? WHERE titleId=?", {value, id});
}
}
static std::string concept_of(layout::DB &db, const layout::Info &info, const std::string &id) {
return db.scalar_str("SELECT localConceptId FROM " + layout::q(info.icon_t) + " WHERE titleId=?", {id});
}
static void upload_db(const std::vector<uint8_t> &orig, layout::DB &db, const std::string &label, json &log) {
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
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 + ")");
}
static json read_originals(ps5::Client &c, const std::string &id) {
if (!exists(c, appmeta(id), ORIGINALS)) return json::object();
try { auto b = c.read_file(appmeta(id) + "/" + ORIGINALS, 1u << 20); return json::parse(b.begin(), b.end()); }
catch (...) { return json::object(); }
}
// Returns the staged slots it handled; the rest fall back to replacing the art-folder files.
static std::set<std::string> game_apply_db(ps5::Client &c, const std::string &id, const std::map<std::string, Staged> &staged, json &log) {
std::set<std::string> done;
std::lock_guard<std::mutex> l(g_layout_mu);
std::vector<uint8_t> orig;
try { orig = pull_db(); }
catch (const std::exception &e) {
log.push_back(std::string("Couldn't read the home-screen database (") + e.what() + "), so only the game's art folder was checked.");
return done;
}
layout::DB db(orig);
auto info = layout::analyze(db, g_layout_user);
if (info.concept_t.empty()) return done;
std::string concept = concept_of(db, info, id);
if (concept.empty()) return done;
const std::string dir = appmeta(id);
json originals = read_originals(c, id);
std::vector<std::string> replaced; // our older files, deleted once the database points away from them
for (auto &[kind, s] : staged) {
const std::string col = art_column(kind);
std::string value = db.scalar_str("SELECT " + layout::q(col) + " FROM " + layout::q(info.concept_t) + " WHERE localConceptId=?", {concept});
std::string path = layout::strip_query(value);
if (path.empty() || path[0] != '/') continue;
std::string base = path.substr(path.find_last_of('/') + 1);
bool in_dir = path == dir + "/" + base, ours = in_dir && base.rfind("psgfx_", 0) == 0;
if (in_dir && !ours) { // a real file in the art folder: replacing it in place works
bool regular = false;
for (auto &e : list_dir(c, dir)) if (e.value("name", "") == base && e.value("kind", "") == "file") regular = true;
if (regular) continue;
}
Target t;
if (!probe_art(read_head(c, path), t)) {
t.dds = ends_with(lower(base), ".dds");
t.w = kind == "icon" ? 512 : t.dds ? 3840 : 1920;
t.h = kind == "icon" ? 512 : t.dds ? 2160 : 1080;
}
std::string stem = kind == "icon" ? "icon0" : "pic0";
t.name = "psgfx_" + stem + "_" + now_stamp("%Y%m%d%H%M%S") + (t.dds ? ".dds" : ".png");
c.upload(dir + "/" + t.name, encode_for(kind, s.src, t));
log.push_back("Uploaded " + t.name + " (" + std::to_string(t.w) + "x" + std::to_string(t.h) + ")");
if (!originals.contains(col)) originals[col] = value;
if (ours) replaced.push_back(dir + "/" + base);
size_t qp = value.find('?');
set_art_column(db, info, id, concept, col, dir + "/" + t.name + (qp == std::string::npos ? "" : value.substr(qp)));
done.insert(kind);
}
if (done.empty()) return done;
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.remove(p); } catch (...) {}
return done;
}
// Points the database back at the game's own art. False if PSGFX never repointed it.
static bool game_restore_db(ps5::Client &c, const std::string &id, json &log) {
json originals = read_originals(c, id);
if (originals.empty()) return false;
{
std::lock_guard<std::mutex> l(g_layout_mu);
auto orig = pull_db();
layout::DB db(orig);
auto info = layout::analyze(db, g_layout_user);
std::string concept = concept_of(db, info, id);
for (auto &[col, value] : originals.items())
if (value.is_string()) set_art_column(db, info, id, concept, col, value.get<std::string>());
upload_db(orig, db, "Before restoring art for " + id, log);
}
for (auto &e : list_dir(c, appmeta(id))) {
std::string n = e.value("name", "");
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;
}
static json do_apply(const std::string &id, const std::string &src, std::string kind_of_app) {
if (mounted_image(src)) kind_of_app = "game";
json log = json::array();
std::map<std::string, Staged> staged;
{ std::lock_guard<std::mutex> l(g_mu); staged = g_staged; }
@@ -308,9 +642,11 @@ static json do_apply(const std::string &id, const std::string &src, const std::s
auto c = client();
if (kind_of_app == "game") {
int written = 0;
auto via_db = game_apply_db(c, id, staged, log);
int written = (int)via_db.size();
std::vector<std::string> seen;
for (auto &[kind, s] : staged) {
if (via_db.count(kind)) continue;
auto targets = game_targets(c, id, kind, log, seen);
if (targets.empty()) log.push_back(std::string("This game has no ") + (kind == "icon" ? "icon" : "background") + " file this tool can replace.");
for (auto &t : targets) {
@@ -324,7 +660,9 @@ static json do_apply(const std::string &id, const std::string &src, const std::s
if (!written) {
std::string list; std::sort(seen.begin(), seen.end()); seen.erase(std::unique(seen.begin(), seen.end()), seen.end());
for (auto &n : seen) list += (list.empty() ? "" : ", ") + n;
throw ps5::Error("Nothing was changed. Files in this game's art folder: " + (list.empty() ? std::string("(none)") : list));
std::string notes;
for (auto &n : log) notes += " " + n.get<std::string>();
throw ps5::Error("Nothing was changed. Files in this game's art folder: " + (list.empty() ? std::string("(none)") : list) + "." + notes);
}
log.push_back("Done. Restart the PS5 to see the new art.");
} else {
@@ -346,30 +684,32 @@ static json do_apply(const std::string &id, const std::string &src, const std::s
return {{"ok", true}, {"log", log}};
}
static json do_restore(const std::string &id, const std::string &src, const std::string &kind_of_app) {
static json do_restore(const std::string &id, const std::string &src, std::string kind_of_app) {
if (mounted_image(src)) kind_of_app = "game";
json log = json::array();
auto c = client();
const std::string dir = kind_of_app == "game" ? appmeta(id) : src + "/sce_sys";
if (kind_of_app != "game" && src.empty()) throw ps5::Error("This app's folder is unknown.");
bool repointed = kind_of_app == "game" && game_restore_db(c, id, log);
std::vector<std::string> names;
for (auto &e : list_dir(c, dir)) {
std::string n = e.value("name", "");
if (ends_with(n, ".bak")) names.push_back(n.substr(0, n.size() - 4));
else if (ends_with(n, ".added")) names.push_back(n.substr(0, n.size() - 6));
}
if (names.empty()) throw ps5::Error("Nothing to restore - this still has its original art.");
if (names.empty() && !repointed) throw ps5::Error("Nothing to restore - this still has its original art.");
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 &copy : {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)");
}
}
@@ -379,6 +719,54 @@ static json do_restore(const std::string &id, const std::string &src, const std:
return {{"ok", true}, {"log", log}};
}
// Homebrew keeps its name in sce_sys/param.json, and re-registering (which Apply does) copies
// it to the home screen. Update it there too, so changing art later doesn't undo a rename.
// The first original is kept as param.json.orig (not .bak, so Restore original art leaves it).
static void rename_homebrew_param(ps5::Client &c, const std::string &id, const std::string &src, const std::string &name, json &log) {
const std::string dir = src + "/sce_sys";
try {
auto b = c.read_file(dir + "/param.json", 1u << 20);
auto j = nlohmann::ordered_json::parse(b.begin(), b.end());
int n = 0;
if (j.contains("localizedParameters") && j["localizedParameters"].is_object())
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");
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); } 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.");
}
}
// Renames a title on the home screen with the same database edit Home layout uses.
static json do_rename(const std::string &id, const std::string &src, const std::string &kind_of_app, std::string name) {
while (!name.empty() && isspace((unsigned char)name.back())) name.pop_back();
while (!name.empty() && isspace((unsigned char)name.front())) name.erase(0, 1);
if (name.empty()) throw ps5::Error("Enter a name.");
json log = json::array();
auto c = client();
{
std::lock_guard<std::mutex> l(g_layout_mu);
auto orig = pull_db();
layout::DB db(orig);
auto info = layout::analyze(db, g_layout_user);
const layout::Tile *t = info.find(id);
if (!t) throw ps5::Error("This title isn't in the home screen's list, so it can't be renamed there.");
std::vector<std::string> lines;
layout::apply(db, info, json{{"edits", {{id, {{"name", name}}}}}}, lines);
for (auto &x : lines) log.push_back(x);
upload_db(orig, db, "Before renaming " + t->name, log);
}
if (kind_of_app == "homebrew" && !src.empty() && !mounted_image(src)) rename_homebrew_param(c, id, src, name, log);
log.push_back("Done. Restart the PS5 to see the new name.");
return {{"ok", true}, {"log", log}, {"name", name}};
}
// ─────────────────────────────── tiny HTTP server ─────────────────────
struct Req { std::string method, path, query; std::map<std::string, std::string> q; std::vector<uint8_t> body; };
@@ -492,6 +880,39 @@ static void handle(sock_t s, Req &r) {
auto id = j.value("title_id", ""), src = j.value("src", ""), kind = j.value("kind", "homebrew");
respond_json(s, r.path == "/api/apply" ? do_apply(id, src, kind) : do_restore(id, src, kind)); return;
}
if (r.path == "/api/rename" && r.method == "POST") {
json j = json::parse(std::string(r.body.begin(), r.body.end()));
respond_json(s, do_rename(j.value("title_id", ""), j.value("src", ""), j.value("kind", "homebrew"), j.value("name", ""))); return;
}
if (r.path == "/api/layout/load" && r.method == "POST") {
json j = json::parse(std::string(r.body.begin(), r.body.end()));
respond_json(s, layout_load(j.value("user", ""))); return;
}
if (r.path == "/api/layout/img") {
auto b = tile_image(r.q["p"]);
if (b.empty()) { respond(s, 404, "text/plain", "none", 4); return; }
respond(s, 200, "image/png", b.data(), b.size()); return;
}
if (r.path == "/api/layout/apply" && r.method == "POST") { respond_json(s, layout_apply(json::parse(std::string(r.body.begin(), r.body.end())))); return; }
if (r.path == "/api/layout/backups") { respond_json(s, layout_backups()); return; }
if (r.path == "/api/layout/restore" && r.method == "POST") {
json j = json::parse(std::string(r.body.begin(), r.body.end()));
respond_json(s, layout_restore(j.value("id", ""))); return;
}
if (r.path == "/api/layout/presets") { respond_json(s, layout_presets()); return; }
if (r.path == "/api/layout/preset/save" && r.method == "POST") {
json j = json::parse(std::string(r.body.begin(), r.body.end()));
std::string name = preset_file(j.value("name", ""));
std::ofstream(data_path("presets") / fs::u8path(name + ".json")) << j.value("data", json::object()).dump(2);
respond_json(s, {{"ok", true}, {"name", name}}); return;
}
if (r.path == "/api/layout/preset/load" && r.method == "POST") {
json j = json::parse(std::string(r.body.begin(), r.body.end()));
std::ifstream f(data_path("presets") / fs::u8path(preset_file(j.value("name", "")) + ".json"));
if (!f) throw ps5::Error("Preset not found.");
json d; f >> d;
respond_json(s, {{"ok", true}, {"data", d}}); return;
}
respond(s, 404, "text/plain", "not found", 9);
} catch (std::exception &e) {
respond_json(s, {{"ok", false}, {"error", e.what()}}, 200);
@@ -504,6 +925,7 @@ int main(int argc, char **argv) {
char exe[MAX_PATH]; GetModuleFileNameA(nullptr, exe, MAX_PATH);
std::string ep = exe; std::string exedir = ep.substr(0, ep.find_last_of("\\/") + 1);
g_config_path = exedir + "psgfx.json"; g_legacy_config_path = exedir + "ps5-art-studio.json";
g_data_dir = exedir + "PSGFX Layout";
// Close any other running copy (e.g. an older version still open in its console window),
// so the browser can only ever talk to this one.
{
+134 -145
View File
@@ -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
+5 -5
View File
@@ -2,8 +2,8 @@
IDI_ICON1 ICON "../assets/psgfx.ico"
VS_VERSION_INFO VERSIONINFO
FILEVERSION 1,0,0,0
PRODUCTVERSION 1,0,0,0
FILEVERSION 1,1,0,0
PRODUCTVERSION 1,1,0,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -11,10 +11,10 @@ BEGIN
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "FileDescription", "PSGFX - custom icons and backgrounds for your PS5"
VALUE "FileVersion", "1.0.0"
VALUE "FileDescription", "PSGFX - custom art and home-screen layout for your PS5"
VALUE "FileVersion", "1.1.0"
VALUE "ProductName", "PSGFX"
VALUE "ProductVersion", "1.0.0"
VALUE "ProductVersion", "1.1.0"
VALUE "OriginalFilename", "PSGFX.exe"
END
END
+392 -8
View File
@@ -76,6 +76,8 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
.stage-hint{position:absolute;right:2%;bottom:3%;font-size:12px;color:rgba(255,255,255,.75)}
.staged-badge{position:absolute;top:3%;right:2%;padding:4px 10px;border-radius:999px;background:rgba(61,123,255,.85);font-size:12px;font-weight:600;display:none}
.namerow{display:grid;gap:6px;max-width:480px}
.namerow label{font-size:12.5px;color:var(--muted)}
.slots{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
.slot{background:var(--glass);border:1px solid var(--line);border-radius:var(--r-md);padding:14px;display:flex;gap:12px;align-items:center;min-height:92px}
.slot .thumb{width:64px;height:64px;border-radius:10px;background:var(--bg2) center/cover no-repeat;flex:none;border:1px solid var(--line)}
@@ -94,6 +96,79 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
.log.ok{border-color:rgba(61,220,151,.4)} .log.bad{border-color:rgba(255,107,107,.5);color:#ffd0d0}
.welcome{max-width:560px;color:var(--muted)}
.welcome h1{color:var(--text);font-size:26px;letter-spacing:-.02em;margin:0 0 8px}
/* ── header views ── */
.views{display:flex;gap:4px;margin-left:8px;padding:3px;border-radius:999px;background:var(--glass);border:1px solid var(--line)}
.views button{border:0;background:none;padding:6px 14px;border-radius:999px;color:var(--muted);cursor:pointer;font-weight:600;font-size:13px}
.views button[aria-pressed="true"]{background:var(--glass-hi);color:var(--text)}
/* ── home layout ── */
.lv{display:grid;grid-template-columns:1fr 320px;min-height:calc(100vh - 66px)}
.lv-main{padding:24px;display:flex;flex-direction:column;gap:16px;min-width:0}
.lv-empty{max-width:560px;color:var(--muted)}
.lv-empty h1{color:var(--text);font-size:26px;letter-spacing:-.02em;margin:0 0 8px}
.lstage{position:relative;aspect-ratio:16/9;width:min(100%,calc((100vh - 470px)*16/9));min-width:min(100%,520px);margin:0 auto;border-radius:var(--r-lg);overflow:hidden;background:linear-gradient(135deg,#101b33,#060913);border:1px solid var(--line);box-shadow:0 30px 80px rgba(0,0,0,.5)}
.lstage .bgimg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.ltabs{position:absolute;left:4.5%;top:4%;display:flex;gap:clamp(14px,2.4vw,30px);font-size:clamp(11px,1.4vw,17px);color:rgba(255,255,255,.65)}
.ltabs span{padding-bottom:3px;border-bottom:2px solid transparent;cursor:pointer}
.ltabs span.on{color:#fff;border-color:#fff}
.lrow{position:absolute;left:4.5%;right:0;top:14%;display:flex;gap:.9%;align-items:flex-start;overflow:hidden}
.ltile{flex:none;width:6.2%;aspect-ratio:1;border-radius:8%;background:rgba(255,255,255,.12) center/cover;cursor:pointer;position:relative}
.ltile.sel{width:12.5%;border-radius:7%;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 8px 30px rgba(0,0,0,.5)}
.ltile i,.lcard .im i{position:absolute;inset:0;display:grid;place-items:center;font-style:normal;font-weight:600;font-size:clamp(8px,1vw,14px);color:rgba(255,255,255,.8)}
.ltitle{position:absolute;left:4.5%;top:42%;font-size:clamp(14px,2.2vw,30px);font-weight:600;text-shadow:0 2px 12px rgba(0,0,0,.6);max-width:60%}
.lsub{position:absolute;left:4.5%;top:calc(42% + clamp(20px,3vw,40px));font-size:clamp(10px,1vw,13px);color:rgba(255,255,255,.7)}
.lbar{display:flex;align-items:center;gap:4px;flex-wrap:wrap;width:min(100%,calc((100vh - 470px)*16/9));min-width:min(100%,520px);margin:0 auto}
.ltab{border:0;background:none;padding:7px 12px;border-radius:var(--r-sm);color:var(--muted);cursor:pointer;font-weight:600}
.ltab[aria-pressed="true"]{background:var(--glass-hi);color:var(--text)}
.ltab small{font-weight:400;color:var(--muted);margin-left:4px}
.lsorts{margin-left:auto;display:flex;align-items:center;gap:2px}
.lsorts .btn{padding:5px 10px;font-size:12px}
.lsorts label{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted);margin-left:8px;cursor:pointer}
.lstrip{display:flex;gap:16px;overflow-x:auto;padding:4px 2px 16px;width:min(100%,calc((100vh - 470px)*16/9));min-width:min(100%,520px);margin:0 auto}
.lgrp{display:flex;flex-direction:column;gap:8px;flex:none}
.lgrp + .lgrp{padding-left:16px;border-left:1px solid var(--line)}
.lgrp>span{font-size:12px;color:var(--muted)}
.lcards{display:flex;gap:10px}
.lcard{flex:none;width:112px;padding:7px;border-radius:var(--r-md);border:1px solid transparent;background:var(--glass);cursor:pointer;position:relative;text-align:left}
.lcard:hover{background:var(--glass-hi)}
.lcard[aria-current="true"]{border-color:var(--blue-hi);background:var(--glass-hi)}
.lcard.drag{opacity:.35}
.lcard.over{box-shadow:-5px 0 0 -2px var(--blue-hi)}
.lcard .im{aspect-ratio:1;border-radius:9px;background:var(--bg2) center/cover;position:relative}
.lcard .nm{margin-top:6px;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lcard .bd{position:absolute;top:12px;right:12px;font-size:10.5px;font-weight:600;padding:2px 6px;border-radius:999px;background:var(--warn);color:#2a1800}
.lempty{color:var(--muted);padding:20px;border:1px dashed var(--line);border-radius:var(--r-md);flex:1;text-align:center}
.lwarn{width:min(100%,calc((100vh - 470px)*16/9));margin:0 auto;padding:10px 14px;border-radius:var(--r-md);background:rgba(255,181,71,.08);border:1px solid rgba(255,181,71,.3);color:#ffd9a0;font-size:13px}
.lside{border-left:1px solid var(--line);padding:20px;display:flex;flex-direction:column;gap:18px}
.lside h2{font-size:18px;margin:0;letter-spacing:-.01em}
.lside .id{font-size:12px;color:var(--muted)}
.lsec{display:grid;gap:8px}
.lsec>label,.lsec>.lbl{font-size:12.5px;color:var(--muted)}
.lseg{display:grid;grid-template-columns:repeat(3,1fr);padding:3px;border-radius:var(--r-sm);background:var(--glass);border:1px solid var(--line)}
.lseg button{border:0;background:none;padding:7px 0;border-radius:6px;color:var(--muted);cursor:pointer}
.lseg button[aria-pressed="true"]{background:var(--glass-hi);color:var(--text)}
.ltxt{width:100%;padding:9px 11px;border-radius:var(--r-sm);border:1px solid var(--line);background:var(--glass)}
.lnote{font-size:12px;color:var(--muted)}
.linker{border:0;background:none;color:var(--blue-hi);padding:0;cursor:pointer;justify-self:start;font-size:13px}
.lfoot{position:sticky;bottom:0;display:flex;align-items:center;gap:10px;padding:12px 20px;border-top:1px solid var(--line);background:rgba(6,9,19,.92);backdrop-filter:blur(8px);grid-column:1/-1}
.lfoot .pend{color:var(--muted);display:flex;align-items:center;gap:8px}
.lfoot .pend b{width:8px;height:8px;border-radius:50%;background:#4a5570}
.lfoot .pend.has{color:var(--text)} .lfoot .pend.has b{background:var(--warn)}
.lfoot .sp{flex:1}
.scrim{position:fixed;inset:0;display:none;place-items:center;background:rgba(3,5,10,.7);z-index:20}
.scrim.open{display:grid}
.dlg{width:min(560px,92vw);max-height:84vh;overflow:auto;background:#0c1426;border:1px solid var(--line);border-radius:var(--r-lg);padding:22px}
.dlg h3{margin:0 0 6px;font-size:18px}
.dlg p{margin:0 0 12px;color:var(--muted)}
.dlg .li{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:var(--r-sm);background:var(--glass);border:1px solid var(--line);margin-bottom:6px}
.dlg .li div{flex:1;min-width:0}
.dlg .li small{display:block;color:var(--muted)}
.dlg .acts{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
.dlg pre{white-space:pre-wrap;font-size:12px;background:var(--glass);border:1px solid var(--line);border-radius:var(--r-sm);padding:10px;color:var(--muted);max-height:240px;overflow:auto}
.dlg .chk{display:flex;align-items:center;gap:8px;margin:14px 0 8px;cursor:pointer}
.busyv{position:fixed;inset:0;display:none;place-items:center;background:rgba(3,5,10,.55);z-index:30;color:var(--muted)}
.busyv.open{display:grid}
@media (max-width:900px){.lv{grid-template-columns:1fr}.lside{border-left:0;border-top:1px solid var(--line)}}
.hidden{display:none!important}
@media (max-width:900px){.layout{grid-template-columns:1fr}.rail{max-height:none;position:static;border-bottom:1px solid var(--line)}main{border-left:0}.slots{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
@@ -101,7 +176,8 @@ 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.0</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>
<label for="ip">PS5 IP</label>
@@ -118,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">
@@ -137,6 +213,8 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
<div class="drop d-icon" data-kind="icon">Icon</div>
</div>
<div class="namerow" style="margin-top:20px"><label for="aname">Name on the home screen</label><input class="ltxt" id="aname" maxlength="80" autocomplete="off"></div>
<div class="slots" style="margin-top:20px">
<div class="slot" data-kind="icon"><div class="thumb"></div><div class="meta"><div class="t">Icon</div><div class="s">Square, 512 × 512 or larger</div><div class="row"><button class="btn pick">Choose image</button><button class="btn clear hidden">Remove</button></div></div></div>
<div class="slot bgslot" data-kind="background"><div class="thumb"></div><div class="meta"><div class="t">Background</div><div class="s">16:9, ideally 3840 × 2160</div><div class="row"><button class="btn pick">Choose image</button><button class="btn clear hidden">Remove</button></div></div></div>
@@ -151,6 +229,49 @@ main{border-left:1px solid var(--line);padding:24px;display:flex;flex-direction:
</section>
</main>
</div>
<section class="lv hidden" id="lv" aria-label="Home layout">
<div class="lv-main">
<div class="lv-empty" id="lvEmpty">
<h1>Arrange your home screen.</h1>
<p>Hide tiles like the PlayStation Store, move apps between Games and Media, rename anything, and put your games in any order. Connect to your PS5 above to start. PSGFX backs up the home screen before every change.</p>
</div>
<div id="lvBody" class="hidden" style="display:contents">
<div id="lvWarn"></div>
<div class="lstage" id="lstage">
<img class="bgimg hidden" id="lbg" alt="">
<div class="shade" style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.45) 0%,transparent 30%,transparent 55%,rgba(0,0,0,.7) 100%)"></div>
<div class="ltabs"><span data-w="games">Games</span><span data-w="media">Media</span></div>
<div class="lrow" id="lrow"></div>
<div class="ltitle" id="ltitle"></div>
<div class="lsub" id="lsub"></div>
</div>
<div class="lbar">
<button class="ltab" data-w="games">Games<small id="lcG"></small></button>
<button class="ltab" data-w="media">Media<small id="lcM"></small></button>
<button class="ltab" data-w="hidden">Hidden<small id="lcH"></small></button>
<div class="lsorts" id="lsorts">
<button class="btn" data-sort="az" title="Sort A to Z">A–Z</button>
<button class="btn" data-sort="za" title="Sort Z to A">Z–A</button>
<button class="btn" data-sort="recent" title="Back to recently played order">Recent</button>
<label title="Keeps this order even after you play other games"><input type="checkbox" id="llock"> Lock order</label>
</div>
</div>
<div class="lstrip" id="lstrip"></div>
</div>
</div>
<aside class="lside" id="lside"><p class="lnote">Select a tile to change it.</p></aside>
<footer class="lfoot">
<div class="pend" id="lpend"><b></b><span>No changes</span></div>
<span class="sp"></span>
<button class="btn" id="lPresets">Presets</button>
<button class="btn" id="lBackups">Backups</button>
<button class="btn" id="lReload">Reload</button>
<button class="btn" id="lDiscard" disabled>Discard</button>
<button class="btn primary" id="lApply" disabled>Apply to PS5</button>
</footer>
</section>
<div class="scrim" id="dlg"><div class="dlg" id="dlgBody"></div></div>
<div class="busyv" id="busyv"><span id="busyT">Working…</span></div>
<input type="file" id="file" accept="image/png,image/jpeg" hidden>
<script>
@@ -174,6 +295,7 @@ $('#cf').addEventListener('submit', async e => {
try {
const j = await api('/api/connect', {method:'POST', body: JSON.stringify({ip})});
st.apps = j.apps; setDot('ok'); renderApps();
L.loaded = false; if (!$('#lv').classList.contains('hidden')) lload();
} catch (err) {
setDot(''); $('#apps').innerHTML = ''; const p=document.createElement('p'); p.className='empty'; p.textContent = err.message; $('#apps').appendChild(p);
} finally { $('#cbtn').disabled = false; }
@@ -225,6 +347,7 @@ async function select(a, btn){
st.staged = {};
$('#welcome').classList.add('hidden'); $('#editor').classList.remove('hidden'); $('#log').style.display='none';
$('#tname').textContent = a.title_name || a.title_id;
$('#aname').value = a.title_name || a.title_id;
const game = a.kind === 'game';
$('#anote').textContent = game ? 'Close the game first. The PS5 may put back its original art when the game updates.' : 'Close the app on your PS5 before applying.';
refreshPreview(true);
@@ -246,9 +369,15 @@ function refreshPreview(loadCurrent){
else if (s) { sEl.textContent = 'Ready to apply'; sEl.classList.remove('warn'); }
else { sEl.textContent = {icon:'Square, 512 × 512 or larger',background:'16:9, ideally 3840 × 2160'}[k]; sEl.classList.remove('warn'); }
});
const any = KINDS.some(k => st.staged[k]);
updateApply();
}
const newName = () => { const v = $('#aname').value.trim(); return v && v !== (st.sel.title_name || st.sel.title_id) ? v : null; };
function updateApply(){
const any = KINDS.some(k => st.staged[k]) || !!newName();
$('#apply').disabled = !any; $('#badge').style.display = any ? 'block' : 'none';
}
$('#aname').addEventListener('input', () => { $('#tname').textContent = $('#aname').value.trim() || st.sel.title_name || st.sel.title_id; updateApply(); });
$('#aname').addEventListener('keydown', e => { if (e.key === 'Enter' && !$('#apply').disabled) $('#apply').click(); });
async function stage(kind, file){
const sl = document.querySelector(`.slot[data-kind="${kind}"] .s`);
@@ -287,12 +416,24 @@ stageEl.addEventListener('drop', e => {
window.addEventListener('dragover', e => e.preventDefault()); window.addEventListener('drop', e => e.preventDefault());
$('#apply').onclick = async () => {
const a = st.sel; $('#apply').disabled = true; showLog(['Converting and uploading to '+(a.title_name||a.title_id)+'…'], '');
const a = st.sel, art = KINDS.some(k => st.staged[k]), name = newName(), lines = [];
$('#apply').disabled = true; showLog([art ? 'Converting and uploading to '+(a.title_name||a.title_id)+'…' : 'Renaming '+(a.title_name||a.title_id)+'…'], '');
const ids = {title_id:a.title_id, src:a.src, kind:a.kind};
let artDone = false;
try {
const j = await api('/api/apply', {method:'POST', body: JSON.stringify({title_id:a.title_id, src:a.src, kind:a.kind})});
showLog(j.log, 'ok'); st.staged = {}; refreshPreview(true);
refreshRailIcon(a);
} catch (err) { showLog(['Apply failed: '+err.message], 'bad'); $('#apply').disabled = false; }
if (art) {
const j = await api('/api/apply', {method:'POST', body: JSON.stringify(ids)});
lines.push(...j.log); st.staged = {}; artDone = true; refreshRailIcon(a);
}
if (name) {
const j = await api('/api/rename', {method:'POST', body: JSON.stringify({...ids, name})});
lines.push(...j.log); a.title_name = j.name; L.loaded = false;
const n = document.querySelector('.app[aria-current="true"] .n'); if (n) n.textContent = j.name;
$('#aname').value = j.name; $('#tname').textContent = j.name;
}
showLog(lines, 'ok');
} catch (err) { showLog([...lines, (art && !artDone ? 'Apply failed: ' : 'Rename failed: ')+err.message], 'bad'); }
if (artDone) refreshPreview(true); else updateApply();
};
$('#restore').onclick = async () => {
const a = st.sel; showLog(['Restoring original art…'], '');
@@ -304,6 +445,249 @@ $('#restore').onclick = async () => {
};
function refreshRailIcon(a){ const im = document.querySelector('.app[aria-current="true"] .ic'); if (im) { im.classList.remove('none'); im.src = '/api/icon/'+encodeURIComponent(a.title_id)+'?t='+Date.now(); } }
// ─────────────────────────── Home layout ───────────────────────────
const L = { info:null, edits:{}, order:[], base:[], dragged:false, w:'games', sel:null, focus:0, loaded:false, lastPreset:'' };
const LIB = 'NPXS40071';
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const limg = p => p ? '/api/layout/img?p=' + encodeURIComponent(p) : '';
const ltiles = () => L.info?.tiles ?? [];
const lbyId = id => ltiles().find(t => t.id === id);
function leff(t){
const e = L.edits[t.id] || {};
return {...t, where: e.where ?? t.where, name: e.name ?? t.name, changed: !!L.edits[t.id]};
}
function lchanges(){ return Object.keys(L.edits).length + (L.dragged ? 1 : 0); }
function lgroups(w){
const all = ltiles().map(leff);
if (w === 'hidden') return [{key:'hidden', label:'Hidden from the home screen', list: all.filter(t => t.where === 'hidden')}];
const sys = all.filter(t => t.kind === 'system' && t.where === w).sort((a,b) => (b.home - a.home) || (a.homeIdx - b.homeIdx) || (b.access - a.access));
return [{key:'system', label:'System', list: sys},
{key:'mine', label: w === 'games' ? 'Games' : 'Apps', list: L.order.map(id => leff(lbyId(id))).filter(t => t.where === w)}];
}
function lrowFor(w){
const [sys, mine] = lgroups(w).map(g => g.list);
if (w === 'media') return [...mine, ...sys];
const lib = sys.filter(t => t.id === LIB), lead = sys.filter(t => t.home && t.id !== LIB), rest = sys.filter(t => !t.home && t.id !== LIB);
return [...lead, ...mine, ...lib, ...rest];
}
const initials = n => esc(String(n).replace(/[^\p{L}\p{N}\s]/gu, '').split(/\s+/).filter(Boolean).slice(0,2).map(x => x[0]).join('').toUpperCase());
function setImg(el, src, name){
el.innerHTML = `<i>${initials(name)}</i>`;
if (!src) return;
const im = new Image(); im.onload = () => { el.style.backgroundImage = `url('${src}')`; el.innerHTML = ''; }; im.src = src;
}
function lrender(){
const has = !!L.info;
$('#lvEmpty').classList.toggle('hidden', has); $('#lvBody').classList.toggle('hidden', !has);
lfooter();
if (!has) { $('#lside').innerHTML = '<p class="lnote">Select a tile to change it.</p>'; return; }
$('#lvWarn').innerHTML = (L.info.warnings || []).map(w => `<div class="lwarn">${esc(w)}</div>`).join('');
document.querySelectorAll('.ltab').forEach(b => b.setAttribute('aria-pressed', b.dataset.w === L.w));
$('#lcG').textContent = lgroups('games').reduce((n,g) => n + g.list.length, 0);
$('#lcM').textContent = lgroups('media').reduce((n,g) => n + g.list.length, 0);
$('#lcH').textContent = lgroups('hidden')[0].list.length;
$('#lsorts').classList.toggle('hidden', !L.info.canOrder || L.w === 'hidden');
// strip
const gs = lgroups(L.w);
const strip = $('#lstrip');
if (!gs.some(g => g.list.length)) strip.innerHTML = `<div class="lempty">${L.w === 'hidden' ? 'Nothing is hidden. Select a tile and choose Hidden to take it off the home screen.' : 'No tiles here.'}</div>`;
else {
strip.innerHTML = gs.filter(g => g.list.length).map(g => `<div class="lgrp"><span>${g.label}</span><div class="lcards">${g.list.map(t =>
`<button class="lcard" data-id="${t.id}" data-g="${g.key}" draggable="${g.key === 'mine' && L.info.canOrder}" ${t.id === L.sel ? 'aria-current="true"' : ''}>
<div class="im" data-src="${esc(limg(t.icon))}" data-n="${esc(t.name)}"></div>${t.changed ? '<span class="bd">Edited</span>' : ''}
<div class="nm" title="${esc(t.name)}">${esc(t.name)}</div></button>`).join('')}</div></div>`).join('');
strip.querySelectorAll('.im').forEach(el => setImg(el, el.dataset.src, el.dataset.n));
strip.querySelector('[aria-current="true"]')?.scrollIntoView({block:'nearest', inline:'nearest'});
}
// preview
const pw = L.w === 'hidden' ? 'games' : L.w;
document.querySelectorAll('.ltabs span').forEach(s => s.classList.toggle('on', s.dataset.w === pw));
const row = lrowFor(pw);
if (L.sel) { const i = row.findIndex(t => t.id === L.sel); if (i >= 0) L.focus = i; }
L.focus = Math.max(0, Math.min(L.focus, row.length - 1));
$('#lrow').innerHTML = row.slice(0, 16).map((t,i) => `<div class="ltile ${i === L.focus ? 'sel' : ''}" data-id="${t.id}" data-src="${esc(limg(t.icon))}" data-n="${esc(t.name)}"></div>`).join('');
$('#lrow').querySelectorAll('.ltile').forEach(el => setImg(el, el.dataset.src, el.dataset.n));
const f = row[L.focus];
$('#ltitle').textContent = f ? f.name : 'Nothing on this tab';
$('#lsub').textContent = f && f.kind === 'system' ? 'System' : '';
const bg = $('#lbg');
if (f && f.bg) { bg.onload = () => bg.classList.remove('hidden'); bg.onerror = () => bg.classList.add('hidden'); bg.src = limg(f.bg); }
else bg.classList.add('hidden');
lside();
}
function lside(){
const box = $('#lside'), t0 = L.sel && lbyId(L.sel);
if (!t0) { box.innerHTML = '<p class="lnote">Select a tile to change it.</p>'; return; }
const t = leff(t0), e = L.edits[t.id] || {};
const art = st.apps.find(a => a.title_id === t.id && a.editable);
box.innerHTML = `<div><h2>${esc(t.name)}</h2><div class="id">${esc(t.id)}${t.kind === 'system' ? ' · system tile' : ''}</div></div>
<div class="lsec"><span class="lbl">Shows on</span><div class="lseg">${['games','media','hidden'].map(v =>
`<button data-where="${v}" aria-pressed="${t.where === v}">${v[0].toUpperCase() + v.slice(1)}</button>`).join('')}</div></div>
<div class="lsec"><label for="lname">Name on the home screen</label><input class="ltxt" id="lname" value="${esc(t.name)}" maxlength="80">
${e.name != null ? '<button class="linker" id="lname0">Use original name</button>' : ''}</div>
${art ? '<div class="lsec"><span class="lbl">Icon and background</span><button class="btn" id="lart">Edit art</button></div>' : ''}
${t.changed ? '<button class="linker" id="lundo" style="color:var(--bad)">Undo changes to this tile</button>' : ''}`;
}
function lfooter(){
const n = lchanges(), p = $('#lpend');
p.classList.toggle('has', n > 0);
p.querySelector('span').textContent = n ? `${n} unsaved change${n > 1 ? 's' : ''}` : 'No changes';
$('#lApply').disabled = !n || !L.info; $('#lDiscard').disabled = !n;
['#lPresets','#lReload'].forEach(s => $(s).disabled = !L.info);
}
function lsetEdit(id, key, val){
const t = lbyId(id), e = L.edits[id] ||= {};
if (val === t[key] || val === '' || val == null) delete e[key]; else e[key] = val;
if (!Object.keys(e).length) delete L.edits[id];
lrender();
}
function lreset(){
L.order = ltiles().filter(t => t.kind !== 'system').sort((a,b) => b.access - a.access).map(t => t.id);
L.base = [...L.order]; L.edits = {}; L.dragged = false; $('#llock').checked = false;
}
function busyv(on, t){ $('#busyv').classList.toggle('open', on); if (t) $('#busyT').textContent = t; }
function dlg(html){ $('#dlgBody').innerHTML = html; $('#dlg').classList.add('open'); }
function dlgClose(){ $('#dlg').classList.remove('open'); }
$('#dlg').addEventListener('click', e => { if (e.target.id === 'dlg' || e.target.closest('[data-close]')) dlgClose(); });
async function lload(){
busyv(true, 'Reading your home screen…');
try { L.info = await api('/api/layout/load', {method:'POST', body: JSON.stringify({})}); lreset(); L.loaded = true; }
catch (err) { L.info = null; $('#lvEmpty').innerHTML = `<h1>Couldn't read the home screen</h1><p>${esc(err.message)}</p>`; }
busyv(false); lrender();
}
// views
function setView(v){
document.querySelectorAll('.views button').forEach(b => b.setAttribute('aria-pressed', b.dataset.v === v));
document.querySelector('.layout').classList.toggle('hidden', v !== 'art');
$('#lv').classList.toggle('hidden', v !== 'layout');
if (v === 'layout' && !L.loaded && $('#dot').classList.contains('ok')) lload();
lrender();
}
document.querySelectorAll('.views button').forEach(b => b.onclick = () => setView(b.dataset.v));
// interactions
document.querySelectorAll('.ltab').forEach(b => b.onclick = () => { L.w = b.dataset.w; L.focus = 0; lrender(); });
document.querySelectorAll('.ltabs span').forEach(s => s.onclick = () => { L.w = s.dataset.w; L.focus = 0; L.sel = null; lrender(); });
$('#lstrip').addEventListener('click', e => { const c = e.target.closest('.lcard'); if (c) { L.sel = c.dataset.id; lrender(); } });
$('#lrow').addEventListener('click', e => { const c = e.target.closest('.ltile'); if (c) { L.sel = c.dataset.id; lrender(); } });
document.addEventListener('keydown', e => {
if ($('#lv').classList.contains('hidden') || !L.info || /INPUT|TEXTAREA/.test(document.activeElement.tagName) || $('#dlg').classList.contains('open')) return;
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const row = lrowFor(L.w === 'hidden' ? 'games' : L.w); if (!row.length) return;
L.focus = Math.max(0, Math.min(row.length - 1, L.focus + (e.key === 'ArrowRight' ? 1 : -1)));
L.sel = row[L.focus].id; lrender(); e.preventDefault();
});
$('#lside').addEventListener('click', e => {
const id = L.sel; if (!id) return;
const w = e.target.closest('[data-where]'); if (w) return lsetEdit(id, 'where', w.dataset.where);
if (e.target.closest('#lname0')) return lsetEdit(id, 'name', null);
if (e.target.closest('#lundo')) { delete L.edits[id]; return lrender(); }
if (e.target.closest('#lart')) {
setView('art');
const btn = [...document.querySelectorAll('.app')].find(b => b.querySelector('.i').textContent.startsWith(id));
const a = st.apps.find(x => x.title_id === id);
if (a && a.hidden && !st.showHidden) { st.showHidden = true; renderApps(); }
const b2 = [...document.querySelectorAll('.app')].find(b => b.querySelector('.i').textContent.startsWith(id)) || btn;
if (a && b2) select(a, b2);
}
});
$('#lside').addEventListener('change', e => { if (e.target.id === 'lname') lsetEdit(L.sel, 'name', e.target.value.trim()); });
$('#lside').addEventListener('keydown', e => { if (e.target.id === 'lname' && e.key === 'Enter') e.target.blur(); });
let ldrag = null;
$('#lstrip').addEventListener('dragstart', e => { const c = e.target.closest('.lcard'); if (!c || c.dataset.g !== 'mine') return; ldrag = c.dataset.id; c.classList.add('drag'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); });
$('#lstrip').addEventListener('dragend', () => { ldrag = null; document.querySelectorAll('.lcard').forEach(c => c.classList.remove('drag','over')); });
$('#lstrip').addEventListener('dragover', e => { const c = e.target.closest('.lcard'); if (!ldrag || !c || c.dataset.g !== 'mine') return; e.preventDefault(); document.querySelectorAll('.lcard.over').forEach(x => x.classList.remove('over')); c.classList.add('over'); });
$('#lstrip').addEventListener('drop', e => {
e.preventDefault(); const c = e.target.closest('.lcard'); if (!ldrag || !c || c.dataset.g !== 'mine' || c.dataset.id === ldrag) return;
L.order.splice(L.order.indexOf(ldrag), 1); L.order.splice(L.order.indexOf(c.dataset.id), 0, ldrag);
L.dragged = true; L.sel = ldrag; lrender();
});
document.querySelectorAll('[data-sort]').forEach(b => b.onclick = () => {
const k = b.dataset.sort;
if (k === 'recent') { L.order = [...L.base]; L.dragged = false; return lrender(); }
const key = id => leff(lbyId(id)).name.replace(/^[^\p{L}\p{N}]+/u, '').toLocaleLowerCase();
L.order.sort((a,b) => key(a).localeCompare(key(b)) * (k === 'za' ? -1 : 1));
L.dragged = true; lrender();
});
$('#lDiscard').onclick = () => { lreset(); lrender(); };
$('#lReload').onclick = () => { if (lchanges() && !confirm('Reloading discards your unsaved changes. Continue?')) return; lload(); };
const presetData = () => ({edits: L.edits, order: L.order, dragged: L.dragged, lock: $('#llock').checked});
function describe(e){
const p = [];
if (e.where) p.push(e.where === 'hidden' ? 'hide' : 'show on ' + e.where);
if (e.name != null) p.push(`rename to “${e.name}”`);
return p.join(', ');
}
$('#lApply').onclick = () => {
const lines = Object.entries(L.edits).map(([id, e]) => `<div class="li"><div>${esc(lbyId(id).name)}<small>${esc(describe(e))}</small></div></div>`);
if (L.dragged) lines.push(`<div class="li"><div>${$('#llock').checked ? 'Locked order' : 'New order'}<small>${esc(L.order.slice(0,6).map(id => leff(lbyId(id)).name).join(' → '))}${L.order.length > 6 ? ' …' : ''}</small></div></div>`);
dlg(`<h3>Apply ${lchanges()} change${lchanges() > 1 ? 's' : ''} to your PS5?</h3>
<p>PSGFX saves a backup of your current home screen first. Restart the PS5 afterwards to see the result.</p>
${lines.join('')}
<label class="chk"><input type="checkbox" id="dSave" checked> Also save this layout as a preset</label>
<input class="ltxt" id="dName" value="${esc(L.lastPreset || 'My layout')}" maxlength="60" aria-label="Preset name">
<p class="lnote" style="margin-top:6px">Re-apply it in one click after a firmware update or database rebuild resets your home screen.</p>
<div class="acts"><button class="btn" data-close>Cancel</button><button class="btn primary" id="dGo">Apply changes</button></div>`);
$('#dSave').onchange = e => $('#dName').disabled = !e.target.checked;
$('#dGo').onclick = async () => {
if ($('#dSave').checked) {
const name = $('#dName').value.trim() || 'My layout';
try { await api('/api/layout/preset/save', {method:'POST', body: JSON.stringify({name, data: presetData()})}); L.lastPreset = name; }
catch (err) { if (!confirm(`Couldn't save the preset (${err.message}). Apply anyway?`)) return; }
}
dlgClose(); busyv(true, 'Backing up and applying…');
const edits = {};
for (const [id, e] of Object.entries(L.edits)) edits[id] = e;
try {
const j = await api('/api/layout/apply', {method:'POST', body: JSON.stringify({edits, recent: L.order, recentChanged: L.dragged, lock: $('#llock').checked})});
L.info = j.layout; lreset(); lrender();
dlg(`<h3>Changes applied</h3><p>Restart your PS5 to see the new home screen. If anything looks wrong, restore backup ${esc(j.backup)} from Backups.</p><pre>${esc(j.log.join('\n'))}</pre><div class="acts"><button class="btn primary" data-close>Done</button></div>`);
} catch (err) {
dlg(`<h3>Changes weren't applied</h3><p>${esc(err.message)}</p><div class="acts"><button class="btn primary" data-close>OK</button></div>`);
}
busyv(false);
};
};
$('#lBackups').onclick = async () => {
const j = await api('/api/layout/backups');
dlg(`<h3>Home screen backups</h3><p>Saved in the "PSGFX Layout" folder next to PSGFX.exe, before every change, plus one of the very first home screen PSGFX read.</p>
${j.backups.length ? j.backups.map(b => `<div class="li"><div>${esc(b.label)}<small>${esc(b.created)} · ${esc(b.ps5)}</small></div><button class="btn" data-restore="${esc(b.id)}" ${$('#dot').classList.contains('ok') ? '' : 'disabled'}>Restore</button></div>`).join('') : '<p class="lnote">No backups yet. One is made the first time you open Home layout.</p>'}
<div class="acts"><button class="btn primary" data-close>Close</button></div>`);
document.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => {
if (!confirm('Upload this backup to your PS5? Your current home screen is replaced by it.')) return;
dlgClose(); busyv(true, 'Restoring backup…');
try { const r = await api('/api/layout/restore', {method:'POST', body: JSON.stringify({id: b.dataset.restore})}); L.info = r.layout; lreset(); lrender();
dlg('<h3>Backup restored</h3><p>Restart your PS5 to load it.</p><div class="acts"><button class="btn primary" data-close>Done</button></div>'); }
catch (err) { alert(err.message); }
busyv(false);
});
};
$('#lPresets').onclick = async () => {
const j = await api('/api/layout/presets');
dlg(`<h3>Layout presets</h3><p>Load one, check the preview, then Apply to PS5.</p>
${j.presets.length ? j.presets.map(n => `<div class="li"><div>${esc(n)}</div><button class="btn" data-load="${esc(n)}">Load</button></div>`).join('') : '<p class="lnote">No presets yet. Tick "Also save this layout as a preset" when you apply.</p>'}
<div class="acts"><button class="btn primary" data-close>Close</button></div>`);
document.querySelectorAll('[data-load]').forEach(b => b.onclick = async () => {
const p = (await api('/api/layout/preset/load', {method:'POST', body: JSON.stringify({name: b.dataset.load})})).data;
lreset(); let n = 0;
for (const [id, e] of Object.entries(p.edits || {})) if (lbyId(id)) { L.edits[id] = {...e}; n++; }
if (p.dragged && p.order) { const known = p.order.filter(id => L.order.includes(id)); L.order = [...known, ...L.order.filter(id => !known.includes(id))]; L.dragged = true; }
$('#llock').checked = !!p.lock; L.lastPreset = b.dataset.load;
dlgClose(); lrender();
dlg(`<h3>Preset loaded</h3><p>${n} tile${n === 1 ? '' : 's'} matched this console${p.dragged ? ', plus your order' : ''}. Check the preview, then Apply to PS5.</p><div class="acts"><button class="btn primary" data-close>OK</button></div>`);
});
};
window.addEventListener('beforeunload', e => { if (lchanges()) { e.preventDefault(); e.returnValue = ''; } });
api('/api/config').then(j => { if (j.ip) { $('#ip').value = j.ip; $('#cf').requestSubmit(); } });
</script>
</body>
+117
View File
@@ -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()
-125
View File
@@ -1,125 +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 = {}
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 == 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)