mirror of
https://github.com/s0liton/ps5-romm.git
synced 2026-10-28 22:01:45 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d42c0578d | ||
|
|
5c63aa5352 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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", ""));
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user