mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-18 23:09:29 -05:00
Users reported FTP permission refusals again. Since v0.6.7 the title usually hands itself over at once (to the picker or ES-DE, through LoadExec, which ends the process), so the start-up repair that opens /app0 to FTP was cut short; and the picker and ES-DE, other executables, wrote with their own modes. - Files are 0777 as folders are (were 0666): the link-time wrappers and the repair (src/permissions_ps5.cpp). - ps5_permissions_settle waits for the repair (at most a minute) before every handover: to a frontend, closing the title, the relaunch test. - ES-DE's library files and folders, the remembered frontend (src/ps5_frontend_choice.h) and the picker's test record are 0777; the picker opens RADV's shader cache to FTP after its first 30 frames and again when it hands over. Verified on the base PS5: the trace reads "permissions: /app0 checked=17992 repaired=174 refused=0" then "handover waited 596.2 ms"; after a home-screen launch held at the picker and closed there, an FTP walk of /data/homebrew/PPSA99169 finds 17992 entries, 0 not 0777 (before: 175 files at 0666) (evidence/permissions-0777). tools/verify.sh format, unit and evidence PASS.
412 lines
14 KiB
C++
412 lines
14 KiB
C++
/*
|
|
* PS5 RetroArch - the frontend picker: RetroArch or EmulationStation, in Gloss.
|
|
*
|
|
* Copyright (C) 2026 Mihawk
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* The title's pre-screen (docs/FRONTENDS.md). It is a program of PS5_VulkanTemplate's
|
|
* UI module (BlackBearReloaded's kit, drawn with Vulkan), built into its own
|
|
* executable, /app0/picker/picker.bin, by tools/build-picker.sh; eboot.bin starts it
|
|
* through LoadExec when the title is launched from the home screen. It always wears
|
|
* the kit's "gloss" theme (Gloss, Skeuomorphic), whatever theme the kit would pick.
|
|
*
|
|
* Two cards, each with a picture of the frontend it starts: RetroArch's XMB menu,
|
|
* and EmulationStation's Alekfull NX system view, both captured on the console
|
|
* (/app0/picker/previews/*.rgba, written by the build from assets/picker/). LEFT and
|
|
* RIGHT move, CROSS starts the frontend, CIRCLE closes the title. Both frontends
|
|
* play every game through RetroArch, with its cores, saves, shaders and settings.
|
|
*
|
|
* SQUARE switches Remember: with it on, the frontend started is written to
|
|
* /app0/config/frontend.cfg (src/ps5_frontend_choice.h, copied beside this file by
|
|
* the build), and the title starts it straight away from then on; holding L1 while
|
|
* the title starts comes back here, which shows the switch on and that frontend
|
|
* chosen. With it off, the file says "ask" and this screen opens every time.
|
|
*
|
|
* Choosing ends the program, so its device and display are released, and then
|
|
* ps5_title_next (called by the title's main once the program is gone) restarts
|
|
* the title as the frontend: eboot.bin with --ps5-mode=retroarch, or
|
|
* /app0/es-de/es-de.bin.
|
|
*
|
|
* Armed by /app0/picker/picker-test.txt ("<frames> <retroarch|es-de|none>
|
|
* [remember|forget]", tools/run-title.sh --picker-test), the picker draws that many
|
|
* frames without the pad, sets Remember when the third word asks, moves to the card
|
|
* named, saves its last frame as
|
|
* /app0/picker/screenshots/picker.ppm, chooses it, and records what it chose in
|
|
* /app0/picker/picker-test.jsonl. The file is removed when read: it arms one launch.
|
|
*/
|
|
|
|
#include "kit.hpp"
|
|
#include "ps5_frontend_choice.h"
|
|
#include <ps5platform/libc.h>
|
|
#include <sys/stat.h>
|
|
|
|
extern "C" int sceSystemServiceLoadExec(const char *path, const char *const *argv);
|
|
extern "C" int sceKernelUsleep(uint32_t microseconds);
|
|
|
|
namespace {
|
|
|
|
using hui::gfx::Color;
|
|
using hui::gfx::Rect;
|
|
|
|
constexpr int cardCount = 2;
|
|
constexpr const char *testPath = PS5_APP_ROOT "/picker-test.txt";
|
|
constexpr const char *testRecord = PS5_APP_ROOT "/picker-test.jsonl";
|
|
constexpr const char *screenshotPath = PS5_APP_ROOT "/screenshots/picker.ppm";
|
|
|
|
struct Frontend {
|
|
const char *id;
|
|
const char *title;
|
|
const char *subtitle;
|
|
const char *preview;
|
|
const char *next; // the executable that runs it
|
|
const char *argument; // its one argument
|
|
Color accent;
|
|
};
|
|
|
|
const Frontend frontends[cardCount] = {
|
|
{ "retroarch", "RetroArch", "The XMB menu: every core, setting and shader", PS5_APP_ROOT "/previews/retroarch.rgba",
|
|
"/app0/eboot.bin", "--ps5-mode=retroarch", Color::rgb(0x7c4dff) },
|
|
{ "es-de", "EmulationStation", "ES-DE in the Alekfull NX theme; games start in RetroArch",
|
|
PS5_APP_ROOT "/previews/es-de.rgba", "/app0/es-de/es-de.bin", "", Color::rgb(0x00b4d8) },
|
|
};
|
|
|
|
// What runs once the program has ended: -1 for nothing (the title closes)
|
|
int nextFrontend = -1;
|
|
// Remember's state when the program ended: the frontend chosen is written, or "ask"
|
|
bool rememberChoice = false;
|
|
std::string testLine;
|
|
|
|
const hui::ui::Theme &glossTheme()
|
|
{
|
|
for (const hui::ui::Theme &theme : hui::ui::themes()) {
|
|
if (std::strcmp(theme.id, "gloss") == 0) {
|
|
return theme;
|
|
}
|
|
}
|
|
return hui::ui::default_theme();
|
|
}
|
|
|
|
// A preview: "PS5RGBA1", the width and height (little-endian 32-bit), then the pixels
|
|
std::vector<std::uint8_t> readPreview(const char *path, int &width, int &height)
|
|
{
|
|
std::vector<std::uint8_t> pixels;
|
|
FILE *file = std::fopen(path, "rb");
|
|
if (!file) {
|
|
return pixels;
|
|
}
|
|
char magic[8];
|
|
std::uint32_t size[2];
|
|
if (std::fread(magic, 1, 8, file) == 8 && std::memcmp(magic, "PS5RGBA1", 8) == 0 &&
|
|
std::fread(size, sizeof(size[0]), 2, file) == 2 && size[0] > 0 && size[1] > 0 && size[0] <= 4096 &&
|
|
size[1] <= 4096) {
|
|
pixels.resize((size_t)size[0] * size[1] * 4);
|
|
if (std::fread(pixels.data(), 1, pixels.size(), file) == pixels.size()) {
|
|
width = (int)size[0];
|
|
height = (int)size[1];
|
|
} else {
|
|
pixels.clear();
|
|
}
|
|
}
|
|
std::fclose(file);
|
|
return pixels;
|
|
}
|
|
|
|
void openForFtp(const char *path, int depth); // below
|
|
|
|
class VulkanExample : public ps5ui::KitExample
|
|
{
|
|
unsigned framesSinceStart = 0; // for opening the shader cache to FTP
|
|
public:
|
|
int focus{ 0 };
|
|
int chosen{ -2 }; // -2: still choosing, -1: close, else the frontend
|
|
hui::tween::Spring shown[cardCount];
|
|
std::uint32_t previews[cardCount]{};
|
|
float previewAspect[cardCount]{ 16.0f / 9.0f, 16.0f / 9.0f };
|
|
const hui::ui::Theme &theme = glossTheme();
|
|
hui::gfx::DrawList scene;
|
|
hui::ui::Feedback feedback;
|
|
float clock{ 0.0f };
|
|
int testChoice{ -2 }; // armed: the frontend to choose, or -1 for none
|
|
int testRemember{ -1 }; // armed: 1 remember, 0 forget, -1 leave the switch
|
|
bool remember{ false };
|
|
|
|
VulkanExample() : KitExample()
|
|
{
|
|
title = "Frontend picker";
|
|
name = "picker";
|
|
settings.overlay = false;
|
|
ps5.ownOverlay = true;
|
|
defaultClearColor = { { 0.0f, 0.0f, 0.0f, 1.0f } };
|
|
}
|
|
|
|
void readTest()
|
|
{
|
|
FILE *file = std::fopen(testPath, "r");
|
|
if (!file) {
|
|
return;
|
|
}
|
|
unsigned frames = 0;
|
|
char choice[16] = {}, switchWord[16] = {};
|
|
const int fields = std::fscanf(file, "%u %15s %15s", &frames, choice, switchWord);
|
|
std::fclose(file);
|
|
std::remove(testPath);
|
|
if (fields < 2 || frames < 10 || frames > 36000) {
|
|
return;
|
|
}
|
|
if (fields == 3) {
|
|
testRemember = std::strcmp(switchWord, "remember") == 0 ? 1 : std::strcmp(switchWord, "forget") == 0 ? 0 : -1;
|
|
}
|
|
testChoice = -1;
|
|
for (int i = 0; i < cardCount; i++) {
|
|
if (std::strcmp(choice, frontends[i].id) == 0) {
|
|
testChoice = i;
|
|
}
|
|
}
|
|
benchmark.active = true;
|
|
ps5.frameBudget = frames;
|
|
mkdir(PS5_APP_ROOT "/screenshots", 0777);
|
|
ps5.screenshotPath = screenshotPath;
|
|
char line[96];
|
|
std::snprintf(line, sizeof(line), "\"frames\":%u,\"choice\":\"%s\",\"remember\":%d", frames, choice,
|
|
testRemember);
|
|
testLine = line;
|
|
}
|
|
|
|
void prepare() override
|
|
{
|
|
VulkanExampleBase::prepare();
|
|
// A frontend remembered (this screen came back because L1 was held): the switch
|
|
// is on and that frontend's card is the one chosen
|
|
const char *remembered = ps5_frontend_choice_read(PS5_FRONTEND_CHOICE_PATH);
|
|
for (int i = 0; i < cardCount; i++) {
|
|
if (std::strcmp(remembered, frontends[i].id) == 0) {
|
|
remember = true;
|
|
focus = i;
|
|
}
|
|
}
|
|
readTest();
|
|
prepareKit({ .music = false, .covers = false });
|
|
for (int i = 0; i < cardCount; i++) {
|
|
int width = 0, height = 0;
|
|
const std::vector<std::uint8_t> pixels = readPreview(frontends[i].preview, width, height);
|
|
if (!pixels.empty()) {
|
|
previews[i] = kit.renderer.create_texture(width, height, pixels.data());
|
|
previewAspect[i] = (float)width / (float)height;
|
|
}
|
|
shown[i].snap(i == focus ? 1.0f : 0.0f);
|
|
}
|
|
prepared = true;
|
|
}
|
|
|
|
void move(int to)
|
|
{
|
|
if (to == focus) {
|
|
return;
|
|
}
|
|
feedback.play(hui::audio::Cue::focus, 1.0f, to == 0 ? -0.35f : 0.35f);
|
|
focus = to;
|
|
}
|
|
|
|
void update(float dt)
|
|
{
|
|
feedback.clear();
|
|
hui::InputFrame input = kit.input();
|
|
if (ps5.frameBudget) {
|
|
// A test run is the same whoever holds the pad: it moves to its card a third
|
|
// of the way in, and chooses it on its last frame
|
|
input = hui::InputFrame{};
|
|
input.connected = true;
|
|
if (testChoice >= 0 && ps5.framesDrawn == ps5.frameBudget / 3) {
|
|
move(testChoice);
|
|
}
|
|
if (testRemember >= 0 && ps5.framesDrawn == ps5.frameBudget / 2) {
|
|
remember = testRemember == 1;
|
|
}
|
|
if (ps5.framesDrawn + 1 == ps5.frameBudget) {
|
|
chosen = testChoice;
|
|
}
|
|
}
|
|
clock += dt;
|
|
if (input.nav == hui::Direction::left && focus > 0) {
|
|
move(focus - 1);
|
|
} else if (input.nav == hui::Direction::right && focus < cardCount - 1) {
|
|
move(focus + 1);
|
|
} else if (input.nav != hui::Direction::none && !input.nav_repeat) {
|
|
feedback.play(hui::audio::Cue::error, 1.0f, 0.0f, 0.5f);
|
|
}
|
|
if (input.is_pressed(hui::Action::confirm)) {
|
|
feedback.play(hui::audio::Cue::select);
|
|
chosen = focus;
|
|
} else if (input.is_pressed(hui::Action::back)) {
|
|
chosen = -1;
|
|
} else if (input.is_pressed(hui::Action::west)) {
|
|
remember = !remember;
|
|
feedback.play(hui::audio::Cue::toggle);
|
|
}
|
|
for (int i = 0; i < cardCount; i++) {
|
|
shown[i].target = i == focus ? 1.0f : 0.0f;
|
|
shown[i].update(dt, theme.omega);
|
|
}
|
|
kit.play(feedback, theme.sounds);
|
|
kit.tick(dt);
|
|
}
|
|
|
|
void compose()
|
|
{
|
|
const std::uint32_t glass = kit.renderer.glass_texture();
|
|
scene.clear();
|
|
hui::ui::Canvas canvas{ scene, kit.fonts, glass, clock };
|
|
hui::ui::Painter painter(scene, kit.fonts, theme, glass);
|
|
const hui::ui::ComponentStyle style{ theme };
|
|
|
|
// The entrance: the heading fades in, then the cards rise one after the other
|
|
const auto arrive = [this](float delay, float length) {
|
|
return hui::tween::smoothstep(std::clamp((clock - delay) / length, 0.0f, 1.0f));
|
|
};
|
|
const float heading = arrive(0.0f, 0.5f);
|
|
scene.push_opacity(heading);
|
|
scene.push_transform(1.0f, 0.0f, 0.0f, 0.0f, (1.0f - heading) * 24.0f);
|
|
painter.label("PS5 RETROARCH", 200.0f, 140.0f, 22.0f, painter.page_text_muted());
|
|
painter.heading("Choose a frontend", 196.0f, 236.0f, 84.0f, painter.page_text());
|
|
painter.body("Both play every game through RetroArch, with the same cores, saves, shaders and settings",
|
|
200.0f, 292.0f, 26.0f, painter.page_text_muted());
|
|
if (remember) {
|
|
painter.body("Remember is on: the title starts this frontend straight away. Hold L1 while it starts to come back here.",
|
|
200.0f, 940.0f, 24.0f, painter.page_text_muted());
|
|
}
|
|
scene.pop_transform();
|
|
scene.pop_opacity();
|
|
|
|
hui::ui::CardLook look;
|
|
look.art_aspect = 16.0f / 9.0f;
|
|
look.title_size = 44.0f;
|
|
look.subtitle_size = 24.0f;
|
|
look.text_gap = 20.0f;
|
|
look.glow = true;
|
|
const float cardWidth = 700.0f;
|
|
const float gap = 120.0f;
|
|
for (int i = 0; i < cardCount; i++) {
|
|
hui::ui::CardItem item;
|
|
item.title = frontends[i].title;
|
|
item.subtitle = frontends[i].subtitle;
|
|
item.texture = previews[i];
|
|
item.uv = hui::gfx::kFullUv;
|
|
item.image_aspect = previewAspect[i];
|
|
item.accent = frontends[i].accent;
|
|
hui::ui::CardState state;
|
|
state.focus = shown[i].value;
|
|
const Rect card{ 200.0f + (cardWidth + gap) * (float)i, 360.0f, cardWidth,
|
|
hui::ui::card_height(look, cardWidth) };
|
|
const float rise = arrive(0.25f + 0.12f * (float)i, 0.55f);
|
|
scene.push_opacity(rise);
|
|
scene.push_transform(1.0f, 0.0f, 0.0f, 0.0f, (1.0f - rise) * 120.0f);
|
|
hui::ui::draw_card(canvas, style, look, card, item, state);
|
|
scene.pop_transform();
|
|
scene.pop_opacity();
|
|
}
|
|
|
|
const float foot = arrive(0.75f, 0.4f);
|
|
scene.push_opacity(foot);
|
|
ps5ui::draw_hints(scene, kit.fonts, theme,
|
|
{ { hui::ui::Button::dpad, "Move" }, { hui::ui::Button::cross, "Start" },
|
|
{ hui::ui::Button::square, remember ? "Remember: on" : "Remember: off" },
|
|
{ hui::ui::Button::circle, "Close" } },
|
|
960.0f, 1010.0f, 0);
|
|
scene.pop_opacity();
|
|
|
|
hui::gfx::BackdropSpec backdrop = theme.backdrop;
|
|
backdrop.time = clock;
|
|
kit.renderer.begin();
|
|
kit.renderer.backdrop(backdrop);
|
|
kit.renderer.glass();
|
|
kit.renderer.draw(scene);
|
|
}
|
|
|
|
void render() override
|
|
{
|
|
if (!prepared) {
|
|
return;
|
|
}
|
|
prepareFrame();
|
|
update(std::min(frameTimer, 0.05f));
|
|
compose();
|
|
buildKitCommandBuffer();
|
|
submitFrame();
|
|
// Once the first frames are drawn RADV has written its shader cache: open it
|
|
// to FTP now, as the title may be closed from here without a choice.
|
|
if (++framesSinceStart == 30) {
|
|
openForFtp("/app0/radv-shader-cache", 6);
|
|
}
|
|
if (chosen != -2) {
|
|
nextFrontend = chosen;
|
|
rememberChoice = remember;
|
|
quit = true;
|
|
}
|
|
}
|
|
|
|
~VulkanExample() override
|
|
{
|
|
if (device) {
|
|
vkDeviceWaitIdle(device);
|
|
}
|
|
}
|
|
};
|
|
|
|
// What the picker wrote below a folder is given 0777, as eboot.bin gives everything
|
|
// under /app0 (src/permissions_ps5.cpp): this executable has none of the title's
|
|
// wrappers, and RADV writes its shader cache 0666. Without it FTP met those files
|
|
// as the picker left them until eboot.bin's next start.
|
|
void openForFtp(const char *path, int depth)
|
|
{
|
|
chmod(path, 0777);
|
|
DIR *directory = depth > 0 ? ps5_opendir(path) : nullptr;
|
|
if (!directory) {
|
|
return;
|
|
}
|
|
while (const struct dirent *entry = ps5_readdir(directory)) {
|
|
if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) {
|
|
continue;
|
|
}
|
|
char child[1024];
|
|
const int length = std::snprintf(child, sizeof(child), "%s/%s", path, entry->d_name);
|
|
if (length > 0 && static_cast<size_t>(length) < sizeof(child)) {
|
|
openForFtp(child, depth - 1);
|
|
}
|
|
}
|
|
ps5_closedir(directory);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// Called by the title's main once the program has ended and released its device
|
|
// (tools/build-picker.sh adds the call): the title restarts as the frontend chosen.
|
|
extern "C" void ps5_title_next(void)
|
|
{
|
|
const Frontend *next = nextFrontend >= 0 && nextFrontend < cardCount ? &frontends[nextFrontend] : nullptr;
|
|
openForFtp("/app0/radv-shader-cache", 6); // the device is released: the cache is final
|
|
// Starting a frontend records Remember: that frontend, or "ask"; closing leaves it
|
|
if (next) {
|
|
const int written = ps5_frontend_choice_write(PS5_FRONTEND_CHOICE_PATH, rememberChoice ? next->id : "ask");
|
|
say("picker: remember %s (write %d)", rememberChoice ? next->id : "ask", written);
|
|
}
|
|
if (!testLine.empty()) {
|
|
if (FILE *record = std::fopen(testRecord, "a")) {
|
|
std::fprintf(record, "{%s,\"next\":\"%s\"}\n", testLine.c_str(), next ? next->next : "");
|
|
std::fclose(record);
|
|
chmod(testRecord, 0777);
|
|
}
|
|
}
|
|
if (!next) {
|
|
return; // the title closes
|
|
}
|
|
say("picker: %s -> LoadExec(%s, \"%s\")", next->id, next->next, next->argument);
|
|
const char *const argv[] = { next->argument, nullptr };
|
|
const int result = sceSystemServiceLoadExec(next->next, argv);
|
|
say("picker: LoadExec returned %d", result);
|
|
for (;;) {
|
|
sceKernelUsleep(100000);
|
|
}
|
|
}
|
|
|
|
VULKAN_EXAMPLE_MAIN()
|