Files
PS5_RetroArch/frontends/es-de/ps5/library_ps5.cpp
T
Mihawk 4812ffdee0 FTP: everything in the title folder is 0777, and the repair finishes
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.
2026-10-07 00:00:46 -04:00

328 lines
14 KiB
C++

/* PS5 RetroArch - EmulationStation's systems and game lists, from RetroArch's playlists.
*
* Copyright (C) 2026 Mihawk
* SPDX-License-Identifier: GPL-3.0-or-later
*
* ES-DE keeps no library of its own here: each time it starts (main_ps5.cpp, before
* ES-DE reads anything), the game library every frontend shares (src/ps5_library.h)
* is read from RetroArch's playlists and core info, and ES-DE's files are written
* from it:
*
* - ES-DE/custom_systems/es_systems.xml: a system for each of the library's, named
* as ES-DE names the platform (its full name, platform and theme taken from
* ES-DE's own systems file, resources/systems/unix/es_systems.xml), its path the
* folder of its games, its command game mode's: "/app0/eboot.bin -L <core> %ROM%";
* - ES-DE/gamelists/<system>/gamelist.xml: the system's games. A game ES-DE already
* lists keeps everything ES-DE knows of it (play count and time, favourite,
* scraped details); a new one gets its playlist label; one no playlist has any
* more is left out. What RetroArch remembers of play is added:
* - favourite: RetroArch's favourites as they changed since the last start
* (ES-DE/ps5-favorites.txt holds them as they were): a game RetroArch added
* or removed since is ES-DE's favourite or not, and any other keeps ES-DE's
* own, so a favourite set in either frontend shows here;
* - lastplayed, playcount, playtime: the later and the larger of ES-DE's and
* RetroArch's runtime log, so a game played from RetroArch shows in ES-DE's
* Last Played collection.
* ES-DE shows both as its Favorites and Last Played collections: the port's
* default (patches/0001), set once on an install whose settings had none.
*
* ES-DE then runs with --gamelist-only, so its systems hold exactly those games, as
* RetroArch's playlists do, and nothing is scanned for.
*/
#include "library_ps5.h"
#include "ps5_library.h"
#include <pugixml.hpp>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <map>
#include <set>
#include <string>
#include <sys/stat.h>
namespace
{
struct Reference
{
std::string fullname, platform, theme;
};
/* ES-DE's own systems: name -> full name, platform, theme. */
std::map<std::string, Reference> read_reference(const std::string &path)
{
std::map<std::string, Reference> systems;
pugi::xml_document document;
if (!document.load_file(path.c_str()))
return systems;
for (pugi::xml_node system : document.child("systemList").children("system"))
systems[system.child_value("name")] = {system.child_value("fullname"),
system.child_value("platform"),
system.child_value("theme")};
return systems;
}
std::string extensions(const char *list)
{
std::string out;
for (const char *at = list; at && *at;)
{
const char *bar = std::strchr(at, '|');
const std::string extension(at, bar ? static_cast<size_t>(bar - at) : std::strlen(at));
std::string upper = extension;
for (char &c : upper)
c = static_cast<char>(c >= 'a' && c <= 'z' ? c - 'a' + 'A' : c);
out += (out.empty() ? "." : " .") + extension;
if (upper != extension)
out += " ." + upper;
at = bar ? bar + 1 : nullptr;
}
return out;
}
/* A game list's path, absolute: "./x" is relative to the system's folder. */
std::string absolute(const std::string &folder, const std::string &path)
{
if (path.rfind("./", 0) == 0)
return (folder == "/" ? "" : folder) + "/" + path.substr(2);
return path;
}
/* A child's text, set, the child made when the node has none. */
void set_child(pugi::xml_node node, const char *name, const std::string &value)
{
pugi::xml_node child = node.child(name);
if (!child)
child = node.append_child(name);
child.text().set(value.c_str());
}
/* RetroArch's "YYYY-MM-DD HH:MM:SS" as ES-DE's "YYYYMMDDTHHMMSS". */
std::string esde_time(const char *retroarch)
{
int year, month, day, hour, minute, second;
if (std::sscanf(retroarch, "%4d-%2d-%2d %2d:%2d:%2d", &year, &month, &day, &hour, &minute,
&second) != 6)
return "";
char out[20];
std::snprintf(out, sizeof(out), "%04d%02d%02dT%02d%02d%02d", year, month, day, hour, minute,
second);
return out;
}
/* What RetroArch remembers of a game's play, into its game list entry. */
void add_play(pugi::xml_node node, const struct ps5_library_game &game, bool was_favorite)
{
const bool es_favorite = std::strcmp(node.child_value("favorite"), "true") == 0;
const bool favorite = game.favorite != 0 ? (was_favorite ? es_favorite : true)
: (was_favorite ? false : es_favorite);
if (favorite)
set_child(node, "favorite", "true");
else if (node.child("favorite"))
node.remove_child("favorite");
const std::string played = esde_time(game.last_played);
const std::string known = node.child_value("lastplayed");
if (!played.empty() && (known.size() != played.size() || played > known))
set_child(node, "lastplayed", played);
if (game.play_count > std::strtoul(node.child_value("playcount"), nullptr, 10))
set_child(node, "playcount", std::to_string(game.play_count));
if (game.play_seconds > std::strtoul(node.child_value("playtime"), nullptr, 10))
set_child(node, "playtime", std::to_string(game.play_seconds));
}
std::set<std::string> read_lines(const std::string &path)
{
std::set<std::string> lines;
std::ifstream input(path);
for (std::string line; std::getline(input, line);)
if (!line.empty())
lines.insert(line);
return lines;
}
/* An install whose settings predate the collections default (it wrote
* CollectionSystemsAuto empty) gets Favorites and Last Played once; the marker keeps
* a later choice of the user's. */
void enable_collections(const std::string &data)
{
const std::string settings = data + "/settings/es_settings.xml";
const std::string marker = data + "/ps5-collections-enabled";
struct stat status;
if (stat(marker.c_str(), &status) == 0)
return;
pugi::xml_document document;
if (document.load_file(settings.c_str()))
for (pugi::xml_node node : document.children("string"))
if (std::strcmp(node.attribute("name").value(), "CollectionSystemsAuto") == 0 &&
node.attribute("value").value()[0] == '\0')
{
node.attribute("value").set_value("favorites,recent");
document.save_file(settings.c_str()); // as ES-DE saves it (Settings::saveFile)
}
if (std::FILE *file = std::fopen(marker.c_str(), "w"))
std::fclose(file);
}
bool save(pugi::xml_document &document, const std::string &path)
{
const std::string temporary = path + ".tmp";
if (!document.save_file(temporary.c_str(), "\t", pugi::format_default, pugi::encoding_utf8))
return false;
chmod(temporary.c_str(), 0777); /* FTP's reach: the title's files are 0777 */
if (std::rename(temporary.c_str(), path.c_str()) != 0)
{
std::remove(temporary.c_str());
return false;
}
return true;
}
} // namespace
extern "C" int ps5_esde_write_library_to(const struct ps5_esde_library_paths *paths, char *summary,
size_t summary_size)
{
struct ps5_library library;
if (ps5_library_load_content(&library, paths->playlists, paths->info, paths->cores,
paths->content) != 0)
{
ps5_library_free(&library);
std::snprintf(summary, summary_size, "the library could not be read (memory)");
return -1;
}
const std::map<std::string, Reference> reference = read_reference(paths->reference);
const std::string data = paths->data;
/* Folders 0777 whatever the umask, for FTP's reach (src/permissions_ps5.cpp). */
for (const char *folder : {"/custom_systems", "/gamelists"})
{
mkdir((data + folder).c_str(), 0777);
chmod((data + folder).c_str(), 0777);
}
enable_collections(data);
/* RetroArch's favourites as they were at the last start. */
const std::string favorites_record = data + "/ps5-favorites.txt";
const std::set<std::string> were_favorites = read_lines(favorites_record);
std::string favorites_now;
size_t favorites = 0, played = 0;
pugi::xml_document systems;
systems.append_child(pugi::node_comment)
.set_value(" Written by es-de.bin each time it starts, from RetroArch's playlists "
"and the content folders (src/ps5_library.h). "
"Changes here are lost: change the playlists in RetroArch. ");
pugi::xml_node list = systems.append_child("systemList");
std::set<std::string> names;
size_t games_written = 0, games_kept = 0, failures = 0;
for (size_t s = 0; s < library.system_count; s++)
{
const struct ps5_library_system &system = library.systems[s];
std::string name = system.id;
for (int suffix = 2; names.count(name); suffix++)
name = std::string(system.id) + "-" + std::to_string(suffix);
names.insert(name);
const auto known = reference.find(system.id);
pugi::xml_node entry = list.append_child("system");
entry.append_child("name").text().set(name.c_str());
entry.append_child("fullname")
.text()
.set(known != reference.end() ? known->second.fullname.c_str() : system.name);
entry.append_child("path").text().set(system.folder);
entry.append_child("extension").text().set(extensions(system.extensions).c_str());
const std::string command =
system.core[0] ? std::string("/app0/eboot.bin -L ") + system.core + " %ROM%"
: std::string("/app0/eboot.bin %ROM%");
pugi::xml_node launch = entry.append_child("command");
launch.append_attribute("label").set_value("RetroArch");
launch.text().set(command.c_str());
entry.append_child("platform")
.text()
.set(known != reference.end() ? known->second.platform.c_str() : system.id);
entry.append_child("theme").text().set(
known != reference.end() ? known->second.theme.c_str() : system.id);
/* Its game list: what ES-DE knows of each game kept, the library's games only. */
const std::string folder = data + "/gamelists/" + name;
const std::string gamelist = folder + "/gamelist.xml";
mkdir(folder.c_str(), 0777);
chmod(folder.c_str(), 0777);
pugi::xml_document previous, games;
previous.load_file(gamelist.c_str());
std::map<std::string, pugi::xml_node> known_games;
for (pugi::xml_node game : previous.child("gameList").children("game"))
known_games[absolute(system.folder, game.child_value("path"))] = game;
pugi::xml_node root = games.append_child("gameList");
for (pugi::xml_node node : previous.child("gameList").children("folder"))
root.append_copy(node);
const std::string prefix =
std::string(system.folder) == "/" ? "/" : std::string(system.folder) + "/";
for (size_t g = 0; g < system.game_count; g++)
{
const struct ps5_library_game &game = library.games[system.first_game + g];
const std::string path = game.path;
const std::string relative =
"./" + (path.rfind(prefix, 0) == 0 ? path.substr(prefix.size())
: path.substr(path.rfind('/') + 1));
const auto previous_game = known_games.find(path);
pugi::xml_node node;
if (previous_game != known_games.end())
{
node = root.append_copy(previous_game->second);
node.child("path").text().set(relative.c_str());
if (!node.child("name"))
node.append_child("name").text().set(game.label);
games_kept++;
}
else
{
node = root.append_child("game");
node.append_child("path").text().set(relative.c_str());
node.append_child("name").text().set(game.label);
}
add_play(node, game, were_favorites.count(path) != 0);
if (game.favorite)
favorites_now += path + "\n";
favorites += std::strcmp(node.child_value("favorite"), "true") == 0;
played +=
node.child("lastplayed") && std::strcmp(node.child_value("lastplayed"), "0") != 0;
games_written++;
}
if (!save(games, gamelist))
failures++;
}
if (!save(systems, data + "/custom_systems/es_systems.xml"))
failures++;
if (std::FILE *record = std::fopen((favorites_record + ".tmp").c_str(), "w"))
{
std::fputs(favorites_now.c_str(), record);
std::fclose(record);
if (std::rename((favorites_record + ".tmp").c_str(), favorites_record.c_str()) != 0)
failures++;
}
const size_t system_count = library.system_count;
std::snprintf(
summary, summary_size,
"%zu systems, %zu games (%zu with ES-DE's details kept), %zu cores, %zu favourites, "
"%zu played, %zu failures",
system_count, games_written, games_kept, library.core_count, favorites, played, failures);
ps5_library_free(&library);
return failures ? -1 : static_cast<int>(system_count);
}
extern "C" int ps5_esde_write_library(char *summary, size_t summary_size)
{
/* The title's content folder, and the USB and extended storage drives a title sees
* when ShadowMountPlus mounts them (src/frontend_ps5.cpp lists the same in RetroArch's
* browser); an empty slot holds no system folder, so it adds nothing. */
static const char *const content[] = {"/app0/content", "/mnt/usb0", "/mnt/usb1", "/mnt/usb2",
"/mnt/usb3", "/mnt/usb4", "/mnt/usb5", "/mnt/usb6",
"/mnt/usb7", "/mnt/ext0", "/mnt/ext1", nullptr};
const struct ps5_esde_library_paths paths = {
PS5_LIBRARY_PLAYLISTS, PS5_LIBRARY_INFO,
PS5_LIBRARY_CORES, "/app0/es-de/resources/systems/unix/es_systems.xml",
"/app0/es-de/ES-DE", content};
return ps5_esde_write_library_to(&paths, summary, summary_size);
}