ES-DE: RetroArch's favourites, history and play in its collections

The shared game library (src/ps5_library.h) now carries what RetroArch
remembers of each game:
- whether it is a favourite (playlists/builtin/content_favorites.lpl);
- its place in the history (builtin/content_history.lpl);
- its runtime log (logs/<core's corename>/<content name>.lrtl): when it
  was last played, how often, and for how long.
A game in the history with no runtime log is given a time from its
place: the history file's time, a minute earlier for each place below
the top.

ES-DE's writer (frontends/es-de/ps5/library_ps5.cpp) merges these into
each game list entry:
- favorite: a game RetroArch added or removed since ES-DE's last start
  follows RetroArch (ES-DE/ps5-favorites.txt records RetroArch's set);
  any other game keeps ES-DE's own choice. A favourite set in either
  frontend therefore shows in ES-DE.
- lastplayed, playcount, playtime: the later or larger of ES-DE's and
  RetroArch's.
On the PS5, ES-DE's automatic collections default to Favorites and Last
Played (patches/0001). An install whose settings already said none gets
them once; a marker file keeps any later choice of the user's.

Evidence: evidence/esde-collections/ (capture.json, expectation.json).
Base PS5 13.40:
  RetroArch's History tab, the game's menu, Add to Favorites (pad
    script): RetroArch's favourites held Donkey Kong Country 2
  ES-DE's next start: 1 favourite, 15 games played; CollectionSystemsAuto
    favorites,recent; Donkey Kong Country 2 favourite, playcount 8,
    playtime 838 s (the runtime log's 0:13:58), its later lastplayed kept
  ES-DE's carousel: the SNES card reads "1 GAME (1 FAVORITE)", and the
    Favorites and Last Played collections are there
The test favourite's file was removed afterwards; the console had none.
Host tests: ps5_library and esde_library PASS.
Command:
  tools/run-title.sh --no-build --no-deploy --pad-script=tests/pad/history-favourite.txt --watch 25
  tools/run-title.sh --no-build --no-deploy --relaunch-test=1 --relaunch-image=es-de/es-de.bin --frontend-capture=4 --watch 30
Gates: tools/verify.sh PASS (format unit build integration evidence).
This commit is contained in:
Mihawk committed 2026-10-04 22:34:28 -04:00
1 parent 1e6b8cba4d
commit 5ee2d48455
9 files changed
+471 -42

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
{
"step": "esde-collections",
"recorded": "2026-10-05T02:32:04Z",
"console_run": "base PS5 13.40, runs 222652 (favourite), 222946 and 223054 (ES-DE)",
"revision": "59098d5 + RetroArch's favourites and play in ES-DE",
"command": "tools/run-title.sh --no-build --no-deploy --pad-script=tests/pad/history-favourite.txt --watch 25; tools/run-title.sh --no-build --no-deploy --relaunch-test=1 --relaunch-image=es-de/es-de.bin --frontend-capture=4 --watch 30",
"raw_capture": "klog/collections-evidence/summary.txt",
"raw_lines": 11,
"raw_bytes": 1283,
"lines": [
"==> base PS5 13.40, build of this commit (PS5_FRONTENDS='es-de picker')",
"RetroArch, tests/pad/history-favourite.txt (History tab, the newest game's menu, Add to Favorites):",
" input: pad script MENU at 9.51 s: depth 2, list 'deferred_rpl_entry_actions' '', entry 3 of 7 'Add to Favorites'",
" RetroArch's favourites (playlists/builtin/content_favorites.lpl): Donkey Kong Country 2",
"ES-DE's next start (--relaunch-test=1 --relaunch-image=es-de/es-de.bin --frontend-capture=4 --watch 30):",
" start: library from RetroArch's playlists: 14 systems, 16 games (16 with ES-DE's details kept), 15 cores, 1 favourites, 15 played, 0 failures",
" es_settings.xml: CollectionSystemsAuto = favorites,recent (enabled once; it was empty)",
" gamelist snes, Donkey Kong Country 2: favorite true, lastplayed 20261004T221855 (ES-DE's, later than the runtime log's 22:18:53), playcount 8 (the runtime log's), playtime 838 (the runtime log's 0:13:58)",
" the SNES card: 1 GAME (1 FAVORITE)",
"ES-DE scrolled (--frontend-capture=4,6,8,10 --frontend-scroll): the carousel holds the Favorites (heart) and Last Played (clock) collections",
"host tests: ps5_library (favourites, history, runtime logs, a time from the history place) PASS; esde_library (favourites from both frontends, play records, the collections once) PASS"
]
}
@@ -0,0 +1,12 @@
{
"step": "esde-collections",
"note": "RetroArch's favourites, history and runtime logs reach ES-DE's game lists through the shared library: a favourite added in RetroArch's menu is ES-DE's favourite, RetroArch's play counts, times and last played show there, and ES-DE shows its Favorites and Last Played collections.",
"must_contain": [
"1 favourites, 15 played, 0 failures",
"CollectionSystemsAuto = favorites,recent",
"favorite true, lastplayed 20261004T221855",
"playcount 8 (the runtime log's), playtime 838"
],
"must_not_contain": [],
"human_check": "klog/frontend-20261004-223054 pictures: the carousel shows a heart (Favorites) and a clock (Last Played) card after PSP; klog/frontend-20261004-222946 shows '1 GAME (1 FAVORITE)' under SNES."
}
+15 -1
View File
@@ -281,7 +281,7 @@ index a9af826..f911a41 100644
// the system view or the gamelist view.
std::string requestedSystem {Settings::getInstance()->getString("StartupSystem")};
diff --git a/es-core/src/Settings.cpp b/es-core/src/Settings.cpp
index d0c8264..c5a4ee5 100644
index d0c8264..cfeadd0 100644
--- a/es-core/src/Settings.cpp
+++ b/es-core/src/Settings.cpp
@@ -161,7 +161,12 @@ void Settings::setDefaults()
@@ -297,6 +297,20 @@ index d0c8264..c5a4ee5 100644
mStringMap["ThemeVariant"] = {"", ""};
mStringMap["ThemeColorScheme"] = {"", ""};
mStringMap["ThemeFontSize"] = {"", ""};
@@ -256,7 +261,13 @@ void Settings::setDefaults()
mBoolMap["InputDeviceNotifications"] = {true, true};
// Game collection settings.
+#if defined(__PROSPERO__)
+ // PS5: RetroArch's favourites and last played games, which the port writes into the
+ // game lists (frontends/es-de/ps5/library_ps5.cpp), as collections from the start.
+ mStringMap["CollectionSystemsAuto"] = {"favorites,recent", "favorites,recent"};
+#else
mStringMap["CollectionSystemsAuto"] = {"", ""};
+#endif
mStringMap["CollectionSystemsCustom"] = {"", ""};
mStringMap["CollectionCustomGrouping"] = {"unthemed", "unthemed"};
mBoolMap["FavFirstCustom"] = {false, false};
diff --git a/es-core/src/renderers/RendererOpenGL.cpp b/es-core/src/renderers/RendererOpenGL.cpp
index 6e78440..3216de8 100644
--- a/es-core/src/renderers/RendererOpenGL.cpp
+140 -23
View File
@@ -15,7 +15,16 @@
* - 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.
* 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.
@@ -26,8 +35,11 @@
#include <pugixml.hpp>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <map>
#include <set>
#include <string>
@@ -48,7 +60,8 @@ std::map<std::string, Reference> read_reference(const std::string &path)
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"),
systems[system.child_value("name")] = {system.child_value("fullname"),
system.child_value("platform"),
system.child_value("theme")};
return systems;
}
@@ -79,6 +92,81 @@ std::string absolute(const std::string &folder, const std::string &path)
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";
@@ -108,10 +196,17 @@ extern "C" int ps5_esde_write_library_to(const struct ps5_esde_library_paths *pa
const std::string data = paths->data;
mkdir((data + "/custom_systems").c_str(), 0777);
mkdir((data + "/gamelists").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 (src/ps5_library.h). "
.set_value(" Written by es-de.bin each time it starts, from RetroArch's playlists "
"(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;
@@ -126,19 +221,22 @@ extern "C" int ps5_esde_write_library_to(const struct ps5_esde_library_paths *pa
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("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%");
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);
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 playlists' games only. */
const std::string folder = data + "/gamelists/" + name;
@@ -152,28 +250,37 @@ extern "C" int ps5_esde_write_library_to(const struct ps5_esde_library_paths *pa
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) + "/";
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));
"./" + (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())
{
pugi::xml_node copy = root.append_copy(previous_game->second);
copy.child("path").text().set(relative.c_str());
if (!copy.child("name"))
copy.append_child("name").text().set(game.label);
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
{
pugi::xml_node node = root.append_child("game");
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))
@@ -181,17 +288,27 @@ extern "C" int ps5_esde_write_library_to(const struct ps5_esde_library_paths *pa
}
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 failures",
system_count, games_written, games_kept, library.core_count, failures);
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)
{
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"};
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"};
return ps5_esde_write_library_to(&paths, summary, summary_size);
}
+111
View File
@@ -16,6 +16,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
/* --- the JSON RetroArch writes its playlists in ----------------------------------- */
@@ -729,6 +730,7 @@ static void load_cores(struct ps5_library *library, const char *info, const char
while (fgets(line, sizeof(line), file))
{
info_value(line, "display_name", core->name, sizeof(core->name));
info_value(line, "corename", core->core_name, sizeof(core->core_name));
info_value(line, "database", core->databases, sizeof(core->databases));
info_value(line, "supported_extensions", core->extensions,
sizeof(core->extensions));
@@ -863,6 +865,114 @@ static void finish_systems(struct ps5_library *library)
}
}
/* --- what RetroArch remembers of play ---------------------------------------------- */
static struct ps5_library_game *game_by_path(struct ps5_library *library, const char *path)
{
for (size_t i = 0; i < library->game_count; i++)
if (strcmp(library->games[i].path, path) == 0)
return &library->games[i];
return NULL;
}
struct marking
{
struct ps5_library *library;
int history; /* 0: the favourites, 1: the history */
unsigned place;
};
static void mark_entry(void *context, const struct ps5_playlist_entry *entry)
{
struct marking *marking = (struct marking *)context;
marking->place++;
struct ps5_library_game *game = game_by_path(marking->library, entry->path);
if (!game)
return;
if (!marking->history)
game->favorite = 1;
else if (!game->history)
game->history = marking->place;
}
/* A builtin playlist, from builtin/ (where RetroArch keeps them) or beside the others;
* its file's time in *written. */
static void read_builtin(struct ps5_library *library, const char *playlists, const char *name,
int history, time_t *written)
{
static const char *const places[] = {"builtin/", ""};
for (size_t i = 0; i < sizeof(places) / sizeof(places[0]); i++)
{
char path[PS5_LIBRARY_PATH_MAX + 256];
snprintf(path, sizeof(path), "%s/%s%s", playlists, places[i], name);
struct marking marking = {library, history, 0};
if (ps5_playlist_read(path, NULL, 0, mark_entry, &marking) >= 0)
{
struct stat status;
if (written && stat(path, &status) == 0)
*written = status.st_mtime;
return;
}
}
}
/* A game's runtime log, as RetroArch names it: logs/<core name>/<the content's file
* name without its extension>.lrtl, under the playlists folder. */
static void read_runtime(struct ps5_library *library, struct ps5_library_game *game,
const char *playlists)
{
const char *core_path = game->core[0] ? game->core : library->systems[game->system].core;
const struct ps5_library_core *core = core_by_path(library, core_path);
if (!core || !core->core_name[0])
return;
const char *slash = strrchr(game->path, '/');
const char *file = slash ? slash + 1 : game->path;
const char *dot = strrchr(file, '.');
char path[PS5_LIBRARY_PATH_MAX + 512];
snprintf(path, sizeof(path), "%s/logs/%s/%.*s.lrtl", playlists, core->core_name,
(int)(dot ? (size_t)(dot - file) : strlen(file)), file);
FILE *input = fopen(path, "rb");
if (!input)
return;
char text[1024];
const size_t length = fread(text, 1, sizeof(text), input);
fclose(input);
char last[32], count[16], runtime[32];
struct member wanted[] = {{"last_played", last, sizeof(last)},
{"play_count", count, sizeof(count)},
{"runtime", runtime, sizeof(runtime)}};
struct json json = {text, text + length};
if (json_object(&json, wanted, 3, NULL, NULL) != 0)
return;
int year, month, day, hour, minute, second;
if (sscanf(last, "%4d-%2d-%2d %2d:%2d:%2d", &year, &month, &day, &hour, &minute, &second) == 6)
snprintf(game->last_played, sizeof(game->last_played), "%04d-%02d-%02d %02d:%02d:%02d",
year, month, day, hour, minute, second);
game->play_count = (unsigned)strtoul(count, NULL, 10);
unsigned hours = 0, minutes = 0, seconds = 0;
if (sscanf(runtime, "%u:%u:%u", &hours, &minutes, &seconds) == 3)
game->play_seconds = hours * 3600UL + minutes * 60UL + seconds;
}
static void read_play(struct ps5_library *library, const char *playlists)
{
time_t history_written = 0;
read_builtin(library, playlists, "content_favorites.lpl", 0, NULL);
read_builtin(library, playlists, "content_history.lpl", 1, &history_written);
for (size_t i = 0; i < library->game_count; i++)
{
struct ps5_library_game *game = &library->games[i];
read_runtime(library, game, playlists);
if (!game->last_played[0] && game->history && history_written)
{
const time_t when = history_written - (time_t)(game->history - 1) * 60;
struct tm local;
if (localtime_r(&when, &local))
strftime(game->last_played, sizeof(game->last_played), "%Y-%m-%d %H:%M:%S", &local);
}
}
}
int ps5_library_load(struct ps5_library *library, const char *playlists, const char *info,
const char *cores)
{
@@ -884,6 +994,7 @@ int ps5_library_load(struct ps5_library *library, const char *playlists, const c
if (loading.failed)
return -1;
finish_systems(library);
read_play(library, playlists);
return 0;
}
+14 -1
View File
@@ -22,7 +22,14 @@
* cores the title carries; a game's own playlist core still wins when it is
* launched (game mode);
* - a system's folder is the deepest folder all its games are in, and its
* extensions those of its games and of its core.
* extensions those of its games and of its core;
* - what RetroArch remembers of play goes with each game: whether it is in the
* favourites (builtin/content_favorites.lpl), its place in the history
* (builtin/content_history.lpl), and its runtime log
* (logs/<core name>/<content name>.lrtl): when it was last played, how often and
* for how long. A game in the history with no runtime log is given a time from
* its place: the history file's, a minute earlier for each place below the top,
* so the order holds.
*
* A frontend writes its own files from this (frontends/es-de/ps5/library_ps5.cpp
* writes ES-DE's systems file and game lists) and launches every game through game
@@ -48,6 +55,7 @@ extern "C"
{
char path[PS5_LIBRARY_PATH_MAX]; /* /app0/cores/<name>_libretro.so */
char name[128]; /* its display_name */
char core_name[128]; /* its corename: its runtime logs' folder */
char databases[1024]; /* RetroArch databases, '|'-separated */
char extensions[512]; /* supported extensions, '|'-separated */
};
@@ -71,6 +79,11 @@ extern "C"
char crc32[32];
char playlist[256]; /* the playlist's file name */
size_t system;
int favorite; /* in RetroArch's favourites */
unsigned history; /* its place in RetroArch's history, 1 the newest, or 0 */
char last_played[20]; /* "YYYY-MM-DD HH:MM:SS", local time, or "" */
unsigned play_count; /* from its runtime log */
unsigned long play_seconds; /* from its runtime log */
};
struct ps5_library
+83 -12
View File
@@ -2,7 +2,9 @@
* (frontends/es-de/ps5/library_ps5.cpp), on the host: ES-DE's names, full names and
* themes for the platforms it knows, a system of its own for one it does not, game
* mode's commands, and game lists that keep what ES-DE knows of a game, add new games
* and drop the ones no playlist has. argv[1] is a scratch folder. */
* and drop the ones no playlist has, and what RetroArch remembers of play merged in:
* favourites from either frontend, last played times and counts, and the collections
* enabled once. argv[1] is a scratch folder. */
#include <cassert>
#include <cstdio>
#include <cstring>
@@ -38,14 +40,15 @@ int main(int argc, char **argv)
{
assert(argc == 2);
const std::string dir = argv[1];
for (const char *folder :
{"/cores", "/info", "/playlists", "/data", "/data/gamelists", "/data/gamelists/snes"})
for (const char *folder : {"/cores", "/info", "/playlists", "/playlists/builtin",
"/playlists/logs", "/playlists/logs/Snes9x", "/data",
"/data/gamelists", "/data/gamelists/snes", "/data/settings"})
mkdir((dir + folder).c_str(), 0777);
write_text(dir + "/cores/snes9x_libretro.so", "");
write_text(
dir + "/info/snes9x_libretro.info",
"display_name = \"Snes9x\"\ndatabase = \"Nintendo - Super Nintendo Entertainment System\"\n"
"supported_extensions = \"smc|sfc\"\n");
write_text(dir + "/info/snes9x_libretro.info",
"display_name = \"Snes9x\"\ncorename = \"Snes9x\"\n"
"database = \"Nintendo - Super Nintendo Entertainment System\"\n"
"supported_extensions = \"smc|sfc\"\n");
write_text(dir + "/reference.xml",
"<?xml version=\"1.0\"?>\n<systemList>\n"
"<system><name>snes</name><fullname>Nintendo SNES (Super Nintendo)</fullname>"
@@ -75,6 +78,20 @@ int main(int argc, char **argv)
"<game><path>./Gone (USA).zip</path><name>Gone</name><playcount>9</playcount></game>\n"
"</gameList>\n");
/* What RetroArch remembers: a favourite, the history, a runtime log; and ES-DE's
* settings from before the collections default. */
const std::string luigi = snes + "/Hacks/Mario & Luigi.sfc";
write_text(dir + "/playlists/builtin/content_favorites.lpl",
"{\"items\": [{\"path\": \"" + luigi + "\"}]}");
write_text(dir + "/playlists/builtin/content_history.lpl",
"{\"items\": [{\"path\": \"/app0/content/Homebrew/demo.bin\"}]}");
write_text(dir + "/playlists/logs/Snes9x/Donkey Kong 2 (USA).lrtl",
"{\"version\": \"1.0\", \"runtime\": \"0:02:05\", \"last_played\": "
"\"2026-10-02 08:30:00\", \"play_count\": \"5\"}");
write_text(dir + "/data/settings/es_settings.xml",
"<?xml version=\"1.0\"?>\n<bool name=\"Other\" value=\"true\" />\n"
"<string name=\"CollectionSystemsAuto\" value=\"\" />\n");
const std::string playlists = dir + "/playlists", info = dir + "/info", cores = dir + "/cores",
reference = dir + "/reference.xml", data = dir + "/data";
const struct ps5_esde_library_paths paths = {playlists.c_str(), info.c_str(), cores.c_str(),
@@ -109,12 +126,16 @@ int main(int argc, char **argv)
{
count++;
const std::string path = game.child_value("path");
if (path == "./Donkey Kong 2 (USA).zip")
if (path == "./Donkey Kong 2 (USA).zip") /* ES-DE's favourite kept; RetroArch's play */
kept = std::string(game.child_value("name")) == "DKC 2 (renamed in ES-DE)" &&
std::string(game.child_value("playcount")) == "3" &&
std::string(game.child_value("playcount")) == "5" &&
std::string(game.child_value("playtime")) == "125" &&
std::string(game.child_value("lastplayed")) == "20261002T083000" &&
std::string(game.child_value("favorite")) == "true";
if (path == "./Hacks/Mario & Luigi.sfc")
added = std::string(game.child_value("name")) == "Mario & Luigi";
if (path == "./Hacks/Mario & Luigi.sfc") /* RetroArch's favourite */
added = std::string(game.child_value("name")) == "Mario & Luigi" &&
std::string(game.child_value("favorite")) == "true" &&
!game.child("lastplayed");
assert(path != "./Gone (USA).zip");
}
assert(count == 2 && kept && added);
@@ -122,10 +143,60 @@ int main(int argc, char **argv)
assert(homebrew.load_file((data + "/gamelists/homebrew/gamelist.xml").c_str()));
assert(std::string(homebrew.child("gameList").child("game").child_value("path")) ==
"./demo.bin");
/* In the history only: a time from its place. */
assert(std::strlen(homebrew.child("gameList").child("game").child_value("lastplayed")) == 15);
assert(std::string(summary).find("2 favourites, 2 played") != std::string::npos);
/* The collections, enabled once, beside the other settings. */
pugi::xml_document settings;
assert(settings.load_file((data + "/settings/es_settings.xml").c_str()));
assert(std::string(settings.find_child_by_attribute("string", "name", "CollectionSystemsAuto")
.attribute("value")
.value()) == "favorites,recent");
assert(settings.find_child_by_attribute("bool", "name", "Other"));
/* Again: the same files. */
assert(ps5_esde_write_library_to(&paths, summary, sizeof(summary)) == 2);
assert(std::string(summary).find("2 systems, 3 games (3 with ES-DE's details kept)") == 0);
std::puts("esde_library: systems, full names, themes, commands and merged game lists PASS");
/* Favourites in both frontends: in ES-DE the user takes DKC 2's away and Mario &
* Luigi's too; RetroArch did not change either since, so ES-DE's choice stands. */
const auto favourite = [&](const char *path)
{
pugi::xml_document list;
assert(list.load_file((data + "/gamelists/snes/gamelist.xml").c_str()));
for (pugi::xml_node game : list.child("gameList").children("game"))
if (std::strcmp(game.child_value("path"), path) == 0)
return std::strcmp(game.child_value("favorite"), "true") == 0;
assert(!"a game the list should have");
return false;
};
assert(favourite("./Donkey Kong 2 (USA).zip") && favourite("./Hacks/Mario & Luigi.sfc"));
const auto unfavourite_in_esde = [&]()
{
pugi::xml_document list;
assert(list.load_file((data + "/gamelists/snes/gamelist.xml").c_str()));
for (pugi::xml_node game : list.child("gameList").children("game"))
game.remove_child("favorite");
list.save_file((data + "/gamelists/snes/gamelist.xml").c_str());
};
unfavourite_in_esde();
assert(ps5_esde_write_library_to(&paths, summary, sizeof(summary)) == 2);
assert(!favourite("./Donkey Kong 2 (USA).zip") && !favourite("./Hacks/Mario & Luigi.sfc"));
/* RetroArch takes Mario & Luigi out and puts DKC 2 in: ES-DE follows. */
write_text(dir + "/playlists/builtin/content_favorites.lpl",
"{\"items\": [{\"path\": \"" + snes + "/Donkey Kong 2 (USA).zip\"}]}");
assert(ps5_esde_write_library_to(&paths, summary, sizeof(summary)) == 2);
assert(favourite("./Donkey Kong 2 (USA).zip") && !favourite("./Hacks/Mario & Luigi.sfc"));
/* A later choice of the user's about the collections is kept. */
write_text(dir + "/data/settings/es_settings.xml",
"<?xml version=\"1.0\"?>\n<string name=\"CollectionSystemsAuto\" value=\"\" />\n");
assert(ps5_esde_write_library_to(&paths, summary, sizeof(summary)) == 2);
assert(settings.load_file((data + "/settings/es_settings.xml").c_str()));
assert(std::string(settings.find_child_by_attribute("string", "name", "CollectionSystemsAuto")
.attribute("value")
.value())
.empty());
std::puts("esde_library: systems, full names, themes, commands, merged game lists, favourites "
"from both frontends, RetroArch's play records and the collections PASS");
return 0;
}
+15
View File
@@ -0,0 +1,15 @@
# RetroArch's History tab: the newest game's menu, then Add to Favorites, as a
# person adds a favourite (the frontends then show it: src/ps5_library.h). The
# menu's OK is B on this console, and XMB's tabs are Main Menu, Settings, History,
# Favourites, Images, Import Content. The game menu is Run, Rename, Remove, Add to
# Favorites, Set Core Association, ...
4.0 RIGHT
4.5 RIGHT
6.0 MENU # History, its newest game selected
6.5 B # that game's menu
8.0 DOWN
8.5 DOWN
9.0 DOWN
9.5 MENU # Add to Favorites selected
10.0 B # added
11.5 STOP
+58 -5
View File
@@ -9,6 +9,7 @@
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include "../src/ps5_game.h"
#include "../src/ps5_library.h"
@@ -35,9 +36,9 @@ static void core(const char *dir, const char *name, const char *database, const
write_text(path, "");
snprintf(path, sizeof(path), "%s/info/%s_libretro.info", dir, name);
snprintf(text, sizeof(text),
"display_name = \"%s\"\ndatabase_match_archive_member = \"false\"\ndatabase = \"%s\"\n"
"supported_extensions = \"%s\"\n",
name, database, extensions);
"display_name = \"%s\"\ncorename = \"%s\"\ndatabase_match_archive_member = \"false\"\n"
"database = \"%s\"\nsupported_extensions = \"%s\"\n",
name, name, database, extensions);
write_text(path, text);
}
@@ -139,6 +140,30 @@ int main(int argc, char **argv)
write_text(path, "{\"items\": [{\"path\": \"/app0/content/elsewhere/history.zip\", "
"\"core_path\": \"DETECT\"}]}");
/* What RetroArch remembers of play: favourites and history in builtin/, and a
* runtime log per core and game. */
snprintf(path, sizeof(path), "%s/playlists/builtin", dir);
mkdir(path, 0777);
snprintf(path, sizeof(path), "%s/playlists/builtin/content_favorites.lpl", dir);
write_text(path, "{\"items\": [{\"path\": \"/app0/content/Nintendo - Super Nintendo "
"Entertainment System/It's Mario (USA).sfc\", \"core_path\": \"DETECT\"},"
"{\"path\": \"/app0/content/not/in/the/library.zip\"}]}");
char history[2048];
snprintf(history, sizeof(history), "%s/playlists/builtin/content_history.lpl", dir);
write_text(history, "{\"items\": [{\"path\": \"/app0/content/PS2/Okami (USA).iso\"},"
"{\"path\": \"/app0/content/PS1/Crash (USA).cue\"},"
"{\"path\": \"/app0/content/Nintendo - Super Nintendo Entertainment "
"System/It's Mario (USA).sfc\"},"
"{\"path\": \"/app0/content/PS2/Okami (USA).iso\"}]}");
snprintf(path, sizeof(path), "%s/playlists/logs", dir);
mkdir(path, 0777);
snprintf(path, sizeof(path), "%s/playlists/logs/snes9x", dir);
mkdir(path, 0777);
snprintf(path, sizeof(path), "%s/playlists/logs/snes9x/It's Mario (USA).lrtl", dir);
write_text(path,
"{\n \"version\": \"1.0\",\n \"runtime\": \"1:05:07\",\n \"last_played\": "
"\"2026-10-01 10:00:00\",\n \"play_count\": \"3\",\n \"state_slot\": \"0\"\n}\n");
char playlists[1024], info[1024], cores[1024];
snprintf(playlists, sizeof(playlists), "%s/playlists", dir);
snprintf(info, sizeof(info), "%s/info", dir);
@@ -187,6 +212,34 @@ int main(int argc, char **argv)
assert(homebrew && !homebrew->known && strcmp(homebrew->name, "Homebrew") == 0 &&
!homebrew->core[0]);
/* What RetroArch remembers of play, with each game. */
{
const struct ps5_library_game *mario = &library.games[snes->first_game + 1];
assert(mario->favorite && mario->history == 3 && mario->play_count == 3 &&
mario->play_seconds == 3907 &&
strcmp(mario->last_played, "2026-10-01 10:00:00") == 0);
const struct ps5_library_game *dkc2 = &library.games[snes->first_game];
assert(!dkc2->favorite && !dkc2->history && !dkc2->last_played[0] && !dkc2->play_count);
/* No runtime log: a time from the place in the history, a minute a place. */
struct stat written;
assert(stat(history, &written) == 0);
const struct ps5_library_game *okami = &library.games[ps2->first_game + 1];
const struct ps5_library_game *crash = NULL;
for (size_t i = 0; i < library.game_count; i++)
if (strcmp(library.games[i].path, "/app0/content/PS1/Crash (USA).cue") == 0)
crash = &library.games[i];
assert(strcmp(okami->label, "Okami") == 0 && okami->history == 1 && crash &&
crash->history == 2);
char expected[20];
struct tm local;
time_t when = written.st_mtime;
strftime(expected, sizeof(expected), "%Y-%m-%d %H:%M:%S", localtime_r(&when, &local));
assert(strcmp(okami->last_played, expected) == 0);
when -= 60;
strftime(expected, sizeof(expected), "%Y-%m-%d %H:%M:%S", localtime_r(&when, &local));
assert(strcmp(crash->last_played, expected) == 0);
}
/* Game mode's commands, parsed back. */
char command[4096];
struct ps5_game parsed;
@@ -204,7 +257,7 @@ int main(int argc, char **argv)
assert(ps5_library_load(&library, "/nonexistent", "/nonexistent", "/nonexistent") == 0 &&
library.system_count == 0 && library.game_count == 0);
ps5_library_free(&library);
puts("ps5_library: platforms, playlists, systems, cores, folders, labels and game mode "
"commands PASS");
puts("ps5_library: platforms, playlists, systems, cores, folders, labels, favourites, history, "
"runtime logs and game mode commands PASS");
return 0;
}