2 Commits
Author SHA1 Message Date
Pedro Santos 2d42c0578d Merge pull request #18 from s0liton/release/v1.2.1
v1.2.1 to fix changes in RetroArch 2.0.0 ROM folder naming and changed PS2 core.
2026-10-15 19:23:39 -04:00
Pedro Santos 5c63aa5352 1.2.1: RetroArch 2.0 folders, PS5SX2 memory cards, covers keep their shape
- RetroArch 2.0 (webui/version.json): each system goes to its libretro-named
  content folder when 2.0 has one, unless a folder was picked; games already
  in the old folders move once the user says so (RommPS prompt, web UI card),
  their records and RetroArch's playlists and history follow them
- PS2 on 2.0 plays with PS5SX2, whose two shared memory cards are backed up;
  a leftover LRPS2 core is ignored
- PS2 BIOS for RetroArch goes to system/pcsx2/bios; failed BIOS downloads say why
- RommPS: covers keep their own shape in the shelf, grids, search and details;
  the grid's columns follow its covers; the top row brightens as it scrolls in
- Fixed a log line reading freed memory when an emulator's save sync changes
- Tests: e2e case for the 2.0 move, PS5SX2 cards and the history paths
2026-10-15 19:09:19 -04:00
25 changed files with 823 additions and 52 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@
CHDR_SRCS := $(filter-out %microflac.cpp,$(wildcard third_party/libchdr/src/*.c)) \
third_party/libchdr/deps/lzma/src/LzmaDec.c third_party/libchdr/deps/zstd/zstddeclib.c
SRCS := src/main.c src/util.c src/http.c src/romm.c src/config.c src/state.c \
src/profiles.c src/sync.c src/watch.c src/detect.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/gcmem.c src/statefmt.c src/events.c src/gameid.c src/covers.c src/library.c src/pair.c src/web.c src/update.c \
src/profiles.c src/sync.c src/watch.c src/detect.c src/ra2.c src/playlist.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/gcmem.c src/statefmt.c src/events.c src/gameid.c src/covers.c src/library.c src/pair.c src/web.c src/update.c \
third_party/cJSON.c third_party/md5.c third_party/monocypher.c third_party/monocypher-ed25519.c \
$(CHDR_SRCS)
HDRS := $(wildcard src/*.h) third_party/cJSON.h third_party/md5.h third_party/monocypher.h third_party/monocypher-ed25519.h
+15
View File
@@ -0,0 +1,15 @@
Update RommPS and open it once. It updates everything else.
### New
- RetroArch 2.0 support. Its games live in new folders, named after each system (`content/Nintendo - Game Boy Advance`). New downloads go there, and RommPS offers to move the games already in the old folders. Saves and states stay where they are.
- RetroArch 2.0 plays PS2 with PS5SX2. Its two shared memory cards are backed up to RomM.
- Covers keep their own shape. Square and wide boxes (SNES, N64 and others) are no longer squeezed into portrait cards.
### Fixed
- PS2 BIOS files for RetroArch go to `system/pcsx2/bios`, where its PS2 cores look for them.
### Good to know
- PS2 save states made with LRPS2 don't load in PS5SX2. Save in-game before upgrading RetroArch.
@@ -26,14 +26,16 @@ using hui::gfx::Rect;
constexpr float kMargin = 96.0f;
// "On this console"
constexpr float kRecentW = 150.0f, kRecentH = 200.0f, kRecentGap = 22.0f;
// "On this console": covers side by side, each as wide as its shape
constexpr float kRecentH = 200.0f, kRecentGap = 22.0f;
// Platform tiles
constexpr int kTileCols = 4;
constexpr float kTileW = 408.0f, kTileH = 220.0f, kTileGap = 32.0f, kTilePitch = kTileH + kTileGap;
// A grid of games
constexpr int kCols = 8;
constexpr float kGridTop = 400.0f, kCardW = 176.0f, kCardH = 235.0f, kColPitch = 200.0f, kRowPitch = 260.0f;
// A grid of games: its columns and cards follow the shape of its covers
constexpr int kMinCols = 4, kMaxCols = 8;
constexpr float kGridTop = 400.0f, kGridW = 1620.0f, kCardTallest = 235.0f, kColGap = 24.0f, kRowGap = 25.0f;
// Search results, in a row
constexpr float kResultH = 224.0f, kResultGap = 24.0f, kResultsEnd = 1824.0f;
constexpr float kBarX = 1726.0f, kBarW = 98.0f, kBarTop = 392.0f, kBarH = 600.0f;
// The A to Z bar's slots: # (digits and the rest) then A to Z.
constexpr int kSlots = 27;
@@ -86,6 +88,41 @@ std::string tail(const hui::gfx::Font &font, const std::string &text, float size
return from ? "\xE2\x80\xA6" + text.substr(from + 1) : text;
}
struct GridLayout
{
float shape; // of the cards, width over height
int cols;
float card_w, card_h, col_pitch, row_pitch;
};
// As many columns as cards of the grid's shape fit across (8 of 3:4 covers,
// 6 of square ones, 5 of the wide SNES and N64 boxes), the cards a little
// shorter when that fills the width better. Every card the same size.
GridLayout grid_layout(const GameList &l)
{
const float shape = std::clamp(l.shape > 0 ? l.shape : kPortrait, 0.7f, kWidest);
const int cols = std::clamp(static_cast<int>(std::lround((kGridW + kColGap) / (kCardTallest * shape + kColGap))),
kMinCols, kMaxCols);
const float w = std::min(kCardTallest * shape, (kGridW - kColGap * static_cast<float>(cols - 1)) / static_cast<float>(cols));
return {shape, cols, w, w / shape, w + kColGap, w / shape + kRowGap};
}
// A cover in a grid's card: its own shape, centred. One much wider than the
// card keeps most of the card's height, cropped at the sides.
Rect in_card(float shape, const Rect &card)
{
Rect r = fit_cover(shape, card);
const float least = card.h * 0.75f;
if (r.h < least)
r.y = card.y + (card.h - least) * 0.5f, r.h = least;
return r;
}
float shelf_width(App &app, const Game &g)
{
return kRecentH * cover_shape(app, &g);
}
} // namespace
// ------------------------------------------------------------------ shared
@@ -131,13 +168,40 @@ std::string play_time(double ms)
return text;
}
float cover_shape(App &app, const Game *g, float fallback)
{
double since = 0;
const float own = g && !g->cover.empty() ? app.cover_aspect(g->cover, &since) : 0.0f;
if (own <= 0)
return fallback;
const float t = tween::smoothstep(tween::clamp01(static_cast<float>(app.clock() - since) / 0.3f));
return fallback + (std::clamp(own, kNarrowest, kWidest) - fallback) * t;
}
Rect fit_cover(float shape, const Rect &box)
{
shape = std::clamp(shape, kNarrowest, kWidest);
float w = box.h * shape, h = box.h;
if (w > box.w)
w = box.w, h = box.w / shape;
return {box.x + (box.w - w) * 0.5f, box.y + (box.h - h) * 0.5f, w, h};
}
void draw_cover(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, const Game *g,
const Rect &rect, float alpha, float radius, bool name)
{
const std::uint32_t texture = g ? app.cover(g->cover).texture : 0;
if (texture)
static const Cover kNone;
const Cover &c = g ? app.cover(g->cover) : kNone;
if (c.texture)
{
list.image(texture, rect, hui::gfx::kFullUv, Color::rgb(0xffffff, alpha), radius);
// The middle of the cover in the rectangle's shape: cropped, never stretched.
Rect uv = hui::gfx::kFullUv;
const float want = rect.h > 0 ? rect.w / rect.h : 1.0f;
if (c.aspect > want)
uv.w = want / c.aspect, uv.x = (1.0f - uv.w) * 0.5f;
else if (c.aspect > 0 && c.aspect < want)
uv.h = c.aspect / want, uv.y = (1.0f - uv.h) * 0.5f;
list.image(c.texture, rect, uv, Color::rgb(0xffffff, alpha), radius);
return;
}
// No cover (yet): a tinted card with the name.
@@ -270,10 +334,17 @@ bool LibraryPage::update(App &app, const hui::InputFrame &input, float dt, hui::
if (tile_row > tiles_scroll_.target + static_cast<float>(tile_rows - 1))
tiles_scroll_.target = static_cast<float>(tile_row - tile_rows + 1);
tiles_scroll_.update(dt, 14.0f);
recent_scroll_.reveal(static_cast<float>(recent_) * (kRecentW + kRecentGap),
static_cast<float>(recent_) * (kRecentW + kRecentGap) + kRecentW, 1728.0f, 120.0f);
const auto &recent = app.installed();
if (recent_ < static_cast<int>(recent.size()))
{
float x = 0;
for (int i = 0; i < recent_; ++i)
x += shelf_width(app, recent[static_cast<std::size_t>(i)]) + kRecentGap;
recent_scroll_.reveal(x, x + shelf_width(app, recent[static_cast<std::size_t>(recent_)]), 1728.0f, 120.0f);
}
recent_scroll_.update(dt, 14.0f);
grid_scroll_.target = static_cast<float>(std::max(0, index_ / kCols - 1));
if (mode_ == kGrid)
grid_scroll_.target = static_cast<float>(std::max(0, index_ / grid_layout(grid(app)).cols - 1));
grid_scroll_.update(dt, 14.0f);
const Game *g = focused(app);
const int id = g ? g->id : -1;
@@ -425,11 +496,13 @@ void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts
std::snprintf(here, sizeof here, "On this console \xC2\xB7 %d", static_cast<int>(recent.size()));
ui::text(list, fonts.semibold, here, kMargin, 190, 24, section_ == 0 ? look.text : look.muted);
list.push_clip({0, 150, 1920, 330});
for (int i = 0; i < static_cast<int>(recent.size()); ++i)
float x = kMargin - recent_scroll_.offset();
for (int i = 0; i < static_cast<int>(recent.size()) && x < 1920; ++i)
{
const Game &g = recent[static_cast<std::size_t>(i)];
Rect r{kMargin + static_cast<float>(i) * (kRecentW + kRecentGap) - recent_scroll_.offset(), 214, kRecentW, kRecentH};
if (r.x > 1920 || r.x + r.w < 0)
Rect r{x, 214, shelf_width(app, g), kRecentH};
x += r.w + kRecentGap;
if (r.x + r.w < 0)
continue;
const bool on = section_ == 0 && i == recent_;
if (on)
@@ -482,8 +555,10 @@ void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts
const Game *g = games.at(k);
if (!g)
continue;
const Rect c{r.x + r.w - 128 - static_cast<float>(k) * 40, r.y + 28 + static_cast<float>(k) * 10, 100,
133};
// Each its own shape, as tall as fits 120 wide, standing on the same line.
const float shape = cover_shape(app, g);
const float h = std::min(133.0f, 120.0f / shape), w = h * shape;
const Rect c{r.x + r.w - 28 - w - static_cast<float>(k) * 40, r.y + 161 + static_cast<float>(k) * 10 - h, w, h};
list.shadow({c.x, c.y + 8, c.w, c.h}, 10, 16, Color::rgb(0x000000, 0.35f));
draw_cover(list, fonts, look, app, g, c, 1.0f - 0.15f * static_cast<float>(k), 10, false);
}
@@ -512,7 +587,7 @@ void LibraryPage::open_grid(App &app, int platform_id, const std::string &search
index_ = 0;
on_letters_ = false;
grid_scroll_.snap(0.0f);
app.ensure(grid(app), 0, kCols * 4);
app.ensure(grid(app), 0, kMaxCols * 4);
}
int LibraryPage::letter_at(const GameList &l, int index) const
@@ -548,6 +623,7 @@ void LibraryPage::update_grid(App &app, const hui::InputFrame &input, hui::ui::F
index_ = l.shown(focus_raw_);
if (l.ready())
index_ = std::min(index_, std::max(0, l.total - 1)); // the list can come out shorter than first thought
settle_shape(app, l);
move_in_grid(app, input, feedback, details);
GameList &now = grid(app);
focus_list_ = &now;
@@ -555,13 +631,39 @@ void LibraryPage::update_grid(App &app, const hui::InputFrame &input, hui::ui::F
focus_hidden_ = now.hidden.size();
}
void LibraryPage::settle_shape(App &app, GameList &l) const
{
if (l.shape_settled || !l.ready())
return;
// The covers of the first rows: the grid takes their middle shape.
std::vector<float> seen;
int wanted = 0;
for (int i = 0; i < std::min(l.total, 2 * kMaxCols); ++i)
if (const Game *g = l.at(i); g && !g->cover.empty())
{
++wanted;
if (const float a = app.cover_aspect(g->cover); a > 0)
seen.push_back(std::clamp(a, kNarrowest, kWidest));
}
if (!seen.empty())
{
std::nth_element(seen.begin(), seen.begin() + static_cast<std::ptrdiff_t>(seen.size() / 2), seen.end());
l.shape = seen[seen.size() / 2];
}
// It settles once those covers are all here, or enough of them are and
// the grid is in use, so it never changes under the focus.
const int known = static_cast<int>(seen.size());
l.shape_settled = known >= wanted || (known >= 4 && (index_ > 0 || mode_age_ > 1.5f)) || mode_age_ > 4.0f;
}
void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details)
{
GameList &l = grid(app);
const int cols = grid_layout(l).cols;
// The games around where the grid is going, not the rows it scrolls past
// on the way (a jump to a letter animates through everything between).
const int target_row = std::max(0, index_ / kCols - 2);
app.ensure(l, target_row * kCols, (target_row + 7) * kCols);
const int target_row = std::max(0, index_ / cols - 2);
app.ensure(l, target_row * cols, (target_row + 7) * cols);
const int total = std::max(0, l.total);
const Slots slots = slots_of(l);
const bool repeat = input.nav_repeat;
@@ -614,7 +716,7 @@ void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::
switch (input.nav)
{
case Direction::left:
if (index_ % kCols > 0)
if (index_ % cols > 0)
{
--index_;
feedback.play(Cue::focus);
@@ -623,7 +725,7 @@ void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::
refuse(feedback);
break;
case Direction::right:
if (index_ % kCols < kCols - 1 && index_ + 1 < total)
if (index_ % cols < cols - 1 && index_ + 1 < total)
{
++index_;
feedback.play(Cue::focus);
@@ -638,21 +740,21 @@ void LibraryPage::move_in_grid(App &app, const hui::InputFrame &input, hui::ui::
refuse(feedback);
break;
case Direction::up:
if (index_ - kCols >= 0)
if (index_ - cols >= 0)
{
index_ -= kCols;
index_ -= cols;
feedback.play(Cue::focus);
}
else if (!repeat)
refuse(feedback);
break;
case Direction::down:
if (index_ + kCols < total)
if (index_ + cols < total)
{
index_ += kCols;
index_ += cols;
feedback.play(Cue::focus);
}
else if (index_ / kCols < (total - 1) / kCols)
else if (index_ / cols < (total - 1) / cols)
{
index_ = total - 1;
feedback.play(Cue::focus);
@@ -772,26 +874,33 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
list.push_opacity(appear);
const float shake = ui::shake(nudge_.value, clock, 10.0f, 9.0f);
const int total = std::max(0, l.total);
const GridLayout layout = grid_layout(l);
const int cols = layout.cols;
const int first_row = std::max(0, static_cast<int>(grid_scroll_.value) - 1);
const int rows = static_cast<int>((1080.0f - kGridTop) / layout.row_pitch) + 3;
list.push_clip({0, kGridTop - 30, kBarX - 10, 1080 - kGridTop + 30});
Rect focus_rect{};
for (int row = first_row; row < first_row + 5; ++row)
for (int col = 0; col < kCols; ++col)
for (int row = first_row; row < first_row + rows; ++row)
for (int col = 0; col < cols; ++col)
{
const int i = row * kCols + col;
const int i = row * cols + col;
if (i >= total)
break;
const float y = kGridTop + (static_cast<float>(row) - grid_scroll_.value) * kRowPitch + 24 * (1.0f - appear);
if (y > 1080 || y + kCardH < kGridTop - 40)
const float y = kGridTop + (static_cast<float>(row) - grid_scroll_.value) * layout.row_pitch + 24 * (1.0f - appear);
if (y > 1080 || y + layout.card_h < kGridTop - 40)
continue;
const Rect r{kMargin + static_cast<float>(col) * kColPitch, y, kCardW, kCardH};
const Game *game = l.at(i);
const Rect card{kMargin + static_cast<float>(col) * layout.col_pitch, y, layout.card_w, layout.card_h};
const Rect r = game ? in_card(cover_shape(app, game, layout.shape), card) : card;
if (i == index_)
{
focus_rect = r;
continue; // drawn last, on top
}
const Game *game = l.at(i);
const float dim = y < kGridTop - 4 ? 0.35f : 0.85f;
// Rows above where the grid is going are dimmed: one scrolling in
// brightens as soon as the move starts, not once it has settled.
const float above = tween::clamp01(grid_scroll_.target - static_cast<float>(row));
const float dim = 0.85f - 0.5f * above;
if (game)
{
draw_cover(list, fonts, look, app, game, r, dim, 14);
@@ -818,7 +927,7 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font
list.pop_clip();
// The covers of the two rows below are asked for too (after the ones on
// screen), so scrolling down finds them ready.
for (int i = (first_row + 5) * kCols; i < (first_row + 7) * kCols && i < total; ++i)
for (int i = (first_row + rows) * cols; i < (first_row + rows + 2) * cols && i < total; ++i)
if (const Game *game = l.at(i))
app.cover(game->cover);
// The row below fades out under the button hints.
@@ -928,12 +1037,16 @@ void LibraryPage::draw_search(hui::gfx::DrawList &list, const hui::ui::Fonts &fo
Align::right);
if (l)
{
float x = kMargin;
for (int i = 0; i < 9; ++i)
{
const Game *g = l->at(i);
if (!g)
break;
const Rect r{kMargin + static_cast<float>(i) * 192.0f, 340, 168, 224};
const Rect r{x, 340, kResultH * cover_shape(app, g), kResultH};
if (r.x + r.w > kResultsEnd)
break;
x += r.w + kResultGap;
draw_cover(list, fonts, look, app, g, r, 1.0f, 14);
draw_badge(list, look, app, g, r, accent);
draw_version(list, fonts, look, g, r);
@@ -29,6 +29,14 @@
namespace rommps
{
// Covers keep their own shape (width over height) within these: narrower or
// wider ones are cropped, never stretched.
constexpr float kPortrait = 0.75f, kNarrowest = 0.6f, kWidest = 1.45f;
// A game's cover shape: fallback until its cover has been loaded, then its
// own, eased in over a moment so what's around it slides rather than jumps.
float cover_shape(App &app, const Game *g, float fallback = kPortrait);
// The largest rectangle of a shape that fits in box, centred in it.
hui::gfx::Rect fit_cover(float shape, const hui::gfx::Rect &box);
// A game's cover, or a card with its name while there's none.
void draw_cover(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, const Game *g,
const hui::gfx::Rect &rect, float alpha, float radius, bool name = true);
@@ -79,6 +87,8 @@ class LibraryPage
// ---- a grid of games
void open_grid(App &app, int platform_id, const std::string &search);
void update_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details);
// Picks the grid's shape from its first covers, until it settles.
void settle_shape(App &app, GameList &l) const;
void move_in_grid(App &app, const hui::InputFrame &input, hui::ui::Feedback &feedback, bool &details);
void draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, App &app, Color accent,
float clock, float age) const;
@@ -283,6 +283,9 @@ void App::tick(float dt, hui::gfx::Renderer *renderer)
continue;
}
cover.palette = palette_of(rgba, w, h);
cover.aspect = h > 0 ? static_cast<float>(w) / static_cast<float>(h) : 0.0f;
if (shapes_[job.path].aspect != cover.aspect)
shapes_[job.path] = {cover.aspect, clock_};
if (renderer)
cover.texture = renderer->create_texture(w, h, rgba);
stbi_image_free(rgba);
@@ -505,6 +508,14 @@ void App::refresh_status()
status_.last_up = static_cast<int>(num(last, "uploaded"));
status_.last_down = static_cast<int>(num(last, "downloaded"));
status_.server_version = str(j.get(), "server_version");
const cJSON *move = cJSON_GetObjectItemCaseSensitive(j.get(), "retroarch_move"), *folder;
status_.retroarch_move = static_cast<int>(num(move, "games"));
status_.retroarch_folders.clear();
cJSON_ArrayForEach(folder, cJSON_GetObjectItemCaseSensitive(move, "folders"))
{
const std::string from = str(folder, "from");
status_.retroarch_folders += (status_.retroarch_folders.empty() ? "" : ", ") + from.substr(from.find_last_of('/') + 1);
}
const cJSON *covers = cJSON_GetObjectItemCaseSensitive(j.get(), "covers");
status_.covers_state = str(covers, "state");
status_.covers_done = static_cast<int>(num(covers, "done"));
@@ -762,6 +773,16 @@ const Cover &App::cover(const std::string &path)
return c;
}
float App::cover_aspect(const std::string &path, double *since) const
{
auto found = shapes_.find(path);
if (found == shapes_.end())
return 0.0f;
if (since)
*since = found->second.since;
return found->second.aspect;
}
void App::refresh_downloads()
{
downloads_timer_ = 0;
@@ -38,6 +38,7 @@ struct Palette
struct Cover
{
std::uint32_t texture = 0; // 0 until it's loaded (or if there is none)
float aspect = 0; // its width over its height, 0 until it's loaded
Palette palette;
bool requested = false;
bool failed = false;
@@ -94,6 +95,10 @@ struct GameList
std::vector<std::pair<std::string, int>> raw_letters; // the same, by RomM's offset
std::vector<int> hidden; // RomM offsets of the files that aren't games, sorted
std::string error;
// The shape of the grid's cards (width over height), from the covers at
// the top; 0 until the library has seen some. Settled: it stays.
float shape = 0;
bool shape_settled = false;
bool ready() const { return total >= 0; }
// Positions in the list as shown, which skips what isn't a game, and
// RomM's offsets. What isn't a game is known once its page is here.
@@ -173,6 +178,10 @@ struct Status
// Pairing in progress (/api/pair/start): "pending" while RomM waits for
// the code to be approved, "error" when it failed, "" otherwise.
std::string pairing, pairing_message, user_code, verification_url;
// Games left in the folders RetroArch used before 2.0, and those
// folders' names (/api/retroarch/move moves them to the new ones).
int retroarch_move = 0;
std::string retroarch_folders;
};
// The payload's settings that RommPS shows (/api/config).
@@ -218,7 +227,7 @@ class App
// (assets/rommps/romm-sync.elf, version kMinPayload): it starts it when
// none is running, puts it in place of an older one (never of a newer
// one), and keeps the copies the console starts it from up to date.
static constexpr const char *kMinPayload = "1.2.0";
static constexpr const char *kMinPayload = "1.2.1";
bool payload_too_old() const;
void start_payload(); // sends the payload to the HEN's loader
// Sending it, or RomM Sync is running and not answering yet (starting up,
@@ -252,6 +261,10 @@ class App
}
// A cover's texture and palette; asks for it the first time.
const Cover &cover(const std::string &path);
// A cover's width over its height, kept after its texture is let go;
// 0 until it has been loaded once. since: when it became known.
float cover_aspect(const std::string &path, double *since = nullptr) const;
double clock() const { return clock_; }
void download(int rom_id, const std::string &name);
void cancel_download(int job_id);
@@ -340,6 +353,12 @@ class App
float update_timer_ = 0;
bool update_busy_ = false;
std::unordered_map<std::string, Cover> covers_;
struct Shape
{
float aspect = 0;
double since = 0;
};
std::unordered_map<std::string, Shape> shapes_; // every cover loaded this launch
struct Pending
{
std::string path, bytes;
@@ -22,6 +22,7 @@
// the look picked in Settings (rommps_look.hpp): the cover's colours, or one
// of the kit's themes.
#include "cJSON.h"
#include "concepts/concepts.hpp"
#include "core/tween.hpp"
@@ -145,6 +146,18 @@ class RommPS final : public app::Concept
}
}
}
// RetroArch 2.0 looks for games in new folders: asked once a launch.
if (ready && !move_asked_ && app_.status().retroarch_move > 0 && !calling && !setup_.editing())
{
move_asked_ = move_open_ = true;
move_list_.set_focus(0);
feedback.play(audio::Cue::modal_open);
}
if (move_open_)
{
update_move(input, dt, feedback);
return;
}
// Settings' Emulators opens that setup step on its own.
if (settings_.take_emulators_request())
setup_.edit_emulators();
@@ -254,8 +267,11 @@ class RommPS final : public app::Concept
settings_.call().draw(frame.overlay, context_.fonts, clock_);
return;
}
draw_toast(back > 0.5f ? frame.overlay : frame.scene);
draw_hints(back > 0.5f ? frame.overlay : frame.scene);
if (move_open_)
draw_move(frame.overlay);
draw_toast(back > 0.5f || move_open_ ? frame.overlay : frame.scene);
if (!move_open_)
draw_hints(back > 0.5f ? frame.overlay : frame.scene);
}
private:
@@ -518,6 +534,62 @@ class RommPS final : public app::Concept
}
}
// ---------------------------------------------------------------- RetroArch 2.0 folders
std::vector<rommps::Row> move_rows() const
{
const int n = app_.status().retroarch_move;
return {{rommps::RowKind::action, n == 1 ? "Move it" : "Move them"}, {rommps::RowKind::action, "Not now"}};
}
void update_move(const InputFrame &input, float dt, app::Feedback &feedback)
{
const std::vector<rommps::Row> rows = move_rows();
const rommps::RowEvent pick = move_list_.handle(rows, input, feedback);
move_list_.update(dt, static_cast<int>(rows.size()), 2 * rommps::RowList::kRowHeight);
if (input.is_pressed(Action::back) || pick.row == 1)
{
feedback.play(audio::Cue::back);
move_open_ = false;
}
else if (pick.row == 0)
{
feedback.play(audio::Cue::select);
move_open_ = false;
app_.post("/api/retroarch/move", "{}", [this](const rommps::Response &r) {
cJSON *j = r.ok() ? r.json() : nullptr;
const cJSON *moved = cJSON_GetObjectItemCaseSensitive(j, "moved");
const int n = cJSON_IsNumber(moved) ? moved->valueint : 0;
if (!r.ok())
app_.say("Couldn't move the games: " + r.message());
else
app_.say("Moved " + std::to_string(n) + (n == 1 ? " game" : " games") +
(cJSON_GetObjectItemCaseSensitive(j, "error") ? ", some couldn't be" : ""));
cJSON_Delete(j);
app_.refresh_now();
});
}
}
void draw_move(gfx::DrawList &list) const
{
const rommps::Look &L = look();
const rommps::Status &s = app_.status();
list.rounded_rect({0, 0, gfx::kVirtualWidth, gfx::kVirtualHeight}, 0, Color::rgb(0x000000, 0.55f));
const Rect box{460, 280, 1000, 520};
Color solid = L.page; // nothing shows through the panel
solid.a = 1.0f;
list.rounded_rect(box, L.radius, solid);
draw_panel(list, box);
ui::text(list, context_.fonts.display, "RetroArch 2.0 has new game folders", box.x + 48, box.y + 84, 40, L.panel_text);
const std::string text = "It no longer sees " + std::to_string(s.retroarch_move) +
(s.retroarch_move == 1 ? " game" : " games") + " in its old folders (" + s.retroarch_folders +
"). Move " + (s.retroarch_move == 1 ? "it" : "them") + " to the new ones?";
ui::paragraph(list, context_.fonts.regular, text, box.x + 48, box.y + 146, 26, box.w - 96, 36, L.muted, 3);
move_list_.draw(list, context_.fonts, L, move_rows(), {box.x + 16, box.y + 300, box.w - 32, 2 * rommps::RowList::kRowHeight},
clock_);
}
void update_downloads(const InputFrame &input, float dt, app::Feedback &feedback)
{
const int count = static_cast<int>(app_.downloads().size());
@@ -842,9 +914,10 @@ class RommPS final : public app::Concept
list.rounded_rect(sheet, 44, L.covers ? gfx::mix(palette.dark, kInk, 0.5f).with_alpha(0.62f) : L.panel);
list.bordered_rect(sheet, 44, Color::rgb(0x000000, 0.0f), 1.5f, L.outline);
const Rect art{sheet.x + 56, sheet.y + 56, 300, 400};
rommps::draw_cover(list, fonts, L, app_, g, art, 1.0f, 26);
const float x = art.x + art.w + 56;
// The cover in its own shape, as large as fits the space for it.
const Rect space{sheet.x + 56, sheet.y + 56, 300, 400};
rommps::draw_cover(list, fonts, L, app_, g, rommps::fit_cover(rommps::cover_shape(app_, g), space), 1.0f, 26);
const float x = space.x + space.w + 56;
ui::text(list, fonts.semibold, ui::upper(p ? p->name : ""), x, sheet.y + 92, 20, acc, gfx::Align::left, 4.0f);
float y = ui::paragraph(list, fonts.display, g->name, x - 2, sheet.y + 156, 54, 640, 62, L.panel_text, 2) - 18;
if (g->versions > 1)
@@ -1032,6 +1105,9 @@ class RommPS final : public app::Concept
rommps::Game sheet_game_; // the game the details sheet is for
rommps::SetupPage setup_;
bool setting_up_ = false;
bool move_asked_ = false; // this launch
bool move_open_ = false; // asking whether to move games to RetroArch 2.0's folders
rommps::RowList move_list_;
};
} // namespace
@@ -10,7 +10,7 @@
"conceptId": "76677",
"contentBadgeType": 1,
"contentId": "UP9000-PPSA76677_00-ROMMPS0000000000",
"contentVersion": "01.002.000",
"contentVersion": "01.002.001",
"downloadDataSize": 256,
"gameIntent": {
"permittedIntents": [
+31
View File
@@ -20,6 +20,7 @@
#include "platform.h"
#include "profiles.h"
#include "ra2.h"
#include "state.h"
#include "util.h"
@@ -71,6 +72,23 @@ static const struct {
{"DOS", "dos"},
};
const char *detect_db_name(const cJSON *entry) {
const char *db = jget_str(entry, "db", NULL);
if (db) return db;
if (str_ieq(jget_str(entry, "core_name", ""), "MAME")) return "MAME"; /* arcade, like FBNeo */
const cJSON *slug;
cJSON_ArrayForEach(slug, cJSON_GetObjectItemCaseSensitive(entry, "romm")) {
if (!cJSON_IsString(slug)) continue;
for (size_t i = 0; i < sizeof DB_MAP / sizeof DB_MAP[0]; i++) {
char slugs[256], *save = NULL;
str_copy(slugs, sizeof slugs, DB_MAP[i].slugs);
for (char *t = strtok_r(slugs, ",", &save); t; t = strtok_r(NULL, ",", &save))
if (str_ieq(t, slug->valuestring)) return DB_MAP[i].db;
}
}
return NULL;
}
/* Value of key = "value" in a libretro .info file. */
static int info_get(const char *txt, const char *key, char *out, size_t n) {
size_t kl = strlen(key);
@@ -142,6 +160,7 @@ static void add_core_platforms(cJSON *plats, cJSON *cores, const char *core_id,
cJSON *e = cJSON_CreateObject();
cJSON_AddItemToObject(e, "romm", slug_array(slugs));
cJSON_AddStringToObject(e, "dir", first);
cJSON_AddStringToObject(e, "db", db);
cJSON_AddStringToObject(e, "emulator", core_id);
cJSON_AddStringToObject(e, "core_name", corename);
/* Saves other emulators keep in the same format share one save on RomM. */
@@ -227,6 +246,17 @@ static cJSON *retroarch_candidate(const char *root) {
if (!dup) str_copy(names[n++], sizeof names[0], id);
}
if (d) closedir(d);
/* RetroArch 2.0's PS5SX2 replaces LRPS2, whose files an upgrade can leave behind. */
int has_ps5sx2 = 0;
for (int i = 0; i < n; i++) has_ps5sx2 |= !strcmp(names[i], "ps5sx2");
if (has_ps5sx2 && ra2_is_v2(root))
for (int i = 0; i < n; i++)
if (!strcmp(names[i], "pcsx2")) {
LOGI("ignoring LRPS2 at %s: RetroArch 2.0 plays PS2 with PS5SX2", root);
memmove(names[i], names[n - 1], sizeof names[0]);
n--;
break;
}
/* Sorted so the first core per system doesn't depend on readdir order. */
for (int i = 0; i < n; i++)
for (int k = i + 1; k < n; k++)
@@ -268,6 +298,7 @@ static cJSON *retroarch_candidate(const char *root) {
} else {
cJSON_Delete(plats); /* no .info files, keep the default platforms */
}
ra2_update_profile(profile);
cJSON *c = cJSON_CreateObject();
cJSON_AddStringToObject(c, "kind", "retroarch");
+5
View File
@@ -15,4 +15,9 @@ int detect_refresh_profiles(cJSON *profiles);
* Parsed once; don't free. */
const cJSON *detect_catalog_entry(const char *id);
/* The libretro database a RetroArch platform entry plays ("Sony - PlayStation
* 2"): its own "db", else the first that takes one of its RomM slugs. NULL if
* none does. */
const char *detect_db_name(const cJSON *entry);
#endif
+2 -1
View File
@@ -392,7 +392,7 @@ static void run_firmware_job(job *j) {
if (cancelled(j)) break;
const char *fn = jget_str(f, "file_name", "");
char dest[PATH_MAX_LEN];
path_join(dest, sizeof dest, m->bios_dir, fn);
path_join(dest, sizeof dest, m->firmware_dir, fn);
if (file_size(dest) == (int64_t)jget_num(f, "file_size_bytes", -1)) continue;
char *q = http_escape(fn);
snprintf(path, sizeof path, "/api/firmware/%d/content/%s", (int)jget_num(f, "id", 0), q);
@@ -402,6 +402,7 @@ static void run_firmware_job(job *j) {
count++;
LOGI("downloaded BIOS %s", fn);
} else {
LOGW("BIOS %s didn't download: %s", fn, r.error);
discard_partial(dest, 0);
failed++;
}
+3
View File
@@ -17,6 +17,7 @@
#include "http.h"
#include "library.h"
#include "platform.h"
#include "ra2.h"
#include "romm.h"
#include "state.h"
#include "sync.h"
@@ -127,6 +128,8 @@ static void *app_main(void *arg) {
config_load();
if (g_cfg.profiles_custom && detect_refresh_profiles(g_cfg.profiles) > 0) config_save();
state_load();
/* RetroArch 2.0 keeps games in new folders. */
ra2_check(1);
events_load();
/* Copies of older versions left on the console are brought up to date. */
autostart_repair();
+8 -4
View File
@@ -19,7 +19,8 @@
#include "zip.h"
/* The shared memory cards backed up: LRPS2's (<system>/pcsx2/memcards/
* Mcd00N.ps2, when its "backup" mode is on) and those a profile lists in
* Mcd00N.ps2, when its "backup" mode is on), RetroArch 2.0's PS5SX2's (the
* same two, always: it has no per-game cards) and those a profile lists in
* "memcards" (PS5SX2's Mcd001.ps2 and Mcd002.ps2). RomM only takes cards as
* zips, so each goes up as a zip holding exactly one file. */
#define MAX_CARDS 8
@@ -42,12 +43,15 @@ static int cards_list(card *out, int max) {
for (int i = 0; i < cnt && n < max; i++) {
const profile_map *m = &maps[i];
if (m->sync_off) continue;
if (!strcmp(mode, "backup") && profile_map_handles(m, "ps2", NULL) && str_ieq(m->core_name, "LRPS2")) {
const int lrps2 = !strcmp(mode, "backup") && str_ieq(m->core_name, "LRPS2");
const int ps5sx2 = str_ieq(m->core_name, "PS5SX2");
if ((lrps2 || ps5sx2) && profile_map_handles(m, "ps2", NULL)) {
for (int s = 1; s <= 2 && n < max; s++) {
card *c = &out[n++];
snprintf(c->path, sizeof c->path, "%s/pcsx2/memcards/Mcd%03d.ps2", m->bios_dir, s);
str_copy(c->emulator, sizeof c->emulator, "lrps2");
c->label[0] = 0;
/* Not "ps5sx2": standalone PS5SX2's cards are RomM's "ps5sx2". */
str_copy(c->emulator, sizeof c->emulator, lrps2 ? "lrps2" : "ps5sx2-retroarch");
str_copy(c->label, sizeof c->label, lrps2 ? "" : "RetroArch PS5SX2");
c->slot = s;
}
}
+80
View File
@@ -0,0 +1,80 @@
#include "playlist.h"
#include <dirent.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "state.h"
#include "util.h"
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
/* The PS5's RetroArch runs from its title folder, which it sees as /app0:
* its playlists name files that way. */
static int app_folder(const char *root) {
char p[PATH_MAX_LEN];
path_join(p, sizeof p, root, "sce_sys/param.json");
return file_exists(p);
}
static void to_app_path(const char *root, const char *real, char *out, size_t n) {
size_t rl = strlen(root);
if (app_folder(root) && !strncmp(real, root, rl) && real[rl] == '/') snprintf(out, n, "/app0%s", real + rl);
else str_copy(out, n, real);
}
static void to_real_path(const char *root, const char *p, char *out, size_t n) {
if (!strncmp(p, "/app0/", 6) && app_folder(root)) snprintf(out, n, "%s%s", root, p + 5);
else str_copy(out, n, p);
}
static int save(const char *path, const cJSON *j) {
char *txt = cJSON_Print(j);
int rc = txt ? write_file_atomic(path, txt, strlen(txt)) : -1;
free(txt);
if (rc) LOGW("could not write %s", path);
return rc;
}
static void move_in(const char *root, const char *dir, const char *from, const char *to) {
size_t fl = strlen(from);
DIR *d = opendir(dir);
struct dirent *de;
while (d && (de = readdir(d))) {
if (!str_ends_with_ci(de->d_name, ".lpl")) continue;
char file[PATH_MAX_LEN];
path_join(file, sizeof file, dir, de->d_name);
char *txt = read_file(file, NULL);
cJSON *pl = txt ? cJSON_Parse(txt) : NULL, *it;
free(txt);
int changed = 0;
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(pl, "items")) {
const char *p = jget_str(it, "path", "");
char real[PATH_MAX_LEN], moved[PATH_MAX_LEN + 256], back[PATH_MAX_LEN + 256];
to_real_path(root, p, real, sizeof real);
if (strncmp(real, from, fl) != 0 || real[fl] != '/') continue;
snprintf(moved, sizeof moved, "%s%s", to, real + fl);
if (!strncmp(p, "/app0/", 6)) to_app_path(root, moved, back, sizeof back);
else str_copy(back, sizeof back, moved);
jset_str(it, "path", back);
changed++;
}
if (changed && !save(file, pl)) LOGI("RetroArch playlist %s: %d game%s moved", de->d_name, changed, changed == 1 ? "" : "s");
cJSON_Delete(pl);
}
if (d) closedir(d);
}
void playlists_move(const char *root, const char *playlist_dir, const char *from, const char *to) {
char dir[PATH_MAX_LEN], builtin[PATH_MAX_LEN];
if (playlist_dir && playlist_dir[0]) str_copy(dir, sizeof dir, playlist_dir);
else path_join(dir, sizeof dir, root, "playlists");
path_join(builtin, sizeof builtin, dir, "builtin");
pthread_mutex_lock(&g_lock);
move_in(root, dir, from, to);
move_in(root, builtin, from, to);
pthread_mutex_unlock(&g_lock);
}
+11
View File
@@ -0,0 +1,11 @@
/* RetroArch's playlists name games by path: when RommPS moves games to
* another folder, the entries that point at them move too, so the history
* and any playlist the user made keep working. */
#ifndef ROMM_PLAYLIST_H
#define ROMM_PLAYLIST_H
/* Paths under from move to under to in every playlist of the RetroArch at
* root (playlist_dir, "" for <root>/playlists, and its builtin/ history). */
void playlists_move(const char *root, const char *playlist_dir, const char *from, const char *to);
#endif
+9
View File
@@ -286,6 +286,7 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
str_copy(root, sizeof root, jstr(p, NULL, "root", ""));
char ra_save[PATH_MAX_LEN] = "", ra_state[PATH_MAX_LEN] = "", ra_sys[PATH_MAX_LEN] = "";
char ra_playlists[PATH_MAX_LEN] = "";
int ra_in_content[2] = {0, 0}, ra_by_content[2] = {0, 0}, ra_by_core[2] = {0, 0};
char ra_cfg_dir[PATH_MAX_LEN] = "";
const char *ra_tpl = jstr(p, NULL, "retroarch_cfg", NULL);
@@ -303,6 +304,7 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
if (slash) *slash = 0;
apply_ra_cfg(cfg, root, none, ra_save, ra_state, sizeof ra_save, ra_in_content, ra_by_content, ra_by_core);
ra_apply(cfg, "system_directory", root, ra_sys, sizeof ra_sys);
ra_apply(cfg, "playlist_directory", root, ra_playlists, sizeof ra_playlists);
free(cfg);
break;
}
@@ -344,6 +346,12 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
str_copy(m->state_dir, sizeof m->state_dir, ra_state);
if (ra_sys[0] && !cJSON_GetObjectItemCaseSensitive(e, "bios_dir"))
str_copy(m->bios_dir, sizeof m->bios_dir, ra_sys);
/* RetroArch's PS2 cores read their BIOS from <system>/pcsx2/bios. */
const char *core = jstr(p, e, "core_name", "");
const int pcsx2 = str_ieq(core, "LRPS2") || str_ieq(core, "PCSX2") || str_ieq(core, "PS5SX2");
const char *sub = jstr(p, e, "firmware_subdir", pcsx2 && ra_tpl ? "pcsx2/bios" : "");
if (sub[0]) path_join(m->firmware_dir, sizeof m->firmware_dir, m->bios_dir, sub);
else str_copy(m->firmware_dir, sizeof m->firmware_dir, m->bios_dir);
for (int k = 0; k < 2; k++) {
m->explicit_dir[k] = cJSON_GetObjectItemCaseSensitive(e, k ? "state_dir" : "save_dir") != NULL;
if (m->explicit_dir[k]) continue;
@@ -352,6 +360,7 @@ int profiles_expand(const cJSON *profiles, profile_map **out) {
m->by_core[k] = ra_by_core[k];
}
str_copy(m->core_name, sizeof m->core_name, jstr(p, e, "core_name", ""));
str_copy(m->playlist_dir, sizeof m->playlist_dir, ra_playlists);
str_copy(m->save_layout, sizeof m->save_layout, jstr(p, e, "save_layout", ""));
str_copy(m->bundle_dirs, sizeof m->bundle_dirs, jstr(p, e, "bundle_dirs", ""));
+4
View File
@@ -56,6 +56,9 @@ typedef struct {
char save_dir[PATH_MAX_LEN];
char state_dir[PATH_MAX_LEN];
char bios_dir[PATH_MAX_LEN];
/* Where RomM's firmware goes: bios_dir, or a folder in it the core reads
* ("firmware_subdir": "pcsx2/bios" for RetroArch's PS2 cores). */
char firmware_dir[PATH_MAX_LEN];
char save_exts[128];
char state_exts[128];
char save_name[128];
@@ -103,6 +106,7 @@ typedef struct {
* by_core = <dir>/<core_name>/. Both sort flags nest content then core. */
int in_content[2], by_content[2], by_core[2];
char core_name[64]; /* core library name, e.g. "mGBA" */
char playlist_dir[PATH_MAX_LEN]; /* RetroArch's, "" for <root>/playlists */
char save_layout[8]; /* "" for one file per game, or psp, gci, wii (see bundle.h) */
char bundle_dirs[512]; /* candidate folders for bundled saves, comma separated */
/* Why saves aren't synced for this platform, shown in the UI. */
+254
View File
@@ -0,0 +1,254 @@
#include "ra2.h"
#include <dirent.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "config.h"
#include "detect.h"
#include "library.h"
#include "playlist.h"
#include "profiles.h"
#include "state.h"
#include "sync.h"
#include "util.h"
#define RA_ROM_DIR "{root}/content/{platform}"
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static time_t g_checked;
static cJSON *g_pending;
int ra2_is_v2(const char *root) {
char p[PATH_MAX_LEN];
path_join(p, sizeof p, root, "webui/version.json");
char *txt = read_file(p, NULL);
cJSON *j = txt ? cJSON_Parse(txt) : NULL;
free(txt);
const char *release = jget_str(j, "release", ""); /* "v2.0.0-beta-2" */
int major = 0;
sscanf(release + (*release == 'v' || *release == 'V'), "%d", &major);
cJSON_Delete(j);
return major >= 2;
}
/* 2.0 plays PS2 with PS5SX2, not LRPS2: two shared memory cards (memcard.c
* backs them up), none per game. */
static int to_ps5sx2(cJSON *p, const char *root) {
char core[PATH_MAX_LEN];
path_join(core, sizeof core, root, "cores/ps5sx2_libretro.info");
if (!file_exists(core)) return 0;
cJSON *e;
cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
if (strcmp(profiles_system_key(e), "ps2") != 0 || !str_ieq(jget_str(e, "core_name", ""), "LRPS2")) continue;
jset_str(e, "emulator", "ps5sx2");
jset_str(e, "core_name", "PS5SX2");
cJSON_DeleteItemFromObjectCaseSensitive(e, "save_exts");
cJSON_DeleteItemFromObjectCaseSensitive(e, "sync_requires_option");
LOGI("RetroArch 2.0 at %s: PS2 plays with PS5SX2", root);
return 1;
}
return 0;
}
int ra2_update_profile(cJSON *p) {
const char *root = jget_str(p, "root", "");
/* A RetroArch profile, its games in content/<system>: the preset's. */
if (!jget_str(p, "retroarch_cfg", NULL) || strcmp(jget_str(p, "rom_dir", ""), RA_ROM_DIR) != 0 || !ra2_is_v2(root))
return 0;
int changed = 0;
cJSON *e;
cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(p, "platforms")) {
const char *db = detect_db_name(e);
char now[PATH_MAX_LEN], old[PATH_MAX_LEN], tpl[PATH_MAX_LEN];
if (!db) continue;
snprintf(now, sizeof now, "%s/content/%s", root, db);
if (!dir_exists(now)) continue; /* a system RetroArch has no folder for */
/* Unless the user picked a folder other than the old one. */
snprintf(old, sizeof old, "%s/content/%s", root, jget_str(e, "dir", profiles_system_key(e)));
const char *set = jget_str(e, "rom_dir", NULL);
if (set && strcmp(set, old) != 0 && strcmp(set, RA_ROM_DIR) != 0) continue;
snprintf(tpl, sizeof tpl, "{root}/content/%s", db);
jset_str(e, "rom_dir", tpl);
changed++;
}
if (changed) LOGI("RetroArch 2.0 at %s: %d system%s get their new folders", root, changed, changed == 1 ? "" : "s");
return changed + to_ps5sx2(p, root);
}
/* A game folder a download is still filling (its files arrive as .part). */
static int still_downloading(const char *dir) {
DIR *d = opendir(dir);
struct dirent *de;
int part = 0;
while (d && !part && (de = readdir(d))) part = ext_in_list(de->d_name, ".part");
if (d) closedir(d);
return part;
}
/* What moves: everything but the downloads still arriving. */
static int movable(const char *dir, const char *name) {
if (name[0] == '.' || ext_in_list(name, ".part,.tmp")) return 0;
char p[PATH_MAX_LEN];
path_join(p, sizeof p, dir, name);
return !(dir_exists(p) && still_downloading(p));
}
/* Games in from that aren't in to yet. */
static int games_to_move(const char *from, const char *to, const profile_map *m) {
DIR *d = opendir(from);
struct dirent *de;
int n = 0;
while (d && (de = readdir(d))) {
if (!movable(from, de->d_name) || ext_in_list(de->d_name, m->save_exts) ||
ext_in_list(de->d_name, m->state_exts) || library_not_game(de->d_name))
continue;
char there[PATH_MAX_LEN];
path_join(there, sizeof there, to, de->d_name);
if (!file_exists(there) && !dir_exists(there)) n++;
}
if (d) closedir(d);
return n;
}
void ra2_check(int now) {
pthread_mutex_lock(&g_lock);
const char *every = getenv("ROMM_SYNC_RA2_EVERY"); /* seconds, for tests */
if (!now && time(NULL) - g_checked < (every && *every ? atoi(every) : 60)) {
pthread_mutex_unlock(&g_lock);
return;
}
g_checked = time(NULL);
config_lock();
int changed = 0;
cJSON *p;
cJSON_ArrayForEach(p, g_cfg.profiles) changed += ra2_update_profile(p);
if (changed && g_cfg.profiles_custom) config_save();
profile_map *maps = NULL;
int n = profiles_expand(g_cfg.profiles, &maps);
const int ready = g_cfg.setup_complete;
config_unlock();
cJSON_Delete(g_pending);
g_pending = NULL;
cJSON *folders = cJSON_CreateArray();
int total = 0;
for (int i = 0; ready && i < n; i++) {
const profile_map *m = &maps[i];
char content[PATH_MAX_LEN], from[PATH_MAX_LEN];
snprintf(content, sizeof content, "%s/content/", m->root);
if (strncmp(m->rom_dir, content, strlen(content)) != 0 || !ra2_is_v2(m->root)) continue;
snprintf(from, sizeof from, "%s%s", content, m->platform_dir);
int seen = 0;
const cJSON *f;
cJSON_ArrayForEach(f, folders) seen |= !strcmp(jget_str(f, "from", ""), from);
const int games = !seen && strcmp(from, m->rom_dir) != 0 ? games_to_move(from, m->rom_dir, m) : 0;
if (!games) continue;
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "from", from);
cJSON_AddStringToObject(o, "to", m->rom_dir);
cJSON_AddStringToObject(o, "root", m->root);
cJSON_AddStringToObject(o, "playlists", m->playlist_dir);
cJSON_AddNumberToObject(o, "games", games);
cJSON_AddItemToArray(folders, o);
total += games;
}
if (total) {
g_pending = cJSON_CreateObject();
cJSON_AddNumberToObject(g_pending, "games", total);
cJSON_AddItemToObject(g_pending, "folders", folders);
} else {
cJSON_Delete(folders);
}
free(maps);
pthread_mutex_unlock(&g_lock);
}
cJSON *ra2_pending(void) {
pthread_mutex_lock(&g_lock);
cJSON *j = g_pending ? cJSON_Duplicate(g_pending, 1) : NULL;
pthread_mutex_unlock(&g_lock);
return j;
}
/* Records under from (games, and saves kept beside them) follow it to to. */
static void rekey(const char *from, const char *to) {
static const char *const sections[] = {"roms", "saves", "states", "conflicts"};
size_t fl = strlen(from);
for (size_t s = 0; s < sizeof sections / sizeof *sections; s++) {
cJSON *sec = state_section(sections[s]), *found = cJSON_CreateArray(), *e = sec->child;
while (e) {
cJSON *next = e->next;
if (!strncmp(e->string, from, fl) && (!e->string[fl] || e->string[fl] == '/' || e->string[fl] == '#'))
cJSON_AddItemToArray(found, cJSON_DetachItemViaPointer(sec, e));
e = next;
}
while ((e = cJSON_DetachItemFromArray(found, 0))) {
char key[PATH_MAX_LEN + 256];
snprintf(key, sizeof key, "%s%s", to, e->string + fl);
cJSON_DeleteItemFromObjectCaseSensitive(sec, key); /* an old record of something gone */
cJSON_AddItemToObject(sec, key, e);
}
cJSON_Delete(found);
}
}
/* Moves what's in from to to; how many failed. */
static int move_folder(const char *from, const char *to, int *moved) {
int failed = 0;
mkdir_p(to);
DIR *d = opendir(from);
struct dirent *de;
while (d && (de = readdir(d))) {
if (!movable(from, de->d_name)) continue;
char src[PATH_MAX_LEN], dst[PATH_MAX_LEN];
path_join(src, sizeof src, from, de->d_name);
path_join(dst, sizeof dst, to, de->d_name);
if (file_exists(dst) || dir_exists(dst)) continue; /* already there: left alone */
if (rename(src, dst) != 0) {
LOGW("could not move %s to %s", src, dst);
failed++;
continue;
}
state_lock();
rekey(src, dst);
state_unlock();
(*moved)++;
}
if (d) closedir(d);
rmdir(from); /* only once it's empty */
LOGI("moved %s to %s%s", from, to, failed ? " (some failed)" : "");
return failed;
}
int ra2_move_games(int *moved, char *err, int en) {
*moved = 0;
ra2_check(1);
cJSON *pending = ra2_pending();
if (!pending) {
snprintf(err, (size_t)en, "no games to move");
return -1;
}
int failed = 0;
sync_pause();
const cJSON *f;
cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(pending, "folders")) {
failed += move_folder(jget_str(f, "from", ""), jget_str(f, "to", ""), moved);
/* RetroArch's history and playlists follow the games. */
playlists_move(jget_str(f, "root", ""), jget_str(f, "playlists", ""), jget_str(f, "from", ""), jget_str(f, "to", ""));
}
state_lock();
state_save();
state_unlock();
sync_resume();
cJSON_Delete(pending);
LOGI("moved %d game%s to RetroArch 2.0's folders", *moved, *moved == 1 ? "" : "s");
ra2_check(1);
if (*moved) sync_request("games moved");
if (failed) snprintf(err, (size_t)en, "%d couldn't be moved", failed);
return failed ? -1 : 0;
}
+30
View File
@@ -0,0 +1,30 @@
/* RetroArch 2.0 on the PS5 keeps each system's games in a folder named after
* its libretro database (content/Sony - PlayStation 2, content/Nintendo -
* Game Boy Advance), where 1.x had content/ps2 and content/gba. It makes
* those folders itself. Each system with one gets it (unless the user picked
* a folder), and the games already in the old folders move there once the
* user says so. */
#ifndef ROMM_RA2_H
#define ROMM_RA2_H
#include "cJSON.h"
/* RetroArch at root is 2.0 or newer, as its webui/version.json says. */
int ra2_is_v2(const char *root);
/* Points a RetroArch 2.0 profile's systems at their new folders, unless the
* user picked others. How many changed. */
int ra2_update_profile(cJSON *profile);
/* Looks at the profiles and the old folders again: at most once a minute,
* unless now. Takes the config lock. */
void ra2_check(int now);
/* The move waiting for the user, or NULL. Caller frees.
* {"games": 12, "folders": [{"from", "to", "games"}]} */
cJSON *ra2_pending(void);
/* Moves the games in the old folders to the new ones. 0 when all of them moved. */
int ra2_move_games(int *moved, char *err, int en);
#endif
+3
View File
@@ -2778,6 +2778,9 @@ void sync_request(const char *reason) {
}
int sync_is_running(void) { return g_running; }
void sync_pause(void) { pthread_mutex_lock(&g_run_lock); }
void sync_resume(void) { pthread_mutex_unlock(&g_run_lock); }
time_t sync_last_run(void) { return g_last_run; }
void sync_add_play_session(time_t start, time_t end, const char *title_id) {
+3
View File
@@ -11,6 +11,9 @@ void sync_request(const char *reason); /* queued; repeats are merged */
/* After pairing: forgets the game and save ids of any server before. */
void sync_new_pairing(void);
int sync_is_running(void);
/* Waits for a sync running and holds the next back while files move. */
void sync_pause(void);
void sync_resume(void);
time_t sync_last_run(void);
cJSON *sync_status_json(void); /* caller frees */
+1 -1
View File
@@ -8,7 +8,7 @@
#define APP_NAME "romm-sync"
#ifndef APP_VERSION
#define APP_VERSION "1.2.0"
#define APP_VERSION "1.2.1"
#endif
#ifndef APP_BUILD /* git describe of the build, from the Makefile */
#define APP_BUILD "unknown"
+18 -1
View File
@@ -26,6 +26,7 @@
#include "pair.h"
#include "platform.h"
#include "profiles.h"
#include "ra2.h"
#include "state.h"
#include "sync.h"
#include "ui_index.h" /* generated from ui/index.html */
@@ -280,6 +281,9 @@ static cJSON *status_json(void) {
cJSON_AddItemToObject(j, "sync", sync_status_json());
cJSON_AddItemToObject(j, "pairing", pair_status());
cJSON_AddItemToObject(j, "covers", covers_status());
ra2_check(0);
cJSON *move = ra2_pending();
if (move) cJSON_AddItemToObject(j, "retroarch_move", move);
return j;
}
@@ -1152,13 +1156,13 @@ static void route(request *r) {
}
config_unlock();
}
cJSON_Delete(j);
if (!found) send_error(fd, 400, "unknown emulator or choice");
else if (rc) send_error(fd, 500, "could not save config");
else {
LOGI("save sync for %s: %s", at, mode);
send_ok(fd);
}
cJSON_Delete(j); /* at and mode are in it */
return;
}
if (is_get && !strcmp(r->path, "/api/setup/preview")) {
@@ -1238,6 +1242,19 @@ static void route(request *r) {
else send_ok(fd);
return;
}
if (is_post && !strcmp(r->path, "/api/retroarch/move")) {
int moved = 0;
int rc = ra2_move_games(&moved, err, sizeof err);
if (rc && !moved) {
send_error(fd, 400, err);
return;
}
cJSON *j = cJSON_CreateObject();
cJSON_AddNumberToObject(j, "moved", moved);
if (rc) cJSON_AddStringToObject(j, "error", err);
send_json(fd, 200, j);
return;
}
if (is_post && !strcmp(r->path, "/api/strays/move")) {
cJSON *j = body_json(r);
int rc = sync_move_stray(jget_str(j, "path", ""), err, sizeof err);
+47 -1
View File
@@ -58,7 +58,7 @@ EOF
# An installed payload in a stand-in etaHEN folder, for the update test.
mkdir -p "$T/etaHEN/payloads"; echo "old-payload" > "$T/etaHEN/payloads/romm-sync.elf"
ROMM_SYNC_HOMEBREW="$T/homebrew" ROMM_SYNC_DATA="$T/data" ROMM_SYNC_AUTOSTART="$T/etaHEN/payloads" \
ROMM_SYNC_CATALOG="$T/catalog.json" ROMM_SYNC_APP_DIRS="$T/apps" ROMM_SYNC_FS_ROOTS="$T" ROMM_SYNC_COVERS_TICK=1 \
ROMM_SYNC_CATALOG="$T/catalog.json" ROMM_SYNC_APP_DIRS="$T/apps" ROMM_SYNC_FS_ROOTS="$T" ROMM_SYNC_COVERS_TICK=1 ROMM_SYNC_RA2_EVERY=1 \
ROMM_SYNC_UPDATE_URL="http://127.0.0.1:$MOCK_PORT/_github/release" "$ROOT/build/host/romm-sync" >"$T/daemon.log" 2>&1 &
sleep 6 # let the (unpaired, skipped) startup sync pass
@@ -835,4 +835,50 @@ rm -f "$stdir/No Sync Game (USA).state1"
syncnow
[[ "$(cat "$stdir/No Sync Game (USA).state1" 2>/dev/null)" == "emu-state" ]] || fail "a deleted state must come back"
echo "26. RetroArch 2.0: games go to the folders named after libretro's databases, and the ones already here move there"
PS2N="$HB/content/Sony - PlayStation 2"; SNESN="$HB/content/Nintendo - Super Nintendo Entertainment System"
post /api/setup/emulators "{\"roots\":[\"$HB\"]}" >/dev/null || fail "setting up RetroArch refused"
dirs_of() { curl -sf "$API/api/paths" | python3 -c "import json,sys; print(sorted(m['rom_dir'] for m in json.load(sys.stdin) if m['platforms'].split(',')[0] == '$1'))"; }
to_move() { curl -sf "$API/api/status" | python3 -c "import json,sys; print(json.load(sys.stdin).get('retroarch_move',{}).get('games',0))"; }
[[ "$(dirs_of ps2)" == "['$HB/content/ps2']" ]] || fail "before 2.0, PS2 games go to content/ps2: $(dirs_of ps2)"
mkdir -p "$HB/content/ps2/Folder Game (USA)" "$HB/content/snes" "$HB/webui" "$PS2N" "$SNESN"
echo iso > "$HB/content/ps2/Gran Turismo 4 (USA).iso"
echo bin > "$HB/content/ps2/Folder Game (USA)/game.bin"
echo old > "$HB/content/ps2/Both (USA).iso"; echo new > "$PS2N/Both (USA).iso"
echo part > "$HB/content/ps2/Arriving (USA).iso.part"
echo sfc > "$HB/content/snes/Super Metroid (USA).sfc"
echo '{"release": "v1.0.0-beta.1"}' > "$HB/webui/version.json"
sleep 1.1
[[ "$(to_move)" == 0 ]] || fail "nothing moves before 2.0"
# 2.0 plays PS2 with PS5SX2 (an upgrade can leave LRPS2 behind), on two shared cards.
printf 'corename = "PS5SX2"\ndatabase = "Sony - PlayStation 2"\n' > "$HB/cores/ps5sx2_libretro.info"
mkdir -p "$HB/system/pcsx2/memcards"; echo card1 > "$HB/system/pcsx2/memcards/Mcd001.ps2"; echo card2 > "$HB/system/pcsx2/memcards/Mcd002.ps2"
echo '{"release": "v2.0.0-beta-2"}' > "$HB/webui/version.json"
sleep 1.1
waiting=$(curl -sf "$API/api/status" | python3 -c "import json,sys; print(sorted((f['from'].split('/')[-1], f['games']) for f in json.load(sys.stdin)['retroarch_move']['folders']))")
snes_games=$(ls "$HB/content/snes" | wc -l)
[[ "$waiting" == "[('ps2', 2), ('snes', $snes_games)]" ]] || fail "waiting to move: $waiting"
[[ "$(dirs_of ps2)" == "['$PS2N']" && "$(dirs_of snes)" == "['$SNESN']" ]] || fail "with 2.0, games go to the new folders: $(dirs_of ps2) $(dirs_of snes)"
ps2core=$(curl -sf "$API/api/config" | python3 -c "import json,sys; print([(e['emulator'], e['core_name'], 'sync_requires_option' in e) for p in json.load(sys.stdin)['profiles'] if p.get('root')=='$HB' for e in p['platforms'] if e['romm'][0]=='ps2'])")
[[ "$ps2core" == "[('ps5sx2', 'PS5SX2', False)]" ]] || fail "with 2.0, PS2 plays with PS5SX2: $ps2core"
cards=$(curl -sf "$API/api/memcards" | python3 -c "import json,sys; print(sorted((s['emulator'], s['card']) for s in json.load(sys.stdin)['slots']))")
[[ "$cards" == *"('RetroArch PS5SX2', 1)"*"('RetroArch PS5SX2', 2)"* ]] || fail "PS5SX2's two shared cards are backed up: $cards"
# RetroArch's history names games as it sees them, under /app0.
mkdir -p "$HB/playlists/builtin"
cat > "$HB/playlists/builtin/content_history.lpl" <<'LPL'
{"version": "1.5", "items": [
{"path": "/app0/content/snes/Super Metroid (USA).sfc", "label": "Super Metroid", "core_path": "DETECT", "core_name": "DETECT"},
{"path": "/app0/content/Saturn/Other.chd", "label": "Not ours", "core_path": "DETECT", "core_name": "DETECT"}]}
LPL
r=$(post /api/retroarch/move) || fail "moving refused"
[[ "$r" == *"\"moved\":$((snes_games + 2))"* ]] || fail "moved: $r"
[[ -f "$PS2N/Gran Turismo 4 (USA).iso" && -f "$PS2N/Folder Game (USA)/game.bin" && -f "$SNESN/Super Metroid (USA).sfc" ]] || fail "the games didn't move"
[[ ! -e "$HB/content/snes" ]] || fail "an emptied old folder must go"
[[ "$(cat "$PS2N/Both (USA).iso")" == new && "$(cat "$HB/content/ps2/Both (USA).iso")" == old ]] || fail "a game in both folders must stay as it is"
[[ -f "$HB/content/ps2/Arriving (USA).iso.part" ]] || fail "a download still arriving must stay"
[[ "$(to_move)" == 0 ]] || fail "nothing should be left to move: $(to_move)"
hist=$(python3 -c "import json,sys; print([i['path'] for i in json.load(open(sys.argv[1]))['items']])" "$HB/playlists/builtin/content_history.lpl")
[[ "$hist" == "['/app0/content/Nintendo - Super Nintendo Entertainment System/Super Metroid (USA).sfc', '/app0/content/Saturn/Other.chd']" ]] || fail "the history must follow the moved games, and only them: $hist"
curl -s -X POST "$API/api/retroarch/move" | grep -q error || fail "a second move must say there's nothing to move"
echo "PASS"
+16
View File
@@ -130,6 +130,11 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
<section id="home" class="active">
<div class="notice" id="updNotice" style="display:none"></div>
<div class="card" id="raMoveCard" style="display:none">
<h2 class="warn" style="margin-top:0">RetroArch 2.0 has new game folders</h2>
<p class="muted" id="raMoveText"></p>
<button id="raMove">Move them</button>
</div>
<div class="card" id="syncOff" style="display:none"><h2 style="margin:0">Save sync is off</h2><p class="muted">RomM is only for games. Turn it on in Settings.</p></div>
<div class="card syncCard">
<div class="row">
@@ -376,6 +381,10 @@ function renderStatus(s) {
if (document.querySelector("#pair.active") && !wz.editing) showTab("home");
var sy = s.sync;
$("syncOff").style.display = s.save_sync === false ? "" : "none";
var mv = s.retroarch_move;
$("raMoveCard").style.display = mv ? "" : "none";
if (mv) $("raMoveText").textContent = "It no longer sees " + mv.games + " game" + (mv.games === 1 ? "" : "s") + " in its old folders (" +
mv.folders.map(function (f) { return f.from.split("/").pop(); }).join(", ") + "). Move " + (mv.games === 1 ? "it" : "them") + " to the new ones?";
each("#home .syncCard", function (x) { x.style.display = s.save_sync === false ? "none" : ""; });
if (sy.sync_count !== state.shownSyncs && !sy.running) { state.shownSyncs = sy.sync_count; loadActivity(); loadTracked(); }
$("syncNow").disabled = sy.running;
@@ -432,6 +441,13 @@ $("strays").onclick = function (ev) {
b.disabled = true;
api("POST", "/api/strays/move", { path: b.dataset.stray }).then(function () { toast("Moved"); setTimeout(poll, 1500); }).catch(function (e) { b.disabled = false; toast(e.message, 1); });
};
$("raMove").onclick = function () {
var b = this; b.disabled = true;
api("POST", "/api/retroarch/move").then(function (r) {
toast("Moved " + r.moved + " game" + (r.moved === 1 ? "" : "s") + (r.error ? ", " + r.error : ""), !!r.error);
poll();
}).catch(function (e) { toast(e.message, 1); }).then(function () { b.disabled = false; });
};
$("rescan").onclick = function () { api("POST", "/api/rescan").then(function () { toast("Checking again"); }); };
// Setup wizard