mirror of
https://github.com/kamb205/KB-RPCS3.git
synced 2026-10-18 16:00:52 +02:00
986 lines
32 KiB
C++
986 lines
32 KiB
C++
// RPCS3 PS5 - PS3 games on the PS5's own home screen, and deleting games (tiles.hpp).
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "tiles.hpp"
|
|
#include "core.hpp"
|
|
#include "requests.hpp"
|
|
|
|
#include "stb_image.h" // implemented in base/VulkanglTFModel.cpp
|
|
|
|
#include <arpa/inet.h>
|
|
#include <dirent.h>
|
|
#include <netdb.h>
|
|
#include <netinet/in.h>
|
|
#include <netinet/tcp.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/time.h>
|
|
#include <unistd.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <fstream>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <sstream>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#ifndef PS5_APP_ROOT
|
|
#define PS5_APP_ROOT "/app0"
|
|
#endif
|
|
|
|
// zlib, linked in with RADV (the PNG encoder below)
|
|
extern "C" {
|
|
int compress2(unsigned char *dest, unsigned long *dest_len, const unsigned char *source, unsigned long source_len, int level);
|
|
unsigned long compressBound(unsigned long source_len);
|
|
unsigned long crc32(unsigned long crc, const unsigned char *buf, unsigned int len);
|
|
}
|
|
|
|
namespace rpcs3ps5
|
|
{
|
|
|
|
namespace
|
|
{
|
|
|
|
constexpr int kHelperPorts[] = {9078, 9079}; // 9079: when 9078 is held by a stuck helper
|
|
constexpr int kLoaderPort = 9021;
|
|
constexpr std::int64_t kHelperVersion = 9;
|
|
constexpr std::uint32_t kMagic = 0x4f493352u; // "R3IO"
|
|
constexpr std::size_t kChunk = 1u << 20;
|
|
|
|
std::atomic<bool> g_busy{false};
|
|
std::atomic<bool> g_rescan{false};
|
|
std::mutex g_list_mutex;
|
|
|
|
std::string tiles_file()
|
|
{
|
|
return Library::root() + "/ps5-tiles.txt";
|
|
}
|
|
|
|
// ---- the write helper (tools/iohelper) ----
|
|
|
|
struct __attribute__((packed)) HelperRequest
|
|
{
|
|
std::uint32_t magic;
|
|
std::uint32_t path_len;
|
|
std::uint64_t inode;
|
|
std::uint64_t offset;
|
|
std::uint64_t size;
|
|
};
|
|
|
|
bool send_all(int s, const void *data, std::size_t size)
|
|
{
|
|
const char *p = static_cast<const char *>(data);
|
|
while (size) {
|
|
const ssize_t n = ::send(s, p, size, 0);
|
|
if (n <= 0)
|
|
return false;
|
|
p += n;
|
|
size -= static_cast<std::size_t>(n);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool recv_all(int s, void *data, std::size_t size)
|
|
{
|
|
char *p = static_cast<char *>(data);
|
|
while (size) {
|
|
const ssize_t n = ::recv(s, p, size, 0);
|
|
if (n <= 0)
|
|
return false;
|
|
p += n;
|
|
size -= static_cast<std::size_t>(n);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
int connect_local(int port)
|
|
{
|
|
const int s = ::socket(AF_INET, SOCK_STREAM, 0);
|
|
if (s < 0)
|
|
return -1;
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_port = htons(static_cast<std::uint16_t>(port));
|
|
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
if (::connect(s, reinterpret_cast<sockaddr *>(&a), sizeof(a)) != 0) {
|
|
::close(s);
|
|
return -1;
|
|
}
|
|
const int one = 1;
|
|
::setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
|
// a helper stuck in a system call accepts but never answers
|
|
timeval timeout{8, 0};
|
|
::setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
|
return s;
|
|
}
|
|
|
|
class Helper
|
|
{
|
|
public:
|
|
~Helper()
|
|
{
|
|
if (socket_ >= 0)
|
|
::close(socket_);
|
|
}
|
|
|
|
// Connected to a helper of this version, starting (or replacing) it when needed
|
|
bool ready()
|
|
{
|
|
if (socket_ >= 0)
|
|
return true;
|
|
for (int attempt = 0; attempt < 2; ++attempt) {
|
|
bool any = false;
|
|
for (const int port : kHelperPorts) {
|
|
socket_ = connect_local(port);
|
|
if (socket_ < 0)
|
|
continue;
|
|
any = true;
|
|
const std::int64_t version = call("\x04version", 0, nullptr, 0);
|
|
if (version == kHelperVersion)
|
|
return true;
|
|
trace("tiles: helper on :%d answered %lld", port, static_cast<long long>(version));
|
|
if (version != INT64_MIN && socket_ >= 0)
|
|
call("\x01quit", 0, nullptr, 0); // an older one: replaced below
|
|
drop();
|
|
}
|
|
(void)any;
|
|
if (!start())
|
|
return false;
|
|
for (int i = 0; i < 50; ++i) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
for (const int port : kHelperPorts) {
|
|
socket_ = connect_local(port);
|
|
if (socket_ >= 0 && call("\x04version", 0, nullptr, 0) == kHelperVersion)
|
|
return true;
|
|
drop();
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// One request; the helper's answer (bytes written, 0, or -errno); INT64_MIN when the link failed
|
|
std::int64_t call(const std::string &path, std::uint64_t offset, const void *data, std::size_t size)
|
|
{
|
|
if (socket_ < 0)
|
|
return INT64_MIN;
|
|
const HelperRequest q{kMagic, static_cast<std::uint32_t>(path.size()), 0, offset, size};
|
|
std::int64_t result = 0;
|
|
if (!send_all(socket_, &q, sizeof(q)) || !send_all(socket_, path.data(), path.size()) ||
|
|
(size && !send_all(socket_, data, size)) || !recv_all(socket_, &result, sizeof(result))) {
|
|
drop();
|
|
return INT64_MIN;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// A whole file into a tile folder ("PPSA8nnnn/sce_sys/icon0.png"), in 1 MiB requests
|
|
bool write_tile_file(const std::string &relative, const std::vector<std::uint8_t> &data)
|
|
{
|
|
std::size_t done = 0;
|
|
do {
|
|
const std::size_t n = std::min(kChunk, data.size() - done);
|
|
const std::int64_t r = call("\x02tile/" + relative, done, data.data() + done, n);
|
|
if (r != static_cast<std::int64_t>(n)) {
|
|
trace("tiles: writing %s failed (%lld)", relative.c_str(), static_cast<long long>(r));
|
|
return false;
|
|
}
|
|
done += n;
|
|
} while (done < data.size());
|
|
return true;
|
|
}
|
|
|
|
private:
|
|
void drop()
|
|
{
|
|
if (socket_ >= 0)
|
|
::close(socket_);
|
|
socket_ = -1;
|
|
}
|
|
|
|
// The helper's ELF to the ELF loader (as the game-install write path does)
|
|
bool start()
|
|
{
|
|
std::vector<std::uint8_t> elf;
|
|
std::ifstream in(PS5_APP_ROOT "/rpcs3ps5-io.elf", std::ios::binary);
|
|
elf.assign(std::istreambuf_iterator<char>(in), {});
|
|
const int loader = elf.empty() ? -1 : connect_local(kLoaderPort);
|
|
if (loader < 0) {
|
|
trace("tiles: no write helper and no ELF loader on :%d", kLoaderPort);
|
|
return false;
|
|
}
|
|
send_all(loader, elf.data(), elf.size());
|
|
::close(loader);
|
|
return true;
|
|
}
|
|
|
|
int socket_ = -1;
|
|
};
|
|
|
|
// GameTDB's high-resolution box art for a serial, its region first. The helper downloads it (the title's
|
|
// sandbox has no network) into <root>/covers/<serial>.jpg, which the tile and the launch screen use.
|
|
bool download_cover(Helper &helper, const std::string &serial, std::vector<std::uint8_t> &jpeg)
|
|
{
|
|
if (serial.size() != 9)
|
|
return false;
|
|
std::vector<std::string> regions;
|
|
switch (serial[2]) {
|
|
case 'U': regions = {"US", "EN"}; break;
|
|
case 'J': regions = {"JA", "US", "EN"}; break;
|
|
case 'A': case 'H': case 'K': regions = {"ZH", "KO", "JA", "US", "EN"}; break;
|
|
default: regions = {"EN", "FR", "DE", "ES", "IT", "NL", "PT", "RU", "US"}; break;
|
|
}
|
|
for (const std::string ®ion : regions) {
|
|
const std::string path = "/ps3/coverHQ/" + region + "/" + serial + ".jpg";
|
|
const std::int64_t r = helper.call("\x06get/" + serial + ".jpg|" + path, 0, nullptr, 0);
|
|
trace("tiles: http://art.gametdb.com%s: %lld", path.c_str(), static_cast<long long>(r));
|
|
if (r > 1000) {
|
|
std::ifstream in(Library::root() + "/covers/" + serial + ".jpg", std::ios::binary);
|
|
jpeg.assign(std::istreambuf_iterator<char>(in), {});
|
|
return !jpeg.empty();
|
|
}
|
|
if (r != -404)
|
|
return false; // offline, or GameTDB unreachable
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---- images ----
|
|
|
|
struct Image
|
|
{
|
|
int w = 0, h = 0;
|
|
std::vector<float> px; // RGBA, 0..1
|
|
|
|
bool decode(const std::vector<std::uint8_t> &file)
|
|
{
|
|
int channels = 0;
|
|
stbi_uc *rgba = file.empty() ? nullptr
|
|
: stbi_load_from_memory(file.data(), static_cast<int>(file.size()), &w, &h, &channels, 4);
|
|
if (!rgba)
|
|
return false;
|
|
px.resize(static_cast<std::size_t>(w) * h * 4);
|
|
for (std::size_t i = 0; i < px.size(); ++i)
|
|
px[i] = rgba[i] / 255.0f;
|
|
stbi_image_free(rgba);
|
|
return true;
|
|
}
|
|
bool empty() const
|
|
{
|
|
return px.empty();
|
|
}
|
|
float *at(int x, int y)
|
|
{
|
|
return &px[(static_cast<std::size_t>(y) * w + x) * 4];
|
|
}
|
|
const float *at(int x, int y) const
|
|
{
|
|
return &px[(static_cast<std::size_t>(y) * w + x) * 4];
|
|
}
|
|
};
|
|
|
|
Image blank(int w, int h)
|
|
{
|
|
Image out;
|
|
out.w = w;
|
|
out.h = h;
|
|
out.px.assign(static_cast<std::size_t>(w) * h * 4, 0.0f);
|
|
for (std::size_t i = 3; i < out.px.size(); i += 4)
|
|
out.px[i] = 1.0f;
|
|
return out;
|
|
}
|
|
|
|
// The source rectangle (sx, sy, sw, sh) scaled to dw x dh: area averages when shrinking, bilinear when growing
|
|
Image resample(const Image &src, float sx, float sy, float sw, float sh, int dw, int dh)
|
|
{
|
|
Image out = blank(dw, dh);
|
|
const float fx = sw / dw, fy = sh / dh;
|
|
for (int y = 0; y < dh; ++y) {
|
|
for (int x = 0; x < dw; ++x) {
|
|
float acc[4] = {};
|
|
if (fx > 1.0f || fy > 1.0f) {
|
|
const int x0 = std::clamp(static_cast<int>(sx + x * fx), 0, src.w - 1);
|
|
const int x1 = std::clamp(static_cast<int>(std::ceil(sx + (x + 1) * fx)), x0 + 1, src.w);
|
|
const int y0 = std::clamp(static_cast<int>(sy + y * fy), 0, src.h - 1);
|
|
const int y1 = std::clamp(static_cast<int>(std::ceil(sy + (y + 1) * fy)), y0 + 1, src.h);
|
|
for (int yy = y0; yy < y1; ++yy)
|
|
for (int xx = x0; xx < x1; ++xx)
|
|
for (int c = 0; c < 4; ++c)
|
|
acc[c] += src.at(xx, yy)[c];
|
|
const float n = static_cast<float>((x1 - x0) * (y1 - y0));
|
|
for (float &a : acc)
|
|
a /= n;
|
|
} else {
|
|
const float u = std::clamp(sx + (x + 0.5f) * fx - 0.5f, 0.0f, static_cast<float>(src.w - 1));
|
|
const float v = std::clamp(sy + (y + 0.5f) * fy - 0.5f, 0.0f, static_cast<float>(src.h - 1));
|
|
const int x0 = static_cast<int>(u), y0 = static_cast<int>(v);
|
|
const int x1 = std::min(x0 + 1, src.w - 1), y1 = std::min(y0 + 1, src.h - 1);
|
|
const float tx = u - x0, ty = v - y0;
|
|
for (int c = 0; c < 4; ++c) {
|
|
const float top = src.at(x0, y0)[c] * (1 - tx) + src.at(x1, y0)[c] * tx;
|
|
const float bottom = src.at(x0, y1)[c] * (1 - tx) + src.at(x1, y1)[c] * tx;
|
|
acc[c] = top * (1 - ty) + bottom * ty;
|
|
}
|
|
}
|
|
std::copy(acc, acc + 4, out.at(x, y));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Scaled and centre-cropped to fill w x h
|
|
Image fill(const Image &src, int w, int h)
|
|
{
|
|
const float scale = std::max(static_cast<float>(w) / src.w, static_cast<float>(h) / src.h);
|
|
const float sw = w / scale, sh = h / scale;
|
|
return resample(src, (src.w - sw) * 0.5f, (src.h - sh) * 0.5f, sw, sh, w, h);
|
|
}
|
|
|
|
// Scaled to fit inside w x h
|
|
Image fit(const Image &src, int w, int h)
|
|
{
|
|
const float scale = std::min(static_cast<float>(w) / src.w, static_cast<float>(h) / src.h);
|
|
return resample(src, 0, 0, static_cast<float>(src.w), static_cast<float>(src.h),
|
|
std::max(1, static_cast<int>(std::lround(src.w * scale))), std::max(1, static_cast<int>(std::lround(src.h * scale))));
|
|
}
|
|
|
|
// A soft, darkened copy for behind the art: shrunk to a few pixels and grown back
|
|
Image backdrop(const Image &src, int w, int h, float brightness)
|
|
{
|
|
const Image tiny = fill(src, std::max(4, w / 40), std::max(4, h / 40));
|
|
Image out = resample(tiny, 0, 0, static_cast<float>(tiny.w), static_cast<float>(tiny.h), w, h);
|
|
for (std::size_t i = 0; i < out.px.size(); ++i)
|
|
if (i % 4 != 3)
|
|
out.px[i] *= brightness;
|
|
return out;
|
|
}
|
|
|
|
void place(Image &canvas, const Image &art, int left, int top)
|
|
{
|
|
for (int y = 0; y < art.h; ++y) {
|
|
for (int x = 0; x < art.w; ++x) {
|
|
const int cx = left + x, cy = top + y;
|
|
if (cx < 0 || cy < 0 || cx >= canvas.w || cy >= canvas.h)
|
|
continue;
|
|
const float *s = art.at(x, y);
|
|
float *d = canvas.at(cx, cy);
|
|
for (int c = 0; c < 3; ++c)
|
|
d[c] = s[c] * s[3] + d[c] * (1 - s[3]);
|
|
}
|
|
}
|
|
}
|
|
|
|
void put_u32(std::vector<std::uint8_t> &out, std::uint32_t v)
|
|
{
|
|
for (int i = 3; i >= 0; --i)
|
|
out.push_back(static_cast<std::uint8_t>(v >> (i * 8)));
|
|
}
|
|
|
|
void chunk(std::vector<std::uint8_t> &out, const char *type, const std::vector<std::uint8_t> &data)
|
|
{
|
|
put_u32(out, static_cast<std::uint32_t>(data.size()));
|
|
const std::size_t start = out.size();
|
|
out.insert(out.end(), type, type + 4);
|
|
out.insert(out.end(), data.begin(), data.end());
|
|
put_u32(out, static_cast<std::uint32_t>(crc32(0, out.data() + start, static_cast<unsigned>(out.size() - start))));
|
|
}
|
|
|
|
// RGB PNG, each row "Sub"-filtered
|
|
std::vector<std::uint8_t> encode_png(const Image &image)
|
|
{
|
|
const std::size_t stride = static_cast<std::size_t>(image.w) * 3 + 1;
|
|
std::vector<std::uint8_t> raw(stride * image.h);
|
|
for (int y = 0; y < image.h; ++y) {
|
|
std::uint8_t *row = &raw[stride * y];
|
|
row[0] = 1; // Sub
|
|
std::uint8_t previous[3] = {};
|
|
for (int x = 0; x < image.w; ++x) {
|
|
for (int c = 0; c < 3; ++c) {
|
|
const std::uint8_t v = static_cast<std::uint8_t>(std::lround(std::clamp(image.at(x, y)[c], 0.0f, 1.0f) * 255.0f));
|
|
row[1 + x * 3 + c] = static_cast<std::uint8_t>(v - previous[c]);
|
|
previous[c] = v;
|
|
}
|
|
}
|
|
}
|
|
unsigned long packed_size = compressBound(raw.size());
|
|
std::vector<std::uint8_t> packed(packed_size);
|
|
if (compress2(packed.data(), &packed_size, raw.data(), raw.size(), 6) != 0)
|
|
return {};
|
|
packed.resize(packed_size);
|
|
|
|
std::vector<std::uint8_t> png = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'};
|
|
std::vector<std::uint8_t> header;
|
|
put_u32(header, static_cast<std::uint32_t>(image.w));
|
|
put_u32(header, static_cast<std::uint32_t>(image.h));
|
|
header.insert(header.end(), {8, 2, 0, 0, 0}); // 8-bit RGB
|
|
chunk(png, "IHDR", header);
|
|
chunk(png, "IDAT", packed);
|
|
chunk(png, "IEND", {});
|
|
return png;
|
|
}
|
|
|
|
// ---- DDS (BC7): the PS5 home screen draws a game's background (pic0) and icon from these ----
|
|
|
|
// A 128-bit BC7 block in mode 6 (one subset, RGBA endpoints of 7 bits + a p-bit each, 4-bit indices)
|
|
struct Bits
|
|
{
|
|
std::uint8_t bytes[16] = {};
|
|
int at = 0;
|
|
void put(std::uint32_t value, int count)
|
|
{
|
|
for (int i = 0; i < count; ++i, ++at)
|
|
if (value >> i & 1)
|
|
bytes[at >> 3] |= static_cast<std::uint8_t>(1u << (at & 7));
|
|
}
|
|
};
|
|
|
|
void bc7_block(const Image &image, int bx, int by, std::uint8_t *out)
|
|
{
|
|
static const int kWeights[16] = {0, 4, 9, 13, 17, 21, 26, 30, 34, 38, 43, 47, 51, 55, 60, 64};
|
|
float px[16][4];
|
|
float mean[4] = {};
|
|
for (int i = 0; i < 16; ++i) {
|
|
const int x = std::min(bx * 4 + i % 4, image.w - 1), y = std::min(by * 4 + i / 4, image.h - 1);
|
|
for (int c = 0; c < 4; ++c) {
|
|
px[i][c] = std::clamp(image.at(x, y)[c], 0.0f, 1.0f) * 255.0f;
|
|
mean[c] += px[i][c] / 16.0f;
|
|
}
|
|
}
|
|
// The main direction of the colours (a few power iterations), and the extremes along it
|
|
float axis[4] = {1, 1, 1, 0};
|
|
for (int iteration = 0; iteration < 4; ++iteration) {
|
|
float next[4] = {};
|
|
for (int i = 0; i < 16; ++i) {
|
|
float d[4], dot = 0;
|
|
for (int c = 0; c < 4; ++c) {
|
|
d[c] = px[i][c] - mean[c];
|
|
dot += d[c] * axis[c];
|
|
}
|
|
for (int c = 0; c < 4; ++c)
|
|
next[c] += d[c] * dot;
|
|
}
|
|
const float length = std::sqrt(next[0] * next[0] + next[1] * next[1] + next[2] * next[2] + next[3] * next[3]);
|
|
if (length < 1e-6f)
|
|
break;
|
|
for (int c = 0; c < 4; ++c)
|
|
axis[c] = next[c] / length;
|
|
}
|
|
float lo = 1e9f, hi = -1e9f;
|
|
for (int i = 0; i < 16; ++i) {
|
|
float t = 0;
|
|
for (int c = 0; c < 4; ++c)
|
|
t += (px[i][c] - mean[c]) * axis[c];
|
|
lo = std::min(lo, t);
|
|
hi = std::max(hi, t);
|
|
}
|
|
int q[2][4], p[2];
|
|
for (int e = 0; e < 2; ++e) {
|
|
const float t = e ? hi : lo;
|
|
float target[4];
|
|
for (int c = 0; c < 4; ++c)
|
|
target[c] = std::clamp(mean[c] + axis[c] * t, 0.0f, 255.0f);
|
|
// the p-bit (shared by the endpoint's four channels) that fits best
|
|
float best = 1e9f;
|
|
for (int bit = 0; bit < 2; ++bit) {
|
|
float error = 0;
|
|
int candidate[4];
|
|
for (int c = 0; c < 4; ++c) {
|
|
candidate[c] = std::clamp(static_cast<int>(std::lround((target[c] - bit) / 2.0f)), 0, 127);
|
|
const float v = static_cast<float>(candidate[c] * 2 + bit);
|
|
error += (v - target[c]) * (v - target[c]);
|
|
}
|
|
if (error < best) {
|
|
best = error;
|
|
p[e] = bit;
|
|
std::copy(candidate, candidate + 4, q[e]);
|
|
}
|
|
}
|
|
}
|
|
int palette[16][4];
|
|
for (int k = 0; k < 16; ++k)
|
|
for (int c = 0; c < 4; ++c) {
|
|
const int e0 = q[0][c] * 2 + p[0], e1 = q[1][c] * 2 + p[1];
|
|
palette[k][c] = ((64 - kWeights[k]) * e0 + kWeights[k] * e1 + 32) >> 6;
|
|
}
|
|
int index[16];
|
|
for (int i = 0; i < 16; ++i) {
|
|
float best = 1e18f;
|
|
for (int k = 0; k < 16; ++k) {
|
|
float error = 0;
|
|
for (int c = 0; c < 4; ++c) {
|
|
const float d = px[i][c] - static_cast<float>(palette[k][c]);
|
|
error += d * d;
|
|
}
|
|
if (error < best) {
|
|
best = error;
|
|
index[i] = k;
|
|
}
|
|
}
|
|
}
|
|
// The first index is stored in 3 bits: its top bit must be 0, so swap the endpoints when it is not
|
|
if (index[0] >= 8) {
|
|
std::swap(q[0], q[1]);
|
|
std::swap(p[0], p[1]);
|
|
for (int &i : index)
|
|
i = 15 - i;
|
|
}
|
|
Bits bits;
|
|
bits.put(1u << 6, 7); // mode 6
|
|
for (int c = 0; c < 4; ++c) {
|
|
bits.put(static_cast<std::uint32_t>(q[0][c]), 7);
|
|
bits.put(static_cast<std::uint32_t>(q[1][c]), 7);
|
|
}
|
|
bits.put(static_cast<std::uint32_t>(p[0]), 1);
|
|
bits.put(static_cast<std::uint32_t>(p[1]), 1);
|
|
bits.put(static_cast<std::uint32_t>(index[0]), 3);
|
|
for (int i = 1; i < 16; ++i)
|
|
bits.put(static_cast<std::uint32_t>(index[i]), 4);
|
|
std::memcpy(out, bits.bytes, 16);
|
|
}
|
|
|
|
void put_le32(std::vector<std::uint8_t> &out, std::size_t at, std::uint32_t v)
|
|
{
|
|
for (int i = 0; i < 4; ++i)
|
|
out[at + i] = static_cast<std::uint8_t>(v >> (i * 8));
|
|
}
|
|
|
|
// A BC7 DDS with the DX10 header, laid out as the PS5's own games have theirs (sce_sys/pic0.dds)
|
|
std::vector<std::uint8_t> encode_dds(const Image &image)
|
|
{
|
|
const int blocks_x = (image.w + 3) / 4, blocks_y = (image.h + 3) / 4;
|
|
const std::size_t data = static_cast<std::size_t>(blocks_x) * blocks_y * 16;
|
|
std::vector<std::uint8_t> dds(148 + data, 0);
|
|
std::memcpy(dds.data(), "DDS ", 4);
|
|
put_le32(dds, 4, 124);
|
|
put_le32(dds, 8, 0x000A1007); // caps, height, width, pixel format, linear size
|
|
put_le32(dds, 12, static_cast<std::uint32_t>(image.h));
|
|
put_le32(dds, 16, static_cast<std::uint32_t>(image.w));
|
|
put_le32(dds, 20, static_cast<std::uint32_t>(data));
|
|
put_le32(dds, 28, 1); // one mip level
|
|
put_le32(dds, 76, 32); // pixel format: a FourCC, "DX10"
|
|
put_le32(dds, 80, 4);
|
|
std::memcpy(dds.data() + 84, "DX10", 4);
|
|
put_le32(dds, 108, 0x1000); // a texture
|
|
put_le32(dds, 128, 98); // DXGI_FORMAT_BC7_UNORM
|
|
put_le32(dds, 132, 3); // 2D
|
|
put_le32(dds, 140, 1); // array size
|
|
for (int by = 0; by < blocks_y; ++by)
|
|
for (int bx = 0; bx < blocks_x; ++bx)
|
|
bc7_block(image, bx, by, &dds[148 + (static_cast<std::size_t>(by) * blocks_x + bx) * 16]);
|
|
return dds;
|
|
}
|
|
|
|
Image solid(int w, int h, float r, float g, float b, float a)
|
|
{
|
|
Image out = blank(w, h);
|
|
for (std::size_t i = 0; i < out.px.size(); i += 4) {
|
|
out.px[i] = r;
|
|
out.px[i + 1] = g;
|
|
out.px[i + 2] = b;
|
|
out.px[i + 3] = a;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Only the artwork of a PS3 box cover: without the top banner (the "PS3 / PlayStation Network" strip, the top
|
|
// 9%) and the bottom strip where the age rating (bottom left) and the publisher's logo (bottom right) sit
|
|
Image cover_art(const Image &cover)
|
|
{
|
|
const float top = cover.h * 0.09f, bottom = cover.h * 0.18f;
|
|
const float h = cover.h - top - bottom;
|
|
return resample(cover, 0, top, static_cast<float>(cover.w), h, cover.w, static_cast<int>(h));
|
|
}
|
|
|
|
// The tile (512x512): the box art (its artwork only) filling it, or the game's ICON0 on its PIC1
|
|
Image make_icon(const Image &cover, const Image &icon0, const Image &pic1)
|
|
{
|
|
constexpr int kSize = 512;
|
|
if (!cover.empty())
|
|
return fill(cover_art(cover), kSize, kSize);
|
|
Image canvas = backdrop(pic1.empty() ? icon0 : pic1, kSize, kSize, 0.55f);
|
|
if (!icon0.empty()) {
|
|
const Image front = fit(icon0, kSize - 24, kSize - 24);
|
|
place(canvas, front, (kSize - front.w) / 2, (kSize - front.h) / 2);
|
|
}
|
|
return canvas;
|
|
}
|
|
|
|
// The background (3840x2160): the game's PIC1, else a soft copy of the box art with the cover on it
|
|
Image make_background(const Image &cover, const Image &icon0, const Image &pic1)
|
|
{
|
|
constexpr int kW = 3840, kH = 2160;
|
|
if (!pic1.empty())
|
|
return fill(pic1, kW, kH);
|
|
const Image source = cover.empty() ? icon0 : cover_art(cover);
|
|
Image canvas = backdrop(source, kW, kH, 0.45f);
|
|
const Image front = fit(source, kW * 2 / 5, kH * 4 / 5);
|
|
place(canvas, front, (kW - front.w) / 2, (kH - front.h) / 2);
|
|
return canvas;
|
|
}
|
|
|
|
std::string json_escape(const std::string &text)
|
|
{
|
|
std::string out;
|
|
for (const char c : text) {
|
|
if (c == '"' || c == '\\')
|
|
out += '\\';
|
|
if (static_cast<unsigned char>(c) < 0x20)
|
|
continue;
|
|
out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string param_json(const std::string &id, const std::string &name)
|
|
{
|
|
std::ostringstream j;
|
|
j << "{\n"
|
|
<< " \"ageLevel\": {\"default\": 0},\n"
|
|
<< " \"applicationCategoryType\": 0,\n"
|
|
<< " \"applicationDrmType\": \"free\",\n"
|
|
<< " \"attribute\": 0,\n"
|
|
<< " \"attribute2\": 0,\n"
|
|
<< " \"attribute3\": 524352,\n"
|
|
<< " \"conceptId\": \"" << id.substr(4) << "\",\n"
|
|
<< " \"contentBadgeType\": 1,\n"
|
|
<< " \"contentId\": \"UP9000-" << id << "_00-RPCS3TILE0000000\",\n"
|
|
<< " \"contentVersion\": \"01.000.000\",\n"
|
|
<< " \"downloadDataSize\": 0,\n"
|
|
<< " \"gameIntent\": {\"permittedIntents\": [{\"intentType\": \"launchActivity\"}]},\n"
|
|
<< " \"localizedParameters\": {\"defaultLanguage\": \"en-US\", \"en-US\": {\"titleName\": \"" << json_escape(name) << "\"}},\n"
|
|
<< " \"masterVersion\": \"01.00\",\n"
|
|
<< " \"pubtools\": {\"creationDate\": \"2026-10-07 00:00:00\", \"loudnessSnd0\": \"-28.00\", \"toolVersion\": \"2.00\"},\n"
|
|
<< " \"requiredSystemSoftwareVersion\": \"0x0000000000000000\",\n"
|
|
<< " \"sdkVersion\": \"0x0000000000000000\",\n"
|
|
<< " \"titleId\": \"" << id << "\",\n"
|
|
<< " \"versionFileUri\": \"\"\n"
|
|
<< "}\n";
|
|
return j.str();
|
|
}
|
|
|
|
std::vector<std::uint8_t> bytes(const std::string &text)
|
|
{
|
|
return {text.begin(), text.end()};
|
|
}
|
|
|
|
bool read_whole(const std::string &path, std::vector<std::uint8_t> &data)
|
|
{
|
|
std::ifstream in(path, std::ios::binary);
|
|
if (!in)
|
|
return false;
|
|
data.assign(std::istreambuf_iterator<char>(in), {});
|
|
return !data.empty();
|
|
}
|
|
|
|
// ---- the list of games with a tile ----
|
|
|
|
// One line per tile: "<tile title ID> <key>", the key being the game's serial (or, for a game without a
|
|
// readable one, its path). Older lines were "<serial> <tile ID>".
|
|
struct TileEntry
|
|
{
|
|
std::string id, key;
|
|
};
|
|
|
|
std::vector<TileEntry> read_tiles()
|
|
{
|
|
std::vector<TileEntry> entries;
|
|
std::ifstream in(tiles_file());
|
|
for (std::string line; std::getline(in, line);) {
|
|
if (line.size() < 11 || line[9] != ' ')
|
|
continue;
|
|
if (line.compare(0, 5, "PPSA8") == 0)
|
|
entries.push_back({line.substr(0, 9), line.substr(10)});
|
|
else
|
|
entries.push_back({line.substr(10, 9), line.substr(0, 9)});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
void write_tiles(const std::vector<TileEntry> &entries)
|
|
{
|
|
std::ofstream out(tiles_file(), std::ios::trunc);
|
|
for (const TileEntry &e : entries)
|
|
out << e.id << ' ' << e.key << '\n';
|
|
}
|
|
|
|
std::string tile_key(const Game &game)
|
|
{
|
|
return game.title_id.size() == 9 && game.title_id != "????00000" ? game.title_id : game.boot;
|
|
}
|
|
|
|
// The tile ID a game has, or a free one for it: PPSA8 + four digits from the key's crc32, the next free
|
|
// number when another game's tile already has it
|
|
std::string assigned_tile_id(const Game &game, bool *existing = nullptr)
|
|
{
|
|
const std::string key = tile_key(game);
|
|
const std::vector<TileEntry> entries = read_tiles();
|
|
for (const TileEntry &e : entries)
|
|
if (e.key == key) {
|
|
if (existing)
|
|
*existing = true;
|
|
return e.id;
|
|
}
|
|
if (existing)
|
|
*existing = false;
|
|
unsigned number = static_cast<unsigned>(crc32(0, reinterpret_cast<const unsigned char *>(key.data()), static_cast<unsigned>(key.size())) % 10000);
|
|
for (int tries = 0; tries < 10000; ++tries, number = (number + 1) % 10000) {
|
|
char id[16];
|
|
std::snprintf(id, sizeof(id), "PPSA8%04u", number);
|
|
if (std::none_of(entries.begin(), entries.end(), [&](const TileEntry &e) { return e.id == id; }))
|
|
return id;
|
|
}
|
|
return "PPSA80000";
|
|
}
|
|
|
|
void remember_tile(const Game &game, const std::string &id, bool present)
|
|
{
|
|
std::lock_guard lock(g_list_mutex);
|
|
std::vector<TileEntry> entries = read_tiles();
|
|
const std::string key = tile_key(game);
|
|
std::erase_if(entries, [&](const TileEntry &e) { return e.key == key; });
|
|
if (present)
|
|
entries.push_back({id, key});
|
|
write_tiles(entries);
|
|
}
|
|
|
|
// ---- the jobs ----
|
|
|
|
void add_tile(const Game &game)
|
|
{
|
|
std::string id;
|
|
{
|
|
std::lock_guard lock(g_list_mutex);
|
|
id = assigned_tile_id(game);
|
|
}
|
|
set_status("Adding " + game.title + " to the PS5 home screen: getting the box art...");
|
|
Helper helper;
|
|
if (!helper.ready()) {
|
|
set_status("The write helper did not start (is the ELF loader running?): " + game.title + " not added");
|
|
return;
|
|
}
|
|
std::vector<std::uint8_t> file;
|
|
Image cover, icon0, pic1;
|
|
if (download_cover(helper, game.title_id, file) && !cover.decode(file))
|
|
trace("tiles: the cover of %s did not decode", game.title_id.c_str());
|
|
if (game_file(game, "ICON0.PNG", file))
|
|
icon0.decode(file);
|
|
if (game_file(game, "PIC1.PNG", file))
|
|
pic1.decode(file);
|
|
if (cover.empty() && icon0.empty() && pic1.empty()) {
|
|
set_status("No artwork for " + game.title + " (no box art online, no ICON0.PNG): not added");
|
|
return;
|
|
}
|
|
trace("tiles: %s -> %s: cover %dx%d, ICON0 %dx%d, PIC1 %dx%d", game.title_id.c_str(), id.c_str(), cover.w, cover.h,
|
|
icon0.w, icon0.h, pic1.w, pic1.h);
|
|
|
|
set_status("Adding " + game.title + " to the PS5 home screen: making the icon and background...");
|
|
const Image icon = make_icon(cover, icon0, pic1);
|
|
const Image background = make_background(cover, icon0, pic1);
|
|
const std::vector<std::uint8_t> icon_png = encode_png(icon);
|
|
const std::vector<std::uint8_t> background_png = encode_png(background);
|
|
// The home screen's own forms: the background behind a focused tile is pic0.dds; pic1 black and pic2 (a
|
|
// logo layer) clear, as a PS5 game has them
|
|
const std::vector<std::uint8_t> icon_dds = encode_dds(icon);
|
|
const std::vector<std::uint8_t> background_dds = encode_dds(background);
|
|
const std::vector<std::uint8_t> black_dds = encode_dds(solid(background.w, background.h, 0, 0, 0, 1));
|
|
const std::vector<std::uint8_t> clear_dds = encode_dds(solid(background.w, background.h, 1, 1, 1, 0));
|
|
std::vector<std::uint8_t> launcher, libc, launch_elf;
|
|
if (icon_png.empty() || background_png.empty() || !read_whole(PS5_APP_ROOT "/tile-launcher.bin", launcher) ||
|
|
!read_whole(PS5_APP_ROOT "/sce_module/libc.prx", libc) || !read_whole(PS5_APP_ROOT "/rpcs3ps5-launch.elf", launch_elf)) {
|
|
set_status("Could not make the tile for " + game.title + " (see ps5-title.log)");
|
|
trace("tiles: icon %zu, background %zu, launcher %zu, libc %zu bytes", icon_png.size(), background_png.size(),
|
|
launcher.size(), libc.size());
|
|
return;
|
|
}
|
|
|
|
set_status("Adding " + game.title + " to the PS5 home screen: installing the tile...");
|
|
if (!helper.ready()) {
|
|
set_status("The write helper stopped: " + game.title + " not added");
|
|
return;
|
|
}
|
|
// param.json last: ShadowMountPlus installs the folder once it is complete
|
|
const bool ok = helper.write_tile_file(id + "/eboot.bin", launcher) &&
|
|
helper.write_tile_file(id + "/sce_module/libc.prx", libc) &&
|
|
// the one-shot payload the tile sends to the ELF loader to start RPCS3 PS5 with the game
|
|
helper.write_tile_file(id + "/rpcs3ps5-launch.elf", launch_elf) &&
|
|
helper.write_tile_file(id + "/rpcs3-launch.txt", bytes(game.boot + "\n")) &&
|
|
helper.write_tile_file(id + "/sce_sys/icon0.png", icon_png) &&
|
|
helper.write_tile_file(id + "/sce_sys/pic0.png", background_png) &&
|
|
helper.write_tile_file(id + "/sce_sys/icon0.dds", icon_dds) &&
|
|
helper.write_tile_file(id + "/sce_sys/pic0.dds", background_dds) &&
|
|
helper.write_tile_file(id + "/sce_sys/pic1.dds", black_dds) &&
|
|
helper.write_tile_file(id + "/sce_sys/pic2.dds", clear_dds) &&
|
|
helper.write_tile_file(id + "/sce_sys/param.json", bytes(param_json(id, game.title)));
|
|
if (!ok) {
|
|
set_status("Writing the tile of " + game.title + " failed (see ps5-title.log)");
|
|
return;
|
|
}
|
|
remember_tile(game, id, true);
|
|
set_status(game.title + " is on the PS5 home screen (" + (cover.empty() ? "the game's own art" : "box art from GameTDB") +
|
|
"): close RPCS3 PS5 and it appears there");
|
|
}
|
|
|
|
bool remove_tile(Helper &helper, const Game &game)
|
|
{
|
|
std::string id;
|
|
bool existing = false;
|
|
{
|
|
std::lock_guard lock(g_list_mutex);
|
|
id = assigned_tile_id(game, &existing);
|
|
}
|
|
if (!existing)
|
|
return true;
|
|
const std::int64_t r = helper.call("\x03rmtile/" + id, 0, nullptr, 0);
|
|
trace("tiles: removing tile %s of %s: %lld", id.c_str(), game.title_id.c_str(), static_cast<long long>(r));
|
|
if (r == 0)
|
|
remember_tile(game, id, false);
|
|
return r == 0;
|
|
}
|
|
|
|
// Path relative to the data folder ("games/WWE12"), or "" when outside it
|
|
std::string relative(const std::string &path)
|
|
{
|
|
const std::string root = Library::root() + "/";
|
|
return path.compare(0, root.size(), root) == 0 ? path.substr(root.size()) : std::string();
|
|
}
|
|
|
|
void delete_game(const Game &game, bool with_saves)
|
|
{
|
|
set_status("Deleting " + game.title + "...");
|
|
Helper helper;
|
|
if (!helper.ready()) {
|
|
set_status("The write helper did not start (is the ELF loader running?): " + game.title + " not deleted");
|
|
return;
|
|
}
|
|
const std::string &serial = game.title_id;
|
|
remove_tile(helper, game);
|
|
|
|
std::vector<std::string> targets;
|
|
const std::string boot = relative(game.boot);
|
|
if (!boot.empty())
|
|
targets.push_back(boot);
|
|
const bool real_serial = serial.size() == 9 && serial != "????00000";
|
|
if (real_serial) {
|
|
targets.push_back("dev_hdd0/game/" + serial); // installed game data, updates, DLC
|
|
targets.push_back("cache/" + serial); // compiled PPU/SPU code and shaders
|
|
targets.push_back("custom_configs/config_" + serial + ".yml");
|
|
if (with_saves) {
|
|
const std::string homes = Library::root() + "/dev_hdd0/home";
|
|
if (DIR *users = ::opendir(homes.c_str())) {
|
|
while (const dirent *user = ::readdir(users)) {
|
|
if (user->d_name[0] == '.')
|
|
continue;
|
|
const std::string saves = homes + "/" + user->d_name + "/savedata";
|
|
if (DIR *dir = ::opendir(saves.c_str())) {
|
|
while (const dirent *save = ::readdir(dir))
|
|
if (std::strncmp(save->d_name, serial.c_str(), 9) == 0)
|
|
targets.push_back("dev_hdd0/home/" + std::string(user->d_name) + "/savedata/" + save->d_name);
|
|
::closedir(dir);
|
|
}
|
|
}
|
|
::closedir(users);
|
|
}
|
|
}
|
|
}
|
|
int failed = 0;
|
|
for (const std::string &target : targets) {
|
|
const std::int64_t r = helper.call("\x03rm/" + target, 0, nullptr, 0);
|
|
trace("tiles: delete %s: %lld", target.c_str(), static_cast<long long>(r));
|
|
if (r != 0)
|
|
failed++;
|
|
}
|
|
g_rescan = true;
|
|
set_status(failed ? game.title + " deleted, but " + std::to_string(failed) + " item(s) could not be (see ps5-title.log)"
|
|
: game.title + (with_saves ? " and its saves deleted" : " deleted (its saves are kept)"));
|
|
}
|
|
|
|
void run_job(std::function<void()> job)
|
|
{
|
|
if (g_busy.exchange(true)) {
|
|
set_status("Busy: wait for the current job to finish");
|
|
return;
|
|
}
|
|
std::thread([job = std::move(job)] {
|
|
job();
|
|
g_busy = false;
|
|
}).detach();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool tile_present(const Game &game)
|
|
{
|
|
std::lock_guard lock(g_list_mutex);
|
|
bool existing = false;
|
|
assigned_tile_id(game, &existing);
|
|
return existing;
|
|
}
|
|
|
|
bool tiles_busy()
|
|
{
|
|
return g_busy;
|
|
}
|
|
|
|
void tile_add_async(const Game &game)
|
|
{
|
|
Game copy = game;
|
|
copy.icon = 0;
|
|
run_job([copy] { add_tile(copy); });
|
|
}
|
|
|
|
void tile_remove_async(const Game &game)
|
|
{
|
|
Game copy = game;
|
|
copy.icon = 0;
|
|
run_job([copy] {
|
|
set_status("Removing " + copy.title + " from the PS5 home screen...");
|
|
Helper helper;
|
|
if (!helper.ready()) {
|
|
set_status("The write helper did not start (is the ELF loader running?)");
|
|
return;
|
|
}
|
|
set_status(remove_tile(helper, copy) ? copy.title + "'s tile files are removed: on the PS5 home screen, highlight its icon and press Options > Delete"
|
|
: "Removing the tile of " + copy.title + " failed (see ps5-title.log)");
|
|
});
|
|
}
|
|
|
|
void game_delete_async(const Game &game, bool with_saves)
|
|
{
|
|
Game copy = game;
|
|
copy.icon = 0;
|
|
run_job([copy, with_saves] { delete_game(copy, with_saves); });
|
|
}
|
|
|
|
bool take_rescan_wanted()
|
|
{
|
|
return g_rescan.exchange(false);
|
|
}
|
|
|
|
std::string cover_path(const std::string &serial)
|
|
{
|
|
return Library::root() + "/covers/" + serial + ".jpg";
|
|
}
|
|
|
|
namespace
|
|
{
|
|
std::string g_splash_boot;
|
|
}
|
|
|
|
void set_launch_splash(const std::string &boot)
|
|
{
|
|
g_splash_boot = boot;
|
|
}
|
|
|
|
const std::string &launch_splash()
|
|
{
|
|
return g_splash_boot;
|
|
}
|
|
|
|
} // namespace rpcs3ps5
|