From 5ee2d484553555adcca82933794ca5c4e9f1cd3f Mon Sep 17 00:00:00 2001 From: Mihawk <163614687+mihawk-99@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:34:28 -0400 Subject: [PATCH] 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//.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). --- evidence/esde-collections/capture.json | 23 +++ evidence/esde-collections/expectation.json | 12 ++ frontends/es-de/patches/0001-ps5-port.patch | 16 +- frontends/es-de/ps5/library_ps5.cpp | 163 +++++++++++++++++--- src/ps5_library.c | 111 +++++++++++++ src/ps5_library.h | 15 +- tests/esde_library_test.cpp | 95 ++++++++++-- tests/pad/history-favourite.txt | 15 ++ tests/ps5_library_test.c | 63 +++++++- 9 files changed, 471 insertions(+), 42 deletions(-) create mode 100644 evidence/esde-collections/capture.json create mode 100644 evidence/esde-collections/expectation.json create mode 100644 tests/pad/history-favourite.txt diff --git a/evidence/esde-collections/capture.json b/evidence/esde-collections/capture.json new file mode 100644 index 0000000..4ad6c8a --- /dev/null +++ b/evidence/esde-collections/capture.json @@ -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" + ] +} diff --git a/evidence/esde-collections/expectation.json b/evidence/esde-collections/expectation.json new file mode 100644 index 0000000..bd2f3d4 --- /dev/null +++ b/evidence/esde-collections/expectation.json @@ -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." +} diff --git a/frontends/es-de/patches/0001-ps5-port.patch b/frontends/es-de/patches/0001-ps5-port.patch index e7f4e19..2c1f353 100644 --- a/frontends/es-de/patches/0001-ps5-port.patch +++ b/frontends/es-de/patches/0001-ps5-port.patch @@ -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 diff --git a/frontends/es-de/ps5/library_ps5.cpp b/frontends/es-de/ps5/library_ps5.cpp index 101bac5..bc0e6e5 100644 --- a/frontends/es-de/ps5/library_ps5.cpp +++ b/frontends/es-de/ps5/library_ps5.cpp @@ -15,7 +15,16 @@ * - ES-DE/gamelists//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 +#include #include +#include #include +#include #include #include #include @@ -48,7 +60,8 @@ std::map 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 read_lines(const std::string &path) +{ + std::set 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 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 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(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); } diff --git a/src/ps5_library.c b/src/ps5_library.c index 1da2464..8ccc157 100644 --- a/src/ps5_library.c +++ b/src/ps5_library.c @@ -16,6 +16,7 @@ #include #include #include +#include /* --- 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//.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; } diff --git a/src/ps5_library.h b/src/ps5_library.h index 8327f74..b725350 100644 --- a/src/ps5_library.h +++ b/src/ps5_library.h @@ -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//.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/_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 diff --git a/tests/esde_library_test.cpp b/tests/esde_library_test.cpp index af0d962..dee584c 100644 --- a/tests/esde_library_test.cpp +++ b/tests/esde_library_test.cpp @@ -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 #include #include @@ -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", "\n\n" "snesNintendo SNES (Super Nintendo)" @@ -75,6 +78,20 @@ int main(int argc, char **argv) "./Gone (USA).zipGone9\n" "\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", + "\n\n" + "\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", + "\n\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; } diff --git a/tests/pad/history-favourite.txt b/tests/pad/history-favourite.txt new file mode 100644 index 0000000..685fc51 --- /dev/null +++ b/tests/pad/history-favourite.txt @@ -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 diff --git a/tests/ps5_library_test.c b/tests/ps5_library_test.c index 12c493a..d77ebe7 100644 --- a/tests/ps5_library_test.c +++ b/tests/ps5_library_test.c @@ -9,6 +9,7 @@ #include #include #include +#include #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; }