mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-18 23:09:29 -05:00
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:
1 parent
1e6b8cba4d
commit
5ee2d48455
9 files changed
+471
-42
No files matched your search
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user