mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-18 23:09:29 -05:00
Open the title on a frontend picker: RetroArch or EmulationStation
A launch from the home screen now shows a pre-screen in
PS5_VulkanTemplate's Gloss theme with two cards, each with a preview
captured on the console: RetroArch's XMB and ES-DE's Alekfull NX SNES
view. Choosing one restarts the title as that frontend (approach B,
LoadExec). Quitting either one comes back to the picker, and CIRCLE
in the picker closes the title.
- frontends/picker/picker.cpp: the picker, a program of the template's
UI module. tools/build-picker.sh builds it the template's way (a
generated one-program title at the pinned template commit, with its
app root moved to /app0/picker) and stages picker/picker.bin, the
kit's assets and the previews (assets/picker, NOTICE.md credits them).
- src/frontend_mode_ps5.cpp: eboot.bin decides first. --ps5-mode=
retroarch runs RetroArch, es-de starts ES-DE, and no mode starts the
picker, except for a test run. RetroArch the picker started goes
back to the picker when it quits.
- tools/run-title.sh writes /app0/test-run.txt before every launch (as
src/main.cpp documents), so scripted runs keep starting RetroArch;
--picker-test=FRAMES:CHOICE, --retroarch-frames, --frontend-quit.
Evidence: evidence/frontend-picker/ (capture.json, expectation.json,
picker-test-es-de.jsonl, picker-test-retroarch.jsonl); base PS5 13.40:
eboot, picker, es-de, eboot, picker (ES-DE quit) and eboot, picker,
eboot (RetroArch, 600 frames), eboot, picker, with no fatal signal.
Command:
PS5_FRONTENDS='es-de picker' tools/build-title.sh
tools/run-title.sh --no-build --picker-test=300:es-de \
--frontend-capture=15 --frontend-quit --watch 60
tools/run-title.sh --no-build --picker-test=300:retroarch \
--retroarch-frames=600 --watch 60
Gates: tools/verify.sh PASS (format unit build integration evidence).
This commit is contained in:
1 parent
f0750abec2
commit
8cb7e980bf
16 files changed
+1055
-9
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"step": "frontend-picker",
|
||||
"recorded": "2026-10-04T21:35:13Z",
|
||||
"console_run": "base PS5 13.40, runs picker-172323 (picture), frontend-20261004-172701 (es-de chain), picker-173316 (retroarch chain)",
|
||||
"revision": "baebf77 + picker, frontend dispatch",
|
||||
"command": "PS5_FRONTENDS='es-de picker' tools/build-title.sh; tools/run-title.sh --no-build --picker-test=300:es-de --frontend-capture=15 --frontend-quit --watch 60; tools/run-title.sh --no-build --picker-test=300:retroarch --retroarch-frames=600 --watch 60",
|
||||
"raw_capture": "klog/frontend-picker-summary.txt",
|
||||
"raw_lines": 15,
|
||||
"raw_bytes": 1526,
|
||||
"lines": [
|
||||
"==> PS5_FRONTENDS='es-de picker' tools/build-title.sh; tools/run-title.sh --no-build --picker-test=300:<choice> [...] --watch 60 (base PS5, 13.40)",
|
||||
"picker/picker.bin: PS5_VulkanTemplate fdd41492 UI module, Gloss theme, two cards (RetroArch XMB, EmulationStation Alekfull NX SNES previews)",
|
||||
"picker picture (klog/picker-172323/picker.png): 1920x1080, 143330 colours, focus on EmulationStation",
|
||||
"picker record: {\"frames\":300,\"choice\":\"es-de\",\"next\":\"/app0/es-de/es-de.bin\"}",
|
||||
"es-de chain (klog/run-PPSA99169-172717.log, --picker-test=300:es-de --frontend-capture=15 --frontend-quit): 181 eboot.bin, 182 picker/picker.bin, 183 es-de/es-de.bin, 184 eboot.bin, 185 picker/picker.bin; fatal signals 0",
|
||||
"es-de after the picker: capture 15 s, frame 829, 1920x1080, GL error 0; es-de-ps5.log: ES-DE returned 0",
|
||||
"trace: frontend: mode '', test run 0, picker test 1, picker 1, es-de 1 -> picker",
|
||||
"trace: frontend: mode '', test run 0, picker test 0, picker 1, es-de 1 -> picker",
|
||||
"picker record: {\"frames\":300,\"choice\":\"retroarch\",\"next\":\"/app0/eboot.bin\"}",
|
||||
"retroarch chain (klog/run-PPSA99169-173316.log, --picker-test=300:retroarch --retroarch-frames=600): 186 eboot.bin, 187 picker/picker.bin, 188 eboot.bin, 189 eboot.bin, 190 picker/picker.bin; fatal signals 0",
|
||||
"trace: frontend: mode 'retroarch', test run 1, picker test 0, picker 1, es-de 1 -> retroarch",
|
||||
"trace: rarch_main returned = 0",
|
||||
"trace: frontend: RetroArch quit; back to the picker",
|
||||
"trace: frontend: mode '', test run 0, picker test 0, picker 1, es-de 1 -> picker",
|
||||
"picker test PASS (both runs)"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"step": "frontend-picker",
|
||||
"note": "The title opens on a frontend picker in PS5_VulkanTemplate's Gloss theme, with a preview of each frontend; choosing one restarts the title as it (RetroArch, or ES-DE), and quitting either goes back to the picker.",
|
||||
"must_contain": [
|
||||
"181 eboot.bin, 182 picker/picker.bin, 183 es-de/es-de.bin, 184 eboot.bin, 185 picker/picker.bin; fatal signals 0",
|
||||
"186 eboot.bin, 187 picker/picker.bin, 188 eboot.bin, 189 eboot.bin, 190 picker/picker.bin; fatal signals 0",
|
||||
"trace: frontend: RetroArch quit; back to the picker",
|
||||
"picker record: {\"frames\":300,\"choice\":\"es-de\",\"next\":\"/app0/es-de/es-de.bin\"}",
|
||||
"picker record: {\"frames\":300,\"choice\":\"retroarch\",\"next\":\"/app0/eboot.bin\"}"
|
||||
],
|
||||
"must_not_contain": [],
|
||||
"human_check": "The owner confirmed quitting ES-DE returns to the picker; the picker picture shows the Gloss pre-screen with both previews."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"frames":300,"choice":"es-de","next":"/app0/es-de/es-de.bin"}
|
||||
@@ -0,0 +1 @@
|
||||
{"frames":300,"choice":"retroarch","next":"/app0/eboot.bin"}
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* Armed by /app0/es-de/capture-test.txt (tools/run-title.sh --frontend-capture):
|
||||
*
|
||||
* <seconds>[,<seconds>...] <run id> [scroll][,profile]
|
||||
* <seconds>[,<seconds>...] <run id> [scroll][,profile][,quit]
|
||||
*
|
||||
* ES-DE presents each frame with SDL_GL_SwapWindow, which the link wraps
|
||||
* (frontends/es-de/link.sh). When a frame is presented that many seconds after
|
||||
@@ -20,7 +20,9 @@
|
||||
* process is sampled every millisecond of CPU time (an ITIMER_PROF timer): each
|
||||
* sample is the interrupted instruction and up to eight return addresses from its
|
||||
* frame-pointer chain, written to /app0/es-de/profile-<run id>.txt at the last
|
||||
* capture, one sample a line, in hex, for symbolizing against the linked ELF.
|
||||
* capture, one sample a line, in hex, for symbolizing against the linked ELF. With
|
||||
* "quit", ES-DE is sent SDL_QUIT after the last capture, as its Quit entry does,
|
||||
* so it returns to eboot.bin (main_ps5.cpp).
|
||||
* Unarmed, this costs one failed open, once.
|
||||
*/
|
||||
#include <SDL.h>
|
||||
@@ -56,6 +58,7 @@ struct Capture
|
||||
size_t next = 0;
|
||||
bool scroll = false;
|
||||
bool profile = false;
|
||||
bool quit = false;
|
||||
unsigned long long frames = 0;
|
||||
std::chrono::steady_clock::time_point first, last_swap, last_press;
|
||||
std::vector<float> intervals_ms, swaps_ms; // since the last record
|
||||
@@ -88,6 +91,7 @@ void read_arm()
|
||||
return;
|
||||
state.scroll = fields == 3 && std::strstr(mode, "scroll") != nullptr;
|
||||
state.profile = fields == 3 && std::strstr(mode, "profile") != nullptr;
|
||||
state.quit = fields == 3 && std::strstr(mode, "quit") != nullptr;
|
||||
unsigned last = 0;
|
||||
for (char *token = std::strtok(list, ","); token; token = std::strtok(nullptr, ","))
|
||||
{
|
||||
@@ -303,6 +307,12 @@ extern "C" void __wrap_SDL_GL_SwapWindow(SDL_Window *window)
|
||||
state.next++;
|
||||
if (state.profile && state.next == state.seconds.size())
|
||||
write_profile();
|
||||
if (state.quit && state.next == state.seconds.size())
|
||||
{
|
||||
SDL_Event event{};
|
||||
event.type = SDL_QUIT;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto before = std::chrono::steady_clock::now();
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* 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>",
|
||||
* tools/run-title.sh --picker-test), the picker draws that many frames without the
|
||||
* pad, 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"
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
class VulkanExample : public ps5ui::KitExample
|
||||
{
|
||||
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
|
||||
|
||||
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] = {};
|
||||
const int fields = std::fscanf(file, "%u %15s", &frames, choice);
|
||||
std::fclose(file);
|
||||
std::remove(testPath);
|
||||
if (fields != 2 || frames < 10 || frames > 36000) {
|
||||
return;
|
||||
}
|
||||
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[64];
|
||||
std::snprintf(line, sizeof(line), "\"frames\":%u,\"choice\":\"%s\"", frames, choice);
|
||||
testLine = line;
|
||||
}
|
||||
|
||||
void prepare() override
|
||||
{
|
||||
VulkanExampleBase::prepare();
|
||||
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 (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;
|
||||
}
|
||||
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());
|
||||
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::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();
|
||||
if (chosen != -2) {
|
||||
nextFrontend = chosen;
|
||||
quit = true;
|
||||
}
|
||||
}
|
||||
|
||||
~VulkanExample() override
|
||||
{
|
||||
if (device) {
|
||||
vkDeviceWaitIdle(device);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // 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;
|
||||
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, 0666);
|
||||
}
|
||||
}
|
||||
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()
|
||||
@@ -0,0 +1,161 @@
|
||||
/* PS5 RetroArch - which frontend a launch of eboot.bin starts.
|
||||
*
|
||||
* Copyright (C) 2026 Mihawk
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* The title has a pre-screen that chooses between RetroArch and EmulationStation
|
||||
* (docs/FRONTENDS.md), and each runs in a process of its own: the title restarts
|
||||
* itself through LoadExec (approach B). eboot.bin is what the home screen starts,
|
||||
* so it decides first, before RetroArch sets anything up:
|
||||
*
|
||||
* --ps5-mode=retroarch RetroArch, here (the picker chose it)
|
||||
* --ps5-mode=es-de EmulationStation, /app0/es-de/es-de.bin
|
||||
* --ps5-mode=picker the picker, /app0/picker/picker.bin
|
||||
* no mode a launch from the home screen, or EmulationStation
|
||||
* quitting: the picker; but a test run's launch
|
||||
* (/app0/test-run.txt, which tools/run-title.sh writes)
|
||||
* stays RetroArch, as every test expects, unless a picker
|
||||
* test is armed (/app0/picker/picker-test.txt)
|
||||
*
|
||||
* Quitting a frontend goes back to the picker: EmulationStation restarts eboot.bin
|
||||
* with no mode (frontends/es-de/ps5/main_ps5.cpp), and RetroArch, when the picker
|
||||
* started it, does the same once it has quit and closed its drivers
|
||||
* (ps5_frontend_after_retroarch, from src/main.cpp). The picker's CIRCLE closes the
|
||||
* title. RetroArch started any other way (a test run) closes the title as before.
|
||||
*
|
||||
* A title built without the picker, or without EmulationStation, runs RetroArch
|
||||
* as it always has. LoadExec, accepted, returns and the shell replaces the process a
|
||||
* moment later (evidence/loadexec-relaunch): this waits for that, up to a minute. A
|
||||
* LoadExec refused, or a process still here after the wait, is recorded in the trace
|
||||
* and RetroArch runs instead, so a launch never ends on a black screen.
|
||||
*/
|
||||
#include "frontend_mode_ps5.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "trace.hpp"
|
||||
|
||||
extern "C" int sceSystemServiceLoadExec(const char *path, const char *const *argv);
|
||||
|
||||
namespace ps5::frontend_mode
|
||||
{
|
||||
namespace
|
||||
{
|
||||
/* This launch's --ps5-mode, kept for the quit */
|
||||
std::string launch_mode;
|
||||
|
||||
bool exists(const std::string &path)
|
||||
{
|
||||
std::FILE *file = std::fopen(path.c_str(), "rb");
|
||||
if (file)
|
||||
std::fclose(file);
|
||||
return file != nullptr;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string mode_argument(int argc, char **argv)
|
||||
{
|
||||
static const char prefix[] = "--ps5-mode=";
|
||||
for (int i = 0; i < argc && argv && argv[i]; i++)
|
||||
if (std::strncmp(argv[i], prefix, sizeof(prefix) - 1) == 0)
|
||||
return argv[i] + sizeof(prefix) - 1;
|
||||
return "";
|
||||
}
|
||||
|
||||
Next decide(const Launch &launch)
|
||||
{
|
||||
if (launch.mode == "es-de")
|
||||
return launch.es_de_present ? Next::es_de : Next::retroarch;
|
||||
if (launch.mode == "picker")
|
||||
return launch.picker_present ? Next::picker : Next::retroarch;
|
||||
if (!launch.mode.empty())
|
||||
return Next::retroarch; /* retroarch, or a mode this build does not know */
|
||||
if (!launch.picker_present)
|
||||
return Next::retroarch;
|
||||
if (launch.picker_test)
|
||||
return Next::picker;
|
||||
return launch.test_run ? Next::retroarch : Next::picker;
|
||||
}
|
||||
|
||||
const char *name(Next next)
|
||||
{
|
||||
switch (next)
|
||||
{
|
||||
case Next::picker:
|
||||
return "picker";
|
||||
case Next::es_de:
|
||||
return "es-de";
|
||||
default:
|
||||
return "retroarch";
|
||||
}
|
||||
}
|
||||
|
||||
void run(const Paths &paths, int argc, char **argv, unsigned replaced_wait_seconds)
|
||||
{
|
||||
Launch launch;
|
||||
launch.mode = mode_argument(argc, argv);
|
||||
launch_mode = launch.mode;
|
||||
launch.test_run = exists(paths.test_run);
|
||||
launch.picker_test = exists(paths.picker_test);
|
||||
launch.picker_present = exists(paths.picker);
|
||||
launch.es_de_present = exists(paths.es_de);
|
||||
const Next next = decide(launch);
|
||||
char line[256];
|
||||
std::snprintf(line, sizeof line,
|
||||
"frontend: mode '%s', test run %d, picker test %d, picker %d, es-de %d -> %s",
|
||||
launch.mode.c_str(), launch.test_run, launch.picker_test, launch.picker_present,
|
||||
launch.es_de_present, name(next));
|
||||
ps5::debug::mark(line);
|
||||
if (next == Next::retroarch)
|
||||
return;
|
||||
const std::string &image = next == Next::picker ? paths.picker : paths.es_de;
|
||||
const char *const arguments[] = {"", nullptr};
|
||||
std::fflush(nullptr);
|
||||
const int result = sceSystemServiceLoadExec(image.c_str(), arguments);
|
||||
if (result >= 0)
|
||||
for (unsigned waited = 0; waited < replaced_wait_seconds * 10; waited++)
|
||||
usleep(100000);
|
||||
/* Only a LoadExec that did not replace the process gets here. */
|
||||
ps5::debug::mark_value("frontend: LoadExec did not replace the process; RetroArch runs; result",
|
||||
result);
|
||||
}
|
||||
bool back_to_picker(const std::string &mode, bool picker_present)
|
||||
{
|
||||
return mode == "retroarch" && picker_present;
|
||||
}
|
||||
|
||||
void after_retroarch(const Paths &paths, const std::string &mode, unsigned replaced_wait_seconds)
|
||||
{
|
||||
if (!back_to_picker(mode, exists(paths.picker)))
|
||||
return;
|
||||
ps5::debug::mark("frontend: RetroArch quit; back to the picker");
|
||||
const char *const arguments[] = {"", nullptr};
|
||||
std::fflush(nullptr);
|
||||
const int result = sceSystemServiceLoadExec(paths.eboot.c_str(), arguments);
|
||||
if (result >= 0)
|
||||
for (unsigned waited = 0; waited < replaced_wait_seconds * 10; waited++)
|
||||
usleep(100000);
|
||||
ps5::debug::mark_value(
|
||||
"frontend: LoadExec did not replace the process; the title closes; result", result);
|
||||
}
|
||||
} // namespace ps5::frontend_mode
|
||||
|
||||
namespace
|
||||
{
|
||||
const ps5::frontend_mode::Paths title_paths{"/app0/picker/picker.bin", "/app0/es-de/es-de.bin",
|
||||
"/app0/test-run.txt", "/app0/picker/picker-test.txt",
|
||||
"/app0/eboot.bin"};
|
||||
}
|
||||
|
||||
extern "C" void ps5_frontend_dispatch(int argc, char **argv)
|
||||
{
|
||||
ps5::frontend_mode::run(title_paths, argc, argv, 60);
|
||||
}
|
||||
|
||||
extern "C" void ps5_frontend_after_retroarch(void)
|
||||
{
|
||||
ps5::frontend_mode::after_retroarch(title_paths, ps5::frontend_mode::launch_mode, 60);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* PS5 RetroArch - which frontend a launch of eboot.bin starts (src/frontend_mode_ps5.cpp says why).
|
||||
*
|
||||
* Copyright (C) 2026 Mihawk
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
#ifndef PS5_RETROARCH_FRONTEND_MODE_PS5_H
|
||||
#define PS5_RETROARCH_FRONTEND_MODE_PS5_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <string>
|
||||
|
||||
namespace ps5::frontend_mode
|
||||
{
|
||||
enum class Next
|
||||
{
|
||||
retroarch, /* RetroArch, in this process */
|
||||
picker, /* the frontend picker, /app0/picker/picker.bin */
|
||||
es_de /* EmulationStation, /app0/es-de/es-de.bin */
|
||||
};
|
||||
|
||||
struct Launch
|
||||
{
|
||||
std::string mode; /* --ps5-mode=<mode>, or empty */
|
||||
bool test_run = false; /* a test run's launch (/app0/test-run.txt) */
|
||||
bool picker_test = false; /* an armed picker test (/app0/picker/picker-test.txt) */
|
||||
bool picker_present = false; /* the title carries the picker */
|
||||
bool es_de_present = false; /* the title carries EmulationStation */
|
||||
};
|
||||
|
||||
struct Paths
|
||||
{
|
||||
std::string picker, es_de, test_run, picker_test, eboot;
|
||||
};
|
||||
|
||||
/* The mode the process arguments name with --ps5-mode=, from argv[0] on (LoadExec's
|
||||
* arguments are the whole argv), or an empty string. */
|
||||
std::string mode_argument(int argc, char **argv);
|
||||
Next decide(const Launch &launch);
|
||||
const char *name(Next next);
|
||||
/* Starts what this launch is for. It returns only when RetroArch runs in this
|
||||
* process: chosen, or a LoadExec refused or that did not replace the process within
|
||||
* replaced_wait_seconds. */
|
||||
void run(const Paths &paths, int argc, char **argv, unsigned replaced_wait_seconds);
|
||||
/* After RetroArch has quit: true when the title goes back to the picker instead of
|
||||
* closing, which it does when the picker started RetroArch (--ps5-mode=retroarch). */
|
||||
bool back_to_picker(const std::string &mode, bool picker_present);
|
||||
/* Restarts the title into the picker when back_to_picker says so. It returns only
|
||||
* when it does not, or when LoadExec did not replace the process. */
|
||||
void after_retroarch(const Paths &paths, const std::string &mode, unsigned replaced_wait_seconds);
|
||||
} // namespace ps5::frontend_mode
|
||||
|
||||
extern "C"
|
||||
{
|
||||
#endif
|
||||
|
||||
void ps5_frontend_dispatch(int argc, char **argv);
|
||||
/* Called once RetroArch has quit, before the title closes. */
|
||||
void ps5_frontend_after_retroarch(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -54,6 +54,7 @@
|
||||
#include "title_threads.hpp"
|
||||
#include "display_modes_ps5.h"
|
||||
#include "relaunch_ps5.h"
|
||||
#include "frontend_mode_ps5.h"
|
||||
#include "webui_ps5.h"
|
||||
#include "webui_update.h"
|
||||
|
||||
@@ -386,6 +387,10 @@ int main(int process_argc, char **process_argv)
|
||||
/* An armed display modes test (src/display_modes_ps5.cpp) presents every size the
|
||||
* driver's display offers, on a device of its own, before RetroArch makes one. */
|
||||
ps5_display_modes_test_if_requested();
|
||||
/* Which frontend this launch is for (src/frontend_mode_ps5.cpp): from the home
|
||||
* screen the picker, which restarts the title as RetroArch or EmulationStation;
|
||||
* those two are their own executables, started through LoadExec. */
|
||||
ps5_frontend_dispatch(process_argc, process_argv);
|
||||
|
||||
/* argv must be writable and NULL-terminated: RetroArch's option parsing
|
||||
* walks it the way the C runtime would have. */
|
||||
@@ -585,6 +590,8 @@ extern "C" void catchReturnFromMain(int status)
|
||||
/* The output leaves in its default mode (a second release is a no-op). */
|
||||
if (ps5vk_display_retain != nullptr)
|
||||
ps5vk_display_retain(false);
|
||||
/* RetroArch the picker started goes back to the picker (src/frontend_mode_ps5.cpp). */
|
||||
ps5_frontend_after_retroarch();
|
||||
std::fflush(nullptr);
|
||||
const int result = sceSystemServiceLoadExec("exit", nullptr);
|
||||
ps5::debug::mark_value("native quit: system service result", result);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Which frontend a launch of eboot.bin starts (src/frontend_mode_ps5.cpp): the
|
||||
* decision for every launch, the mode argument as the console gives argv, and the
|
||||
* LoadExec each decision makes, against a LoadExec that replaces the process (a
|
||||
* thrown Replaced), refuses, or is accepted and ignored. argv[1] is a scratch
|
||||
* directory for the files the decision reads. */
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "../src/frontend_mode_ps5.cpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
struct Replaced
|
||||
{
|
||||
};
|
||||
enum class Exec
|
||||
{
|
||||
replace,
|
||||
refuse,
|
||||
ignore
|
||||
};
|
||||
Exec exec_mode = Exec::replace;
|
||||
int exec_calls = 0;
|
||||
std::string exec_path, exec_argument;
|
||||
std::vector<std::string> marks;
|
||||
|
||||
void touch(const std::string &path, bool present)
|
||||
{
|
||||
if (!present)
|
||||
{
|
||||
std::remove(path.c_str());
|
||||
return;
|
||||
}
|
||||
std::FILE *file = std::fopen(path.c_str(), "wb");
|
||||
assert(file);
|
||||
std::fclose(file);
|
||||
}
|
||||
|
||||
/* One launch: what it LoadExecs, or "retroarch" when it returns to run RetroArch. */
|
||||
std::string launch(const ps5::frontend_mode::Paths &paths, std::vector<const char *> argv)
|
||||
{
|
||||
argv.push_back(nullptr);
|
||||
exec_path.clear();
|
||||
try
|
||||
{
|
||||
ps5::frontend_mode::run(paths, static_cast<int>(argv.size() - 1),
|
||||
const_cast<char **>(argv.data()), 0);
|
||||
return "retroarch";
|
||||
}
|
||||
catch (const Replaced &)
|
||||
{
|
||||
return exec_path;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" int sceSystemServiceLoadExec(const char *path, const char *const *argv)
|
||||
{
|
||||
exec_calls++;
|
||||
exec_path = path;
|
||||
exec_argument = argv && argv[0] ? argv[0] : "(none)";
|
||||
assert(argv && argv[0] && !argv[1]);
|
||||
if (exec_mode == Exec::replace)
|
||||
throw Replaced{};
|
||||
return exec_mode == Exec::refuse ? -1 : 0;
|
||||
}
|
||||
|
||||
void ps5::debug::mark(const char *step) noexcept
|
||||
{
|
||||
marks.emplace_back(step);
|
||||
}
|
||||
void ps5::debug::mark_value(const char *step, long long value) noexcept
|
||||
{
|
||||
marks.emplace_back(std::string(step) + " " + std::to_string(value));
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
using ps5::frontend_mode::decide;
|
||||
using ps5::frontend_mode::Launch;
|
||||
using ps5::frontend_mode::Next;
|
||||
assert(argc == 2);
|
||||
const std::string dir = argv[1];
|
||||
|
||||
/* The decision, for every launch. */
|
||||
for (unsigned state = 0; state < 64; state++)
|
||||
{
|
||||
for (const char *mode : {"", "retroarch", "es-de", "picker", "game"})
|
||||
{
|
||||
Launch launch;
|
||||
launch.mode = mode;
|
||||
launch.test_run = state & 1;
|
||||
launch.picker_test = state & 2;
|
||||
launch.picker_present = state & 4;
|
||||
launch.es_de_present = state & 8;
|
||||
const Next next = decide(launch);
|
||||
const std::string m = mode;
|
||||
if (m == "es-de")
|
||||
assert(next == (launch.es_de_present ? Next::es_de : Next::retroarch));
|
||||
else if (m == "picker")
|
||||
assert(next == (launch.picker_present ? Next::picker : Next::retroarch));
|
||||
else if (!m.empty())
|
||||
assert(next == Next::retroarch);
|
||||
else if (!launch.picker_present)
|
||||
assert(next == Next::retroarch);
|
||||
else
|
||||
assert(next ==
|
||||
(launch.picker_test || !launch.test_run ? Next::picker : Next::retroarch));
|
||||
}
|
||||
}
|
||||
|
||||
/* The mode argument, as the console gives argv: no program name, and an empty
|
||||
* argument from the home screen. */
|
||||
{
|
||||
char empty[] = "", mode[] = "--ps5-mode=retroarch", other[] = "--ps5-relaunch=2";
|
||||
char *home[] = {empty, nullptr};
|
||||
char *chosen[] = {mode, nullptr};
|
||||
char *both[] = {other, mode, nullptr};
|
||||
assert(ps5::frontend_mode::mode_argument(1, home).empty());
|
||||
assert(ps5::frontend_mode::mode_argument(1, chosen) == "retroarch");
|
||||
assert(ps5::frontend_mode::mode_argument(2, both) == "retroarch");
|
||||
assert(ps5::frontend_mode::mode_argument(0, nullptr).empty());
|
||||
}
|
||||
|
||||
/* The launches, against files in the scratch directory. */
|
||||
const ps5::frontend_mode::Paths paths{dir + "/picker.bin", dir + "/es-de.bin",
|
||||
dir + "/test-run.txt", dir + "/picker-test.txt",
|
||||
dir + "/eboot.bin"};
|
||||
touch(paths.picker, true);
|
||||
touch(paths.es_de, true);
|
||||
touch(paths.test_run, false);
|
||||
touch(paths.picker_test, false);
|
||||
assert(launch(paths, {""}) == paths.picker && exec_argument.empty()); /* the home screen */
|
||||
assert(marks.back() ==
|
||||
"frontend: mode '', test run 0, picker test 0, picker 1, es-de 1 -> picker");
|
||||
assert(launch(paths, {"--ps5-mode=retroarch"}) == "retroarch");
|
||||
assert(launch(paths, {"--ps5-mode=es-de"}) == paths.es_de);
|
||||
touch(paths.test_run, true);
|
||||
assert(launch(paths, {""}) == "retroarch"); /* a test run stays RetroArch */
|
||||
touch(paths.picker_test, true);
|
||||
assert(launch(paths, {""}) == paths.picker); /* unless the picker test is armed */
|
||||
touch(paths.picker, false);
|
||||
assert(launch(paths, {""}) == "retroarch"); /* a title without the picker */
|
||||
touch(paths.es_de, false);
|
||||
assert(launch(paths, {"--ps5-mode=es-de"}) == "retroarch");
|
||||
|
||||
/* A LoadExec refused, or accepted but not replacing the process, runs RetroArch. */
|
||||
touch(paths.picker, true);
|
||||
touch(paths.test_run, false);
|
||||
touch(paths.picker_test, false);
|
||||
for (Exec mode : {Exec::refuse, Exec::ignore})
|
||||
{
|
||||
exec_mode = mode;
|
||||
const int before = exec_calls;
|
||||
assert(launch(paths, {""}) == "retroarch" && exec_calls == before + 1 &&
|
||||
exec_path == paths.picker);
|
||||
assert(marks.back().rfind(
|
||||
"frontend: LoadExec did not replace the process; RetroArch runs; result", 0) ==
|
||||
0);
|
||||
}
|
||||
/* After RetroArch quits: back to the picker only when the picker started it. */
|
||||
for (const char *mode : {"", "retroarch", "es-de", "picker"})
|
||||
for (bool present : {false, true})
|
||||
assert(ps5::frontend_mode::back_to_picker(mode, present) ==
|
||||
(present && std::string(mode) == "retroarch"));
|
||||
const auto quit = [&](const char *mode)
|
||||
{
|
||||
exec_path.clear();
|
||||
try
|
||||
{
|
||||
ps5::frontend_mode::after_retroarch(paths, mode, 0);
|
||||
return std::string("closes");
|
||||
}
|
||||
catch (const Replaced &)
|
||||
{
|
||||
return exec_path;
|
||||
}
|
||||
};
|
||||
exec_mode = Exec::replace;
|
||||
assert(quit("retroarch") == paths.eboot && exec_argument.empty());
|
||||
assert(quit("") == "closes"); /* a test run's RetroArch closes the title */
|
||||
touch(paths.picker, false);
|
||||
assert(quit("retroarch") == "closes");
|
||||
touch(paths.picker, true);
|
||||
exec_mode = Exec::refuse;
|
||||
assert(quit("retroarch") == "closes" &&
|
||||
marks.back().rfind("frontend: LoadExec did not replace the process; the title closes",
|
||||
0) == 0);
|
||||
std::puts("frontend_mode_ps5: decisions, mode argument, LoadExec targets, test runs and "
|
||||
"refused or ignored LoadExec PASS; back to the picker after RetroArch");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Which frontend a launch of eboot.bin starts (src/frontend_mode_ps5.cpp), against a mocked LoadExec."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class FrontendModePs5(unittest.TestCase):
|
||||
def test_decisions_and_launches(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
binary = str(Path(td) / 'frontend-mode-test')
|
||||
subprocess.run(['c++', '-std=c++17', '-O2', '-Wall', '-Wextra', '-Werror', '-Isrc',
|
||||
'tests/frontend_mode_ps5_test.cpp', '-o', binary], cwd=ROOT, check=True)
|
||||
subprocess.run([binary, td], cwd=ROOT, check=True, timeout=15)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# PS5 RetroArch - build the frontend picker, /app0/picker/picker.bin (docs/FRONTENDS.md).
|
||||
#
|
||||
# The picker is a program of ../PS5_VulkanTemplate's UI module (frontends/picker/
|
||||
# picker.cpp, in the kit's Gloss theme), so it is built the template's way, as the
|
||||
# one program of a title the template generates:
|
||||
#
|
||||
# 1. ps5/tools/new-title.py --ui makes that title in build/picker/title, from the
|
||||
# template at the commit pinned here (it exports the commit, not the working
|
||||
# tree), with the template's .deps (the kit and the payload SDK it pins);
|
||||
# 2. picker.cpp replaces the generated program, and three edits are made to the
|
||||
# generated build, each checked: its files live in /app0/picker (PS5_APP_ROOT
|
||||
# and the asset and shader folders), and its main calls ps5_title_next once
|
||||
# the program has ended, which restarts the title as the frontend chosen;
|
||||
# 3. the template's ps5/tools/build.sh builds it against ../PS5_Vulkan (RADV and
|
||||
# the libc.prx this title also carries), and stages the notices of what it is
|
||||
# built from, reading those repositories beside this project's folder;
|
||||
# 4. its eboot.bin is staged as picker/picker.bin, with the kit's assets and
|
||||
# shaders, and the two previews (assets/picker/*.png) as raw RGBA the picker
|
||||
# uploads as they are.
|
||||
#
|
||||
# Run by tools/build-title.sh when PS5_FRONTENDS names "picker", after the title
|
||||
# folder is assembled. Inputs, overridable: PS5_VULKAN_TEMPLATE, PS5_VULKAN_DIR.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$root"
|
||||
template=$(cd -- "${PS5_VULKAN_TEMPLATE:-$root/../PS5_VulkanTemplate}" && pwd)
|
||||
vulkan=$(cd -- "${PS5_VULKAN_DIR:-$root/../PS5_Vulkan}" && pwd)
|
||||
template_commit=fdd41492047b5c02646760f3d705634fb658f743
|
||||
program=frontendpicker
|
||||
title_id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["titleId"])' "$root/sce_sys/param.json")
|
||||
app="$root/dist/$title_id"
|
||||
[[ -f $app/eboot.bin ]] || { echo "error: build the title first (no $app/eboot.bin)" >&2; exit 2; }
|
||||
[[ $(git -C "$template" rev-parse HEAD) == "$template_commit" ]] ||
|
||||
{ echo "error: $template is not at the pinned commit $template_commit" >&2; exit 2; }
|
||||
for dependency in "$template/.deps/hui/src" "$template/.deps/native/ps5-payload-sdk/bin"; do
|
||||
[[ -d $dependency ]] || { echo "error: $dependency is missing (run the template's bootstrap)" >&2; exit 2; }
|
||||
done
|
||||
|
||||
work="$root/build/picker"
|
||||
generated="$work/title"
|
||||
if [[ ! -f $generated/.picker-template || $(<"$generated/.picker-template") != "$template_commit" ]]; then
|
||||
echo "==> [picker] generating its title from PS5_VulkanTemplate ${template_commit:0:8}"
|
||||
rm -rf -- "$generated"
|
||||
mkdir -p "$work"
|
||||
python3 "$template/ps5/tools/new-title.py" "$generated" --title-id PPSA99168 --name "Frontend Picker" \
|
||||
--ui aurora > "$work/generate.log" 2>&1 || { cat "$work/generate.log" >&2; exit 1; }
|
||||
[[ -f $generated/examples/$program/$program.cpp ]] ||
|
||||
{ echo "error: the template did not generate examples/$program" >&2; exit 1; }
|
||||
for file in ps5/CMakeLists.txt ps5/src/main.cpp; do
|
||||
cp "$generated/$file" "$generated/$file.generated"
|
||||
done
|
||||
ln -s "$template/.deps" "$generated/.deps"
|
||||
printf '%s\n' "$template_commit" > "$generated/.picker-template"
|
||||
fi
|
||||
|
||||
cp "$root/frontends/picker/picker.cpp" "$generated/examples/$program/$program.cpp"
|
||||
rm -rf -- "$generated/examples/$program/kit"
|
||||
python3 - "$generated" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
generated = Path(sys.argv[1])
|
||||
def edit(name, old, new):
|
||||
text = (generated / (name + ".generated")).read_text()
|
||||
if text.count(old) != 1:
|
||||
raise SystemExit(f"error: {name} no longer has the text this build edits: {old!r}")
|
||||
return text.replace(old, new)
|
||||
cmake = edit("ps5/CMakeLists.txt", "\tset(app_root /app0)\n\tset(surface_platform _DIRECT2DISPLAY)",
|
||||
'\tset(app_root /app0/picker)\n\tset(surface_platform _DIRECT2DISPLAY "PS5_APP_ROOT=\\"/app0/picker\\"")')
|
||||
(generated / "ps5/CMakeLists.txt").write_text(cmake)
|
||||
main = (generated / "ps5/src/main.cpp.generated").read_text()
|
||||
old = '\t\tstatus = runSample(ps5Samples[0], 0, "", false).ok ? 0 : 1;\n'
|
||||
if main.count(old) != 1 or main.count("\nint main()\n") != 1:
|
||||
raise SystemExit("error: ps5/src/main.cpp no longer has the text this build edits")
|
||||
main = main.replace(old, old + "\t\t// PS5 RetroArch: what runs after the program (frontends/picker/picker.cpp)\n"
|
||||
"\t\tif (ps5_title_next) {\n\t\t\tps5_title_next();\n\t\t}\n")
|
||||
main = main.replace("\nint main()\n", '\nextern "C" __attribute__((weak)) void ps5_title_next(void);\n\nint main()\n')
|
||||
(generated / "ps5/src/main.cpp").write_text(main)
|
||||
PY
|
||||
|
||||
# The template's notices step reads the repositories its title is built from beside
|
||||
# the title's folder; they are this project's neighbours, linked as they stand.
|
||||
for neighbour in PS5_VKHomebrewUI PS5_PayloadSDK PS5_Mesa PS5_LLVM PS5_Vulkan; do
|
||||
[[ ! -d $root/../$neighbour || -e $work/$neighbour ]] || ln -s "$(cd -- "$root/../$neighbour" && pwd)" "$work/$neighbour"
|
||||
done
|
||||
echo "==> [picker] building (log in build/picker/build.log)"
|
||||
PS5_VULKAN_DIR="$vulkan" bash "$generated/ps5/tools/build.sh" > "$work/build.log" 2>&1 ||
|
||||
{ grep -E "error|Error" "$work/build.log" | head -20 >&2; exit 1; }
|
||||
built="$generated/dist/PPSA99168"
|
||||
[[ -f $built/eboot.bin ]] || { echo "error: no $built/eboot.bin" >&2; exit 1; }
|
||||
cmp -s "$built/sce_module/libc.prx" "$app/sce_module/libc.prx" ||
|
||||
{ echo "error: the picker's libc.prx is not the title's" >&2; exit 1; }
|
||||
|
||||
picker="$app/picker"
|
||||
rm -rf -- "$picker"
|
||||
mkdir -p "$picker/previews" "$picker/screenshots"
|
||||
cp "$built/eboot.bin" "$picker/picker.bin"
|
||||
cp -r "$built/assets" "$built/shaders" "$picker/"
|
||||
# The kit's songs play only when a program asks for them, and the picker does not.
|
||||
rm -rf -- "$picker/assets/hui/audio/music"
|
||||
[[ ! -d $built/licenses ]] || cp -r "$built/licenses" "$picker/licenses"
|
||||
python3 - "$root/assets/picker" "$picker/previews" <<'PY'
|
||||
import struct, sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
source, target = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
for name, out in (("retroarch-xmb.png", "retroarch.rgba"), ("esde-alekfull-snes.png", "es-de.rgba")):
|
||||
image = Image.open(source / name).convert("RGBA").resize((1280, 720), Image.LANCZOS)
|
||||
(target / out).write_bytes(b"PS5RGBA1" + struct.pack("<II", *image.size) + image.tobytes())
|
||||
PY
|
||||
cp "$root/assets/picker/NOTICE.md" "$picker/previews/NOTICE.md"
|
||||
printf '==> [picker] picker/picker.bin %s bytes, assets %s, previews 2 (1280x720)\n' \
|
||||
"$(stat -c %s "$picker/picker.bin")" "$(du -sh "$picker" | cut -f1)"
|
||||
@@ -429,7 +429,12 @@ python3 "$root/tools/video-assets.py" stage "$dist"
|
||||
if [[ -n ${PS5_FRONTENDS:-} ]]; then
|
||||
read -r -a frontends <<< "$PS5_FRONTENDS"
|
||||
for frontend in "${frontends[@]}"; do
|
||||
bash "$root/tools/build-frontend.sh" "$frontend"
|
||||
# The picker is a program of ../PS5_VulkanTemplate's UI module, built its way.
|
||||
if [[ $frontend == picker ]]; then
|
||||
bash "$root/tools/build-picker.sh"
|
||||
else
|
||||
bash "$root/tools/build-frontend.sh" "$frontend"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
+102
-6
@@ -14,7 +14,14 @@
|
||||
# tools/run-title.sh --relaunch-test=1 --relaunch-image=es-de/es-de.bin --frontend-capture=20,40 --watch 60
|
||||
# hand over to EmulationStation, picture its frames at 20 and 40 s
|
||||
# (--frontend-scroll: and press Right every 400 ms meanwhile;
|
||||
# --frontend-profile: and sample the CPU, for tools/esde-profile.py)
|
||||
# --frontend-profile: and sample the CPU, for tools/esde-profile.py;
|
||||
# --frontend-quit: then quit it, back to eboot.bin)
|
||||
# tools/run-title.sh --picker-test=300:retroarch --retroarch-frames=600 --watch 60
|
||||
# the picker chooses RetroArch, which quits after 600
|
||||
# frames: the title goes back to the picker
|
||||
# tools/run-title.sh --picker-test=300:es-de --frontend-capture=20 --watch 60
|
||||
# the picker, as the home screen starts it: 300 frames, a
|
||||
# picture of the last, then it chooses EmulationStation
|
||||
#
|
||||
# Why this exists. Every earlier round of the console loop was four hand-driven
|
||||
# steps that needed a person: build, upload, launch, read. Two things went wrong
|
||||
@@ -54,6 +61,8 @@ display_modes_test=0
|
||||
relaunch_image=
|
||||
frontend_capture=
|
||||
frontend_scroll=
|
||||
picker_test=
|
||||
retroarch_frames=0
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--no-build) build=0 ;;
|
||||
@@ -71,7 +80,10 @@ while (( $# )); do
|
||||
--frontend-capture=*) frontend_capture=${1#*=} ;;
|
||||
--frontend-scroll) frontend_scroll=${frontend_scroll:+$frontend_scroll,}scroll ;;
|
||||
--frontend-profile) frontend_scroll=${frontend_scroll:+$frontend_scroll,}profile ;;
|
||||
*) echo "usage: ${0##*/} [--no-build] [--no-deploy] [--watch SECONDS] [--gpu-profile 1..60] [--audio-test] [--core-test[=fceumm|mgba|snes9x|fbneo|genesis_plus_gx|ppsspp]] [--relaunch-test[=1..20] [--relaunch-image=PATH]] [--display-modes-test[=frames]] [--frontend-capture=SECONDS[,SECONDS...] [--frontend-scroll] [--frontend-profile]]" >&2; exit 2 ;;
|
||||
--frontend-quit) frontend_scroll=${frontend_scroll:+$frontend_scroll,}quit ;;
|
||||
--picker-test=*) picker_test=${1#*=} ;;
|
||||
--retroarch-frames=*) retroarch_frames=${1#*=} ;;
|
||||
*) echo "usage: ${0##*/} [--no-build] [--no-deploy] [--watch SECONDS] [--gpu-profile 1..60] [--audio-test] [--core-test[=fceumm|mgba|snes9x|fbneo|genesis_plus_gx|ppsspp]] [--relaunch-test[=1..20] [--relaunch-image=PATH]] [--display-modes-test[=frames]] [--frontend-capture=SECONDS[,SECONDS...] [--frontend-scroll] [--frontend-profile] [--frontend-quit]] [--picker-test=FRAMES:retroarch|es-de|none] [--retroarch-frames=N]" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
@@ -116,6 +128,18 @@ if [[ -n $frontend_capture ]]; then
|
||||
{ echo "--watch must allow the last capture plus 15 seconds" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
# The picker test (frontends/picker/picker.cpp): the frontend picker, as a launch from
|
||||
# the home screen starts it, draws FRAMES frames without the pad and chooses.
|
||||
if [[ -n $picker_test ]]; then
|
||||
[[ $picker_test =~ ^[0-9]+:(retroarch|es-de|none)$ ]] ||
|
||||
{ echo "--picker-test takes FRAMES:retroarch, FRAMES:es-de or FRAMES:none" >&2; exit 2; }
|
||||
(( ${picker_test%%:*} >= 10 && ${picker_test%%:*} <= 36000 )) ||
|
||||
{ echo "--picker-test takes 10..36000 frames" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
[[ $retroarch_frames =~ ^[0-9]+$ ]] && (( retroarch_frames <= 100000 )) ||
|
||||
{ echo "--retroarch-frames takes 1..100000 frames" >&2; exit 2; }
|
||||
|
||||
if (( audio_test && watch < 20 )); then
|
||||
echo "--audio-test requires --watch of at least 20 seconds" >&2
|
||||
exit 2
|
||||
@@ -206,7 +230,7 @@ fi
|
||||
# appeared. A run that does not ask for extras must not inherit them, so the file
|
||||
# is removed on every run - before the launch, because deleting it afterwards
|
||||
# would leave it for the next one if this run dies.
|
||||
python3 - "$title_id" "$profile" "$audio_test" "$core_test" "$relaunch_test" "$relaunch_run" "$display_modes_test" "$relaunch_image" "$frontend_capture" "$capture_run" "$frontend_scroll" <<'PY'
|
||||
python3 - "$title_id" "$profile" "$audio_test" "$core_test" "$relaunch_test" "$relaunch_run" "$display_modes_test" "$relaunch_image" "$frontend_capture" "$capture_run" "$frontend_scroll" "$picker_test" "$retroarch_frames" <<'PY'
|
||||
import importlib.util, sys
|
||||
spec = importlib.util.spec_from_file_location("dt", "tools/deploy-title.py")
|
||||
dt = importlib.util.module_from_spec(spec); spec.loader.exec_module(dt)
|
||||
@@ -266,7 +290,26 @@ with connect(**dt.load_settings()) as ftp:
|
||||
ftp.storbinary(f"STOR {control}", io.BytesIO(f"{sys.argv[9]} {sys.argv[10]} {sys.argv[11]}\n".encode()))
|
||||
print(f" armed frontend capture at {sys.argv[9]} s, run {sys.argv[10]}"
|
||||
f"{', pressing Right every 400 ms' if 'scroll' in sys.argv[11] else ''}"
|
||||
f"{', sampling the CPU every ms' if 'profile' in sys.argv[11] else ''}")
|
||||
f"{', sampling the CPU every ms' if 'profile' in sys.argv[11] else ''}"
|
||||
f"{', then quitting it' if 'quit' in sys.argv[11] else ''}")
|
||||
control = f"/data/homebrew/{sys.argv[1]}/picker/picker-test.txt"
|
||||
remove_if_present(ftp, control)
|
||||
if sys.argv[12]:
|
||||
frames, choice = sys.argv[12].split(":")
|
||||
remove_if_present(ftp, f"/data/homebrew/{sys.argv[1]}/picker/picker-test.jsonl")
|
||||
remove_if_present(ftp, f"/data/homebrew/{sys.argv[1]}/picker/screenshots/picker.ppm")
|
||||
ftp.storbinary(f"STOR {control}", io.BytesIO(f"{frames} {choice}\n".encode()))
|
||||
print(f" armed picker test: {frames} frames, then {choice}")
|
||||
# RetroArch quits by itself after that many frames (its --max-frames), from
|
||||
# /app0/args.txt, which a test run's launch keeps.
|
||||
if int(sys.argv[13]):
|
||||
ftp.storbinary(f"STOR /data/homebrew/{sys.argv[1]}/args.txt",
|
||||
io.BytesIO(f"--max-frames={sys.argv[13]}\n".encode()))
|
||||
print(f" RetroArch quits after {sys.argv[13]} frames")
|
||||
# The launch is a test run's (src/main.cpp): it keeps the test files this run
|
||||
# armed, and starts RetroArch rather than the frontend picker
|
||||
# (src/frontend_mode_ps5.cpp) unless a picker test is armed.
|
||||
ftp.storbinary(f"STOR /data/homebrew/{sys.argv[1]}/test-run.txt", io.BytesIO(b"tools/run-title.sh\n"))
|
||||
PY
|
||||
|
||||
# --- listen first, then launch ----------------------------------------------
|
||||
@@ -326,6 +369,57 @@ except Exception as error:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# --- the picker's record and picture -----------------------------------------
|
||||
if [[ -n $picker_test ]]; then
|
||||
python3 - "$title_id" "$picker_test" "$stamp" <<'PY'
|
||||
import importlib.util, json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, "tools")
|
||||
from ps5_ftp import connect, remove_if_present
|
||||
spec = importlib.util.spec_from_file_location("dt", "tools/deploy-title.py")
|
||||
dt = importlib.util.module_from_spec(spec); spec.loader.exec_module(dt)
|
||||
base = f"/data/homebrew/{sys.argv[1]}/picker"
|
||||
frames, choice = sys.argv[2].split(":")
|
||||
out = Path("klog") / f"picker-{sys.argv[3]}"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
expected = {"retroarch": "/app0/eboot.bin", "es-de": "/app0/es-de/es-de.bin", "none": ""}[choice]
|
||||
with connect(**dt.load_settings()) as ftp:
|
||||
remove_if_present(ftp, f"{base}/picker-test.txt")
|
||||
rows = []
|
||||
for name in ("picker-test.jsonl", "screenshots/picker.ppm"):
|
||||
target = out / Path(name).name
|
||||
try:
|
||||
with target.open("wb") as stream:
|
||||
ftp.retrbinary(f"RETR {base}/{name}", stream.write)
|
||||
print(f" saved picker/{name} to {target}")
|
||||
except Exception as error:
|
||||
target.unlink(missing_ok=True)
|
||||
print(f" could not retrieve picker/{name}: {type(error).__name__}")
|
||||
records = out / "picker-test.jsonl"
|
||||
if records.exists():
|
||||
for line in records.read_text().splitlines():
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except ValueError:
|
||||
print(f" picker: a record line that is not JSON: {line!r}")
|
||||
picture = out / "picker.ppm"
|
||||
if picture.exists():
|
||||
try:
|
||||
from PIL import Image
|
||||
Image.open(picture).save(picture.with_suffix(".png"))
|
||||
picture.unlink()
|
||||
picture = picture.with_suffix(".png")
|
||||
except ImportError:
|
||||
pass
|
||||
for row in rows:
|
||||
print(f" picker: {row['frames']} frames, chose {row['choice']}, next '{row['next']}'")
|
||||
if rows and rows[-1] == {"frames": int(frames), "choice": choice, "next": expected} and picture.exists():
|
||||
print(f" picker test PASS: {frames} frames, a picture of the last ({picture}), then {choice}")
|
||||
else:
|
||||
print(f" picker test FAILED: wanted {frames} frames then {choice} ({expected!r}), got {rows}")
|
||||
PY
|
||||
fi
|
||||
|
||||
# --- the frontend's pictures and logs ----------------------------------------
|
||||
# Collected before RetroArch's logs, whose checks stop the script on a failure.
|
||||
if [[ -n $frontend_capture ]]; then
|
||||
@@ -386,7 +480,7 @@ PY
|
||||
fi
|
||||
|
||||
# --- preserve development logs and optional buffered timing ------------------
|
||||
python3 - "$title_id" "$profile" "$stamp" "$audio_test" "$expected_identity" "$core_test" "$relaunch_test" "$relaunch_run" "$display_modes_test" "$relaunch_image" <<'PY'
|
||||
python3 - "$title_id" "$profile" "$stamp" "$audio_test" "$expected_identity" "$core_test" "$relaunch_test" "$relaunch_run" "$display_modes_test" "$relaunch_image" "$picker_test" <<'PY'
|
||||
import importlib.util, json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, "tools")
|
||||
@@ -419,7 +513,9 @@ with connect(**dt.load_settings()) as ftp:
|
||||
with target.open("wb") as out:
|
||||
ftp.retrbinary(f"RETR /data/homebrew/{sys.argv[1]}/{name}", out.write)
|
||||
expected = sys.argv[5]
|
||||
if name == "retroarch.log":
|
||||
# A picker test that chooses EmulationStation, or nothing, never starts RetroArch.
|
||||
retroarch_runs = not sys.argv[11] or sys.argv[11].endswith(":retroarch")
|
||||
if name == "retroarch.log" and retroarch_runs:
|
||||
if f"build identity: {expected}" not in target.read_text(errors="replace"):
|
||||
raise SystemExit("RetroArch log is stale or logging failed: current build identity absent")
|
||||
if name in ("core-loader-test.json", "core-recovery-test.json"):
|
||||
|
||||
Reference in new issue
Block a user