diff --git a/Makefile b/Makefile index 1272650..c5988cf 100644 --- a/Makefile +++ b/Makefile @@ -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/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/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 @@ -37,7 +37,7 @@ COMMON_CFLAGS += -DAPP_BUILD='"$(BUILD_ID)"' endif COMMON_CFLAGS += $(EXTRA_CFLAGS) -.PHONY: all host ps5 ps4 tools deploy install install-ps4 run clean +.PHONY: unit all host ps5 ps4 tools deploy install install-ps4 run clean all: host @@ -61,6 +61,14 @@ $(HOST_BIN): $(SRCS) platform/host/platform.c $(HDRS) $(UI_HEADER) @mkdir -p $(dir $@) $(HOST_CC) $(COMMON_CFLAGS) -DROMM_UPDATE_TEST_KEY -o $@ $(SRCS) platform/host/platform.c -lcurl -lz -lpthread +# Unit tests for the parts with file formats of their own. +unit: build/host/gcmem_test + build/host/gcmem_test + +build/host/gcmem_test: tests/gcmem_test.c src/gcmem.c src/gcmem.h + @mkdir -p $(dir $@) + $(HOST_CC) -std=gnu11 -g -Wall -Wextra -fsanitize=address,undefined -Isrc -o $@ tests/gcmem_test.c src/gcmem.c + tools: build/host/release-sign build/host/release-sign: tools/release-sign.c src/update_keys.h third_party/monocypher.c third_party/monocypher-ed25519.c diff --git a/README.md b/README.md index d81a498..cf17c5d 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,14 @@ A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN or onionHEN, with kstuff) and PS4 consoles (GoldHEN). Through my insatiable need to mod everything electronic I encounter, I had my 12.70 PS5 jailbroken the minute that P2JB was out. I maintain a RomM server to centralize all my _legal_ ROMs, saves, and savestates, which all my devices sync with, and the PS5 needs similar treatment. -- **Save and sate sync** +- **Save and state sync** - Monitors save file changes in the background, and uploads them as soon as they've changed. Periodic syncs also ensure that your PS4/5 always has the latest saves and save states. + - A game's saves come down the moment it finishes downloading, so it's ready to play. + - Save states only come down to an emulator that can load them (see [Save states](#save-states)). + - Optional: turn save sync off in setup or Settings to use RomM just for games. +- **See what synced** + - An activity log and every save and state kept in sync, by platform, in the app and the Web UI. Settle conflicts and restore older versions from either. + - Settings > Diagnostic bundle saves the logs for a bug report, with your server, names and addresses left out. - **Interactive library** - Browse your RomM library from the RommPS app (or some other device via the Web UI), download games and BIOS files into the right emulator folders, and resolve save conflicts. - **Pluggable emulator profiles and multiple emulators per platform** @@ -32,11 +38,11 @@ A [RomM](https://romm.app) companion client for jailbroken PS5 consoles (etaHEN | Emulator | Systems | Game Downloads | Saves | Comments | | --- | --- | --- | --- | --- | | [RetroArch](https://github.com/mihawk-99/PS5_RetroArch) | NES, Famicom Disk System, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis/Mega Drive, Master System, Game Gear, Sega CD, SG-1000, arcade (FinalBurn Neo), PS2 (LRPS2), PSP (PPSSPP), GameCube and Wii (Dolphin) | Full Support | Full Support | - | -| [PSXS5](https://github.com/SynoPiia/PSXS5) | PS1 | Full Support | One memory card per game, and states | - | -| [SwanStationPS5](https://github.com/darkxex/SwanStationPS5) | PS1 | Full Support | One memory card per game, and states | - | +| [PSXS5](https://github.com/SynoPiia/PSXS5) | PS1 | Full Support | One memory card per game, and states | States work with RomM's web player (PCSX-ReARMed or Beetle, whichever PSXS5 is set to) | +| [SwanStationPS5](https://github.com/darkxex/SwanStationPS5) | PS1 | Full Support | One memory card per game, and states | Plays PSXS5's games, keeps its own saves in `/data/SwanStationPS5`. Its states only load in SwanStationPS5 | | [PS5SX2](https://github.com/Swordpdf/PS5SX2) | PS2 | Full Support | Its two shared memory cards, backed up to RomM (5.3 or newer) | - | | [PS5N64](https://github.com/Globaleliteee/PS5N64) | N64 | Full Support | Full Support | - | -| [PS5NES](https://github.com/Globaleliteee/PS5NES) | NES | Broken | Broken | Broken, tried to report issue, but GH page gone | +| [PS5NES](https://github.com/Globaleliteee/PS5NES) | NES | Untested | Untested | Preset uses `/data/PS5NES/games` and `/data/PS5NES/saves`. GH page gone | | [Porpoise](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5) | Wii/GameCube | Full Support | N/A | Couldn't exit sandbox w/o crash. [Issue](https://github.com/elripalda/Porpoise-Dolphin-Emulator-for-PS5/issues/18) reported. Downloading games should work if the emulator is actually working for you.| | [ProsperoEden](https://github.com/blackbearreloaded/ProsperoEden) | Switch | Manual Support | Researching | - | | [PS5X360](https://github.com/BrinooTk/PS5X360) | Xbox 360 | Manual Support | Researching | - | @@ -61,6 +67,14 @@ For the 1.2.0 release I will be including some fixes as well as some conversion Only RetroArch has been tested on the PS4 so far. +### Save states +Save states only load in the emulator core that made them, sometimes only in the same version of it. RommPS backs every state up to RomM, and with **Save states: Back up and restore** in Settings it brings one down only to an emulator that can load it: +- **RetroArch:** states from the same core, from RomM's web player or another RetroArch. +- **PSXS5:** PCSX-ReARMed or Beetle states, whichever core PSXS5 is set to. They're converted to and from the web player's format. +- **SwanStationPS5, PS5N64:** only their own states. + +A state from elsewhere never replaces one of yours: it goes into the next free slot. + ## PS4/PS5 Games If you are interested in downloading PS4 and PS5 games from your RomM server, RomM has direct FPKGi support, simply add it as a library. This sync client does NOT handle PS4/PS5 save syncing at this time, but is being researched for potential future inclusion. diff --git a/assets/library.png b/assets/library.png index cb6014f..d27b6cf 100644 Binary files a/assets/library.png and b/assets/library.png differ diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..7ee648e Binary files /dev/null and b/assets/social-preview.png differ diff --git a/docs/releases/v1.2.0.md b/docs/releases/v1.2.0.md new file mode 100644 index 0000000..2a95a56 --- /dev/null +++ b/docs/releases/v1.2.0.md @@ -0,0 +1,25 @@ +Update RommPS and open it once. It updates everything else. + +### New + +- Saves come down as soon as a game finishes downloading, so it's ready to play. +- Save states only come down to an emulator that can load them. +- Save sync is optional. Use RomM just for games, or pick per platform: saves and states, saves only, or off. +- Home has Conflicts, Activity (what synced and why) and Tracked saves. Each game has its own saves view, with restore. +- Settings > Emulators is organized by platform: its emulator, folders, save sync and settings in one place. +- GameCube saves keep the form RomM has them in: a zip, a single .gci, or a whole memory card. Other saves on a card are left alone. +- Porpoise is supported, including its save states. SwanStationPS5 and PS5NES are offered too. +- Settings > Diagnostic bundle, for bug reports and troubleshooting. +- Hold Options to close RommPS gracefully, avoiding any weird behaviors that you often see when hard closing homebrew apps. + +### Fixed + +- A save or state deleted from the console comes back from RomM. +- Older saves from before RomM's save slots now reach the console. +- A blank PS1 memory card from RomM never replaces one with saves on it. +- Games in folders download with their file names. +- Notifications say what moved and for which games, and every download says when it's done. + +### Good to know + +- RomM's web player and the PS5 emulators don't always use the same core. A state only comes down where it can load. diff --git a/platform/ps5/app/rommps/examples/rommps/kit/library_page.cpp b/platform/ps5/app/rommps/examples/rommps/kit/library_page.cpp index ef3cc03..17cfb70 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/library_page.cpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/library_page.cpp @@ -421,7 +421,9 @@ void LibraryPage::draw_platforms(hui::gfx::DrawList &list, const hui::ui::Fonts { const float in = tween::stagger(age, 1, 0.08f, 0.6f); list.push_opacity(in); - ui::text(list, fonts.semibold, "On this console", kMargin, 190, 24, section_ == 0 ? look.text : look.muted); + char here[64]; + std::snprintf(here, sizeof here, "On this console \xC2\xB7 %d", static_cast(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(recent.size()); ++i) { @@ -737,6 +739,8 @@ void LibraryPage::draw_grid(hui::gfx::DrawList &list, const hui::ui::Fonts &font const Rect bar{kMargin, 336, 420, 8}; list.rounded_rect(bar, 4, look.muted.with_alpha(0.25f)); list.rounded_rect({bar.x, bar.y, std::max(8.0f, bar.w * d->progress()), bar.h}, 4, accent); + if (d->syncing()) + ui::text(list, fonts.semibold, "Syncing saves", bar.x + bar.w + 20, 344, 20, look.muted); } else { diff --git a/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.cpp b/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.cpp index bcf85a1..71292c9 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.cpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.cpp @@ -33,6 +33,7 @@ namespace constexpr float kStatusEvery = 3.0f; constexpr float kDownloadsEvery = 1.0f; // while one is running constexpr float kDownloadsIdleEvery = 5.0f; // otherwise +constexpr int kCoverRetries = 3; // a failed cover is tried again after 15, 30, 60 s constexpr int kDecodesPerFrame = 2; // covers turned into textures each frame constexpr std::size_t kCoverTextures = 360; // covers kept as textures (about 130 MB); the // least recently drawn go, and come back from @@ -170,6 +171,11 @@ Palette palette_of(const unsigned char *rgba, int w, int h) } // namespace +std::string plain_text(const char *text) +{ + return plain(text ? text : ""); +} + App &app() { static App instance; @@ -191,6 +197,7 @@ void App::tick(float dt, hui::gfx::Renderer *renderer) { clock_ += dt; api_.poll(); + slow_api_.poll(); for (Api &a : covers_api_) a.poll(); for (Api &a : pages_api_) @@ -208,6 +215,8 @@ void App::tick(float dt, hui::gfx::Renderer *renderer) if (covers_in_flight_ >= kCoversInFlight) break; Cover &c = covers_[path]; + if (c.failed && c.retry_at > 0 && clock_ >= c.retry_at) + c.requested = c.failed = false, c.retry_at = 0; // try again if (c.requested) continue; c.requested = true; @@ -218,7 +227,14 @@ void App::tick(float dt, hui::gfx::Renderer *renderer) if (r.ok() && !r.body.empty()) to_decode_.push_back({path, r.body}); else - covers_[path].failed = true; + { + Cover &c = covers_[path]; + c.failed = true; + // RomM has no cover: that stays. Anything else (the service + // busy, a network hiccup): again in a little while. + const bool again = r.status != 404 && c.retries < kCoverRetries; + c.retry_at = again ? clock_ + 15.0 * (1 << c.retries++) : 0; + } }); } wanted_.clear(); @@ -248,7 +264,7 @@ void App::tick(float dt, hui::gfx::Renderer *renderer) else if (start_state_ == 3) { start_state_ = 0; - say("Couldn't start RomM Sync: " + start_error_); + say("Couldn't start RommPS's background service: " + start_error_); } // A few covers a frame become textures; the rest wait their turn, so a @@ -316,7 +332,7 @@ void App::start_payload() const std::string &elf = bundled_payload(); if (elf.size() < 4 || elf.compare(0, 4, "\x7f" "ELF") != 0) { - start_error_ = "this RommPS doesn't carry the RomM Sync payload"; + start_error_ = "this RommPS doesn't carry its background service"; start_state_ = 3; return; } @@ -360,7 +376,7 @@ void App::repair_autostart() { install_autostart([this](const Response &a) { if (a.ok()) - say("RomM Sync now starts with the console. You can turn this off in Set up again."); + say("RommPS now starts with the console. You can turn this off in Set up again."); }); return; } @@ -372,7 +388,7 @@ void App::repair_autostart() return; api_.post("/api/autostart/install", bundled_payload(), [this](const Response &a) { if (!a.ok()) - say("Couldn't update RomM Sync's autostart: " + a.message()); + say("Couldn't update RommPS's autostart: " + a.message()); }); }); } @@ -460,7 +476,7 @@ void App::refresh_status() if (payload_too_old() && !updated_payload_ && idle) { updated_payload_ = true; - say("Updating RomM Sync to " + std::string(kMinPayload)); + say("Updating RommPS's background service to " + std::string(kMinPayload)); start_payload(); } else if (!repaired_ && version_cmp(status_.version, kMinPayload) == 0) @@ -711,7 +727,7 @@ const Details &App::details(int rom_id) void App::refresh_installed() { - api_.get("/api/installed?limit=30", [this](const Response &r) { + api_.get("/api/installed?limit=1000", [this](const Response &r) { // every one: the shelf's count is theirs if (!r.ok()) return; std::unique_ptr j(r.json(), cJSON_Delete); @@ -725,6 +741,7 @@ void App::refresh_installed() game.name = plain(str(g, "name")); game.fs_name = str(g, "fs_name"); game.cover = str(g, "cover"); + game.size = num(g, "size"); game.installed = true; list.push_back(std::move(game)); } @@ -770,6 +787,10 @@ void App::refresh_downloads() dl.done = num(d, "done"); dl.total = num(d, "total"); dl.speed = num(d, "speed"); + dl.saves = static_cast(num(d, "saves")); + dl.states = static_cast(num(d, "states")); + dl.save_conflicts = static_cast(num(d, "save_conflicts")); + dl.saves_failed = flag(d, "saves_failed"); active += dl.active(); // A game that just finished shows as downloaded on its shelf. for (const Download &old : downloads_) @@ -788,6 +809,21 @@ void App::refresh_downloads() }); } +std::string Download::saves_note() const +{ + if (saves_failed) + return "Saves couldn't sync"; + if (save_conflicts) + return "Save conflict to resolve"; + auto count = [](int n, const char *what) { return std::to_string(n) + " " + what + (n == 1 ? "" : "s"); }; + std::string got; + if (saves) + got = count(saves, "save"); + if (states) + got += (got.empty() ? "" : " and ") + count(states, "state"); + return got.empty() ? "" : got + " synced"; +} + const Download *App::download_for(int rom_id) const { const Download *found = nullptr; @@ -833,6 +869,23 @@ void App::cancel_download(int job_id) }); } +void App::remove_game(int rom_id) +{ + char body[64]; + std::snprintf(body, sizeof body, "{\"rom_id\":%d}", rom_id); + api_.post("/api/delete", body, [this](const Response &r) { + if (!r.ok()) + { + say("Couldn't remove the game: " + r.message()); + return; + } + say("Game removed. Its saves are kept."); + lists_.clear(); + refresh_installed(); + refresh_downloads(); + }); +} + void App::sync_now() { api_.post("/api/sync", "{}", [this](const Response &r) { @@ -857,6 +910,7 @@ void App::refresh_config() c.sync_on_game_exit = flag(j.get(), "sync_on_game_exit"); c.sync_on_game_start = flag(j.get(), "sync_on_game_start"); c.sync_on_change = flag(j.get(), "sync_on_change"); + c.save_sync = !cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(j.get(), "save_sync")); // older payloads: on c.notify = flag(j.get(), "notify"); c.update_check = flag(j.get(), "update_check"); c.states = str(j.get(), "states", "off"); @@ -913,10 +967,10 @@ void App::refresh_update() if (u.state == "error") say("Update: " + (u.error.empty() ? u.message : u.error)); else if (u.available) - say(u.self_update ? "RomM Sync " + u.latest + " is available" + say(u.self_update ? "RommPS " + u.latest + " is available" : "RommPS " + u.latest + " is available in ProsperoStore"); else if (u.state == "idle") - say("RomM Sync is up to date"); + say("RommPS is up to date"); else if (!u.message.empty()) say(u.message); } diff --git a/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.hpp b/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.hpp index a657cc4..986170a 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.hpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/rommps_app.hpp @@ -42,6 +42,8 @@ struct Cover bool requested = false; bool failed = false; double used = 0; // when it was last drawn + double retry_at = 0; // a fetch that failed (not a missing cover) is tried again then + int retries = 0; // tries again so far this launch (kCoverRetries at most) }; struct Game @@ -136,11 +138,18 @@ struct Download int id = 0, rom_id = 0; std::string name, platform, status, error; double done = 0, total = 0, speed = 0; + // What the sync after the download brought. + int saves = 0, states = 0, save_conflicts = 0; + bool saves_failed = false; float progress() const { return total > 0 ? static_cast(done / total) : 0.0f; } - // The payload's states: queued, downloading, cancelling, then done, error or cancelled. + // The payload's states: queued, downloading, cancelling, syncing (the + // game's saves, once it's downloaded), then done, error or cancelled. bool running() const { return status == "downloading"; } - bool active() const { return status == "queued" || running() || status == "cancelling"; } + bool syncing() const { return status == "syncing"; } + bool active() const { return status == "queued" || running() || status == "cancelling" || syncing(); } + bool cancellable() const { return active() && !syncing(); } bool finished() const { return !active(); } + std::string saves_note() const; }; struct Status @@ -173,6 +182,7 @@ struct Config std::string server_url; bool tls_verify = true; int sync_interval_min = 0; + bool save_sync = true; // false: RomM only as a game source bool sync_on_game_exit = true, sync_on_game_start = false, sync_on_change = false; bool notify = true, update_check = true; std::string states = "off"; // off, upload, sync @@ -208,7 +218,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.1.1"; + static constexpr const char *kMinPayload = "1.2.0"; 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, @@ -245,6 +255,7 @@ class App void download(int rom_id, const std::string &name); void cancel_download(int job_id); + void remove_game(int rom_id); void clear_finished_downloads(); void sync_now(); @@ -267,8 +278,12 @@ class App // Asks for the status now rather than at the next tick. void refresh_now() { refresh_status(); } - // Any other call to the payload, for the setup's steps. - void get(const std::string &path, Api::Callback done) { api_.get(path, std::move(done)); } + // Any other call to the payload, for the setup's steps. A long one (the + // sync preview) goes on a worker of its own, so the status never waits. + void get(const std::string &path, Api::Callback done, int timeout_ms = 15000) + { + (timeout_ms > 15000 ? slow_api_ : api_).get(path, std::move(done), timeout_ms); + } void post(const std::string &path, const std::string &body, Api::Callback done) { api_.post(path, body, std::move(done)); @@ -294,6 +309,7 @@ class App void refresh_downloads(); Api api_; + Api slow_api_; // calls that may take minutes // Covers on workers of their own, so lists never wait behind them; asked // for only while they're on screen (wanted_), newest first. static constexpr int kCoverWorkers = 3; @@ -348,6 +364,10 @@ class App // The program's one App, created on first use. App &app(); +// Text for the kit's fonts, which are baked for ASCII: accented letters fold +// to plain ones ("Pokémon" reads "Pokemon"). +std::string plain_text(const char *text); + } // namespace rommps // The program hands the kit's renderer over (rommps.cpp is compiled in a diff --git a/platform/ps5/app/rommps/examples/rommps/kit/rows.cpp b/platform/ps5/app/rommps/examples/rommps/kit/rows.cpp index 7ad26cc..b382c06 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/rows.cpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/rows.cpp @@ -5,6 +5,7 @@ #include #include +#include namespace rommps { @@ -35,13 +36,28 @@ RowEvent RowList::handle(const std::vector &rows, const hui::InputFrame &in if (count == 0) return event; focus_ = std::clamp(focus_, 0, count - 1); + // A section's name can't hold the focus: the first row after it does + // (or, at the end of the list, the last one before it). + if (rows[static_cast(focus_)].kind == RowKind::header) + { + int at = focus_; + while (at < count && rows[static_cast(at)].kind == RowKind::header) + ++at; + if (at == count) + for (at = focus_; at > 0 && rows[static_cast(at)].kind == RowKind::header;) + --at; + focus_ = at; + } const Row &row = rows[static_cast(focus_)]; switch (input.nav) { case Direction::up: case Direction::down: { - const int next = focus_ + (input.nav == Direction::down ? 1 : -1); + const int step = input.nav == Direction::down ? 1 : -1; + int next = focus_ + step; + while (next >= 0 && next < count && rows[static_cast(next)].kind == RowKind::header) + next += step; if (next >= 0 && next < count) { focus_ = next; @@ -118,6 +134,14 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const const float y = bounds.y + static_cast(i) * kRowHeight - scroll_.value; if (y + kRowHeight < bounds.y || y > bounds.y + bounds.h) continue; + if (row.kind == RowKind::header) + { + std::string caps = row.label; + for (char &ch : caps) + ch = static_cast(std::toupper(static_cast(ch))); + ui::text(list, fonts.semibold, caps, bounds.x + 32, y + kRowHeight - 20, 18, look.muted); + continue; + } const bool focused = i == focus_; const float dim = row.enabled ? 1.0f : 0.45f; const Color ink = focused ? look.on_highlight : row.danger ? look.danger : look.text; @@ -155,10 +179,27 @@ void RowList::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const } used = w + chevrons + (row.kind == RowKind::choice ? 50.0f : 24.0f); } - const float room = std::max(200.0f, bounds.w - 64 - used); - ui::text(list, fonts.semibold, fonts.semibold.font->fit(row.label, 26, room), x, label_y, 26, ink.with_alpha(dim)); + // A chevron for a row that opens: right when closed, down when open. + const float indent = row.expander ? 30.0f : 0.0f; + if (row.expander) + { + const float cx = x + 8, cy = label_y - 9; + const Color c = ink.with_alpha(dim); + if (row.expander == 1) + { + list.line(cx - 3, cy - 8, cx + 4, cy, 3.0f, c); + list.line(cx + 4, cy, cx - 3, cy + 8, 3.0f, c); + } + else + { + list.line(cx - 8, cy - 3, cx, cy + 4, 3.0f, c); + list.line(cx, cy + 4, cx + 8, cy - 3, 3.0f, c); + } + } + const float room = std::max(200.0f, bounds.w - 64 - used - indent); + ui::text(list, fonts.semibold, fonts.semibold.font->fit(row.label, 26, room), x + indent, label_y, 26, ink.with_alpha(dim)); if (two_lines) - ui::text(list, fonts.regular, fonts.regular.font->fit(row.detail, 19, room), x, label_y + 30, 19, + ui::text(list, fonts.regular, fonts.regular.font->fit(row.detail, 19, room), x + indent, label_y + 30, 19, soft.with_alpha(dim)); } list.pop_clip(); diff --git a/platform/ps5/app/rommps/examples/rommps/kit/rows.hpp b/platform/ps5/app/rommps/examples/rommps/kit/rows.hpp index 53ec696..9fa25a0 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/rows.hpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/rows.hpp @@ -25,6 +25,7 @@ enum class RowKind choice, // left and right step through its options action, // Cross does it info, // only shows something + header // a section's name }; struct Row @@ -36,6 +37,7 @@ struct Row bool on = false; // a toggle's state bool enabled = true; bool danger = false; // an action that can't be undone + int expander = 0; // 1: closed, 2: open: a chevron before the label (Cross opens what's under it) }; struct RowEvent @@ -73,7 +75,7 @@ struct Named }; inline constexpr Named kPolicies[] = { {"ask", "Ask me"}, {"newest", "Keep the newest"}, {"local", "Keep this console's"}, {"server", "Keep RomM's"}}; -inline constexpr Named kStates[] = {{"off", "Off"}, {"upload", "Upload only"}, {"sync", "Upload and download"}}; +inline constexpr Named kStates[] = {{"off", "Off"}, {"upload", "Back up to RomM"}, {"sync", "Back up and restore"}}; inline constexpr int kIntervals[] = {0, 15, 30, 60, 120, 360}; const char *label_of(std::span options, const std::string &id); diff --git a/platform/ps5/app/rommps/examples/rommps/kit/screen.cpp b/platform/ps5/app/rommps/examples/rommps/kit/screen.cpp index 3bac4f0..9140e1e 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/screen.cpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/screen.cpp @@ -8,7 +8,8 @@ // what the pad asks for. // // - L1 / R1 move between Home, Library and Downloads. -// - Home: the sync at a glance; Cross syncs now. +// - Home: the sync at a glance; Cross syncs now. Beside it: the conflicts +// to settle, the sync log and the saves kept in sync (sync_views.hpp). // - Library (library_page.hpp): the games on this console and RomM's // platforms; a platform's games in a grid with an A to Z bar; search. The // backdrop eases toward the focused cover's colours; Cross opens a frosted @@ -29,6 +30,7 @@ #include "library_page.hpp" #include "settings_page.hpp" #include "setup_page.hpp" +#include "sync_views.hpp" #include "ui/glyphs.hpp" #include "ui/motion.hpp" @@ -65,7 +67,7 @@ enum Tab constexpr const char *kTabNames[kTabs] = {"Home", "Library", "Downloads", "Settings"}; constexpr const char *kTechniques[] = { - "The RomM Sync payload's state, read through its local API on a worker thread", + "The background service's state, read through its local API on a worker thread", "Covers fetched once, decoded a few per frame, their colours driving the backdrop", "Spring-driven shelves, card growth and a gliding focus ring (from Aurora Shelf)", "Frosted details sheet over the blurred screen", @@ -127,7 +129,7 @@ class RommPS final : public app::Concept const bool calling = settings_.call().active(); if (calling) settings_.call().update(input, dt, feedback); - if (ready && !sheet_open_ && !calling && !setup_.editing()) + if (ready && !sheet_open_ && !calling && !setup_.editing() && !view_.active()) { if (input.is_pressed(Action::page_prev) || input.is_pressed(Action::page_next)) { @@ -164,6 +166,8 @@ class RommPS final : public app::Concept feedback.play(audio::Cue::focus); } } + else if (view_.active()) + view_.update(app_, input, dt, feedback); else if (tab_ == kHome) update_home(input, feedback); else if (tab_ == kLibrary) @@ -228,6 +232,8 @@ class RommPS final : public app::Concept setup_.draw(list, context_.fonts, L, app_, clock_); else if (!ready) draw_not_ready(list); + else if (view_.active()) + view_.draw(list, context_.fonts, L, app_, clock_); else if (tab_ == kHome) draw_home(list); else if (tab_ == kLibrary) @@ -306,9 +312,27 @@ class RommPS final : public app::Concept nudge_.trigger(); } + // Home's buttons: Sync now, then the lists beside it. + static constexpr int kHomeButtons = 4; + void update_home(const InputFrame &input, app::Feedback &feedback) { - if (input.is_pressed(Action::confirm)) + if (!app_.config().save_sync) + return; // nothing to sync: Settings turns it back on + if (input.nav == Direction::left || input.nav == Direction::right) + { + const int next = home_focus_ + (input.nav == Direction::right ? 1 : -1); + if (next >= 0 && next < kHomeButtons) + { + home_focus_ = next; + feedback.play(audio::Cue::focus); + } + else if (!input.nav_repeat) + refuse(feedback, input.nav == Direction::right ? 1.0f : -1.0f); + } + if (!input.is_pressed(Action::confirm)) + return; + if (home_focus_ == 0) { if (app_.status().syncing) { @@ -318,7 +342,12 @@ class RommPS final : public app::Concept app_.sync_now(); feedback.play(audio::Cue::launch); feedback.rumble(0.5f, 0.12f); + return; } + static const rommps::SyncView::Kind kinds[] = {rommps::SyncView::kConflicts, rommps::SyncView::kActivity, + rommps::SyncView::kTracked}; + view_.open(app_, kinds[home_focus_ - 1]); + feedback.play(audio::Cue::select); } void open_sheet() @@ -337,7 +366,7 @@ class RommPS final : public app::Concept { std::string label; bool enabled; - int kind; // 0 download, 1 cancel, 2 close + int kind; // 0 download, 1 cancel, 3 remove, 4 saves and states (Circle closes the sheet) }; std::vector sheet_actions() const @@ -346,15 +375,19 @@ class RommPS final : public app::Concept const rommps::Game *g = &sheet_game_; const rommps::Platform *p = app_.platform_by_id(sheet_game_.platform_id); const rommps::Download *d = g ? app_.download_for(g->id) : nullptr; - if (d && d->active()) + if (d && d->syncing()) + actions.push_back({"Syncing saves", false, 0}); + else if (d && d->active()) actions.push_back({"Hold to cancel download", true, 1}); else if (g && g->installed) - actions.push_back({"Downloaded", false, 0}); + { + actions.push_back({"Hold to delete this game", true, 3}); + actions.push_back({"Saves and states", true, 4}); + } else if (p && p->profile.empty()) actions.push_back({"No emulator for this platform", false, 0}); else actions.push_back({"Download", true, 0}); - actions.push_back({"Close", true, 2}); return actions; } @@ -366,7 +399,7 @@ class RommPS final : public app::Concept bool hold_to_cancel(const InputFrame &input, float dt, const rommps::Download *d, app::Feedback &feedback) { cancel_seen_ = true; - if (!d || !d->active() || !input.is_held(Action::confirm)) + if (!d || !d->cancellable() || !input.is_held(Action::confirm)) { cancel_hold_ = 0; cancel_job_ = 0; @@ -398,6 +431,35 @@ class RommPS final : public app::Concept return job_id && job_id == cancel_job_ && cancel_hold_ > 0 ? std::min(1.0f, cancel_hold_ / kCancelHold) : 0.0f; } + bool hold_to_remove(const InputFrame &input, float dt, app::Feedback &feedback) + { + if (!input.is_held(Action::confirm)) + { + remove_hold_ = -1; + return false; + } + + if (input.is_pressed(Action::confirm)) + { + remove_hold_ = 0; // every hold begins with a press + feedback.play(audio::Cue::tick, 1.0f, 0.0f, 0.5f); + } + + if (remove_hold_ < 0) + return false; // was held from before or is already done + + remove_hold_ += dt; + + if (remove_hold_ < kCancelHold) + return false; + + app_.remove_game(sheet_game_.id); + feedback.play(audio::Cue::back); + feedback.rumble(0.8f, 0.2f); + remove_hold_ = -1; + return true; + } + void update_sheet(const InputFrame &input, float dt, app::Feedback &feedback) { const std::vector actions = sheet_actions(); @@ -412,11 +474,18 @@ class RommPS final : public app::Concept } } action_ = std::clamp(action_, 0, count - 1); + if (actions[static_cast(action_)].kind != 3) + remove_hold_ = -1; if (actions[static_cast(action_)].kind == 1) { if (hold_to_cancel(input, dt, app_.download_for(sheet_game_.id), feedback)) sheet_open_ = false; } + else if (actions[static_cast(action_)].kind == 3) + { + if (hold_to_remove(input, dt, feedback)) + sheet_open_ = false; + } else if (input.is_pressed(Action::confirm)) { const SheetAction &a = actions[static_cast(action_)]; @@ -430,6 +499,12 @@ class RommPS final : public app::Concept feedback.rumble(0.7f, 0.18f); sheet_open_ = false; } + else if (a.kind == 4 && g) + { + feedback.play(audio::Cue::select); + sheet_open_ = false; + view_.open(app_, rommps::SyncView::kGame, g->id, g->name); + } else { feedback.play(audio::Cue::modal_close); @@ -474,7 +549,7 @@ class RommPS final : public app::Concept if (count > 0 && download_row_ < static_cast(app_.downloads().size())) { const rommps::Download &d = app_.downloads()[static_cast(download_row_)]; - if (d.active()) + if (d.cancellable()) hold_to_cancel(input, dt, &d, feedback); else if (input.is_pressed(Action::confirm)) refuse(feedback, 0.0f); @@ -509,8 +584,8 @@ class RommPS final : public app::Concept const rommps::Status &s = app_.status(); const bool old_payload = s.reachable && app_.payload_too_old(); std::string where = !s.known ? "Connecting" - : !s.reachable ? "RomM Sync isn't running" - : old_payload ? "Updating RomM Sync " + s.version + : !s.reachable ? "The background service isn't running" + : old_payload ? "Updating the background service " + s.version : s.paired ? s.user + " @ " + s.server : "Not paired"; const float dot_x = 1824 - 12 - ui::text(list, fonts.regular, context_.fonts.regular.font->fit(where, 22, 640), 1824, 90, @@ -538,12 +613,12 @@ class RommPS final : public app::Concept const float x = panel.x + 72; const bool starting = app_.payload_starting() || (s.known && !s.reachable && app_.payload_error().empty()); ui::text(list, fonts.display, - !s.known ? "Connecting to RomM Sync" : starting ? "Starting RomM Sync" : "RomM Sync isn't running", x, + !s.known ? "Connecting to the background service" : starting ? "Starting the background service" : "The background service isn't running", x, panel.y + 120, 56, L.panel_text); ui::paragraph(list, fonts.regular, - starting ? "RomM Sync does the syncing in the background, and RommPS starts it for you. " + starting ? "RommPS syncs through a background service, and starts it for you. " "This takes a few seconds." - : "RommPS couldn't start RomM Sync. Press Cross to try again, or load romm-sync.elf " + : "RommPS couldn't start its background service. Press Cross to try again, or load romm-sync.elf " "with your HEN's payload loader.", x, panel.y + 190, 28, 1060, 42, L.panel_muted, 4); if (!starting && !app_.payload_error().empty()) @@ -564,6 +639,13 @@ class RommPS final : public app::Concept list.push_opacity(in); const float x = kMargin + 30.0f * (1.0f - in); ui::text(list, fonts.semibold, "SAVE SYNC", x, 212, 20, accent(), gfx::Align::left, 4.0f); + if (!app_.config().save_sync) + { + ui::text(list, fonts.display, "Save sync is off", x - 4, 304, 80, L.text); + ui::text(list, fonts.regular, "RomM is only for games. Turn it on in Settings.", x, 360, 28, L.muted); + list.pop_opacity(); + return; + } // The headline only says synced when the last sync went through. std::string headline = s.syncing ? "Syncing" : !s.sync_count ? "Not synced yet" @@ -572,23 +654,39 @@ class RommPS final : public app::Concept : s.conflicts > 1 ? std::to_string(s.conflicts) + " saves need you" : "Your saves are synced"; ui::text(list, fonts.display, headline, x - 4, 304, 80, L.text); - // Saves changed in both places are settled in the web UI, for now. - char web[96]; - std::snprintf(web, sizeof web, "http://%s:%d", s.ip.c_str(), s.port); - std::string line = s.syncing ? (s.phase.empty() ? "Working" : s.phase) - : s.conflicts > 0 && !s.ip.empty() ? std::string("Choose which copy to keep at ") + web - : s.sync_count ? "Last sync " + ago(std::difftime(std::time(nullptr), s.last_run)) + - " \xC2\xB7 " + s.last_result - : "No sync yet"; + std::string line = s.syncing ? (s.phase.empty() ? "Working" : s.phase) + : s.conflicts > 0 ? std::string("Open Conflicts to choose which copy to keep") + : s.sync_count ? "Last sync " + ago(std::difftime(std::time(nullptr), s.last_run)) + + " \xC2\xB7 " + s.last_result + : "No sync yet"; ui::text(list, fonts.regular, context_.fonts.regular.font->fit(line, 28, 1500), x, 360, 28, L.muted); - // Sync now - const Rect button{x, 410, 260, 68}; - list.glow(button, 34, 18, accent().with_alpha(0.35f)); - list.rounded_rect(button, 34, s.syncing ? L.button.with_alpha(0.4f) : L.button); - ui::draw_button(list, fonts, L.covers ? ui::GlyphStyle::light() : ui::GlyphStyle::dark(), ui::Button::cross, - button.x + 24, button.cy(), 34); - ui::text(list, fonts.semibold, "Sync now", button.x + 76, button.cy() + 10, 28, L.on_button); + // Sync now, and the lists beside it; the focused one is lit. + std::string labels[kHomeButtons] = {"Sync now", "Conflicts", "Activity", "Tracked saves"}; + if (s.conflicts > 0) + labels[1] += " (" + std::to_string(s.conflicts) + ")"; + float bx = x; + for (int i = 0; i < kHomeButtons; ++i) + { + const bool focused = i == home_focus_; + const float w = fonts.semibold.font->measure(labels[i], 28) + (focused ? 104.0f : 64.0f); + const Rect button{bx, 410, w, 68}; + if (focused) + { + list.glow(button, 34, 18, accent().with_alpha(0.35f)); + list.rounded_rect(button, 34, i == 0 && s.syncing ? L.button.with_alpha(0.4f) : L.button); + ui::draw_button(list, fonts, L.covers ? ui::GlyphStyle::light() : ui::GlyphStyle::dark(), ui::Button::cross, + button.x + 24, button.cy(), 34); + ui::text(list, fonts.semibold, labels[i], button.x + 76, button.cy() + 10, 28, L.on_button); + } + else + { + draw_panel(list, button, 34); + ui::text(list, fonts.semibold, labels[i], button.x + 32, button.cy() + 10, 28, + i == 1 && s.conflicts ? L.warning : L.panel_text); + } + bx += w + 20; + } // Tiles char value[64]; @@ -603,7 +701,7 @@ class RommPS final : public app::Concept tiles[1] = {"CONFLICTS", value}; std::snprintf(value, sizeof value, "%d up \xC2\xB7 %d down", s.last_up, s.last_down); tiles[2] = {"LAST SYNC", s.sync_count ? value : "-"}; - tiles[3] = {"ROMM SYNC", "v" + s.version}; + tiles[3] = {"ROMMPS", "v" + s.version}; for (int i = 0; i < 4; ++i) { const float appear = tween::stagger(tab_age_, 2 + i, 0.07f, 0.5f); @@ -616,8 +714,6 @@ class RommPS final : public app::Concept 46, i == 1 && s.conflicts ? L.warning : L.panel_text); list.pop_opacity(); } - ui::text(list, fonts.regular, context_.fonts.regular.font->fit(s.server, 22, 1700), kMargin, 850, 22, - L.muted.with_alpha(0.7f * L.muted.a)); list.pop_opacity(); } @@ -663,7 +759,7 @@ class RommPS final : public app::Concept } if (downloads.empty()) { - ui::text(list, fonts.regular, "Nothing downloading. Pick a game in Library to download it.", kMargin, 310, 28, + ui::text(list, fonts.regular, "Nothing downloading.", kMargin, 310, 28, L.muted); list.pop_opacity(); return; @@ -688,6 +784,10 @@ class RommPS final : public app::Concept } else if (d.status == "error") meta += d.error; + else if (d.syncing()) + meta += "Syncing saves"; + else if (d.status == "done" && !d.saves_note().empty()) + meta += "Downloaded \xC2\xB7 " + d.saves_note(); else { static const char *const kStates[][2] = {{"queued", "Waiting"}, @@ -815,6 +915,8 @@ class RommPS final : public app::Concept if (const rommps::Download *d = app_.download_for(g->id)) if (const float hold = cancel_progress(d->id); hold > 0) list.rounded_rect({ax, y, std::max(72.0f, 460 * hold), 72}, 36, L.danger.with_alpha(0.85f)); + if (a.kind == 3 && focused && remove_hold_ > 0) + list.rounded_rect({ax, y, std::max(72.0f, 460 * std::min(1.0f, remove_hold_ / kCancelHold)), 72}, 36, L.danger.with_alpha(0.85f)); ui::text(list, fonts.semibold, context_.fonts.semibold.font->fit(a.label, 26, 400), ax + 36, y + 46, 26, (focused ? L.on_highlight : L.panel_text).with_alpha(a.enabled ? 1.0f : 0.5f)); } @@ -857,12 +959,21 @@ class RommPS final : public app::Concept ui::draw_hints(list, fonts, style, hints, 2, 1824, true); return; } + if (view_.active()) + { + const std::vector hints = view_.hints(); + ui::draw_hints(list, fonts, style, hints.data(), static_cast(hints.size()), 1824, true); + return; + } list.push_opacity(tween::stagger(age_, 8, 0.08f, 0.5f) * (1.0f - sheet_.value)); const ui::Hint pages{ui::Button::l1, "Pages", ui::Button::r1}; + const ui::Hint close{ui::Button::options, "Hold to close"}; // rommps.cpp's HoldToLeave if (tab_ == kHome) { - const ui::Hint hints[] = {pages, {ui::Button::cross, "Sync now"}}; - ui::draw_hints(list, fonts, style, hints, 2, 1824, true); + std::vector hints{close, pages}; + if (app_.config().save_sync) + hints.push_back({ui::Button::cross, home_focus_ == 0 ? "Sync now" : "Open"}); + ui::draw_hints(list, fonts, style, hints.data(), static_cast(hints.size()), 1824, true); } else if (tab_ == kLibrary) { @@ -875,7 +986,7 @@ class RommPS final : public app::Concept // came so far; Square takes the finished ones off the list. const auto &downloads = app_.downloads(); const bool on_active = download_row_ < static_cast(downloads.size()) && - downloads[static_cast(download_row_)].active(); + downloads[static_cast(download_row_)].cancellable(); const bool any_finished = std::any_of(downloads.begin(), downloads.end(), [](const rommps::Download &d) { return d.finished(); }); std::vector hints{pages}; @@ -887,8 +998,8 @@ class RommPS final : public app::Concept } else { - const ui::Hint hints[] = {pages, {ui::Button::cross, settings_.cross_hint(app_)}}; - ui::draw_hints(list, fonts, style, hints, 2, 1824, true); + const ui::Hint hints[] = {close, pages, {ui::Button::cross, settings_.cross_hint(app_)}}; + ui::draw_hints(list, fonts, style, hints, 3, 1824, true); } list.pop_opacity(); } @@ -896,6 +1007,8 @@ class RommPS final : public app::Concept app::Context &context_; App &app_; int tab_ = kLibrary; + int home_focus_ = 0; // Home's button with the focus + rommps::SyncView view_; // a list opened from Home or a game's sheet float tab_age_ = 0.0f; tween::Spring tab_position_; float age_ = 0.0f; @@ -910,6 +1023,7 @@ class RommPS final : public app::Concept tween::Spring action_position_; int download_row_ = 0; float cancel_hold_ = 0; // seconds Cross has been held to cancel cancel_job_; -1 once done + float remove_hold_ = -1; // seconds Cross has been held on remove; -1 waits for a press int cancel_job_ = 0; bool cancel_seen_ = false; // hold_to_cancel ran this frame tween::Spring download_position_; diff --git a/platform/ps5/app/rommps/examples/rommps/kit/settings_page.cpp b/platform/ps5/app/rommps/examples/rommps/kit/settings_page.cpp index 81c5a4e..c8b0775 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/settings_page.cpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/settings_page.cpp @@ -3,10 +3,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "settings_page.hpp" +#include "cJSON.h" #include "core/tween.hpp" #include #include +#include namespace rommps { @@ -48,23 +50,25 @@ void SettingsPage::enter(App &app) on_credits_ = false; } -std::vector SettingsPage::rows(const App &app) const +std::vector SettingsPage::rows(const App &app, std::vector *ids) const { const Config &c = app.config(); const Update &u = app.update(); std::vector r(kCount); const Look &l = look(current_look()); + r[kSaveSync] = {RowKind::toggle, "Save sync", "", c.save_sync ? "" : "RomM is only for games", + c.save_sync}; r[kLook] = {RowKind::choice, "Look", l.name, l.family}; - r[kInterval] = {RowKind::choice, "Sync every", interval_label(c.sync_interval_min), "Besides the syncs below"}; - r[kOnExit] = {RowKind::toggle, "Sync when a game closes", "", "Uploads what the game saved", c.sync_on_game_exit}; - r[kOnStart] = {RowKind::toggle, "Sync when a game starts", "", "Fetches newer saves before you play", + r[kInterval] = {RowKind::choice, "Sync every", interval_label(c.sync_interval_min), ""}; + r[kOnExit] = {RowKind::toggle, "Sync when a game closes", "", "", c.sync_on_game_exit}; + r[kOnStart] = {RowKind::toggle, "Sync when a game starts", "", "", c.sync_on_game_start}; r[kOnChange] = {RowKind::toggle, "Upload saves as they change", "", "While you play", c.sync_on_change}; - r[kStates] = {RowKind::choice, "Save states", label_of(rommps::kStates, c.states), "Besides the saves themselves"}; + r[kStates] = {RowKind::choice, "Save states", label_of(rommps::kStates, c.states), ""}; r[kPolicy] = {RowKind::choice, "A save changed in both places", label_of(kPolicies, c.conflict_policy), - "On this console and on RomM since the last sync"}; + ""}; r[kConcurrency] = {RowKind::choice, "Downloads at once", std::to_string(c.download_concurrency), ""}; - r[kNotify] = {RowKind::toggle, "Notifications", "", "On the console when a sync ends or fails", c.notify}; + r[kNotify] = {RowKind::toggle, "Notifications", "", "", c.notify}; r[kUpdateCheck] = {RowKind::toggle, "Look for updates every day", "", "", c.update_check}; { const Status &s = app.status(); @@ -82,22 +86,22 @@ std::vector SettingsPage::rows(const App &app) const else if (s.covers_total > 0) std::snprintf(detail, sizeof detail, "All %d covers are on the console", s.covers_total); else - std::snprintf(detail, sizeof detail, "Faster browsing: every game's cover, kept up to date"); + std::snprintf(detail, sizeof detail, "%s", ""); r[kCovers] = {RowKind::toggle, "Keep all covers on the console", "", detail, c.cover_cache}; } Row &update = r[kUpdate]; update.kind = RowKind::action; - update.label = "RomM Sync " + (u.current.empty() ? app.status().version : u.current); + update.label = "RommPS " + (u.current.empty() ? app.status().version : u.current); if (!u.self_update) { - // RommPS brings RomM Sync with it: the store updates both. + // The app brings its background service with it: the store updates both. update.label = std::string("RommPS ") + App::kMinPayload; update.value = u.state == "checking" ? "Checking" : "Check for updates"; update.enabled = u.state != "checking"; update.detail = u.available ? "RommPS " + u.latest + " is out: update it in ProsperoStore" : u.state == "error" ? (u.error.empty() ? u.message : u.error) - : u.latest.empty() ? "Updates come from ProsperoStore, with RomM Sync in them" + : u.latest.empty() ? "Updates come from ProsperoStore" : "Up to date"; } else if (u.busy()) @@ -114,7 +118,7 @@ std::vector SettingsPage::rows(const App &app) const else if (u.available) { update.value = "Install " + u.latest; - update.detail = "RomM Sync restarts with the new version"; + update.detail = "RommPS restarts with the new version"; } else { @@ -125,16 +129,56 @@ std::vector SettingsPage::rows(const App &app) const : "Up to date"; } - r[kEmulators] = {RowKind::action, "Emulators", "", "Which emulators RomM Sync uses, and for which systems"}; - r[kSetup] = {RowKind::action, "Set up again", "", "Pick your emulators again. Background syncs wait until it's done"}; + r[kEmulators] = {RowKind::action, "Emulators", "", ""}; + r[kSetup] = {RowKind::action, "Set up again", "", ""}; r[kForget] = {RowKind::action, "Unpair from RomM", "", "Files on this console are kept"}; r[kForget].danger = true; + r[kDiagnostics] = {RowKind::action, "Diagnostic bundle", diag_ == 1 ? "Making" : "", + diag_ == 2 ? "Saved to " + diag_where_ + : diag_ == 3 ? "Couldn't make it" + : "Logs for a bug report, nothing personal"}; + r[kDiagnostics].enabled = diag_ != 1; if (armed_ == kSetup || armed_ == kForget) r[static_cast(armed_)].value = "Press again to confirm"; if (!c.known) for (int i = kInterval; i <= kCovers; ++i) r[static_cast(i)].enabled = false; - return r; + + // In sections. + static const struct + { + const char *name; + int ids[7]; + } kSections[] = { + {"Sync", {kSaveSync, kInterval, kOnExit, kOnStart, kOnChange, kPolicy, kStates}}, + {"Downloads", {kConcurrency, kCovers, -1}}, + {"Look and notifications", {kLook, kNotify, -1}}, + {"Emulators", {kEmulators, kSetup, -1}}, + {"Updates", {kUpdate, kUpdateCheck, -1}}, + {"RomM", {kForget, -1}}, + {"Help", {kDiagnostics, -1}}, + }; + std::vector out; + if (ids) + ids->clear(); + for (const auto §ion : kSections) + { + out.push_back({RowKind::header, section.name}); + if (ids) + ids->push_back(kHeader); + for (int id : section.ids) + { + if (id < 0) + break; + // Save sync off: just the switch to turn it back on. + if (!c.save_sync && id >= kInterval && id <= kPolicy) + continue; + out.push_back(r[static_cast(id)]); + if (ids) + ids->push_back(id); + } + } + return out; } const char *SettingsPage::cross_hint(const App &app) const @@ -142,7 +186,7 @@ const char *SettingsPage::cross_hint(const App &app) const if (on_credits_) return "Answer"; const std::vector r = rows(app); - const Row &row = r[static_cast(std::clamp(list_.focus(), 0, kCount - 1))]; + const Row &row = r[static_cast(std::clamp(list_.focus(), 0, static_cast(r.size()) - 1))]; switch (row.kind) { case RowKind::toggle: @@ -157,12 +201,14 @@ const char *SettingsPage::cross_hint(const App &app) const void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback) { avatar_texture(static_cast(rommps_renderer())); - const std::vector r = rows(app); + std::vector ids; + const std::vector r = rows(app, &ids); + const int count = static_cast(r.size()); armed_age_ += dt; // Below the last row: the author's portrait, and the call behind it. if (on_credits_) { - list_.update(dt, kCount, kRows.h); + list_.update(dt, count, kRows.h); if (input.nav == hui::Direction::up || input.is_pressed(hui::Action::back)) { on_credits_ = false; @@ -177,21 +223,26 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui: list_.refuse(feedback); return; } - if (input.nav == hui::Direction::down && !input.nav_repeat && list_.focus() == kCount - 1) + if (input.nav == hui::Direction::down && !input.nav_repeat && list_.focus() == count - 1) { on_credits_ = true; feedback.play(hui::audio::Cue::focus); return; } - if (armed_ >= 0 && (armed_age_ > kArmedFor || list_.focus() != armed_)) + const int focused_id = ids[static_cast(std::clamp(list_.focus(), 0, count - 1))]; + if (armed_ >= 0 && (armed_age_ > kArmedFor || focused_id != armed_)) armed_ = -1; const RowEvent e = list_.handle(r, input, feedback); - list_.update(dt, kCount, kRows.h); + list_.update(dt, count, kRows.h); if (e.row < 0) return; + const int id = ids[static_cast(e.row)]; const Config &c = app.config(); - switch (e.row) + switch (id) { + case kSaveSync: + app.set_config(flag_json("save_sync", !c.save_sync), [](Config &k) { k.save_sync = !k.save_sync; }); + break; case kLook: set_look((current_look() + e.step + look_count()) % look_count()); break; @@ -249,16 +300,27 @@ void SettingsPage::update(App &app, const hui::InputFrame &input, float dt, hui: case kEmulators: emulators_asked_ = true; break; + case kDiagnostics: + diag_ = 1; + app.post("/api/diagnostics", "{}", [this](const Response &r) { + std::unique_ptr j(r.ok() ? r.json() : nullptr, cJSON_Delete); + const cJSON *usb = cJSON_GetObjectItemCaseSensitive(j.get(), "usb"), + *path = cJSON_GetObjectItemCaseSensitive(j.get(), "path"); + const cJSON *at = cJSON_IsString(usb) ? usb : path; + diag_ = cJSON_IsString(at) ? 2 : 3; + diag_where_ = cJSON_IsString(at) ? at->valuestring : ""; + }); + break; case kSetup: case kForget: - if (armed_ != e.row) + if (armed_ != id) { - armed_ = e.row; + armed_ = id; armed_age_ = 0.0f; break; } armed_ = -1; - if (e.row == kSetup) + if (id == kSetup) app.setup_restart(); else app.pair_forget(); diff --git a/platform/ps5/app/rommps/examples/rommps/kit/settings_page.hpp b/platform/ps5/app/rommps/examples/rommps/kit/settings_page.hpp index be0001b..9e1fdc1 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/settings_page.hpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/settings_page.hpp @@ -1,9 +1,12 @@ -// RommPS: the Settings tab. The look, when and what RomM Sync syncs, its -// updates, the setup again and unpairing; an About panel beside them. +// RommPS: the Settings tab, in sections: when and what it syncs, downloads, +// the look and notifications, emulators, updates and the RomM connection; an +// About panel beside them. // // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include + #include "credits.hpp" #include "rommps_app.hpp" #include "rows.hpp" @@ -34,6 +37,7 @@ class SettingsPage private: enum Id { + kSaveSync, kLook, kInterval, kOnExit, @@ -49,14 +53,20 @@ class SettingsPage kEmulators, kSetup, kForget, - kCount + kDiagnostics, + kCount, + kHeader = -1 // a section's name }; - std::vector rows(const App &app) const; + // The rows as shown, sections included; ids[i] is row i's Id. + std::vector rows(const App &app, std::vector *ids = nullptr) const; RowList list_; bool on_credits_ = false; bool emulators_asked_ = false; // the focus is on the author's portrait, below the rows CodecCall call_; + // The diagnostic bundle: 0 not asked, 1 being made, 2 saved (where_), 3 failed. + int diag_ = 0; + std::string diag_where_; int armed_ = -1; // a row that does something lasting, waiting for its second press float armed_age_ = 0.0f; }; diff --git a/platform/ps5/app/rommps/examples/rommps/kit/setup_page.cpp b/platform/ps5/app/rommps/examples/rommps/kit/setup_page.cpp index 7830875..8353865 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/setup_page.cpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/setup_page.cpp @@ -81,9 +81,25 @@ std::string system_name(const std::string &key) {"virtualboy", "Virtual Boy"}, {"wonderswan", "WonderSwan"}, {"neo-geo-pocket", "Neo Geo Pocket"}, {"atari2600", "Atari 2600"}, {"atari7800", "Atari 7800"}, {"c64", "Commodore 64"}, + {"amiga", "Amiga"}, {"satellaview", "Satellaview"}, + {"neo-geo-pocket-color", "Neo Geo Pocket Color"}, {"wonderswan-color", "WonderSwan Color"}, + {"turbografx-cd", "PC Engine CD"}, {"3ds", "Nintendo 3DS"}, + {"atari5200", "Atari 5200"}, {"jaguar", "Jaguar"}, + {"pc-fx", "PC-FX"}, {"neo-geo-cd", "Neo Geo CD"}, + {"pokemon-mini", "Pokémon Mini"}, {"3do", "3DO"}, + {"scummvm", "ScummVM"}, }; const auto it = names.find(key); - return it != names.end() ? it->second : key; + if (it != names.end()) + return it->second; + // Any other: its key, readable ("neo-geo" -> "Neo geo"). + std::string out = key; + for (char &ch : out) + if (ch == '-' || ch == '_') + ch = ' '; + if (!out.empty() && out[0] >= 'a' && out[0] <= 'z') + out[0] = static_cast(out[0] - 32); + return out; } bool at_least(const std::string &version, int major, int minor, int patch) @@ -157,6 +173,8 @@ SetupPage::SetupPage() void SetupPage::edit_emulators() { editing_ = true; + leaving_ = false; + saved_.clear(); go(kEmulators); } @@ -341,6 +359,7 @@ void SetupPage::load_emulators(App &app) error_ = r.message(); return; } + error_.clear(); Json j(r.json(), cJSON_Delete); manual_.clear(); const cJSON *c; @@ -357,6 +376,15 @@ void SetupPage::load_emulators(App &app) const cJSON *core; cJSON_ArrayForEach(core, cJSON_GetObjectItemCaseSensitive(c, "cores")) if (cJSON_IsString(core)) e.cores.push_back(core->valuestring); + const cJSON *d; + cJSON_ArrayForEach(d, cJSON_GetObjectItemCaseSensitive(c, "dirs")) + { + e.dirs[d->string] = {str(d, "rom_dir"), str(d, "save_dir"), str(d, "state_dir")}; + e.games[d->string] = static_cast(num(d, "games")); + } + const cJSON *fx; + cJSON_ArrayForEach(fx, cJSON_GetObjectItemCaseSensitive(c, "fixed")) if (cJSON_IsString(fx)) + e.fixed.insert(fx->valuestring); const cJSON *p; cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(c, "profile"), "platforms")) { @@ -384,22 +412,21 @@ void SetupPage::load_emulators(App &app) } emulators_.push_back(std::move(e)); } - if (!editing_) - { - busy_ = false; - emulators_loaded_ = true; - return; - } - // From Settings: start from the emulators set up now, and mark the new ones. + // Start from the emulators set up now (Settings, or setting up again); + // a first setup starts from what was found. a->get("/api/config", [this](const Response &r2) { busy_ = false; emulators_loaded_ = true; Json cfg(r2.json(), cJSON_Delete); const cJSON *profiles = cJSON_GetObjectItemCaseSensitive(cfg.get(), "profiles"), *p; - if (!cJSON_IsArray(profiles)) + if (!cJSON_IsArray(profiles) || (!editing_ && !cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(cfg.get(), "profiles_custom")))) return; + ps2_cards_ = std::string(str(cfg.get(), "ps2_cards")) == "backup" ? "backup" : "per_game"; choices_.clear(); also_.clear(); + custom_dirs_.clear(); + sync_.clear(); + expanded_.clear(); // Folders picked before: custom- profiles for systems, and // custom-- for emulators with no preset. cJSON_ArrayForEach(p, profiles) @@ -424,10 +451,10 @@ void SetupPage::load_emulators(App &app) cJSON_ArrayForEach(p, profiles) if (e.root == str(p, "root") && !cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(p, "enabled"))) mine = p; - e.fresh = e.ready && !mine; e.on = e.ready && mine != nullptr; if (!mine) continue; + e.sync = *str(mine, "sync") ? str(mine, "sync") : "all"; auto listed = [&](const char *list, const std::string &key) { const cJSON *x; cJSON_ArrayForEach(x, cJSON_GetObjectItemCaseSensitive(mine, list)) if (cJSON_IsString(x) && @@ -439,19 +466,63 @@ void SetupPage::load_emulators(App &app) if (listed("extra", sy.key)) also_[sy.key].insert(e.root); else if (!listed("exclude", sy.key)) + { choices_[sy.key] = e.root; + // Its save sync: on its platform entry, or (before 1.2) the emulator's. + std::string mode = *str(mine, "sync") ? str(mine, "sync") : "all"; + const cJSON *pe; + cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(mine, "platforms")) + { + const cJSON *k = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(pe, "romm"), 0); + if (cJSON_IsString(k) && sy.key == k->valuestring && *str(pe, "sync")) + mode = str(pe, "sync"); + } + sync_[sy.key] = mode; + } + } + // Folders picked for a system: set on its platform entry. + const cJSON *pe; + cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(mine, "platforms")) + { + const cJSON *key = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(pe, "romm"), 0); + if (!cJSON_IsString(key)) + continue; + static const char *const kDirs[] = {"rom_dir", "save_dir", "state_dir"}; + std::array picked; + bool any = false; + for (int k = 0; k < 3; ++k) + { + // A template ("{root}/...") is the preset's own, not a pick. + const std::string v = str(pe, kDirs[k]); + picked[static_cast(k)] = v.find('{') == std::string::npos ? v : ""; + any = any || !picked[static_cast(k)].empty(); + } + if (any) + custom_dirs_[key->valuestring][e.root] = picked; } } + // A system none of them plays now is off. + for (const Conflict &c : systems()) + if (!choices_.count(c.key)) + choices_[c.key] = ""; + saved_ = save_body() + ps2_cards_; }); }); } std::vector SetupPage::conflicts() const +{ + std::vector out = systems(); + out.erase(std::remove_if(out.begin(), out.end(), [](const Conflict &c) { return c.options.size() < 2; }), out.end()); + return out; +} + +std::vector SetupPage::systems() const { std::vector out; for (const Emulator &e : emulators_) { - if (!e.on || !e.ready) + if (!e.ready) continue; for (const System &s : e.systems) { @@ -467,7 +538,8 @@ std::vector SetupPage::conflicts() const it->options.push_back({e.root, s.label, e.kind == "retroarch"}); } } - out.erase(std::remove_if(out.begin(), out.end(), [](const Conflict &c) { return c.options.size() < 2; }), out.end()); + std::stable_sort(out.begin(), out.end(), + [](const Conflict &a, const Conflict &b) { return system_name(a.key) < system_name(b.key); }); return out; } @@ -475,19 +547,54 @@ std::string SetupPage::chosen(const Conflict &c) const { const auto picked = choices_.find(c.key); if (picked != choices_.end()) + { + if (picked->second.empty()) + return ""; // turned off for (const Option &o : c.options) if (o.root == picked->second) return o.root; + } + // Not picked yet: the emulator the platform's games are already in (most + // of them), else RetroArch, else the first found. + const Option *best = nullptr; + int most = 0; + for (const Option &o : c.options) + for (const Emulator &e : emulators_) + if (e.root == o.root && e.games.count(c.key)) + { + const int n = e.games.at(c.key); + if (n > most || (n == most && n > 0 && o.retroarch)) + best = &o, most = n; + } + if (best) + return best->root; for (const Option &o : c.options) if (o.retroarch) return o.root; return c.options.front().root; } +bool SetupPage::used(const Emulator &e) const +{ + if (!e.ready) + return false; + for (const Conflict &c : systems()) + { + if (chosen(c) == e.root) + return true; + const auto set = also_.find(c.key); + if (!chosen(c).empty() && set != also_.end() && set->second.count(e.root)) + for (const Option &o : c.options) + if (o.root == e.root) + return true; + } + return false; +} + bool SetupPage::has_ps2() const { for (const Emulator &e : emulators_) - if (e.on && std::find(e.cores.begin(), e.cores.end(), "LRPS2") != e.cores.end()) + if (used(e) && std::find(e.cores.begin(), e.cores.end(), "LRPS2") != e.cores.end()) return true; return false; } @@ -502,7 +609,7 @@ std::vector SetupPage::uncovered() const for (const Emulator &e : emulators_) for (const System &sy : e.systems) for (const std::string &slug : sy.slugs) - if (e.on && e.ready && std::find(o.slugs.begin(), o.slugs.end(), slug) != o.slugs.end()) + if (e.ready && std::find(o.slugs.begin(), o.slugs.end(), slug) != o.slugs.end()) covered = true; if (!covered) out.push_back(key); @@ -510,14 +617,28 @@ std::vector SetupPage::uncovered() const return out; } -void SetupPage::open_picker(App &app, const std::string &key, int which) +void SetupPage::open_picker(App &app, const std::string &key, int which, const std::string &root) { picking_ = true; pick_key_ = key; pick_which_ = which; - const Own &o = own_[key]; + pick_root_ = root; // Start where the folder is, or where the games are, or at the drives. - std::string start = !o.folder[which].empty() ? o.folder[which] : o.folder[0]; + std::string start; + if (root.empty()) + { + const Own &o = own_[key]; + start = !o.folder[which].empty() ? o.folder[which] : o.folder[0]; + } + else + { + const std::array &d = custom_dirs_[key][root]; + start = !d[static_cast(which)].empty() ? d[static_cast(which)] : d[0]; + if (start.empty()) + for (const Emulator &e : emulators_) + if (e.root == root && e.dirs.count(key)) + start = e.dirs.at(key)[static_cast(which)]; + } browse(app, start); } @@ -588,7 +709,10 @@ void SetupPage::update_picker(App &app, const hui::InputFrame &input, float dt, { if (i == 0) { - own_[pick_key_].folder[pick_which_] = fs_path_; + if (pick_root_.empty()) + own_[pick_key_].folder[pick_which_] = fs_path_; + else + custom_dirs_[pick_key_][pick_root_][static_cast(pick_which_)] = fs_path_; picking_ = false; return; } @@ -608,7 +732,10 @@ void SetupPage::draw_picker(hui::gfx::DrawList &list, const hui::ui::Fonts &font { static const char *const kWhat[] = {"games", "saves", "save states"}; const auto it = own_.find(pick_key_); - const std::string name = it != own_.end() ? it->second.name : pick_key_; + std::string name = it != own_.end() ? it->second.name : system_name(pick_key_); + for (const Emulator &e : emulators_) + if (!pick_root_.empty() && e.root == pick_root_) + name = system_name(pick_key_) + " in " + e.name; char title[160]; std::snprintf(title, sizeof title, "Choose the %s folder", kWhat[pick_which_]); draw_heading(list, fonts, look, title, @@ -629,11 +756,18 @@ void SetupPage::draw_picker(hui::gfx::DrawList &list, const hui::ui::Fonts &font std::vector SetupPage::emulator_rows(const App &app, std::vector *refs) const { std::vector rows; + // Save sync off: only the games folders matter. In the first setup it's on until the question is answered. + const bool saves = editing_ ? app.config().save_sync : save_sync_; auto add = [&](Row row, RowRef ref) { rows.push_back(std::move(row)); if (refs) refs->push_back(std::move(ref)); }; + auto shorten = [](std::string value) { + if (value.size() > 34) + value = "\xE2\x80\xA6" + value.substr(value.size() - 33); + return value; + }; // Something set up by its folders: a switch, then its three folders when on. auto add_own = [&](const std::string &key, const std::string &detail) { const auto it = own_.find(key); @@ -646,9 +780,7 @@ std::vector SetupPage::emulator_rows(const App &app, std::vector *r static const char *const kLabels[] = {" Games folder", " Saves folder", " States folder"}; for (int k = 0; k < 3; ++k) { - std::string value = o.folder[k]; - if (value.size() > 34) - value = "\xE2\x80\xA6" + value.substr(value.size() - 33); + std::string value = shorten(o.folder[k]); if (value.empty()) value = k == 0 ? "Choose" : "Same as games"; add({RowKind::action, kLabels[k], value, ""}, {RowRef::kOwnFolder, k, key}); @@ -663,78 +795,124 @@ std::vector SetupPage::emulator_rows(const App &app, std::vector *r add({RowKind::info, "Couldn't look for emulators", "", error_}, {}); else if (emulators_.empty()) add({RowKind::info, "No emulators found", "", - "Continue with RetroArch's usual folders. You can change them later."}, + "RetroArch's usual folders. You can change them later."}, {}); - for (std::size_t i = 0; i < emulators_.size(); ++i) - { - const Emulator &e = emulators_[i]; - if (!e.ready) - { - add({RowKind::info, e.name, "No preset yet", e.note + " If yours works, turn it on below and pick its folders."}, - {RowRef::kEmulator, static_cast(i)}); - for (const System &sy : e.systems) - add_own(e.id + "-" + sy.key, "Turn on to pick its folders"); - continue; - } - std::string detail = e.kind == "retroarch" ? "RetroArch" : e.kind == "mednafen" ? "Mednafen" : "Standalone"; - if (e.fresh) - detail = "New \xC2\xB7 " + detail; - std::vector names; - for (const System &sy : e.systems) - { - const std::string n = system_name(sy.key); - if (std::find(names.begin(), names.end(), n) == names.end()) - names.push_back(n); - } - char more[48] = ""; - if (names.size() > 3) - std::snprintf(more, sizeof more, " and %d more", static_cast(names.size() - 3)); - if (!names.empty()) - { - detail += " \xC2\xB7 "; - for (std::size_t k = 0; k < names.size() && k < 3; ++k) - detail += (k ? ", " : "") + names[k]; - detail += more; - } - add({RowKind::toggle, e.name, "", detail, e.on}, {RowRef::kEmulator, static_cast(i)}); - } - for (const Conflict &c : conflicts()) + // Every platform an emulator here plays: the one playing it (or off). + // Cross shows its details: the others that also play it, its save sync + // and its folders. + const std::vector all_systems = systems(); + if (!all_systems.empty()) + add({RowKind::header, "On this console"}, {}); + static const char *const kSyncLabel[] = {"Saves and states", "Saves", "Off"}; + auto sync_index = [&](const std::string &key) { + const auto it = sync_.find(key); + const std::string mode = it == sync_.end() ? "all" : it->second; + return mode == "off" ? 2 : mode == "saves" ? 1 : 0; + }; + for (const Conflict &c : all_systems) { const std::string main = chosen(c); - std::string label; + std::string label = "Off", also_names; for (const Option &o : c.options) + { if (o.root == main) label = o.label; - add({RowKind::choice, system_name(c.key), label, "Games download here first"}, - {RowRef::kMain, -1, c.key}); + const auto set = also_.find(c.key); + if (!main.empty() && o.root != main && set != also_.end() && set->second.count(o.root)) + also_names += (also_names.empty() ? "Also in " : ", ") + o.label; + } + const bool open = expanded_ == c.key && !main.empty(); + std::string detail; + if (!open && !main.empty()) + { + detail = also_names; + if (saves && sync_index(c.key)) + detail += std::string(detail.empty() ? "" : " \xC2\xB7 ") + "Sync: " + kSyncLabel[sync_index(c.key)]; + } + Row platform{RowKind::choice, system_name(c.key), label, detail}; + platform.expander = main.empty() ? 0 : open ? 2 : 1; // off: nothing to open + add(platform, {RowRef::kMain, -1, c.key}); + if (!open) + continue; + std::vector on = {}; + for (const Option &o : c.options) + if (o.root == main) + on.push_back(&o); // The others can play it too: the games linked in, one save where they keep the same kind. for (const Option &o : c.options) { if (o.root == main) continue; const auto set = also_.find(c.key); - const bool on = set != also_.end() && set->second.count(o.root); - add({RowKind::toggle, " Also in " + o.label, "", "Gets the same games too", on}, - {RowRef::kAlso, -1, c.key, o.root}); + const bool also = set != also_.end() && set->second.count(o.root); + add({RowKind::toggle, " Also in " + o.label, "", "", also}, {RowRef::kAlso, -1, c.key, o.root}); + if (also) + on.push_back(&o); + } + // Its folders in each emulator that plays it: the preset's, or picked. + for (const Option *o : on) + { + const Emulator *em = nullptr; + for (const Emulator &e : emulators_) + if (e.root == o->root) + em = &e; + if (!em) + continue; + const std::string who = on.size() > 1 ? " Folders in " + em->name : std::string(" Folders"); + if (em->fixed.size() >= 3) + { + add({RowKind::info, who, "Set by the emulator", ""}, {}); + continue; + } + const auto found = em->dirs.find(c.key); + const auto sys = custom_dirs_.find(c.key); + const bool custom = sys != custom_dirs_.end() && sys->second.count(o->root); + std::string folder_detail = found != em->dirs.end() && !found->second[0].empty() ? " Games in " + found->second[0] : ""; + add({RowKind::choice, who, custom ? "Manual" : "Preset", custom ? " Pick each folder below" : folder_detail}, + {RowRef::kFolders, -1, c.key, o->root}); + if (!custom) + continue; + static const char *const kLabels[] = {" Games folder", " Saves folder", " States folder"}; + static const char *const kKeys[] = {"rom_dir", "save_dir", "state_dir"}; + for (int k = 0; k < (saves ? 3 : 1); ++k) + { + const std::string picked = sys->second.at(o->root)[static_cast(k)]; + const std::string def = found != em->dirs.end() ? found->second[static_cast(k)] : ""; + if (em->fixed.count(kKeys[k])) + { + add({RowKind::info, kLabels[k], "Set by the emulator", shorten(def)}, {}); + continue; + } + add({RowKind::action, kLabels[k], picked.empty() ? "Preset" : shorten(picked), picked.empty() ? shorten(def) : ""}, + {RowRef::kSysFolder, k, c.key, o->root}); + } + } + // Then what's this platform's own: its save sync, PS2's memory cards. + if (saves) + add({RowKind::choice, " Sync", kSyncLabel[sync_index(c.key)], ""}, {RowRef::kSysSync, -1, c.key}); + if (saves && c.key == "ps2" && has_ps2()) + { + const bool backup_ok = at_least(app.status().server_version, 5, 3, 0); + Row r{RowKind::choice, " Memory cards", ps2_cards_ == "backup" ? "One card, backed up" : "One card per game", + backup_ok ? " How RetroArch's PS2 core keeps its saves" : " Your RomM is older than 5.3: one card per game"}; + r.enabled = backup_ok; + add(r, {RowRef::kPs2}); } } - // Systems on the server no emulator here plays: off, or folders picked for them. - for (const std::string &key : uncovered()) + // Set up by hand: emulators found with no preset yet, and the systems on + // RomM no emulator here plays. On, then their folders. + const std::vector left = uncovered(); + if (!left.empty() || !manual_.empty()) + add({RowKind::header, "Set up manually"}, {}); + for (const std::string &key : manual_) + add_own(key, "No preset for this emulator yet"); + for (const std::string &key : left) { const Own &o = own_.at(key); char detail[96]; - std::snprintf(detail, sizeof detail, "%d game%s \xC2\xB7 no emulator found. Turn on to pick its folders", - o.games, o.games == 1 ? "" : "s"); + std::snprintf(detail, sizeof detail, "%d game%s \xC2\xB7 no emulator found", o.games, o.games == 1 ? "" : "s"); add_own(key, detail); } - if (has_ps2()) - { - const bool backup_ok = at_least(app.status().server_version, 5, 3, 0); - Row r{RowKind::choice, "PS2 memory cards", ps2_cards_ == "backup" ? "One card, backed up" : "One card per game", - backup_ok ? "How RetroArch's PS2 core keeps its saves" : "Your RomM is older than 5.3: one card per game"}; - r.enabled = backup_ok; - add(r, {RowRef::kPs2}); - } Row next{RowKind::action, editing_ ? "Save" : "Continue", "", ""}; next.enabled = !busy_; add(next, {RowRef::kContinue}); @@ -743,18 +921,60 @@ std::vector SetupPage::emulator_rows(const App &app, std::vector *r void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback) { + if (emulators_loaded_ && !busy_ && !error_.empty() && (retry_in_ -= dt) <= 0.0f) + emulators_loaded_ = false; // look again; the error stays up until it works if (!emulators_loaded_ && !busy_) + { + retry_in_ = 3.0f; load_emulators(app); + } + if (leaving_) + { + const std::vector choices = leave_rows(); + const RowEvent pick = leave_list_.handle(choices, input, feedback); + leave_list_.update(dt, static_cast(choices.size()), 3 * RowList::kRowHeight); + if (input.is_pressed(Action::back) || pick.row == 2) + { + feedback.play(Cue::back); + leaving_ = false; // keep editing + } + else if (pick.row == 0) + { + leaving_ = false; + save_emulators(app, feedback); // back to Settings once saved + } + else if (pick.row == 1) + { + feedback.play(Cue::back); + leaving_ = false; + editing_ = false; // discarded + } + return; + } std::vector refs; const std::vector rows = emulator_rows(app, &refs); const RowEvent e = rows_.handle(rows, input, feedback); rows_.update(dt, static_cast(rows.size()), kRows.h); - if (editing_ && input.is_pressed(Action::back)) + const int at = rows_.focus(); + focus_expander_ = at >= 0 && at < static_cast(rows.size()) ? rows[static_cast(at)].expander : 0; + if (editing_ && input.is_pressed(Action::back) && !busy_) { + if (dirty()) + { + feedback.play(Cue::modal_open); + leaving_ = true; + leave_list_.set_focus(0); + return; + } feedback.play(Cue::back); editing_ = false; // back to Settings, nothing changed return; } + if (editing_ && input.is_pressed(Action::north) && dirty()) + { + save_emulators(app, feedback); + return; + } if (e.row < 0 || !emulators_loaded_ || e.row >= static_cast(refs.size())) return; const RowRef &ref = refs[static_cast(e.row)]; @@ -768,22 +988,58 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d em.on = !em.on; return; } + case RowRef::kEmuSync: + { + static const char *const kModes[] = {"all", "saves", "off"}; + Emulator &em = emulators_[static_cast(ref.emulator)]; + int at = 0; + for (int i = 0; i < 3; ++i) + if (em.sync == kModes[i]) + at = i; + em.sync = kModes[((at + e.step) % 3 + 3) % 3]; + return; + } case RowRef::kMain: - for (const Conflict &c : list) + // Cross: its details; left and right: the emulator that plays it, or off. + if (input.is_pressed(Action::confirm)) + { + expanded_ = expanded_ == ref.key ? "" : ref.key; + return; + } + for (const Conflict &c : systems()) { if (c.key != ref.key) continue; + std::vector roots; + for (const Option &o : c.options) + roots.push_back(o.root); + roots.push_back(""); // off const std::string current = chosen(c); int at = 0; - for (int i = 0; i < static_cast(c.options.size()); ++i) - if (c.options[static_cast(i)].root == current) + for (int i = 0; i < static_cast(roots.size()); ++i) + if (roots[static_cast(i)] == current) at = i; - const int n = static_cast(c.options.size()); - const std::string next = c.options[static_cast(((at + e.step) % n + n) % n)].root; + const int n = static_cast(roots.size()); + const std::string next = roots[static_cast(((at + e.step) % n + n) % n)]; choices_[c.key] = next; - also_[c.key].erase(next); // the main one isn't also an extra + if (next.empty()) + also_[c.key].clear(); // off everywhere + else + also_[c.key].erase(next); // the main one isn't also an extra } return; + case RowRef::kSysSync: + { + static const char *const kModes[] = {"all", "saves", "off"}; + const auto it = sync_.find(ref.key); + const std::string now = it == sync_.end() ? "all" : it->second; + int at = 0; + for (int i = 0; i < 3; ++i) + if (now == kModes[i]) + at = i; + sync_[ref.key] = kModes[((at + e.step) % 3 + 3) % 3]; + return; + } case RowRef::kAlso: { auto &set = also_[ref.key]; @@ -802,28 +1058,37 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d case RowRef::kOwnFolder: open_picker(app, ref.key, ref.emulator); return; + case RowRef::kFolders: + { + auto &sys = custom_dirs_[ref.key]; + if (sys.count(ref.root)) + sys.erase(ref.root); // back to the folders found + else + sys[ref.root] = {}; + return; + } + case RowRef::kSysFolder: + open_picker(app, ref.key, ref.emulator, ref.root); + return; case RowRef::kContinue: - break; + save_emulators(app, feedback); + return; default: return; } - // Everything set up by its folders: the systems no emulator plays, and the - // systems of emulators with no preset. +} + +// What Save sends: the profiles for the emulators kept (the PS2 cards go +// apart). Also what tells a change from what was saved. +std::string SetupPage::save_body() const +{ + const std::vector list = conflicts(); std::vector by_folders = uncovered(); by_folders.insert(by_folders.end(), manual_.begin(), manual_.end()); - // Each one turned on needs its games folder. - for (const std::string &key : by_folders) - if (own_[key].on && own_[key].folder[0].empty()) - { - app.say("Choose a games folder for " + own_[key].name + ", or turn it off"); - rows_.refuse(feedback); - return; - } - // Continue: the profiles for the emulators kept, then the PS2 cards. Json body(cJSON_CreateObject(), cJSON_Delete); cJSON *roots = cJSON_AddArrayToObject(body.get(), "roots"); for (const Emulator &em : emulators_) - if (em.on && em.ready) + if (used(em)) cJSON_AddItemToArray(roots, cJSON_CreateString(em.root.c_str())); cJSON *choices = cJSON_AddObjectToObject(body.get(), "choices"); cJSON *also = cJSON_AddObjectToObject(body.get(), "also"); @@ -837,10 +1102,40 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d if (o.root != main && set != also_.end() && set->second.count(o.root)) cJSON_AddItemToArray(extras, cJSON_CreateString(o.root.c_str())); } + // Each system: its main emulator, the ones on, and folders picked (the + // payload's "systems", which wins over "choices" and "also"). + cJSON *systems_json = cJSON_AddObjectToObject(body.get(), "systems"); + for (const Conflict &c : systems()) + { + const std::string main = chosen(c); + cJSON *sy = cJSON_AddObjectToObject(systems_json, c.key.c_str()); + cJSON_AddStringToObject(sy, "main", main.c_str()); + const auto mode = sync_.find(c.key); + cJSON_AddStringToObject(sy, "sync", mode != sync_.end() ? mode->second.c_str() : "all"); + cJSON *on = cJSON_AddArrayToObject(sy, "on"); + cJSON *folders = cJSON_AddObjectToObject(sy, "folders"); + if (main.empty()) + continue; // off: every emulator leaves it out + const auto set = also_.find(c.key); + const auto picked = custom_dirs_.find(c.key); + for (const Option &o : c.options) + { + if (o.root != main && (set == also_.end() || !set->second.count(o.root))) + continue; + cJSON_AddItemToArray(on, cJSON_CreateString(o.root.c_str())); + if (picked == custom_dirs_.end() || !picked->second.count(o.root)) + continue; + const std::array &d = picked->second.at(o.root); + cJSON *f = cJSON_AddObjectToObject(folders, o.root.c_str()); + cJSON_AddStringToObject(f, "rom_dir", d[0].c_str()); + cJSON_AddStringToObject(f, "save_dir", d[1].c_str()); + cJSON_AddStringToObject(f, "state_dir", d[2].c_str()); + } + } cJSON *custom = cJSON_AddArrayToObject(body.get(), "custom"); for (const std::string &key : by_folders) { - const Own &o = own_[key]; + const Own &o = own_.at(key); if (!o.on) continue; cJSON *c = cJSON_CreateObject(), *slugs = cJSON_AddArrayToObject(c, "slugs"); @@ -854,10 +1149,41 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d cJSON_AddStringToObject(c, "id", o.id.c_str()); cJSON_AddItemToArray(custom, c); } + return print(body.get()); +} + +std::vector SetupPage::leave_rows() const +{ + return {{RowKind::action, "Save changes", "", ""}, + {RowKind::action, "Discard changes", "", ""}, + {RowKind::action, "Keep editing", "", ""}}; +} + +bool SetupPage::dirty() const +{ + return editing_ && emulators_loaded_ && !saved_.empty() && save_body() + ps2_cards_ != saved_; +} + +void SetupPage::save_emulators(App &app, hui::ui::Feedback &feedback) +{ + if (busy_) + return; + // Everything set up by its folders: the systems no emulator plays, and the + // systems of emulators with no preset. + std::vector by_folders = uncovered(); + by_folders.insert(by_folders.end(), manual_.begin(), manual_.end()); + // Each one turned on needs its games folder. + for (const std::string &key : by_folders) + if (own_[key].on && own_[key].folder[0].empty()) + { + app.say("Choose a games folder for " + own_[key].name + ", or turn it off"); + rows_.refuse(feedback); + return; + } busy_ = true; const std::string ps2 = "{\"ps2_cards\":\"" + (has_ps2() ? ps2_cards_ : std::string("per_game")) + "\"}"; App *a = &app; - app.post("/api/setup/emulators", print(body.get()), [this, a, ps2](const Response &r) { + app.post("/api/setup/emulators", save_body(), [this, a, ps2](const Response &r) { if (!r.ok()) { busy_ = false; @@ -867,6 +1193,7 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d a->post("/api/config", ps2, [this, a](const Response &) { if (editing_) { + saved_.clear(); editing_ = false; busy_ = false; a->say("Emulators saved"); @@ -884,6 +1211,7 @@ void SetupPage::update_emulators(App &app, const hui::InputFrame &input, float d void SetupPage::load_preview(App &app) { preview_.loading = true; + // A first setup matches every game here with RomM first: it can take a while. app.get("/api/setup/preview", [this](const Response &r) { preview_.loading = false; preview_.loaded = true; @@ -900,35 +1228,52 @@ void SetupPage::load_preview(App &app) preview_.down = count(j.get(), "downloads"); preview_.both = count(j.get(), "conflicts"); preview_.in_sync = count(j.get(), "in_sync"); - }); + }, 120000); } -std::vector SetupPage::preview_rows() const +// Preview rows: 0 save sync, 1 backed up, 2 continue, 3 check again. +std::vector SetupPage::preview_rows(std::vector *ids) const { std::vector rows; - rows.push_back({RowKind::toggle, "My saves are backed up", "", - "The first sync can replace saves here or on RomM. Keep a copy of any that matter", backed_up_}); + auto add = [&](Row r, int id) { + rows.push_back(std::move(r)); + if (ids) + ids->push_back(id); + }; + add({RowKind::toggle, "Sync saves with RomM", "", save_sync_ ? "Saves and states" : "Off: RomM is only for games", + save_sync_}, + 0); + if (!save_sync_) + { + add({RowKind::action, "Continue", "", ""}, 2); + return rows; + } + add({RowKind::toggle, "My saves are backed up", "", "The first sync can replace saves", backed_up_}, 1); Row next{RowKind::action, "Continue", "", backed_up_ ? "" : "Once your saves are backed up"}; next.enabled = backed_up_; - rows.push_back(next); + add(next, 2); Row again{RowKind::action, "Check again", "", ""}; again.enabled = !preview_.loading; - rows.push_back(again); + add(again, 3); return rows; } void SetupPage::update_preview(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback) { - if (!preview_.loaded && !preview_.loading) + if (save_sync_ && !preview_.loaded && !preview_.loading) load_preview(app); - const std::vector rows = preview_rows(); + std::vector ids; + const std::vector rows = preview_rows(&ids); const RowEvent e = rows_.handle(rows, input, feedback); rows_.update(dt, static_cast(rows.size()), kRows.h); - if (e.row == 0) + const int id = e.row >= 0 ? ids[static_cast(e.row)] : -1; + if (id == 0) + save_sync_ = !save_sync_; + else if (id == 1) backed_up_ = !backed_up_; - else if (e.row == 1) + else if (id == 2) go(kFinish); - else if (e.row == 2) + else if (id == 3) preview_ = Preview{}; if (input.is_pressed(Action::back)) { @@ -957,24 +1302,32 @@ void SetupPage::load_autostart(App &app) }); } -std::vector SetupPage::finish_rows() const +// Finish rows: 0 policy, 1 on change, 2 on exit, 3 on start, 4 notify, +// 5 interval, 6 autostart, 7 finish. The sync ones only with save sync on. +std::vector SetupPage::finish_rows(std::vector *ids) const { const Config &c = finish_; std::vector rows; - rows.push_back({RowKind::choice, "A save changed in both places", label_of(kPolicies, c.conflict_policy), - "On this console and on RomM since the last sync"}); - rows.push_back({RowKind::toggle, "Upload saves as they change", "", "While you play", c.sync_on_change}); - rows.push_back({RowKind::toggle, "Sync when a game closes", "", "Uploads what the game saved", c.sync_on_game_exit}); - rows.push_back({RowKind::toggle, "Sync when a game starts", "", "Fetches newer saves before you play", - c.sync_on_game_start}); - rows.push_back({RowKind::toggle, "Notifications", "", "On the console when a sync ends or fails", c.notify}); - rows.push_back({RowKind::choice, "Sync every", interval_label(c.sync_interval_min), "Besides the syncs above"}); + auto add = [&](Row r, int id) { + rows.push_back(std::move(r)); + if (ids) + ids->push_back(id); + }; + if (save_sync_) + { + add({RowKind::choice, "A save changed in both places", label_of(kPolicies, c.conflict_policy), ""}, 0); + add({RowKind::toggle, "Upload saves as they change", "", "While you play", c.sync_on_change}, 1); + add({RowKind::toggle, "Sync when a game closes", "", "", c.sync_on_game_exit}, 2); + add({RowKind::toggle, "Sync when a game starts", "", "", c.sync_on_game_start}, 3); + } + add({RowKind::toggle, "Notifications", "", "", c.notify}, 4); + if (save_sync_) + add({RowKind::choice, "Sync every", interval_label(c.sync_interval_min), ""}, 5); if (autostart_supported_) - rows.push_back({RowKind::toggle, "Start RomM Sync with the console", "", "With your HEN's payload autostart", - autostart_on_}); - Row done{RowKind::action, "Finish and sync", "", ""}; + add({RowKind::toggle, "Start RommPS with the console", "", "With your HEN's autostart", autostart_on_}, 6); + Row done{RowKind::action, save_sync_ ? "Finish and sync" : "Finish", "", ""}; done.enabled = !busy_; - rows.push_back(done); + add(done, 7); return rows; } @@ -986,7 +1339,8 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt, finish_known_ = true; load_autostart(app); } - const std::vector rows = finish_rows(); + std::vector ids; + const std::vector rows = finish_rows(&ids); const RowEvent e = rows_.handle(rows, input, feedback); rows_.update(dt, static_cast(rows.size()), kRows.h); if (input.is_pressed(Action::back)) @@ -996,7 +1350,7 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt, return; } const bool autostart_row = autostart_supported_; - switch (e.row) + switch (e.row >= 0 ? ids[static_cast(e.row)] : -1) { case 0: finish_.conflict_policy = step_option(kPolicies, finish_.conflict_policy, e.step); @@ -1041,6 +1395,7 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt, if (e.row < 0) break; Json body(cJSON_CreateObject(), cJSON_Delete); + cJSON_AddBoolToObject(body.get(), "save_sync", save_sync_); cJSON_AddStringToObject(body.get(), "conflict_policy", finish_.conflict_policy.c_str()); cJSON_AddBoolToObject(body.get(), "sync_on_change", finish_.sync_on_change); cJSON_AddBoolToObject(body.get(), "sync_on_game_exit", finish_.sync_on_game_exit); @@ -1056,7 +1411,7 @@ void SetupPage::update_finish(App &app, const hui::InputFrame &input, float dt, a->say("Couldn't finish the setup: " + r.message()); return; } - a->say("All set. The first sync has started"); + a->say(save_sync_ ? "All set. Let the first sync finish before you play" : "All set"); a->refresh_config(); a->refresh_now(); }); @@ -1075,15 +1430,23 @@ std::vector SetupPage::hints() const switch (step_) { case kServer: - return {{Button::square, "Delete"}, {Button::triangle, "Certificate check"}, {Button::r2, "Connect"}}; + return {{Button::square, "Delete"}, {Button::triangle, tls_verify_ ? "Don't verify TLS certificate" : "Verify TLS certificate"}, {Button::r2, "Connect"}}; case kApprove: return {{Button::circle, "Use another server"}}; case kEmulators: if (picking_) return {{Button::cross, "Choose"}, {Button::circle, "Back"}}; - if (editing_) - return {{Button::cross, "Choose"}, {Button::circle, "Back"}}; - return {{Button::cross, "Choose"}}; + if (leaving_) + return {{Button::cross, "Choose"}, {Button::circle, "Keep editing"}}; + { + // On a platform: Cross opens or closes it. + const char *cross = focus_expander_ == 1 ? "Expand" : focus_expander_ == 2 ? "Collapse" : "Choose"; + if (editing_ && dirty()) + return {{Button::triangle, "Save"}, {Button::cross, cross}, {Button::circle, "Back"}}; + if (editing_) + return {{Button::cross, cross}, {Button::circle, "Back"}}; + return {{Button::cross, cross}}; + } default: return {{Button::cross, "Choose"}, {Button::circle, "Back"}}; } @@ -1148,7 +1511,7 @@ void SetupPage::draw_server(hui::gfx::DrawList &list, const hui::ui::Fonts &font { ui::draw_button(list, fonts, look.glyphs(), ui::Button::triangle, kField.x + 4, y - 9, 30); ui::text(list, fonts.regular, - tls_verify_ ? "Checking the server's certificate" : "Not checking the certificate (self-signed servers)", + tls_verify_ ? "Verify TLS certificate" : "Don't verify TLS certificate", kField.x + 46, y, 22, look.muted); } (void)app; @@ -1166,7 +1529,7 @@ void SetupPage::draw_approve(hui::gfx::DrawList &list, const hui::ui::Fonts &fon "On your phone or computer, open the address below, sign in to RomM and enter the code."); const float in = tween::stagger(step_age_, 2, 0.08f, 0.6f); list.push_opacity(in); - const Rect panel{360, 380 + 24 * (1.0f - in), 1200, 470}; + const Rect panel{260, 380 + 24 * (1.0f - in), 1400, 470}; draw_panel(list, look, panel); // The address as a QR code too, for a phone's camera. static std::string qr_for; @@ -1188,7 +1551,13 @@ void SetupPage::draw_approve(hui::gfx::DrawList &list, const hui::ui::Fonts &fon code += c, code += ' '; if (!code.empty()) code.pop_back(); - ui::text(list, fonts.display, code, cx, panel.y + 270, has_qr ? 100 : 120, look.panel_text, Align::center, 6.0f); + // As big as fits beside the QR code, with room to spare at the edges. + const float room = (has_qr ? panel.w - 360 : panel.w) - 120; + float size = has_qr ? 100.0f : 120.0f; + const float wide = fonts.display.measure(code, size, 6.0f); + if (wide > room) + size *= room / wide; + ui::text(list, fonts.display, code, cx, panel.y + 270, size, look.panel_text, Align::center, 6.0f); const float pulse = 0.5f + 0.5f * std::sin(clock * 3.0f); list.circle(cx - 150, panel.y + 382, 7, look.accent.with_alpha(0.4f + 0.6f * pulse)); ui::text(list, fonts.regular, "Waiting for approval", cx - 128, panel.y + 390, 26, look.panel_muted); @@ -1222,45 +1591,17 @@ void SetupPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, cons if (step_ == kEmulators) { draw_heading(list, fonts, look, editing_ ? "Emulators" : "Your emulators", - "Saves sync for the emulators you leave on, and games download to their folders."); - rows = emulator_rows(app); - // Beside the list: the focused emulator's systems. - const int focus = rows_.focus() - lead(); - if (emulators_loaded_ && focus >= 0 && focus < static_cast(emulators_.size())) - { - const Emulator &e = emulators_[static_cast(focus)]; - list.push_opacity(in); - draw_panel(list, look, kSide); - ui::text(list, fonts.semibold, "SYSTEMS", kSide.x + 36, kSide.y + 56, 16, look.panel_muted, Align::left, 3.0f); - float y = kSide.y + 100; - std::vector seen; - for (const System &s : e.systems) - { - const std::string n = system_name(s.key); - if (std::find(seen.begin(), seen.end(), n) != seen.end()) - continue; - seen.push_back(n); - if (y > kSide.y + kSide.h - 30) - { - ui::text(list, fonts.regular, "\xE2\x80\xA6", kSide.x + 36, y, 22, look.panel_muted); - break; - } - ui::text(list, fonts.regular, fonts.regular.font->fit(n, 22, kSide.w - 72), kSide.x + 36, y, 22, - look.panel_text); - y += 34; - } - if (e.systems.empty()) - ui::text(list, fonts.regular, "None that RomM Sync knows of", kSide.x + 36, y, 22, look.panel_muted); - list.pop_opacity(); - } + dirty() ? "Unsaved changes. Press Triangle to save them." : "Choose the emulator for each platform."); + std::vector refs; + rows = emulator_rows(app, &refs); } else if (step_ == kPreview) { - draw_heading(list, fonts, look, "Before the first sync", - preview_.loading ? "Comparing the saves on this console with RomM's" - : preview_.error.empty() - ? "What the first sync will do. Nothing has changed yet." - : "Couldn't compare: " + preview_.error); + draw_heading(list, fonts, look, save_sync_ ? "Before the first sync" : "Games only", + !save_sync_ ? "Turn save sync on any time in Settings." + : preview_.loading ? "Comparing your saves with RomM's" + : preview_.error.empty() ? "What the first sync will do." + : "Couldn't compare: " + preview_.error); rows = preview_rows(); struct Tile { @@ -1270,17 +1611,31 @@ void SetupPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, cons } tiles[] = {{"GAMES HERE", preview_.games, false}, {"SAVES HERE", preview_.saves, false}, {"TO UPLOAD", preview_.up, false}, {"TO DOWNLOAD", preview_.down, false}, {"CHANGED ON BOTH", preview_.both, true}, {"IN SYNC", preview_.in_sync, false}}; - for (int i = 0; i < 6; ++i) + for (int i = 0; i < 6 && save_sync_; ++i) { const float appear = tween::stagger(step_age_, 2 + i, 0.06f, 0.5f); const Rect r{kMargin + static_cast(i) * 292, 372 + 20 * (1.0f - appear), 270, 150}; list.push_opacity(appear); draw_panel(list, look, r, 22); ui::text(list, fonts.semibold, tiles[i].label, r.x + 26, r.y + 46, 15, look.panel_muted, Align::left, 2.5f); - char value[16]; - std::snprintf(value, sizeof value, preview_.loaded && preview_.error.empty() ? "%d" : "-", tiles[i].value); - ui::text(list, fonts.semibold, value, r.x + 26, r.y + 114, 48, - tiles[i].warn && tiles[i].value ? look.warning : look.panel_text); + if (!preview_.loaded && save_sync_) + { + // Still comparing: a spinner, eight dots fading round. + const float cx = r.x + 46, cy = r.y + 98; + for (int k = 0; k < 8; ++k) + { + const float a = 6.2831853f * static_cast(k) / 8.0f; + const float phase = std::fmod(clock * 1.4f - static_cast(k) / 8.0f + 1.0f, 1.0f); + list.circle(cx + 18 * std::cos(a), cy + 18 * std::sin(a), 4.0f, look.panel_text.with_alpha(0.15f + 0.85f * phase)); + } + } + else + { + char value[16]; + std::snprintf(value, sizeof value, preview_.loaded && preview_.error.empty() ? "%d" : "-", tiles[i].value); + ui::text(list, fonts.semibold, value, r.x + 26, r.y + 114, 48, + tiles[i].warn && tiles[i].value ? look.warning : look.panel_text); + } list.pop_opacity(); } list.push_opacity(in); @@ -1294,8 +1649,23 @@ void SetupPage::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, cons rows = finish_rows(); } list.push_opacity(in); - rows_.draw(list, fonts, look, rows, {kRows.x, kRows.y + 24 * (1.0f - in), kRows.w, kRows.h}, clock); + // The emulators' list takes the full width: no panel beside it. + const float width = step_ == kEmulators ? 1920.0f - 2 * kRows.x : kRows.w; + rows_.draw(list, fonts, look, rows, {kRows.x, kRows.y + 24 * (1.0f - in), width, kRows.h}, clock); list.pop_opacity(); + if (step_ == kEmulators && leaving_) + { + // Over everything: save before going back? + list.rounded_rect({0, 0, 1920, 1080}, 0, hui::gfx::Color::rgb(0x000000, 0.55f)); + const Rect box{560, 330, 800, 470}; + hui::gfx::Color solid = look.page; // the page's colour under the panel, so nothing shows through + solid.a = 1.0f; + list.rounded_rect(box, look.radius, solid); + draw_panel(list, look, box); + ui::text(list, fonts.display, "Save your changes?", box.x + 48, box.y + 84, 40, look.panel_text); + leave_list_.draw(list, fonts, look, leave_rows(), {box.x + 16, box.y + 130, box.w - 32, 3 * RowList::kRowHeight}, + clock); + } } } // namespace rommps diff --git a/platform/ps5/app/rommps/examples/rommps/kit/setup_page.hpp b/platform/ps5/app/rommps/examples/rommps/kit/setup_page.hpp index 394f443..2a7fac4 100644 --- a/platform/ps5/app/rommps/examples/rommps/kit/setup_page.hpp +++ b/platform/ps5/app/rommps/examples/rommps/kit/setup_page.hpp @@ -2,8 +2,9 @@ // // 1. the RomM server's address, typed on the kit's on-screen keyboard; // 2. the code to approve in RomM (its device pairing), until it's approved; -// 3. the emulators found on the console, and which one plays a system -// several of them can; +// 3. the emulators found on the console, then each system they play: which +// one plays it when several can, and its folders (the ones found, or +// folders the user picks); // 4. what the first sync would do, and a word about backups; // 5. when to sync, then the first sync. // @@ -17,6 +18,7 @@ #include "rows.hpp" #include "ui/components/keyboard.hpp" +#include #include #include #include @@ -72,9 +74,14 @@ class SetupPage std::vector cores; std::vector systems; bool on = true; + std::string sync = "all"; // its save sync: "all", "saves" (no states) or "off" bool ready = true; // false: found, no preset yet (set up by its folders, like a system with no emulator) - bool fresh = false; // found since the last setup std::string note; + // Its folders for each system when left as they are (detection's + // "dirs"): games, saves, states; and which it doesn't let you change. + std::map> dirs; + std::map games; // games already in its folder, by system + std::set fixed; // "rom_dir", "save_dir", "state_dir" }; // What a row of the emulator step does. struct RowRef @@ -83,11 +90,15 @@ class SetupPage { kNone, kEmulator, + kEmuSync, // an emulator's save sync (unused since save sync is per platform) + kSysSync, // a platform's save sync kMain, kAlso, kPs2, kOwn, kOwnFolder, + kFolders, // a system's folders in an emulator: as found, or picked + kSysFolder, // one of them (emulator: 0 games, 1 saves, 2 states) kContinue } kind = kNone; int emulator = -1; // or, for kOwnFolder, which folder (0 games, 1 saves, 2 states) @@ -119,19 +130,31 @@ class SetupPage void load_emulators(App &app); void load_preview(App &app); void load_autostart(App &app); - std::vector conflicts() const; - // The root of the emulator that keeps a system: the one picked, else RetroArch, else the first. + std::vector conflicts() const; // systems several emulators on play + std::vector systems() const; // every system an emulator on plays, by name + // The root of the emulator that keeps a system: the one picked ("" when + // it's off), else RetroArch, else the first. std::string chosen(const Conflict &conflict) const; - // Rows above the emulators' (a message instead of them). - int lead() const { return error_.empty() && !emulators_.empty() ? 0 : 1; } + // Whether an emulator plays a system, as the main one or an extra. + bool used(const Emulator &e) const; + // Rows above the emulators': their heading, or a message instead of them. + int lead() const { return 1; } std::vector emulator_rows(const App &app, std::vector *refs = nullptr) const; - std::vector preview_rows() const; - std::vector finish_rows() const; + // Settings' Emulators: what Save sends, whether it differs from what was + // saved, and saving it. + std::string save_body() const; + bool dirty() const; + void save_emulators(App &app, hui::ui::Feedback &feedback); + // Leaving with changes not saved: Save, Discard or Keep editing. + std::vector leave_rows() const; + // ids: what each row is (the steps' rows depend on save_sync_). + std::vector preview_rows(std::vector *ids = nullptr) const; + std::vector finish_rows(std::vector *ids = nullptr) const; bool has_ps2() const; // The server's systems no ticked emulator plays, in the server's order. std::vector uncovered() const; // The folder picker (/api/fs), for one of a system's folders. - void open_picker(App &app, const std::string &key, int which); + void open_picker(App &app, const std::string &key, int which, const std::string &root = ""); void browse(App &app, const std::string &path); void update_picker(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback); void draw_picker(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, float clock) const; @@ -159,15 +182,26 @@ class SetupPage // 3 std::vector emulators_; bool emulators_loaded_ = false; - std::map choices_; // system key -> the root of its main emulator + float retry_in_ = 0.0f; // after an error: looking again (the service may have been restarting) + std::map choices_; // system key -> the root of its main emulator ("": off) + std::map sync_; // system key -> its save sync: "all", "saves" or "off" + std::string expanded_; // the system whose details show + int focus_expander_ = 0; // the focused row's chevron (for the hint): 0, 1 closed, 2 open std::map> also_; // system key -> roots of its extra emulators + // Folders picked for a system in an emulator, instead of the ones found: + // system key -> emulator root -> games, saves, states ("" keeps that one). + std::map>> custom_dirs_; bool editing_ = false; + std::string saved_; // save_body() + ps2_cards_ as last saved (Settings' Emulators) + bool leaving_ = false; // asking whether to save before going back + RowList leave_list_; std::string ps2_cards_ = "per_game"; std::map own_; // by system key (first slug) std::vector own_order_; // the server's order std::vector manual_; // own_ keys of emulators with no preset, in the order found bool picking_ = false; std::string pick_key_; + std::string pick_root_; // set: the folder is for custom_dirs_[pick_key_][pick_root_], not own_ int pick_which_ = 0; std::string fs_path_, fs_parent_; std::vector fs_folders_; @@ -178,6 +212,7 @@ class SetupPage // 4 Preview preview_; bool backed_up_ = false; + bool save_sync_ = true; // false: RomM only as a game source, the sync steps skipped // 5 Config finish_; diff --git a/platform/ps5/app/rommps/examples/rommps/kit/sync_views.cpp b/platform/ps5/app/rommps/examples/rommps/kit/sync_views.cpp new file mode 100644 index 0000000..7bf433c --- /dev/null +++ b/platform/ps5/app/rommps/examples/rommps/kit/sync_views.cpp @@ -0,0 +1,493 @@ +// RommPS: lists about the sync. See sync_views.hpp. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#include "sync_views.hpp" + +#include "cJSON.h" +#include "core/tween.hpp" + +#include +#include +#include +#include + +namespace rommps +{ + +namespace +{ + +namespace ui = hui::ui; +using hui::Action; +using hui::audio::Cue; +using hui::gfx::Rect; +using Json = std::unique_ptr; + +constexpr float kMargin = 96.0f; +constexpr float kArmedFor = 4.0f; +const Rect kRows{kMargin - 32, 300, 1792, 650}; + +const char *str(const cJSON *o, const char *key, const char *fallback = "") +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key); + return cJSON_IsString(v) ? v->valuestring : fallback; +} + +double num(const cJSON *o, const char *key, double fallback = 0) +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key); + return cJSON_IsNumber(v) ? v->valuedouble : fallback; +} + +std::string json_str(const std::string &s) +{ + std::string out = "\""; + for (char c : s) + { + if (c == '"' || c == '\\') + out += '\\'; + out += c; + } + return out + "\""; +} + +std::string ago(double t) +{ + if (t <= 0) + return "never"; + const double s = std::difftime(std::time(nullptr), static_cast(t)); + char text[32]; + if (s < 60) + return "just now"; + if (s < 3600) + std::snprintf(text, sizeof text, "%d min ago", static_cast(s / 60)); + else if (s < 86400) + std::snprintf(text, sizeof text, "%d h ago", static_cast(s / 3600)); + else + std::snprintf(text, sizeof text, s < 2 * 86400 ? "%d day ago" : "%d days ago", static_cast(s / 86400)); + return text; +} + +// "2026-10-14T06:53:37+00:00" as "2026-10-14 06:53 UTC". +std::string when(const char *iso) +{ + const std::string s = iso ? iso : ""; + if (s.size() < 16) + return s; + return s.substr(0, 10) + " " + s.substr(11, 5) + " UTC"; +} + +std::string local_time(double t) +{ + if (t <= 0) + return "never"; + const std::time_t tt = static_cast(t); + char text[32]; + std::strftime(text, sizeof text, "%Y-%m-%d %H:%M", std::localtime(&tt)); + return text; +} + +std::string base_name(const std::string &path) +{ + const std::string p = path.substr(0, path.find('#')); // a save folder synced as a zip + const std::size_t slash = p.find_last_of('/'); + return slash == std::string::npos ? p : p.substr(slash + 1); +} + +std::string size_text(double bytes) +{ + char text[32]; + if (bytes >= 1024 * 1024) + std::snprintf(text, sizeof text, "%.1f MB", bytes / (1024 * 1024)); + else + std::snprintf(text, sizeof text, "%.0f KB", bytes / 1024); + return text; +} + +// "uploaded" -> "Uploaded" +std::string capital(std::string s) +{ + if (!s.empty() && s[0] >= 'a' && s[0] <= 'z') + s[0] = static_cast(s[0] - 'a' + 'A'); + return s; +} + +const char *status_label(const std::string &status) +{ + return status == "synced" ? "In sync" + : status == "changed" ? "Changed here" + : status == "conflict" ? "Conflict" + : status == "missing" ? "Missing" + : status == "no_game" ? "Game not here" + : status.c_str(); +} + +// A platform's name: RomM's, else the folder's own. +std::string platform_name(const App &app, const std::string &dir) +{ + for (const Platform &p : app.platforms()) + if (p.slug == dir) + return p.name; + std::string up = dir; + for (char &c : up) + if (c >= 'a' && c <= 'z') + c = static_cast(c - 'a' + 'A'); + return up.empty() ? std::string("Other") : up; +} + +// One log entry as a row: the game, its result ("Downloaded") on the right, +// then "Save · RetroArch (mGBA) · 3 min ago". +Row event_row(const cJSON *e, bool with_game) +{ + const std::string kind = str(e, "kind"), action = str(e, "action"); + const std::string what = kind == "card" ? std::string("Memory card") : capital(kind); + std::string label = what; + if (with_game && kind != "card") + { + const std::string game = plain_text(str(e, "game")); + label = game.empty() ? "Game " + std::to_string(static_cast(num(e, "rom_id"))) : game; + } + std::string detail = with_game && kind != "card" ? what : std::string(); + auto more = [&detail](const std::string &part) { + if (!part.empty()) + detail += (detail.empty() ? "" : " \xC2\xB7 ") + part; + }; + more(str(e, "detail")); + if (action != "skipped") + more(plain_text(str(e, "emulator"))); + more(ago(num(e, "t"))); + Row r{RowKind::info, label, capital(action), detail}; + r.danger = action == "failed" || action == "conflict"; + return r; +} + +// One tracked file as a row: the game (or the kind, inside a game's view), +// its status on the right, then "Save · synced 3 min ago". +Row file_row(const cJSON *f, const std::string &game) +{ + const std::string kind = str(f, "kind"); + std::string detail = (game.empty() ? std::string() : capital(kind) + " \xC2\xB7 ") + "synced " + ago(num(f, "synced_at")); + if (kind == "state" && !cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(f, "from_here")) && num(f, "server_id") > 0) + detail += " \xC2\xB7 from another device"; + Row r{RowKind::info, game.empty() ? capital(kind) : game, status_label(str(f, "status")), detail}; + r.danger = std::string(str(f, "status")) == "missing"; + return r; +} + +} // namespace + +void SyncView::open(App &app, Kind kind, int rom_id, const std::string &name) +{ + active_ = true; + kind_ = kind; + rom_id_ = rom_id; + name_ = name; + choosing_ = -1; + armed_ = -1; + age_ = 0.0f; + busy_ = false; + list_.set_focus(0); + load(app); +} + +void SyncView::load(App &app) +{ + const int gen = ++generation_; + items_.clear(); + conflicts_.clear(); + switch (kind_) + { + case kConflicts: + title_ = "Conflicts"; + subtitle_ = "The copy you don't keep is backed up."; + break; + case kActivity: + title_ = "Activity"; + subtitle_ = "Newest first"; + break; + case kTracked: + title_ = "Tracked saves"; + subtitle_ = ""; + break; + case kGame: + title_ = plain_text(name_.c_str()); + subtitle_ = "Cross twice restores a version from RomM"; + break; + } + add({RowKind::info, "Loading", "", ""}); + waiting_ = 1; + + if (kind_ == kConflicts) + { + app.get("/api/status", [this, gen](const Response &r) { + if (gen != generation_) + return; + Json j(r.json(), cJSON_Delete); + const cJSON *c; + cJSON_ArrayForEach(c, cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(j.get(), "sync"), "conflicts")) + { + Conflict k; + k.path = str(c, "path"); + k.game = plain_text(*str(c, "rom_name") ? str(c, "rom_name") : base_name(k.path).c_str()); + k.local = local_time(num(c, "local_mtime")); + k.server = when(str(c, "server_updated_at")); + conflicts_.push_back(std::move(k)); + } + if (!r.ok()) + conflicts_.clear(); + show_conflicts(); + if (!r.ok()) + { + items_.clear(); + add({RowKind::info, "Couldn't load them", "", r.message()}); + } + }); + } + else if (kind_ == kActivity) + { + app.get("/api/events?limit=200", [this, gen](const Response &r) { + if (gen != generation_) + return; + items_.clear(); + Json j(r.json(), cJSON_Delete); + const bool ok = r.ok() && cJSON_IsArray(j.get()); + const cJSON *e; + if (ok) + cJSON_ArrayForEach(e, j.get()) add(event_row(e, true)); + if (items_.empty()) + add({RowKind::info, ok ? "Nothing yet" : "Couldn't load the log", "", + ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()}); + }); + } + else if (kind_ == kTracked) + { + app.get("/api/tracked", [this, gen, &app](const Response &r) { + if (gen != generation_) + return; + items_.clear(); + Json j(r.json(), cJSON_Delete); + const bool ok = r.ok() && cJSON_IsArray(cJSON_GetObjectItemCaseSensitive(j.get(), "platforms")); + struct Plat + { + std::string name; + const cJSON *games; + }; + std::vector plats; + const cJSON *p; + cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(j.get(), "platforms")) + plats.push_back({platform_name(app, str(p, "platform")), cJSON_GetObjectItemCaseSensitive(p, "games")}); + std::sort(plats.begin(), plats.end(), [](const Plat &a, const Plat &b) { return a.name < b.name; }); + for (const Plat &pl : plats) + { + add({RowKind::header, pl.name}); + const cJSON *g, *f; + cJSON_ArrayForEach(g, pl.games) + { + const std::string game = plain_text(str(g, "name")); + cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, game)); + } + } + if (items_.empty()) + add({RowKind::info, ok ? "Nothing tracked yet" : "Couldn't load them", "", + ok ? "" : r.ok() ? "RommPS's background service is out of date" : r.message()}); + char sub[96]; + std::snprintf(sub, sizeof sub, "%d saves, %d states", static_cast(num(j.get(), "saves")), + static_cast(num(j.get(), "states"))); + if (ok) + subtitle_ = sub; + }); + } + else + { + // A game: what's here, RomM's versions and the game's log, shown together. + waiting_ = 3; + game_here_.clear(); + game_history_.clear(); + game_log_.clear(); + const std::string id = std::to_string(rom_id_); + auto done = [this, gen](std::string *slot, const Response &r) { + if (gen != generation_) + return; + *slot = r.ok() ? r.body : std::string("!") + r.message(); + if (--waiting_ > 0) + return; + items_.clear(); + Json here(cJSON_Parse(game_here_.c_str()), cJSON_Delete), history(cJSON_Parse(game_history_.c_str()), cJSON_Delete), + log(cJSON_Parse(game_log_.c_str()), cJSON_Delete); + add({RowKind::header, "On this console"}); + const std::size_t before = items_.size(); + const cJSON *p, *g, *f; + cJSON_ArrayForEach(p, cJSON_GetObjectItemCaseSensitive(here.get(), "platforms")) + cJSON_ArrayForEach(g, cJSON_GetObjectItemCaseSensitive(p, "games")) + cJSON_ArrayForEach(f, cJSON_GetObjectItemCaseSensitive(g, "files")) add(file_row(f, "")); + if (items_.size() == before) + add({RowKind::info, "Nothing here yet", "", ""}); + static const char *const kinds[2][2] = {{"saves", "Saves on RomM"}, {"states", "Save states on RomM"}}; + for (int k = 0; k < 2; ++k) + { + add({RowKind::header, kinds[k][1]}); + const cJSON *list = cJSON_GetObjectItemCaseSensitive(history.get(), kinds[k][0]); + std::vector versions; + const cJSON *v; + cJSON_ArrayForEach(v, list) versions.push_back(v); + std::sort(versions.begin(), versions.end(), [](const cJSON *a, const cJSON *b) { + return std::string(str(a, "updated_at")) > std::string(str(b, "updated_at")); + }); + for (const cJSON *x : versions) + { + std::string from = *str(x, "device") ? str(x, "device") : str(x, "emulator"); + std::string detail = plain_text(str(x, "file_name")) + " \xC2\xB7 " + size_text(num(x, "size")); + if (!from.empty()) + detail += " \xC2\xB7 from " + plain_text(from.c_str()); + add({RowKind::action, when(str(x, "updated_at")), "Restore", detail}, k ? kRestoreState : kRestoreSave, + static_cast(num(x, "id"))); + } + if (versions.empty()) + add({RowKind::info, game_history_[0] == '!' ? "Couldn't ask RomM" : "Nothing on RomM yet", "", + game_history_[0] == '!' ? game_history_.substr(1) : ""}); + } + add({RowKind::header, "Recent"}); + const std::size_t log_at = items_.size(); + const cJSON *e; + int n = 0; + cJSON_ArrayForEach(e, log.get()) if (n++ < 30) add(event_row(e, false)); + if (items_.size() == log_at) + add({RowKind::info, "Nothing synced yet", "", ""}); + }; + app.get("/api/tracked?rom_id=" + id, [done, this](const Response &r) { done(&game_here_, r); }); + app.get("/api/history?rom_id=" + id, [done, this](const Response &r) { done(&game_history_, r); }); + app.get("/api/events?limit=30&rom_id=" + id, [done, this](const Response &r) { done(&game_log_, r); }); + } +} + +void SyncView::show_conflicts() +{ + items_.clear(); + choosing_ = -1; + for (std::size_t i = 0; i < conflicts_.size(); ++i) + { + const Conflict &c = conflicts_[i]; + add({RowKind::action, c.game, "Choose", "This console: " + c.local + " \xC2\xB7 RomM: " + c.server}, kPickConflict, + static_cast(i)); + } + if (items_.empty()) + add({RowKind::info, "No conflicts", "", ""}); +} + +std::vector SyncView::rows() const +{ + std::vector out; + out.reserve(items_.size()); + for (const Item &it : items_) + out.push_back(it.row); + if (armed_ >= 0 && armed_ < static_cast(out.size())) + out[static_cast(armed_)].value = "Press again to restore"; + return out; +} + +void SyncView::update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback) +{ + age_ += dt; + armed_age_ += dt; + if (input.is_pressed(Action::back)) + { + feedback.play(Cue::back); + if (choosing_ >= 0) + show_conflicts(); + else + active_ = false; + return; + } + const std::vector r = rows(); + if (armed_ >= 0 && (armed_age_ > kArmedFor || list_.focus() != armed_)) + armed_ = -1; + const RowEvent e = list_.handle(r, input, feedback); + list_.update(dt, static_cast(r.size()), kRows.h); + if (e.row < 0 || !e.activated || busy_) + return; + const Item it = items_[static_cast(e.row)]; + switch (it.action) + { + case kPickConflict: + { + const Conflict &c = conflicts_[static_cast(it.arg)]; + items_.clear(); + choosing_ = it.arg; + add({RowKind::info, c.game, "", "Which copy should stay?"}); + add({RowKind::action, "Keep this console's", "", "Changed here " + c.local + ". RomM's copy is replaced."}, kKeepLocal); + add({RowKind::action, "Keep RomM's", "", "Changed on RomM " + c.server + ". This console's copy is backed up."}, + kKeepServer); + add({RowKind::action, "Not now"}, kCancel); + list_.set_focus(1); + break; + } + case kKeepLocal: + case kKeepServer: + { + const Conflict c = conflicts_[static_cast(choosing_)]; + const bool local = it.action == kKeepLocal; + busy_ = true; + app.post("/api/conflicts/resolve", + "{\"path\":" + json_str(c.path) + ",\"keep\":\"" + (local ? "local" : "server") + "\"}", + [this, &app, local](const Response &r) { + busy_ = false; + app.say(r.ok() ? (local ? "Kept this console's save" : "Kept RomM's save") : "Couldn't settle it: " + r.message()); + app.refresh_now(); + load(app); + }); + break; + } + case kCancel: + show_conflicts(); + list_.set_focus(0); + break; + case kRestoreSave: + case kRestoreState: + if (armed_ != e.row) + { + armed_ = e.row; + armed_age_ = 0.0f; + break; + } + armed_ = -1; + busy_ = true; + app.post("/api/restore", + std::string("{\"kind\":\"") + (it.action == kRestoreSave ? "save" : "state") + "\",\"id\":" + + std::to_string(it.arg) + "}", + [this, &app](const Response &r) { + busy_ = false; + app.say(r.ok() ? "Restored. The current file was backed up" : "Couldn't restore it: " + r.message()); + if (r.ok()) + load(app); + }); + break; + default: + list_.refuse(feedback); + break; + } +} + +void SyncView::draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app, + float clock) const +{ + (void)app; + const float in = hui::tween::stagger(age_, 1, 0.08f, 0.6f); + list.push_opacity(in); + ui::text(list, fonts.display, fonts.display.font->fit(title_, 64, 1700), kMargin, 220, 64, look.text); + ui::text(list, fonts.regular, fonts.regular.font->fit(subtitle_, 24, 1700), kMargin, 268, 24, look.muted); + list_.draw(list, fonts, look, rows(), {kRows.x, kRows.y + 24 * (1.0f - in), kRows.w, kRows.h}, clock); + list.pop_opacity(); +} + +std::vector SyncView::hints() const +{ + std::vector h; + const int f = list_.focus(); + const bool can = f >= 0 && f < static_cast(items_.size()) && items_[static_cast(f)].action != kNothing; + if (can) + h.push_back({hui::ui::Button::cross, choosing_ >= 0 ? "Choose" : kind_ == kGame ? "Restore" : "Choose"}); + h.push_back({hui::ui::Button::circle, "Back"}); + return h; +} + +} // namespace rommps diff --git a/platform/ps5/app/rommps/examples/rommps/kit/sync_views.hpp b/platform/ps5/app/rommps/examples/rommps/kit/sync_views.hpp new file mode 100644 index 0000000..49ea9d4 --- /dev/null +++ b/platform/ps5/app/rommps/examples/rommps/kit/sync_views.hpp @@ -0,0 +1,93 @@ +// RommPS: lists about the sync, opened from Home and from a game's sheet. +// +// - Conflicts: saves changed here and on RomM since the last sync. Cross on +// one asks which copy to keep; the other is backed up. +// - Activity: the sync log (the payload's /api/events), newest first. +// - Tracked saves: the saves and states kept in sync, by platform, each +// with its status (/api/tracked). +// - A game's saves and states: what's on this console, the versions RomM +// keeps (Cross twice restores one) and the game's log. +// +// Each is a list of rows (rows.hpp) filled from the payload when it opens; +// Circle closes it. +// +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "rommps_app.hpp" +#include "rows.hpp" +#include "ui/glyphs.hpp" + +#include +#include + +namespace rommps +{ + +class SyncView +{ + public: + enum Kind + { + kConflicts, + kActivity, + kTracked, + kGame + }; + + void open(App &app, Kind kind, int rom_id = 0, const std::string &name = ""); + void close() { active_ = false; } + bool active() const { return active_; } + Kind kind() const { return kind_; } + + void update(App &app, const hui::InputFrame &input, float dt, hui::ui::Feedback &feedback); + void draw(hui::gfx::DrawList &list, const hui::ui::Fonts &fonts, const Look &look, const App &app, float clock) const; + std::vector hints() const; + + private: + // What Cross does on a row. + enum Do + { + kNothing, + kPickConflict, // opens the choice for conflicts_[arg] + kKeepLocal, + kKeepServer, + kCancel, + kRestoreSave, // arg: RomM's save id + kRestoreState // arg: RomM's state id + }; + struct Item + { + Row row; + Do action = kNothing; + int arg = 0; + }; + struct Conflict + { + std::string path, game, local, server; + }; + + void load(App &app); + void show_conflicts(); + void add(Row row, Do action = kNothing, int arg = 0) { items_.push_back({std::move(row), action, arg}); } + std::vector rows() const; + + bool active_ = false; + Kind kind_ = kActivity; + int rom_id_ = 0; + std::string name_; + std::string title_, subtitle_; + std::vector items_; + std::vector conflicts_; + int choosing_ = -1; // the conflict whose choice is showing, or -1 + int armed_ = -1; // a restore row waiting for its second press + float armed_age_ = 0.0f, age_ = 0.0f; + int generation_ = 0; // answers for an older open() are dropped + int waiting_ = 0; // requests still out + bool busy_ = false; // a resolve or restore in flight + // A game's view gathers three answers before it shows. + std::string game_here_, game_history_, game_log_; + RowList list_; +}; + +} // namespace rommps diff --git a/platform/ps5/app/rommps/ps5/sce_sys/param.json b/platform/ps5/app/rommps/ps5/sce_sys/param.json index 0dfa2dc..db221b3 100644 --- a/platform/ps5/app/rommps/ps5/sce_sys/param.json +++ b/platform/ps5/app/rommps/ps5/sce_sys/param.json @@ -10,7 +10,7 @@ "conceptId": "76677", "contentBadgeType": 1, "contentId": "UP9000-PPSA76677_00-ROMMPS0000000000", - "contentVersion": "01.001.001", + "contentVersion": "01.002.000", "downloadDataSize": 256, "gameIntent": { "permittedIntents": [ diff --git a/platform/ps5/app/rommps/ps5/ui/kit.cpp b/platform/ps5/app/rommps/ps5/ui/kit.cpp index b960183..112dfe2 100644 --- a/platform/ps5/app/rommps/ps5/ui/kit.cpp +++ b/platform/ps5/app/rommps/ps5/ui/kit.cpp @@ -132,6 +132,12 @@ hui::InputFrame Kit::scripted(const char *program, uint32_t frame) const input.pressed |= hui::action_bit(hui::Action::page_next); } else if (press.action == "l1") { input.pressed |= hui::action_bit(hui::Action::page_prev); + } else if (press.action == "circle") { + input.pressed |= hui::action_bit(hui::Action::back); + } else if (press.action == "triangle") { + input.pressed |= hui::action_bit(hui::Action::north); + } else if (press.action == "square") { + input.pressed |= hui::action_bit(hui::Action::west); } input.held |= input.pressed; } diff --git a/platform/ps5/app/src/rommps_api.cpp b/platform/ps5/app/src/rommps_api.cpp index 5d1b8bd..1900dd2 100644 --- a/platform/ps5/app/src/rommps_api.cpp +++ b/platform/ps5/app/src/rommps_api.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -21,7 +22,7 @@ namespace rommps { cJSON *Response::json() const { return body.empty() ? nullptr : cJSON_ParseWithLength(body.data(), body.size()); } std::string Response::message() const { - if (status == 0) return error.empty() ? "RomM Sync isn't answering" : error; + if (status == 0) return error.empty() ? "RommPS's background service isn't answering" : error; std::string out; if (cJSON *j = json()) { const cJSON *e = cJSON_GetObjectItemCaseSensitive(j, "error"); @@ -42,6 +43,21 @@ static bool send_all(int fd, const char *p, size_t n) { return true; } +namespace { +// The request on the wire, so closing the app needn't wait for its answer. +std::atomic g_open_fd{-1}; + +struct OpenSocket { + int fd; + explicit OpenSocket(int f) : fd(f) { g_open_fd = f; } + ~OpenSocket() { + int expected = fd; + g_open_fd.compare_exchange_strong(expected, -1); + ::close(fd); + } +}; +} // namespace + Response http_request(const std::string &host, int port, const std::string &method, const std::string &path, const std::string &body, int timeout_ms) { Response r; @@ -50,6 +66,7 @@ Response http_request(const std::string &host, int port, const std::string &meth r.error = std::string("socket: ") + std::strerror(errno); return r; } + const OpenSocket open(fd); timeval tv{timeout_ms / 1000, (timeout_ms % 1000) * 1000}; ::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); @@ -57,8 +74,7 @@ Response http_request(const std::string &host, int port, const std::string &meth sa.sin_family = AF_INET; sa.sin_port = htons((uint16_t)port); if (::inet_pton(AF_INET, host.c_str(), &sa.sin_addr) != 1 || ::connect(fd, (sockaddr *)&sa, sizeof sa) != 0) { - r.error = "RomM Sync isn't running (" + std::string(std::strerror(errno)) + ")"; - ::close(fd); + r.error = "RommPS's background service isn't running (" + std::string(std::strerror(errno)) + ")"; return r; } char head[1024]; @@ -68,7 +84,6 @@ Response http_request(const std::string &host, int port, const std::string &meth method.c_str(), path.c_str(), host.c_str(), body.size()); if (hn <= 0 || hn >= (int)sizeof head || !send_all(fd, head, (size_t)hn) || !send_all(fd, body.data(), body.size())) { r.error = "couldn't send the request"; - ::close(fd); return r; } std::string in; @@ -77,18 +92,16 @@ Response http_request(const std::string &host, int port, const std::string &meth ssize_t n = ::recv(fd, buf, sizeof buf, 0); if (n < 0 && errno == EINTR) continue; if (n < 0) { - r.error = errno == EAGAIN || errno == EWOULDBLOCK ? "RomM Sync took too long to answer" : std::strerror(errno); - ::close(fd); + r.error = errno == EAGAIN || errno == EWOULDBLOCK ? "RommPS's background service took too long to answer" : std::strerror(errno); return r; } if (n == 0) break; in.append(buf, (size_t)n); } - ::close(fd); size_t split = in.find("\r\n\r\n"); int status = 0; if (split == std::string::npos || std::sscanf(in.c_str(), "HTTP/%*d.%*d %d", &status) != 1) { - r.error = "RomM Sync sent a reply the app can't read"; + r.error = "RommPS's background service sent a reply the app can't read"; return r; } r.status = status; @@ -127,13 +140,16 @@ Api::~Api() { stopping_ = true; } wake_.notify_all(); + // A request still waiting for its answer ends now. + const int fd = g_open_fd.exchange(-1); + if (fd >= 0) ::shutdown(fd, SHUT_RDWR); thread_.join(); } -void Api::get(const std::string &path, Callback done) { +void Api::get(const std::string &path, Callback done, int timeout_ms) { { std::lock_guard g(lock_); - queue_.push_back(Job{"GET", path, "", std::move(done), {}}); + queue_.push_back(Job{"GET", path, "", std::move(done), {}, timeout_ms}); } wake_.notify_one(); } @@ -172,7 +188,7 @@ void Api::worker() { queue_.pop_front(); in_flight_ = 1; } - job.result = http_request(host_, port_, job.method, job.path, job.body); + job.result = http_request(host_, port_, job.method, job.path, job.body, job.timeout_ms); std::lock_guard g(lock_); in_flight_ = 0; finished_.push_back(std::move(job)); diff --git a/platform/ps5/app/src/rommps_api.hpp b/platform/ps5/app/src/rommps_api.hpp index 39e37c9..485255d 100644 --- a/platform/ps5/app/src/rommps_api.hpp +++ b/platform/ps5/app/src/rommps_api.hpp @@ -49,7 +49,8 @@ class Api { Api(const Api &) = delete; Api &operator=(const Api &) = delete; - void get(const std::string &path, Callback done); + // timeout_ms: how long to wait for the answer (most come in well under a second). + void get(const std::string &path, Callback done, int timeout_ms = 15000); void post(const std::string &path, const std::string &json_body, Callback done); // Runs the callbacks of finished requests. Call once per frame. void poll(); @@ -61,6 +62,7 @@ class Api { std::string method, path, body; Callback done; Response result; + int timeout_ms = 15000; }; void worker(); diff --git a/platform/ps5/emulators.json b/platform/ps5/emulators.json index 9210300..5c9ac06 100644 --- a/platform/ps5/emulators.json +++ b/platform/ps5/emulators.json @@ -1,14 +1,32 @@ [ { - "id": "psxs5", "name": "PSXS5 / SwanStationPS5", - "title_ids": ["PPSA97510", "PPSA98510"], - "detect": ["/data/PSXS5", "/data/homebrew/PPSA97510", "/data/homebrew/PPSA98510"], - "note": "Both apps share /data/PSXS5. One memory card per game, named after the disc.", + "id": "psxs5", "name": "PSXS5", + "title_ids": ["PPSA97510"], + "detect": ["/data/PSXS5", "/data/homebrew/PPSA97510"], + "note": "One memory card per game, named after the disc (after the game file with the Beetle core).", "profile": { "root": "/data/PSXS5", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/states", "bios_dir": "{root}/bios", "save_exts": ".mcd", "save_name": "{serial}_1{ext}", "save_stems": "{serial}_1", "state_exts": ".state*", "state_stems": "{rom_stem_safe}", "state_name": "{rom_stem_safe}{ext}", + "save_name_by_core": {"mednafen_psx_hw": "{rom_stem}_1{ext}"}, + "state_format": "raw", "state_slot0": ".state0", "state_cores": "pcsx_rearmed,mednafen_psx_hw", + "state_core_setting": {"file": "{root}/psxs5.ini", "key": "emulator", + "values": {"0": "pcsx_rearmed", "1": "pcsx_rearmed", "2": "mednafen_psx_hw"}}, + "platforms": [{"romm": ["psx", "ps1", "playstation"], "dir": "psx", "save_format": "psx-card"}] + } + }, + { + "id": "swanstationps5", "name": "SwanStationPS5", + "title_ids": ["PPSA98510"], + "detect": ["/data/SwanStationPS5", "/data/homebrew/PPSA98510"], + "note": "Plays the games in PSXS5's folder. One memory card per game, named after the game file. Its save states load only in SwanStationPS5.", + "profile": { + "root": "/data/SwanStationPS5", "rom_dir": "/data/PSXS5/games", "save_dir": "{root}/saves", "state_dir": "{root}/states", + "bios_dir": "{root}/bios", + "save_exts": ".mcd", "save_name": "{rom_stem}_1{ext}", "save_stems": "{rom_stem}_1", + "state_exts": ".state*", "state_stems": "{rom_stem_safe}", "state_name": "{rom_stem_safe}{ext}", + "state_format": "raw", "state_slot0": ".state0", "state_cores": "swanstation", "platforms": [{"romm": ["psx", "ps1", "playstation"], "dir": "psx", "save_format": "psx-card"}] } }, @@ -30,17 +48,17 @@ "note": "Saves and states are named after a hash of the game file.", "profile": { "root": "/data/PS5N64", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves", + "fixed": ["rom_dir", "save_dir", "state_dir"], "save_exts": ".sav", "save_name": "{rom_fnv64}{ext}", "save_stems": "{rom_fnv64}", - "state_exts": ".state", "state_stems": "{rom_fnv64}", "state_name": "{rom_fnv64}{ext}", + "state_exts": ".state", "state_stems": "{rom_fnv64}", "state_name": "{rom_fnv64}{ext}", "state_format": "raw", "platforms": [{"romm": ["n64"], "dir": "n64", "save_format": "n64-srm"}] } }, { "id": "ps5nes", "name": "PS5NES", "title_ids": ["PPSA99204"], - "detect": ["/data/homebrew/PPSA99204"], - "ready": false, - "note": "PS5NES v7 looks for its games in PS5SX2's folder, so there's no preset for it yet.", + "detect": ["/data/PS5NES", "/data/homebrew/PPSA99204"], + "note": "Untested: v7 may look for games in PS5SX2's folder.", "profile": { "root": "/data/PS5NES", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves", "save_exts": ".sav", "save_name": "{rom_fnv64}{ext}", "save_stems": "{rom_fnv64}", @@ -51,11 +69,14 @@ "id": "porpoise", "name": "Porpoise", "title_ids": ["PPSA99764"], "detect": ["/data/porpoise", "/data/homebrew/PPSA99764"], - "ready": false, - "note": "The current build crashes when it leaves the app sandbox, so there's no preset for it yet.", + "note": "Dolphin, with its user folder in {root}/saves/User.", "profile": { - "root": "/data/porpoise", "rom_dir": "{root}/games", "save_dir": "{root}/saves", - "platforms": [{"romm": ["ngc", "gamecube"], "dir": "gamecube"}, {"romm": ["wii"], "dir": "wii"}] + "root": "/data/porpoise", "rom_dir": "{root}/games", "save_dir": "{root}/saves/User", + "state_dir": "{root}/states/{game_id}", "state_exts": ".state", "state_format": "raw", + "platforms": [ + {"romm": ["ngc", "gamecube"], "dir": "gamecube", "emulator": "dolphin", "save_layout": "gci", "bundle_dirs": "{root}/saves/User/GC"}, + {"romm": ["wii"], "dir": "wii", "emulator": "dolphin", "save_layout": "wii", "bundle_dirs": "{root}/saves/User/Wii"} + ] } } ] diff --git a/src/config.c b/src/config.c index 646f61a..7307b78 100644 --- a/src/config.c +++ b/src/config.c @@ -54,6 +54,7 @@ static void set_defaults(void) { g_cfg.sync_on_game_exit = 1; g_cfg.sync_on_game_start = 1; g_cfg.sync_on_change = 1; + g_cfg.save_sync = 1; g_cfg.exit_delay_sec = 5; g_cfg.sync_states = 1; g_cfg.notify = 1; @@ -65,7 +66,7 @@ static void set_defaults(void) { g_cfg.conflicts = CONFLICT_ASK; str_copy(g_cfg.slot, sizeof g_cfg.slot, "autosave"); str_copy(g_cfg.ps2_cards, sizeof g_cfg.ps2_cards, "per_game"); - str_copy(g_cfg.ui_theme, sizeof g_cfg.ui_theme, "classic"); + str_copy(g_cfg.ui_theme, sizeof g_cfg.ui_theme, "acrylic"); } void config_apply_json(const cJSON *j) { @@ -80,6 +81,7 @@ void config_apply_json(const cJSON *j) { get_int(j, "sync_on_game_exit", &g_cfg.sync_on_game_exit); get_int(j, "sync_on_game_start", &g_cfg.sync_on_game_start); get_int(j, "sync_on_change", &g_cfg.sync_on_change); + get_int(j, "save_sync", &g_cfg.save_sync); get_int(j, "exit_delay_sec", &g_cfg.exit_delay_sec); get_int(j, "sync_states", &g_cfg.sync_states); /* older configs: true/false */ char states[16] = ""; @@ -173,6 +175,7 @@ cJSON *config_to_json(int include_secrets) { cJSON_AddBoolToObject(j, "sync_on_game_exit", g_cfg.sync_on_game_exit); cJSON_AddBoolToObject(j, "sync_on_game_start", g_cfg.sync_on_game_start); cJSON_AddBoolToObject(j, "sync_on_change", g_cfg.sync_on_change); + cJSON_AddBoolToObject(j, "save_sync", g_cfg.save_sync); cJSON_AddNumberToObject(j, "exit_delay_sec", g_cfg.exit_delay_sec); cJSON_AddStringToObject(j, "states", g_cfg.sync_states == 2 ? "sync" : g_cfg.sync_states ? "upload" : "off"); cJSON_AddBoolToObject(j, "notify", g_cfg.notify); diff --git a/src/config.h b/src/config.h index c23232e..7a8e67e 100644 --- a/src/config.h +++ b/src/config.h @@ -19,6 +19,7 @@ typedef struct { int sync_interval_min; /* periodic sync, 0 = off */ int sync_on_game_exit; int sync_on_game_start; + int save_sync; /* 0: RomM only as a game source, no save or state sync */ int sync_on_change; /* upload saves as soon as they change */ int exit_delay_sec; int sync_states; /* 0 off, 1 upload, 2 upload and download */ diff --git a/src/covers.c b/src/covers.c index 376976c..8e8d283 100644 --- a/src/covers.c +++ b/src/covers.c @@ -262,12 +262,39 @@ static int enabled(void) { /* The console is busy with something that matters more. */ static int busy(void) { return sync_game_running() || sync_is_running() || library_busy(); } +struct cover_list { char **list; int n, cap; }; + +/* The covers seen first go first: the games on this console, then each + * platform's tile, then the rest in RomM's order. */ +static void prioritize(struct cover_list *c, const char *heads) { + char **out = malloc(sizeof *out * (size_t)(c->n ? c->n : 1)); + char *taken = calloc((size_t)(c->n ? c->n : 1), 1); + if (!out || !taken) { free(out); free(taken); return; } + int n = 0; + state_lock(); + const cJSON *e; + cJSON_ArrayForEach(e, state_section("roms")) { + const char *cover = jget_str(e, "cover", ""); + if (!cover[0] || !(file_exists(e->string) || dir_exists(e->string))) continue; + for (int i = 0; i < c->n; i++) + if (!taken[i] && !strcmp(c->list[i], cover)) { taken[i] = 1; out[n++] = c->list[i]; break; } + } + state_unlock(); + for (int pass = 0; pass < 2; pass++) + for (int i = 0; i < c->n; i++) + if (!taken[i] && (pass || heads[i])) { taken[i] = 1; out[n++] = c->list[i]; } + free(taken); + free(c->list); + c->list = out; +} + static int collect_cover(const cJSON *it, const cJSON *page, int first, void *ctx) { (void)page, (void)first; if (!it) return 0; const char *p = jget_str(it, "path_cover_small", ""); if (!valid_path(p)) return 0; - struct { char **list; int n, cap; } *c = ctx; + struct cover_list *c = ctx; + if (c->n >= 100000) return 0; if (c->n == c->cap) { c->cap = c->cap ? c->cap * 2 : 512; char **grown = realloc(c->list, sizeof *grown * (size_t)c->cap); @@ -281,7 +308,9 @@ static int collect_cover(const cJSON *it, const cJSON *page, int first, void *ct /* Every game's cover path, platform by platform. 0 when RomM gave the whole * list; -1 if it couldn't be reached part way (what came is still used). */ static int list_all(void) { - struct { char **list; int n, cap; } c = {0}; + struct cover_list c = {0}; + static char heads[100000]; /* a platform's first covers (its tile) */ + memset(heads, 0, sizeof heads); long st = 0; cJSON *plats = romm_call("GET", "/api/platforms", NULL, &st), *p; int ok = st == 200 && cJSON_IsArray(plats); @@ -289,9 +318,12 @@ static int list_all(void) { if (jget_num(p, "rom_count", 0) <= 0) continue; char path[160]; snprintf(path, sizeof path, "/api/roms?platform_ids=%d&order_by=name&order_dir=asc&with_char_index=false" ROMS_LEAN, (int)jget_num(p, "id", 0)); + int before = c.n; if (romm_list(path, 0, 100000, collect_cover, &c, &st) < 0) ok = 0; + for (int k = before; k < c.n && k < before + 3; k++) heads[k] = 1; } cJSON_Delete(plats); + prioritize(&c, heads); pthread_mutex_lock(&g_lock); for (int i = 0; i < g_nall; i++) free(g_all[i]); free(g_all); diff --git a/src/detect.c b/src/detect.c index e8acbfa..330f73d 100644 --- a/src/detect.c +++ b/src/detect.c @@ -13,6 +13,7 @@ #include "detect.h" #include +#include #include #include #include @@ -323,6 +324,18 @@ static cJSON *emulator_catalog(void) { return plat_info()->emulator_catalog ? cJSON_Parse(plat_info()->emulator_catalog) : NULL; } +static cJSON *g_catalog; +static void load_catalog(void) { g_catalog = emulator_catalog(); } + +const cJSON *detect_catalog_entry(const char *id) { + static pthread_once_t once = PTHREAD_ONCE_INIT; + pthread_once(&once, load_catalog); + const cJSON *e; + cJSON_ArrayForEach(e, g_catalog) + if (!strcmp(jget_str(e, "id", ""), id)) return e; + return NULL; +} + static void add_standalone(cJSON *out) { cJSON *catalog = emulator_catalog(); const cJSON *e, *x; @@ -398,12 +411,79 @@ cJSON *detect_emulators(void) { closedir(d); } add_standalone(out); + /* The folders each emulator uses by default, for a given platform. */ + cJSON *c; + cJSON_ArrayForEach(c, out) { + if (cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(c, "ready"))) continue; /* No preset, custom */ + cJSON *profile = cJSON_GetObjectItemCaseSensitive(c, "profile"), *one = cJSON_CreateArray(); + cJSON_AddItemReferenceToArray(one, profile); + profile_map *maps = NULL; + int n = profiles_expand(one, &maps); + cJSON *dirs = cJSON_AddObjectToObject(c, "dirs"); + for (int i = 0; i < n; i++) { + char key[64]; + str_copy(key, sizeof key, maps[i].romm_slugs); + char *comma = strchr(key, ','); + if (comma) *comma = 0; + cJSON *d = cJSON_AddObjectToObject(dirs, key); + cJSON_AddStringToObject(d, "rom_dir", maps[i].rom_dir); + cJSON_AddStringToObject(d, "save_dir", maps[i].save_dir); + cJSON_AddStringToObject(d, "state_dir", maps[i].state_dir); + /* Games already there, so setup can pick the emulator they're in. */ + int games = 0; + DIR *g = opendir(maps[i].rom_dir); + struct dirent *ge; + while (g && (ge = readdir(g))) + if (ge->d_name[0] != '.' && !ext_in_list(ge->d_name, maps[i].save_exts) && + !ext_in_list(ge->d_name, maps[i].state_exts) && !ext_in_list(ge->d_name, ".part,.tmp,.txt,.png,.jpg,.xml")) + games++; + if (g) closedir(g); + cJSON_AddNumberToObject(d, "games", games); + } + free(maps); + cJSON_Delete(one); + const cJSON *fixed = cJSON_GetObjectItemCaseSensitive(profile, "fixed"); + cJSON_AddItemToObject(c, "fixed", fixed ? cJSON_Duplicate(fixed, 1) : cJSON_CreateArray()); + } return out; } +/* Keys a newer preset has that a saved profile lacks (a state folder added + * to Porpoise's, say) are added to it. Nothing already there changes, so + * folders the user picked stay. */ +static int fill_from_catalog(cJSON *p) { + const cJSON *entry = detect_catalog_entry(jget_str(p, "id", "")); + const cJSON *preset = entry ? cJSON_GetObjectItemCaseSensitive(entry, "profile") : NULL, *k; + cJSON *pe; + if (!preset) return 0; + int added = 0; + cJSON_ArrayForEach(k, preset) + if (strcmp(k->string, "platforms") && !cJSON_GetObjectItemCaseSensitive(p, k->string)) { + cJSON_AddItemToObject(p, k->string, cJSON_Duplicate(k, 1)); + added++; + } + cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) { + const cJSON *first = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(pe, "romm"), 0), *ce; + if (!cJSON_IsString(first)) continue; + cJSON_ArrayForEach(ce, cJSON_GetObjectItemCaseSensitive(preset, "platforms")) { + const cJSON *cf = cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(ce, "romm"), 0); + if (!cJSON_IsString(cf) || strcmp(cf->valuestring, first->valuestring)) continue; + cJSON_ArrayForEach(k, ce) + if (!cJSON_GetObjectItemCaseSensitive(pe, k->string)) { + cJSON_AddItemToObject(pe, k->string, cJSON_Duplicate(k, 1)); + added++; + } + } + } + if (added) LOGI("the %s profile got %d setting%s from its newer preset", jget_str(p, "name", ""), added, added == 1 ? "" : "s"); + return added; +} + int detect_refresh_profiles(cJSON *profiles) { int changed = 0; cJSON *found = NULL; + cJSON *each; + cJSON_ArrayForEach(each, profiles) changed += fill_from_catalog(each) > 0; for (int i = 0; i < cJSON_GetArraySize(profiles); i++) { cJSON *p = cJSON_GetArrayItem(profiles, i); const char *id = jget_str(p, "id", ""); @@ -420,6 +500,19 @@ int detect_refresh_profiles(cJSON *profiles) { /* The user's choice of emulator per system survives a refresh. */ const cJSON *ex = cJSON_GetObjectItemCaseSensitive(p, "exclude"); if (ex) cJSON_AddItemToObject(fresh, "exclude", cJSON_Duplicate(ex, 1)); + const cJSON *extra = cJSON_GetObjectItemCaseSensitive(p, "extra"); + if (extra) cJSON_AddItemToObject(fresh, "extra", cJSON_Duplicate(extra, 1)); + const cJSON *mode = cJSON_GetObjectItemCaseSensitive(p, "sync"); + if (cJSON_IsString(mode)) cJSON_AddStringToObject(fresh, "sync", mode->valuestring); + /* ... and each platform's. */ + cJSON *fe; + cJSON_ArrayForEach(fe, cJSON_GetObjectItemCaseSensitive(fresh, "platforms")) { + const cJSON *pe, *m; + cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) + if (!strcmp(profiles_system_key(pe), profiles_system_key(fe)) && + cJSON_IsString(m = cJSON_GetObjectItemCaseSensitive(pe, "sync"))) + cJSON_AddStringToObject(fe, "sync", m->valuestring); + } LOGI("updated the %s profile for %s", jget_str(fresh, "name", ""), jget_str(p, "root", "")); cJSON_ReplaceItemInArray(profiles, i, fresh); changed++; diff --git a/src/detect.h b/src/detect.h index 2bcff4b..8a6e63d 100644 --- a/src/detect.h +++ b/src/detect.h @@ -11,4 +11,8 @@ cJSON *detect_emulators(void); * features. Profiles written by hand are left alone. Returns how many changed. */ int detect_refresh_profiles(cJSON *profiles); +/* The built-in emulator catalog entry with this id (emulators.json), or NULL. + * Parsed once; don't free. */ +const cJSON *detect_catalog_entry(const char *id); + #endif diff --git a/src/events.c b/src/events.c new file mode 100644 index 0000000..5333a73 --- /dev/null +++ b/src/events.c @@ -0,0 +1,134 @@ +#include "events.h" + +#include +#include +#include +#include +#include + +#include "platform.h" +#include "state.h" +#include "util.h" + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static cJSON *g_events; /* oldest first */ +static char g_sync[64] = ""; +static int g_dirty; +static unsigned g_seq; /* entries ever added */ + +static void path_of(char *out, size_t n) { path_join(out, n, plat_data_dir(), "events.json"); } + +void events_load(void) { + char path[PATH_MAX_LEN]; + path_of(path, sizeof path); + char *txt = read_file(path, NULL); + cJSON *j = txt ? cJSON_Parse(txt) : NULL; + free(txt); + pthread_mutex_lock(&g_lock); + cJSON_Delete(g_events); + g_events = cJSON_IsArray(j) ? j : cJSON_CreateArray(); + if (!cJSON_IsArray(j)) cJSON_Delete(j); + pthread_mutex_unlock(&g_lock); +} + +void events_save(void) { + pthread_mutex_lock(&g_lock); + char *txt = g_dirty && g_events ? cJSON_PrintUnformatted(g_events) : NULL; + g_dirty = 0; + pthread_mutex_unlock(&g_lock); + if (!txt) return; + char path[PATH_MAX_LEN]; + path_of(path, sizeof path); + if (write_file_atomic(path, txt, strlen(txt)) != 0) LOGW("could not write %s", path); + free(txt); +} + +void events_set_sync(const char *reason) { + pthread_mutex_lock(&g_lock); + str_copy(g_sync, sizeof g_sync, reason ? reason : ""); + pthread_mutex_unlock(&g_lock); +} + +void events_add(const char *kind, const char *action, int rom_id, const char *path, const char *emulator, + const char *detail) { + cJSON *e = cJSON_CreateObject(); + cJSON_AddNumberToObject(e, "t", (double)time(NULL)); + cJSON_AddNumberToObject(e, "rom_id", rom_id); + cJSON_AddStringToObject(e, "kind", kind); + cJSON_AddStringToObject(e, "action", action); + cJSON_AddStringToObject(e, "path", path ? path : ""); + cJSON_AddStringToObject(e, "emulator", emulator ? emulator : ""); + if (detail && detail[0]) cJSON_AddStringToObject(e, "detail", detail); + pthread_mutex_lock(&g_lock); + if (!g_events) g_events = cJSON_CreateArray(); + cJSON_AddStringToObject(e, "sync", g_sync); + cJSON_AddItemToArray(g_events, e); + g_seq++; + while (cJSON_GetArraySize(g_events) > EVENTS_MAX) cJSON_DeleteItemFromArray(g_events, 0); + g_dirty = 1; + pthread_mutex_unlock(&g_lock); +} + +unsigned events_seq(void) { + pthread_mutex_lock(&g_lock); + unsigned s = g_seq; + pthread_mutex_unlock(&g_lock); + return s; +} + +int events_games(unsigned since, int max, char *out, size_t n) { + int ids[64], nids = 0; + pthread_mutex_lock(&g_lock); + int size = g_events ? cJSON_GetArraySize(g_events) : 0; + unsigned added = g_seq - since; + for (int i = added < (unsigned)size ? size - (int)added : 0; i < size && nids < 64; i++) { + const cJSON *e = cJSON_GetArrayItem(g_events, i); + const char *a = jget_str(e, "action", ""); + int id = (int)jget_num(e, "rom_id", 0), seen = 0; + if (!id || !(!strcmp(a, "uploaded") || !strcmp(a, "downloaded") || !strcmp(a, "conflict") || !strcmp(a, "failed"))) + continue; + for (int k = 0; k < nids; k++) seen |= ids[k] == id; + if (!seen) ids[nids++] = id; + } + pthread_mutex_unlock(&g_lock); + size_t o = 0; + int named = 0, more = 0; + out[0] = 0; + state_lock(); + const cJSON *roms = state_section("roms"), *r; + for (int k = 0; k < nids; k++) { + const char *name = ""; + cJSON_ArrayForEach(r, roms) if ((int)jget_num(r, "rom_id", 0) == ids[k] && jget_str(r, "name", "")[0]) + name = jget_str(r, "name", ""); + if (!name[0]) continue; + if (named == max) { more++; continue; } + if (o < n) o += (size_t)snprintf(out + o, n - o, "%s%s", named ? ", " : "", name); + named++; + } + state_unlock(); + if (more && o < n) snprintf(out + o, n - o, " and %d more", more); + return named > 0; +} + +cJSON *events_json(int rom_id, int limit) { + cJSON *out = cJSON_CreateArray(); + pthread_mutex_lock(&g_lock); + cJSON *copy = g_events ? cJSON_Duplicate(g_events, 1) : cJSON_CreateArray(); + pthread_mutex_unlock(&g_lock); + state_lock(); + const cJSON *roms = state_section("roms"); + for (int i = cJSON_GetArraySize(copy) - 1; i >= 0 && (limit <= 0 || cJSON_GetArraySize(out) < limit); i--) { + cJSON *e = cJSON_GetArrayItem(copy, i); + int id = (int)jget_num(e, "rom_id", 0); + if (rom_id && id != rom_id) continue; + const cJSON *r, *game = NULL; + cJSON_ArrayForEach(r, roms) if ((int)jget_num(r, "rom_id", -1) == id && id) game = r; + cJSON *o = cJSON_Duplicate(e, 1); + cJSON_AddStringToObject(o, "game", game ? jget_str(game, "name", "") : ""); + cJSON_AddStringToObject(o, "platform", game ? jget_str(game, "platform_dir", "") : ""); + cJSON_AddItemToArray(out, o); + } + state_unlock(); + cJSON_Delete(copy); + return out; +} diff --git a/src/events.h b/src/events.h new file mode 100644 index 0000000..7704ee9 --- /dev/null +++ b/src/events.h @@ -0,0 +1,36 @@ +/* The sync log: one entry per file a sync moved or left alone on purpose, + * newest last, the last EVENTS_MAX kept in events.json. + * + * {"t": 1791936000, "sync": "game exited", "rom_id": 878, + * "kind": "save" | "state" | "card", + * "action": "uploaded" | "downloaded" | "conflict" | "resolved" | + * "skipped" | "restored" | "backed up" | "failed", + * "path": "/data/.../Game.srm", "emulator": "mgba", "detail": "..."} + * + * The game's name and platform are filled in when the log is read. */ +#ifndef ROMM_EVENTS_H +#define ROMM_EVENTS_H + +#include "cJSON.h" + +#define EVENTS_MAX 500 + +void events_load(void); +void events_save(void); /* after each sync; cheap when nothing changed */ + +/* Names the sync the next entries come from ("startup", "game exited"...). */ +void events_set_sync(const char *reason); + +void events_add(const char *kind, const char *action, int rom_id, const char *path, const char *emulator, + const char *detail); + +/* Counts the entries added so far; with events_games, the games a sync touched. */ +unsigned events_seq(void); +/* The games of the saves and states moved since events_seq() was "since": + * "Wind Waker, Mario Kart and 2 more" (at most "max" names). 0 if none. */ +int events_games(unsigned since, int max, char *out, size_t n); + +/* Newest first; rom_id 0 for every game. Caller frees. */ +cJSON *events_json(int rom_id, int limit); + +#endif diff --git a/src/gcmem.c b/src/gcmem.c new file mode 100644 index 0000000..daefacd --- /dev/null +++ b/src/gcmem.c @@ -0,0 +1,241 @@ +#include "gcmem.h" + +#include +#include +#include + +#define FIRST_DATA 5 +#define NENTRIES 127 + +static unsigned be16(const unsigned char *p) { return (unsigned)p[0] << 8 | p[1]; } +static void put16(unsigned char *p, unsigned v) { + p[0] = (unsigned char)(v >> 8); + p[1] = (unsigned char)v; +} + +/* Dolphin's two checksums over big-endian words; 0xFFFF counts as 0. */ +static void checksums(const unsigned char *p, size_t n, unsigned *c1, unsigned *c2) { + unsigned a = 0, b = 0; + for (size_t i = 0; i + 1 < n; i += 2) { + unsigned w = be16(p + i); + a = (a + w) & 0xFFFF; + b = (b + (w ^ 0xFFFF)) & 0xFFFF; + } + *c1 = a == 0xFFFF ? 0 : a; + *c2 = b == 0xFFFF ? 0 : b; +} + +static unsigned char *block(const gc_card *c, int n) { return c->data + (size_t)n * GC_BLOCK; } + +/* Directory: checksums over 0..0x1FFB, at 0x1FFC; update counter at 0x1FFA. */ +static int dir_ok(const unsigned char *d) { + unsigned a, b; + checksums(d, 0x1FFC, &a, &b); + return a == be16(d + 0x1FFC) && b == be16(d + 0x1FFE); +} +/* Table: checksums over 0x4..0x1FFF, at 0x0; update counter at 0x4. */ +static int bat_ok(const unsigned char *t) { + unsigned a, b; + checksums(t + 4, GC_BLOCK - 4, &a, &b); + return a == be16(t) && b == be16(t + 2); +} + +/* The current copy: the one that checks out, or the newer of two that do. */ +static int pick(const unsigned char *x, const unsigned char *y, int ok_x, int ok_y, size_t counter_at, int ix, int iy) { + if (ok_x && ok_y) return (short)be16(y + counter_at) > (short)be16(x + counter_at) ? iy : ix; + return ok_x ? ix : ok_y ? iy : -1; +} + +int gc_card_open(gc_card *c, const unsigned char *data, size_t size) { + memset(c, 0, sizeof *c); + if (size < (size_t)GC_BLOCK * (FIRST_DATA + 1) || size % GC_BLOCK) return -1; + c->data = malloc(size); + if (!c->data) return -1; + memcpy(c->data, data, size); + c->size = size; + c->nblocks = (int)(size / GC_BLOCK); + if (c->nblocks > 0xFFB + FIRST_DATA) c->nblocks = 0xFFB + FIRST_DATA; + c->dir = pick(block(c, 1), block(c, 2), dir_ok(block(c, 1)), dir_ok(block(c, 2)), 0x1FFA, 1, 2); + c->bat = pick(block(c, 3), block(c, 4), bat_ok(block(c, 3)), bat_ok(block(c, 4)), 4, 3, 4); + if (c->dir < 0 || c->bat < 0) { + gc_card_free(c); + return -1; + } + return 0; +} + +void gc_card_free(gc_card *c) { + free(c->data); + memset(c, 0, sizeof *c); +} + +static unsigned char *entry(const gc_card *c, int i) { return block(c, c->dir) + i * GC_DENTRY; } +static int empty(const unsigned char *e) { return e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF && e[3] == 0xFF; } +static unsigned char *map_at(const gc_card *c, int blk) { return block(c, c->bat) + 0xA + (size_t)(blk - FIRST_DATA) * 2; } +static int is_data(const gc_card *c, unsigned blk) { return blk >= FIRST_DATA && blk < (unsigned)c->nblocks; } + +int gc_gci_valid(const unsigned char *g, size_t size) { + if (size < GC_DENTRY || (size - GC_DENTRY) % GC_BLOCK || empty(g)) return 0; + unsigned count = be16(g + 0x38); + return count > 0 && (size_t)count * GC_BLOCK == size - GC_DENTRY; +} + +static int same_id(const unsigned char *e, const char *id) { return strlen(id) >= 6 && !memcmp(e, id, 6); } + +int gc_gci_same_save(const unsigned char *a, const unsigned char *b) { + return !memcmp(a, b, 6) && !memcmp(a + 8, b + 8, 32); +} + +void gc_gci_id(const unsigned char *g, char out[7]) { + memcpy(out, g, 6); + out[6] = 0; +} + +/* Copies an entry's blocks after it, following the table. */ +static unsigned char *to_gci(const gc_card *c, const unsigned char *e, size_t *size) { + unsigned count = be16(e + 0x38), blk = be16(e + 0x36); + if (!count || count > (unsigned)c->nblocks) return NULL; + unsigned char *g = malloc(GC_DENTRY + (size_t)count * GC_BLOCK); + if (!g) return NULL; + memcpy(g, e, GC_DENTRY); + for (unsigned k = 0; k < count; k++) { + if (!is_data(c, blk)) { + free(g); + return NULL; + } + memcpy(g + GC_DENTRY + (size_t)k * GC_BLOCK, block(c, (int)blk), GC_BLOCK); + blk = be16(map_at(c, (int)blk)); + } + *size = GC_DENTRY + (size_t)count * GC_BLOCK; + return g; +} + +int gc_card_saves(const gc_card *c, const char *id, gc_save_fn fn, void *ud) { + int n = 0; + for (int i = 0; i < NENTRIES; i++) { + const unsigned char *e = entry(c, i); + if (empty(e) || !same_id(e, id)) continue; + size_t size = 0; + unsigned char *g = to_gci(c, e, &size); + if (!g) continue; + if (fn) fn(g, size, ud); + free(g); + n++; + } + return n; +} + +/* Edits go to a copy of the current directory and table, written over the + * older copies when done. */ +typedef struct { + unsigned char dir[GC_BLOCK], bat[GC_BLOCK]; +} draft; + +static void draft_start(const gc_card *c, draft *d) { + memcpy(d->dir, block(c, c->dir), GC_BLOCK); + memcpy(d->bat, block(c, c->bat), GC_BLOCK); +} + +static void draft_commit(gc_card *c, draft *d) { + unsigned a, b; + put16(d->dir + 0x1FFA, (be16(d->dir + 0x1FFA) + 1) & 0xFFFF); + checksums(d->dir, 0x1FFC, &a, &b); + put16(d->dir + 0x1FFC, a); + put16(d->dir + 0x1FFE, b); + put16(d->bat + 4, (be16(d->bat + 4) + 1) & 0xFFFF); + checksums(d->bat + 4, GC_BLOCK - 4, &a, &b); + put16(d->bat, a); + put16(d->bat + 2, b); + c->dir = c->dir == 1 ? 2 : 1; + c->bat = c->bat == 3 ? 4 : 3; + memcpy(block(c, c->dir), d->dir, GC_BLOCK); + memcpy(block(c, c->bat), d->bat, GC_BLOCK); +} + +static unsigned char *dmap(const gc_card *c, draft *d, unsigned blk) { + (void)c; + return d->bat + 0xA + (size_t)(blk - FIRST_DATA) * 2; +} + +/* Frees entry i's blocks and empties it. */ +static void draft_drop(const gc_card *c, draft *d, int i) { + unsigned char *e = d->dir + i * GC_DENTRY; + unsigned count = be16(e + 0x38), blk = be16(e + 0x36), freed = 0; + for (unsigned k = 0; k < count && is_data(c, blk); k++) { + unsigned char *m = dmap(c, d, blk); + blk = be16(m); + put16(m, 0); + freed++; + } + put16(d->bat + 6, be16(d->bat + 6) + freed); + memset(e, 0xFF, GC_DENTRY); +} + +int gc_card_remove(gc_card *c, const char *id) { + draft d; + draft_start(c, &d); + int n = 0; + for (int i = 0; i < NENTRIES; i++) { + const unsigned char *e = d.dir + i * GC_DENTRY; + if (empty(e) || !same_id(e, id)) continue; + draft_drop(c, &d, i); + n++; + } + if (n) draft_commit(c, &d); + return n; +} + +int gc_card_put(gc_card *c, const unsigned char *g, size_t size) { + if (!gc_gci_valid(g, size)) return -1; + draft d; + draft_start(c, &d); + int slot = -1; + for (int i = 0; i < NENTRIES; i++) { + unsigned char *e = d.dir + i * GC_DENTRY; + if (!empty(e) && gc_gci_same_save(e, g)) draft_drop(c, &d, i); + if (empty(e) && slot < 0) slot = i; + } + const unsigned count = be16(g + 0x38); + if (slot < 0 || be16(d.bat + 6) < count) return -1; + /* Free blocks from just after the last one given out, wrapping round, + * as Dolphin hands them out. */ + unsigned *blocks = malloc(sizeof *blocks * count), found = 0; + if (!blocks) return -1; + unsigned start = be16(d.bat + 8) + 1; + if (start < FIRST_DATA || start >= (unsigned)c->nblocks) start = FIRST_DATA; + for (unsigned k = 0; k < (unsigned)c->nblocks - FIRST_DATA && found < count; k++) { + unsigned blk = FIRST_DATA + (start - FIRST_DATA + k) % ((unsigned)c->nblocks - FIRST_DATA); + if (be16(dmap(c, &d, blk)) == 0) blocks[found++] = blk; + } + if (found < count) { + free(blocks); + return -1; + } + for (unsigned k = 0; k < count; k++) { + memcpy(block(c, (int)blocks[k]), g + GC_DENTRY + (size_t)k * GC_BLOCK, GC_BLOCK); + put16(dmap(c, &d, blocks[k]), k + 1 < count ? blocks[k + 1] : 0xFFFF); + } + put16(d.bat + 6, be16(d.bat + 6) - count); + put16(d.bat + 8, blocks[count - 1]); + unsigned char *e = d.dir + slot * GC_DENTRY; + memcpy(e, g, GC_DENTRY); + put16(e + 0x36, blocks[0]); + free(blocks); + draft_commit(c, &d); + return 0; +} + +void gc_gci_name(const unsigned char *g, char *out, size_t n) { + char name[33 * 6 + 1]; + size_t o = 0; + for (int i = 0; i < 32 && g[8 + i]; i++) { + unsigned char ch = g[8 + i]; + /* Dolphin's escaping of characters a file name can't have. */ + if (ch < 0x20 || strchr("\"*/:<>?\\|", ch)) + o += (size_t)snprintf(name + o, sizeof name - o, "__%02x__", ch); + else + name[o++] = (char)ch; + } + name[o] = 0; + snprintf(out, n, "%.2s-%.4s-%s.gci", (const char *)g + 4, (const char *)g, name); +} diff --git a/src/gcmem.h b/src/gcmem.h new file mode 100644 index 0000000..ceca5d5 --- /dev/null +++ b/src/gcmem.h @@ -0,0 +1,56 @@ +/* GameCube memory cards (Dolphin's .raw) and the .gci files of single saves. + * + * A card is 8 KiB blocks: 0 the header, 1 and 2 the directory and its + * previous copy, 3 and 4 the block allocation table and its previous copy, + * then the saves. Each directory entry (64 bytes) names a save: game code, + * maker, file name, first block and block count; the table chains its + * blocks. A .gci is one entry followed by its blocks. + * + * Changes are made the way Dolphin makes them: the current directory and + * table are edited, their update counters go up by one, their checksums are + * set, and they're written over the older copies, which become the backups. */ +#ifndef ROMM_GCMEM_H +#define ROMM_GCMEM_H + +#include + +#define GC_BLOCK 0x2000 +#define GC_DENTRY 0x40 + +typedef struct { + unsigned char *data; + size_t size; + int nblocks; + int dir, bat; /* which copy is current: block 1 or 2, 3 or 4 */ +} gc_card; + +/* 0 when data (size bytes, copied) is a card whose header, directory and + * table check out. */ +int gc_card_open(gc_card *c, const unsigned char *data, size_t size); +void gc_card_free(gc_card *c); + +/* A .gci in memory: 0 when it's one entry and as many blocks as it says. */ +int gc_gci_valid(const unsigned char *gci, size_t size); + +/* The saves of a game: entries whose game code and maker are id's six + * characters ("GZLE01"). Calls fn with each as a .gci; returns how many. */ +typedef void (*gc_save_fn)(const unsigned char *gci, size_t size, void *ud); +int gc_card_saves(const gc_card *c, const char *id, gc_save_fn fn, void *ud); + +/* Takes the game's saves off the card. */ +int gc_card_remove(gc_card *c, const char *id); + +/* Puts a .gci on the card, over any save with the same game code, maker and + * file name. 0, or -1 when it doesn't fit or the directory is full. */ +int gc_card_put(gc_card *c, const unsigned char *gci, size_t size); + +/* Dolphin's name for a .gci in a GCI folder: "01-GZLE-gczelda2.gci". */ +void gc_gci_name(const unsigned char *gci, char *out, size_t n); + +/* The game id ("GZLE01") a .gci belongs to. */ +void gc_gci_id(const unsigned char *gci, char out[7]); + +/* Whether two .gci are the same save (game code, maker and file name). */ +int gc_gci_same_save(const unsigned char *a, const unsigned char *b); + +#endif diff --git a/src/library.c b/src/library.c index 8eec69d..ff399b5 100644 --- a/src/library.c +++ b/src/library.c @@ -22,7 +22,7 @@ #define MAX_JOBS 50 typedef enum { JOB_ROM, JOB_FIRMWARE } job_type; -typedef enum { JOB_QUEUED, JOB_RUNNING, JOB_DONE, JOB_ERROR, JOB_CANCELLED } job_status; +typedef enum { JOB_QUEUED, JOB_RUNNING, JOB_DONE, JOB_ERROR, JOB_CANCELLED, JOB_SYNCING } job_status; typedef struct job { int id; @@ -36,6 +36,8 @@ typedef struct job { int64_t done, total; int64_t base; /* bytes from files already finished (multi-file games) */ double speed; /* bytes/s, smoothed */ + char note[80]; /* Running job updates this after a download. */ + sync_game_result saves; /* what the sync after the download brought */ double sample_t; int64_t sample_bytes; struct job *next; @@ -253,8 +255,12 @@ static void run_rom_job(job *j) { const char *fs_name = jget_str(rom, "fs_name", ""); const cJSON *files = cJSON_GetObjectItemCaseSensitive(rom, "files"); - int multi = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(rom, "has_multiple_files")) && - cJSON_GetArraySize(files) > 1; + /* fs_name is one of files[] only for a plain single-file rom; otherwise + * it's the folder's name (no extension) and the rom is fetched per file. */ + int multi = cJSON_GetArraySize(files) > 0; + const cJSON *f; + cJSON_ArrayForEach(f, files) + if (strcmp(jget_str(f, "file_name", ""), fs_name) == 0) { multi = 0; break; } if (library_not_game(fs_name)) { job_end(j, JOB_ERROR, "%s isn't a game", fs_name); free(maps); @@ -320,13 +326,36 @@ static void run_rom_job(job *j) { path_join(other, sizeof other, x->rom_dir, fs_name); if (!strcmp(other, dest)) continue; mkdir_p(x->rom_dir); - if (link_or_copy(dest, other) == 0) { + pthread_mutex_lock(&g_q_lock); + snprintf(j->note, sizeof j->note, "Copying to %s", x->profile_name); + j->base = j->done = j->total = 0; + j->speed = 0; + j->sample_t = 0; + pthread_mutex_unlock(&g_q_lock); + if (link_or_copy_progress(dest, other, progress_cb, j) == 0) { record_rom(j, rom, x, other, multi); LOGI("also for %s: %s", x->profile_name, other); } else { LOGW("could not put %s in %s", j->name, x->rom_dir); } } + /* The game's saves land before it shows as ready, so it isn't + * started without them. */ + pthread_mutex_lock(&g_q_lock); + j->status = JOB_SYNCING; + pthread_mutex_unlock(&g_q_lock); + char name[256]; + str_copy(name, sizeof name, j->name); + sync_game_result saves; + if (sync_game(j->target_id, name, &saves) != 0) { /* save sync off: just the game */ + config_lock(); + const int notify = g_cfg.notify; + config_unlock(); + if (notify) plat_notify("RommPS: %s downloaded", name); + } + pthread_mutex_lock(&g_q_lock); + j->saves = saves; + pthread_mutex_unlock(&g_q_lock); job_end(j, JOB_DONE, NULL, NULL); } else { if (cancelled(j) && !(multi && had)) discard_partial(dest, multi); @@ -427,7 +456,8 @@ void library_init(void) { int library_busy(void) { int busy = 0; pthread_mutex_lock(&g_q_lock); - for (job *j = g_jobs; j && !busy; j = j->next) busy = j->status == JOB_QUEUED || j->status == JOB_RUNNING; + for (job *j = g_jobs; j && !busy; j = j->next) + busy = j->status == JOB_QUEUED || j->status == JOB_RUNNING || j->status == JOB_SYNCING; pthread_mutex_unlock(&g_q_lock); return busy; } @@ -457,7 +487,7 @@ static void trim_jobs(void) { static int enqueue(job_type type, int id, const char *name, char *err, int en) { pthread_mutex_lock(&g_q_lock); for (job *p = g_jobs; p; p = p->next) { - if (p->type == type && p->target_id == id && (p->status == JOB_QUEUED || p->status == JOB_RUNNING)) { + if (p->type == type && p->target_id == id && !finished(p)) { pthread_mutex_unlock(&g_q_lock); snprintf(err, (size_t)en, "already downloading"); return -1; @@ -504,6 +534,8 @@ int library_cancel(int id, char *err, int en) { } else if (j->status == JOB_RUNNING) { j->cancel = 1; /* the progress callback aborts the transfer */ rc = 0; + } else if (j->status == JOB_SYNCING) { + snprintf(err, (size_t)en, "the game is downloaded, its saves are syncing"); } else { snprintf(err, (size_t)en, "download already finished"); } @@ -548,12 +580,26 @@ int library_delete_rom(int rom_id, char *err, int en) { } state_save(); state_unlock(); + if (removed) { + /* Can't leave a finished download in the downloads page if we removed that title. */ + pthread_mutex_lock(&g_q_lock); + for (job **pp = &g_jobs; *pp;) { + job *dead = *pp; + if (dead->type == JOB_ROM && dead->target_id == rom_id && finished(dead)) { + *pp = dead->next; + free(dead); + } else { + pp = &dead->next; + } + } + pthread_mutex_unlock(&g_q_lock); + } if (!removed) snprintf(err, (size_t)en, "game is not on this console"); return removed ? 0 : -1; } cJSON *library_downloads(void) { - static const char *names[] = {"queued", "downloading", "done", "error", "cancelled"}; + static const char *names[] = {"queued", "downloading", "done", "error", "cancelled", "syncing"}; cJSON *arr = cJSON_CreateArray(); pthread_mutex_lock(&g_q_lock); for (job *j = g_jobs; j; j = j->next) { @@ -568,6 +614,13 @@ cJSON *library_downloads(void) { cJSON_AddNumberToObject(o, "done", (double)j->done); cJSON_AddNumberToObject(o, "total", (double)j->total); cJSON_AddNumberToObject(o, "speed", j->status == JOB_RUNNING ? j->speed : 0); + cJSON_AddStringToObject(o, "note", j->status == JOB_RUNNING ? j->note : ""); + if (j->status == JOB_DONE && j->type == JOB_ROM) { + cJSON_AddNumberToObject(o, "saves", j->saves.saves); + cJSON_AddNumberToObject(o, "states", j->saves.states); + cJSON_AddNumberToObject(o, "save_conflicts", j->saves.conflicts); + cJSON_AddBoolToObject(o, "saves_failed", j->saves.failed); + } cJSON_AddItemToArray(arr, o); } pthread_mutex_unlock(&g_q_lock); @@ -654,6 +707,10 @@ again:; if (comma) *comma = 0; const profile_map *m = profile_find_for_platform(maps, n, first, NULL); if (!m || m == x || m->extra || !strcmp(m->rom_dir, x->rom_dir)) continue; + /* A folder several of the main emulator's systems share (Porpoise's + * games: GameCube and Wii): only the games matched for this system. */ + int shared = 0; + for (int k = 0; k < n; k++) shared |= &maps[k] != m && !strcmp(maps[k].rom_dir, m->rom_dir); DIR *d = opendir(m->rom_dir); struct dirent *de; int here = 0; @@ -663,6 +720,13 @@ again:; continue; char from[PATH_MAX_LEN], to[PATH_MAX_LEN]; path_join(from, sizeof from, m->rom_dir, de->d_name); + if (shared) { + state_lock(); + const cJSON *rec = cJSON_GetObjectItemCaseSensitive(state_section("roms"), from); + const int ours = rec && jget_num(rec, "rom_id", 0) > 0 && !strcmp(jget_str(rec, "platform_dir", ""), m->platform_dir); + state_unlock(); + if (!ours) continue; + } path_join(to, sizeof to, x->rom_dir, de->d_name); if (file_exists(to) || dir_exists(to) || (dir_exists(from) && still_downloading(from))) continue; mkdir_p(x->rom_dir); @@ -679,7 +743,7 @@ again:; str_copy(sys, sizeof sys, x->platform_dir); for (char *c = sys; *c; c++) if (*c >= 'a' && *c <= 'z') *c -= 32; - plat_notify("RomM Sync: %d %s game%s now also sync with %s", here, sys, here == 1 ? "" : "s", + plat_notify("RommPS: %d %s game%s now also sync with %s", here, sys, here == 1 ? "" : "s", x->profile_name); linked += here; } @@ -871,6 +935,27 @@ static void backfill_installed(void) { } } +/* A game's size here: its file, or everything in its folder. */ +static double local_size(const char *path, int depth) { + if (!dir_exists(path)) return (double)(file_size(path) > 0 ? file_size(path) : 0); + double total = 0; + DIR *d = depth < 3 ? opendir(path) : NULL; + struct dirent *de; + while (d && (de = readdir(d))) { + if (de->d_name[0] == '.') continue; + char p[PATH_MAX_LEN]; + path_join(p, sizeof p, path, de->d_name); + total += local_size(p, depth + 1); + } + if (d) closedir(d); + return total; +} + +static int newest_first(const void *a, const void *b) { + double x = jget_num(*(cJSON *const *)a, "played", 0), y = jget_num(*(cJSON *const *)b, "played", 0); + return x < y ? 1 : x > y ? -1 : 0; +} + cJSON *library_installed(int limit) { backfill_installed(); cJSON *out = cJSON_CreateArray(); @@ -896,22 +981,31 @@ cJSON *library_installed(int limit) { cJSON_AddStringToObject(o, "cover", jget_str(e, "cover", "")); cJSON_AddStringToObject(o, "profile", jget_str(e, "profile", "")); cJSON_AddNumberToObject(o, "played", played); + cJSON_AddStringToObject(o, "path", e->string); /* sized below, off the lock */ cJSON_AddItemToArray(out, o); } state_unlock(); - /* Newest first (a handful of games: a simple selection sort will do). */ - int n = cJSON_GetArraySize(out); + /* Newest first. */ + int n = cJSON_GetArraySize(out), k = 0; + cJSON **all = malloc(sizeof *all * (size_t)(n ? n : 1)), *o; cJSON *sorted = cJSON_CreateArray(); - for (int k = 0; k < n && k < limit; k++) { - int best = -1; - double top = -1; - for (int i = 0; i < cJSON_GetArraySize(out); i++) { - double t = jget_num(cJSON_GetArrayItem(out, i), "played", 0); - if (t > top) top = t, best = i; - } - cJSON_AddItemToArray(sorted, cJSON_DetachItemFromArray(out, best)); + if (!all) { + cJSON_Delete(out); + return sorted; } + while ((o = cJSON_DetachItemFromArray(out, 0))) all[k++] = o; cJSON_Delete(out); + qsort(all, (size_t)n, sizeof *all, newest_first); + for (k = 0; k < n; k++) { + if (k < limit) { + cJSON_AddNumberToObject(all[k], "size", local_size(jget_str(all[k], "path", ""), 0)); + cJSON_DeleteItemFromObjectCaseSensitive(all[k], "path"); + cJSON_AddItemToArray(sorted, all[k]); + } else { + cJSON_Delete(all[k]); + } + } + free(all); return sorted; } diff --git a/src/main.c b/src/main.c index b0d511c..fa34bf8 100644 --- a/src/main.c +++ b/src/main.c @@ -11,6 +11,7 @@ #include "autostart.h" #include "config.h" +#include "events.h" #include "covers.h" #include "detect.h" #include "http.h" @@ -97,7 +98,7 @@ static void *app_main(void *arg) { int init = plat_init(); if (init > 0) { /* RommPS and the HEN can both start a copy at boot; that's no news. */ - if (!plat_app_installed()) plat_notify("RomM Sync is already running"); + if (!plat_app_installed()) plat_notify("RommPS is already running"); return NULL; } if (init != 0) fprintf(stderr, "platform init reported a problem\n"); @@ -120,12 +121,13 @@ static void *app_main(void *arg) { * breaks HTTPS certificate checks and save timestamps. */ if (time(NULL) < 1577836800) { /* 2020-01-01 */ LOGW("the console clock isn't set; HTTPS and save times will be wrong"); - plat_notify("RomM Sync: the console's date and time aren't set. Set them in the console's Settings for syncing to work."); + plat_notify("RommPS: the console's date and time aren't set. Set them in the console's Settings for syncing to work."); } config_load(); if (g_cfg.profiles_custom && detect_refresh_profiles(g_cfg.profiles) > 0) config_save(); state_load(); + events_load(); /* Copies of older versions left on the console are brought up to date. */ autostart_repair(); http_global_init(plat_ca_bundle(), g_cfg.tls_verify); @@ -156,11 +158,11 @@ static void *app_main(void *arg) { plat_notify("RommPS %s is out of date. Update it from ProsperoStore.", app_version); } else if (quiet) { } else if (!plat_info()->self_update) { - plat_notify("RomM Sync is running. Install RommPS from ProsperoStore to use it on the console."); + plat_notify("RommPS is running. Install the RommPS app from ProsperoStore to use it on the console."); } else if (config_is_paired()) { - plat_notify("RomM Sync running - http://%s:%d", ip[0] ? ip : "127.0.0.1", port); + plat_notify("RommPS running - http://%s:%d", ip[0] ? ip : "127.0.0.1", port); } else { - plat_notify("RomM Sync: open http://%s:%d to pair with RomM", ip[0] ? ip : "127.0.0.1", port); + plat_notify("RommPS: open http://%s:%d to pair with RomM", ip[0] ? ip : "127.0.0.1", port); } sleep(5); /* wait for the network after boot */ @@ -198,7 +200,7 @@ static void *app_main(void *arg) { if (ip[0] || --resume_ticks == 0) { resume_ticks = 0; resume_notified = now; - if (!quiet) plat_notify("RomM Sync resumed - http://%s:%d", ip[0] ? ip : "127.0.0.1", port); + if (!quiet) plat_notify("RommPS resumed - http://%s:%d", ip[0] ? ip : "127.0.0.1", port); } } diff --git a/src/memcard.c b/src/memcard.c index c8a22f7..ed23200 100644 --- a/src/memcard.c +++ b/src/memcard.c @@ -1,5 +1,7 @@ #include "memcard.h" +#include "events.h" + #include #include #include @@ -39,6 +41,7 @@ static int cards_list(card *out, int max) { int n = 0; 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")) { for (int s = 1; s <= 2 && n < max; s++) { card *c = &out[n++]; @@ -155,6 +158,7 @@ static int upload_slot(const card *cd, const char *hash) { state_unlock(); cJSON_Delete(v); LOGI("backed up memory card %s", path_basename(path)); + events_add("card", "backed up", 0, path, "", NULL); return 0; } cJSON_Delete(v); @@ -243,6 +247,8 @@ int memcard_restore(int slot, char *err, int en) { state_save(); state_unlock(); LOGI("restored memory card %s from RomM", path_basename(path)); + events_add("card", "restored", 0, path, "", "Older version"); + events_save(); return 0; } diff --git a/src/pair.c b/src/pair.c index fb3a9d7..c044d2a 100644 --- a/src/pair.c +++ b/src/pair.c @@ -82,7 +82,7 @@ static void finish_pairing(const char *base, const char *token, const char *devi snprintf(msg, sizeof msg, "Paired as %s", g_cfg.username[0] ? g_cfg.username : "(unknown user)"); set_status("ok", msg); LOGI("paired with %s, device %s", base, device_id); - if (!plat_app_installed()) plat_notify("RomM Sync paired with %s", base); + if (!plat_app_installed()) plat_notify("RommPS paired with %s", base); sync_new_pairing(); sync_request("paired"); } diff --git a/src/profiles.c b/src/profiles.c index 96ecf79..f4a229c 100644 --- a/src/profiles.c +++ b/src/profiles.c @@ -4,6 +4,7 @@ #include #include +#include "detect.h" #include "platform.h" /* Platforms are matched on RomM's slug or fs_slug. The roots are the PS5's; @@ -89,6 +90,26 @@ cJSON *profiles_default(void) { return defs; } +/* A save state setting: the profile's own, else the built-in catalog's for + * the same emulator and system (profiles saved before these settings existed). */ +static const cJSON *state_setting(const cJSON *p, const cJSON *e, const char *key) { + const cJSON *v = cJSON_GetObjectItemCaseSensitive(e, key); + if (!v) v = cJSON_GetObjectItemCaseSensitive(p, key); + if (v) return v; + const cJSON *cat = cJSON_GetObjectItemCaseSensitive(detect_catalog_entry(jstr(p, NULL, "id", "")), "profile"), *ce; + if (!cat) return NULL; + cJSON_ArrayForEach(ce, cJSON_GetObjectItemCaseSensitive(cat, "platforms")) { + if (strcmp(profiles_system_key(ce), profiles_system_key(e)) != 0) continue; + if ((v = cJSON_GetObjectItemCaseSensitive(ce, key))) return v; + } + return cJSON_GetObjectItemCaseSensitive(cat, key); +} + +static const char *state_str(const cJSON *p, const cJSON *e, const char *key, const char *def) { + const cJSON *v = state_setting(p, e, key); + return cJSON_IsString(v) ? v->valuestring : def; +} + static int jbool(const cJSON *a, const cJSON *b, const char *key, int def) { const cJSON *v = b ? cJSON_GetObjectItemCaseSensitive(b, key) : NULL; if (!cJSON_IsBool(v)) v = cJSON_GetObjectItemCaseSensitive(a, key); @@ -192,6 +213,7 @@ static void check_requirement(const profile_map *m, const char *game, const char size_t nn) { *sync = m->base_sync_saves; note[0] = 0; + if (m->sync_off) str_copy(note, nn, "save sync is off for this platform"); if (!m->req_key[0] || !m->base_sync_saves) return; char val[64]; if (!m->ra_cfg_dir[0] || !core_option(m, game, cdir, val, sizeof val)) str_copy(val, sizeof val, m->req_default); @@ -340,9 +362,42 @@ int profiles_expand(const cJSON *profiles, profile_map **out) { str_copy(m->state_stems, sizeof m->state_stems, jstr(p, e, "state_stems", "")); str_copy(m->state_name, sizeof m->state_name, jstr(p, e, "state_name", "{rom_stem}{ext}")); str_copy(m->save_format, sizeof m->save_format, jstr(p, e, "save_format", "")); + /* Every Dolphin with a GCI folder keeps GameCube saves the same way. */ + if (!m->save_format[0] && !strcmp(jstr(p, e, "save_layout", ""), "gci")) + str_copy(m->save_format, sizeof m->save_format, "gc-gci"); + str_copy(m->state_cores, sizeof m->state_cores, state_str(p, e, "state_cores", m->emulator)); + str_copy(m->state_format, sizeof m->state_format, state_str(p, e, "state_format", "retroarch")); + str_copy(m->state_slot0, sizeof m->state_slot0, state_str(p, e, "state_slot0", ".state")); + const cJSON *nbc = state_setting(p, e, "save_name_by_core"), *nv; + size_t no = 0; + cJSON_ArrayForEach(nv, nbc) { + if (!cJSON_IsString(nv)) continue; + int w = snprintf(m->save_names_by_core + no, sizeof m->save_names_by_core - no, "%s%s=%s", no ? ";" : "", + nv->string, nv->valuestring); + if (w < 0 || (size_t)w >= sizeof m->save_names_by_core - no) break; + no += (size_t)w; + } + const cJSON *cs = state_setting(p, e, "state_core_setting"); + if (cJSON_IsObject(cs)) { + str_template(m->core_file, sizeof m->core_file, jstr(cs, NULL, "file", ""), vars); + str_copy(m->core_key, sizeof m->core_key, jstr(cs, NULL, "key", "")); + const cJSON *v; + size_t o = 0; + cJSON_ArrayForEach(v, cJSON_GetObjectItemCaseSensitive(cs, "values")) { + if (!cJSON_IsString(v)) continue; + int w = snprintf(m->core_values + o, sizeof m->core_values - o, "%s%s=%s", o ? "," : "", v->string, + v->valuestring); + if (w < 0 || (size_t)w >= sizeof m->core_values - o) break; + o += (size_t)w; + } + } str_template(m->memcards, sizeof m->memcards, jstr(p, e, "memcards", ""), vars); m->extra = profile_lists(p, "extra", profiles_system_key(e)); m->sync_saves = m->base_sync_saves = jbool(p, e, "sync_saves", 1); + const char *mode = jstr(p, e, "sync", "all"); /* per platform, or the emulator's (before 1.2) */ + m->sync_off = !strcmp(mode, "off"); + m->sync_states = !m->sync_off && strcmp(mode, "saves") != 0; + if (m->sync_off) m->sync_saves = m->base_sync_saves = 0; str_copy(m->root, sizeof m->root, root); str_copy(m->ra_cfg_dir, sizeof m->ra_cfg_dir, ra_cfg_dir); const cJSON *req = cJSON_GetObjectItemCaseSensitive(e, "sync_requires_option"); @@ -354,6 +409,7 @@ int profiles_expand(const cJSON *profiles, profile_map **out) { } /* Platform-wide answer, for the UI; games can override it. */ check_requirement(m, NULL, NULL, &m->sync_saves, m->sync_note, sizeof m->sync_note); + if (m->sync_off) str_copy(m->sync_note, sizeof m->sync_note, "save sync is off for this platform"); cnt++; } } @@ -362,10 +418,19 @@ int profiles_expand(const cJSON *profiles, profile_map **out) { for (int i = 0; i < cnt; i++) { if (!maps[i].extra) continue; maps[i].own_slot = 1; - for (int k = 0; k < cnt; k++) - if (!maps[k].extra && !strcmp(maps[k].platform_dir, maps[i].platform_dir) && maps[i].save_format[0] && + /* The same system (RomM's first slug: Porpoise calls GameCube's + * folder "gamecube", RetroArch "ngc") and the same format. */ + char sys_i[64]; + str_copy(sys_i, sizeof sys_i, maps[i].romm_slugs); + sys_i[strcspn(sys_i, ",")] = 0; + for (int k = 0; k < cnt; k++) { + char sys_k[64]; + str_copy(sys_k, sizeof sys_k, maps[k].romm_slugs); + sys_k[strcspn(sys_k, ",")] = 0; + if (!maps[k].extra && !strcmp(sys_k, sys_i) && maps[i].save_format[0] && !strcmp(maps[k].save_format, maps[i].save_format)) maps[i].own_slot = 0; + } } *out = maps; return cnt; diff --git a/src/profiles.h b/src/profiles.h index bac5061..05d34d2 100644 --- a/src/profiles.h +++ b/src/profiles.h @@ -22,6 +22,10 @@ * {serial} (a PS1 disc's, "SCUS-94194"), {rom_fnv64} (FNV-1a 64 of the game * file, in hex) and {rom_stem_safe} (the file name, non-alphanumerics as "_"). * + * "state_cores", "state_format", "state_slot0" and "state_core_setting" say + * which save states an emulator can load (see profile_map); profiles saved + * before they existed take them from the built-in catalog, by profile id. + * * "save_layout" (psp, gci or wii) syncs a game's save folder or files as one zip; * "bundle_dirs" lists where to look, with {save_path}, {save_root} and {bios}. * A platform entry can override any profile key. "sync_requires_option" @@ -32,7 +36,10 @@ * the first slugs of detect.c's DB_MAP). When several emulators play one, the * user picks one and the others list it in "exclude": ["psx"], which drops * that platform entry from them, or in "extra": ["psx"], which keeps it as - * an extra emulator for the system (see profile_map.extra). */ + * an extra emulator for the system (see profile_map.extra). + * + * "sync" is the user's choice for the emulator: "all" (saves and states, the + * default), "saves" (no states) or "off" (games only). */ #ifndef ROMM_PROFILES_H #define ROMM_PROFILES_H @@ -60,6 +67,24 @@ typedef struct { char save_stems[256]; char state_stems[256]; char state_name[128]; + /* Save states from elsewhere come down only when the emulator can load + * them: state_cores lists the cores whose states it takes (RomM + * "emulator" names, default the platform's emulator). An emulator that + * switches cores in its own settings ("state_core_setting": {"file", + * "key", "values": {"0": "pcsx_rearmed", ...}}) takes only the current + * one's. state_format is "retroarch" (RetroArch's container, compressed + * or not) or "raw" (the core's bare state, PSXS5); state_slot0 is the + * extension of the first slot (".state", or ".state0"). */ + char state_cores[256]; + char state_format[16]; + char state_slot0[16]; + char core_file[PATH_MAX_LEN]; + char core_key[32]; + char core_values[256]; /* "0=pcsx_rearmed,2=mednafen_psx_hw" */ + /* Saves named differently by some of those cores ("save_name_by_core": + * {"mednafen_psx_hw": "{rom_stem}_1{ext}"}): both names are the game's, + * downloads take the current core's. "core=template;core=template". */ + char save_names_by_core[256]; /* Several emulators for one system: the main one gets downloads first and * the extras ("extra": ["psx"] in their profile) a link to the same file. * Extras whose saves have the main one's format ("save_format": "psx-card") @@ -71,6 +96,8 @@ typedef struct { * backed up to RomM's memory cards (memcard.h). */ char memcards[512]; int sync_saves; + int sync_states; /* 0: the user turned states off for this emulator */ + int sync_off; /* 1: the user turned save sync off for this emulator */ /* RetroArch folder rules, [0] for saves and [1] for states: * in_content = next to the game, by_content = //, * by_core = //. Both sort flags nest content then core. */ diff --git a/src/statefmt.c b/src/statefmt.c new file mode 100644 index 0000000..8a988dd --- /dev/null +++ b/src/statefmt.c @@ -0,0 +1,155 @@ +#include "statefmt.h" + +#include +#include +#include +#include +#include +#include + +#include "util.h" + +static uint32_t le32(const unsigned char *p) { return p[0] | p[1] << 8 | p[2] << 16 | (uint32_t)p[3] << 24; } + +static uint64_t le64(const unsigned char *p) { return le32(p) | (uint64_t)le32(p + 4) << 32; } + +static void put32(unsigned char *p, uint32_t v) { + for (int i = 0; i < 4; i++) p[i] = (unsigned char)(v >> (8 * i)); +} + +state_kind state_kind_of(const unsigned char *b, size_t n) { + if (n >= 20 && !memcmp(b, "#RZIPv\x01#", 8)) return STATE_RZIP; + if (n >= 16 && !memcmp(b, "RASTATE\x01", 8)) return STATE_RASTATE; + return STATE_RAW; +} + +/* Unpacks an RZIP stream. Malloc'd, or NULL. */ +static unsigned char *unrzip(const unsigned char *b, size_t n, size_t *out_n) { + uint64_t total = le64(b + 12); + if (total == 0 || total > (uint64_t)256 << 20) return NULL; /* states are tens of MB at most */ + unsigned char *out = malloc((size_t)total); + if (!out) return NULL; + size_t off = 20, got = 0; + while (off + 4 <= n && got < total) { + uint32_t len = le32(b + off); + off += 4; + if (len > n - off) break; + uLongf room = (uLongf)(total - got); + if (uncompress(out + got, &room, b + off, len) != Z_OK) break; + got += room; + off += len; + } + if (got != total) { + free(out); + return NULL; + } + *out_n = (size_t)total; + return out; +} + +/* The "MEM " block of a RASTATE, pointed into b. */ +static const unsigned char *rastate_mem(const unsigned char *b, size_t n, size_t *mem_n) { + size_t off = 8; + while (off + 8 <= n) { + uint32_t size = le32(b + off + 4); + const unsigned char *tag = b + off; + off += 8; + if (size > n - off) return NULL; + if (!memcmp(tag, "MEM ", 4)) { + *mem_n = size; + return b + off; + } + if (!memcmp(tag, "END ", 4)) return NULL; + off += ((size_t)size + 7) & ~(size_t)7; + } + return NULL; +} + +unsigned char *state_to_raw(const unsigned char *b, size_t n, size_t *out_n) { + unsigned char *unpacked = NULL; + if (state_kind_of(b, n) == STATE_RZIP) { + unpacked = unrzip(b, n, &n); + if (!unpacked) return NULL; + b = unpacked; + } + unsigned char *out = NULL; + if (state_kind_of(b, n) == STATE_RASTATE) { + size_t mem_n; + const unsigned char *mem = rastate_mem(b, n, &mem_n); + if (mem && (out = malloc(mem_n ? mem_n : 1))) { + memcpy(out, mem, mem_n); + *out_n = mem_n; + } + } else if ((out = malloc(n ? n : 1))) { /* already bare */ + memcpy(out, b, n); + *out_n = n; + } + free(unpacked); + return out; +} + +unsigned char *state_to_rastate(const unsigned char *b, size_t n, size_t *out_n) { + state_kind k = state_kind_of(b, n); + if (k == STATE_RZIP) return unrzip(b, n, out_n); + if (k == STATE_RASTATE) { + unsigned char *out = malloc(n); + if (out) { + memcpy(out, b, n); + *out_n = n; + } + return out; + } + size_t pad = (8 - n % 8) % 8, total = 16 + n + pad + 8; + unsigned char *out = calloc(1, total); + if (!out) return NULL; + memcpy(out, "RASTATE\x01" "MEM ", 12); + put32(out + 12, (uint32_t)n); + memcpy(out + 16, b, n); + memcpy(out + 16 + n + pad, "END ", 4); /* size 0 */ + *out_n = total; + return out; +} + +const char *state_core_of(const unsigned char *b, size_t n) { + size_t mem_n = n; + if (state_kind_of(b, n) == STATE_RASTATE) { + b = rastate_mem(b, n, &mem_n); + if (!b) return NULL; + } + static const struct { + const char *magic, *core; + } sigs[] = { + {"STv4 PCSXra", "pcsx_rearmed"}, + {"MDFNSVST", "mednafen_psx_hw"}, + {"DUCC", "swanstation"}, + }; + for (size_t i = 0; i < sizeof sigs / sizeof sigs[0]; i++) { + size_t l = strlen(sigs[i].magic); + if (mem_n >= l && !memcmp(b, sigs[i].magic, l)) return sigs[i].core; + } + return NULL; +} + +void emulator_key(const char *name, char *out, size_t n) { + if (!n) return; + static const char *const prefixes[] = {"retroarch-", "retroarch_", "libretro-", "libretro_"}; + for (size_t i = 0; i < sizeof prefixes / sizeof prefixes[0]; i++) { + size_t l = strlen(prefixes[i]); + if (!strncasecmp(name, prefixes[i], l)) { + name += l; + break; + } + } + size_t o = 0; + for (; *name && o + 1 < n; name++) { + unsigned char ch = (unsigned char)*name; + out[o++] = ch == '-' || ch == ' ' ? '_' : (char)tolower(ch); + } + out[o] = 0; + char *lib = strstr(out, "_libretro"); + if (lib) *lib = 0; + /* Beetle PSX is "mednafen_psx_hw" in RetroArch and RomM's web player. */ + static const char *const aliases[][2] = {{"beetle_psx_hw", "mednafen_psx_hw"}, {"beetle_psx", "mednafen_psx"}}; + for (size_t i = 0; i < sizeof aliases / sizeof aliases[0]; i++) + if (!strcmp(out, aliases[i][0])) str_copy(out, n, aliases[i][1]); +} diff --git a/src/statefmt.h b/src/statefmt.h new file mode 100644 index 0000000..ba94e25 --- /dev/null +++ b/src/statefmt.h @@ -0,0 +1,38 @@ +/* Save state files: the containers emulators keep them in, and the names + * RomM clients give the emulator that made them. + * + * RetroArch (and RomM's web player) wrap a core's state: + * "RASTATE\x01", then blocks of a 4-byte tag, a little-endian u32 size and + * the data padded to 8 bytes: "MEM " (the core's state) ... "END ". + * and RetroArch compresses that by default: + * "#RZIPv\x01#", u32 chunk size, u64 total size, then chunks of a u32 + * length and a zlib stream. + * Standalone emulators built on a libretro core (PSXS5) keep the core's + * state bare, which is what "MEM " holds. */ +#ifndef ROMM_STATEFMT_H +#define ROMM_STATEFMT_H + +#include + +typedef enum { STATE_RAW, STATE_RASTATE, STATE_RZIP } state_kind; + +state_kind state_kind_of(const unsigned char *b, size_t n); + +/* The core's own state, out of whatever container it's in. Returns malloc'd + * bytes, or NULL when the container is broken. */ +unsigned char *state_to_raw(const unsigned char *b, size_t n, size_t *out_n); + +/* An uncompressed RetroArch state: what RomM's web player saves and loads. + * A bare state is wrapped, a compressed one unpacked. Malloc'd, or NULL. */ +unsigned char *state_to_rastate(const unsigned char *b, size_t n, size_t *out_n); + +/* The libretro core a bare (or wrapped) state comes from, by its signature: + * "pcsx_rearmed", "mednafen_psx_hw", "swanstation", or NULL. */ +const char *state_core_of(const unsigned char *b, size_t n); + +/* RomM's emulator names, made comparable: lower case, "-" as "_", without + * the "retroarch-"/"libretro-" prefixes and "_libretro..." suffixes other + * clients add ("mGBA", "retroarch-mgba", "mgba_libretro_ios" -> "mgba"). */ +void emulator_key(const char *name, char *out, size_t n); + +#endif diff --git a/src/sync.c b/src/sync.c index 6ccac91..93a3bdf 100644 --- a/src/sync.c +++ b/src/sync.c @@ -11,13 +11,16 @@ #include "config.h" #include "bundle.h" +#include "gcmem.h" #include "gameid.h" #include "http.h" #include "memcard.h" #include "platform.h" #include "profiles.h" #include "romm.h" +#include "events.h" #include "state.h" +#include "statefmt.h" #include "util.h" #include "zip.h" @@ -51,6 +54,11 @@ typedef struct { int is_bundle; /* several files synced as one zip, see bundle.h */ char game_id[16]; char upload_name[256]; + /* GameCube saves in a GCI folder keep the form RomM has them in (gc_form): + * "zip" (the folder's files for the game), "gci" (one of them, gc_file) + * or "card" (a whole memory card with them on it). */ + char gc_form[8]; + char gc_file[PATH_MAX_LEN]; } local_file; typedef struct { @@ -89,6 +97,13 @@ static void set_phase(const char *p) { str_copy(g_phase, sizeof g_phase, p); } static char *url_q(const char *s) { return http_escape(s ? s : ""); } +/* This console's slots: its own ("autosave"), and the ones derived from it + * ("autosave-rtc", "autosave-"). Other slots are other devices'. */ +static int our_slot(const scan_ctx *c, const char *slot) { + size_t n = strlen(c->slot); + return !strncmp(slot, c->slot, n) && (slot[n] == 0 || slot[n] == '-'); +} + static const char *slot_for(const scan_ctx *c, const profile_map *m, const char *file, char *buf, size_t n) { /* The main save uses the shared slot so other RomM clients pair with it. @@ -150,6 +165,7 @@ static int game_file(const local_rom *r, char *out, size_t n) { static int map_uses(const profile_map *m, const char *var) { return strstr(m->save_stems, var) || strstr(m->state_stems, var) || strstr(m->save_name, var) || + strstr(m->save_names_by_core, var) || strstr(m->state_name, var); } @@ -204,6 +220,15 @@ static void add_derived_stems(local_rom *r, cJSON *e) { if (stem[0] && !strchr(stem, '{') && strcmp(stem, "_1") != 0 && stem[0] != '_') add_stem(r, stem); } } + /* Names other cores of the emulator give the save ("core=template;..."). */ + char list[256], *save = NULL; + str_copy(list, sizeof list, m->save_names_by_core); + for (char *t = strtok_r(list, ";", &save); t; t = strtok_r(NULL, ";", &save)) { + char *eq = strchr(t, '='), stem[256]; + if (!eq) continue; + str_template(stem, sizeof stem, eq + 1, vars); + if (stem[0] && !strchr(stem, '{') && stem[0] != '_') add_stem(r, stem); + } } static int platform_ids_for_map(const scan_ctx *c, const profile_map *m, int *ids, int max) { @@ -260,6 +285,14 @@ static cJSON *resolve_rom_on_server(const scan_ctx *c, const profile_map *m, con return match.by_stem; } +/* Whether a comma separated list has item. */ +static int list_has(const char *list, const char *item) { + size_t n = strlen(item); + for (const char *p = list; n && (p = strstr(p, item)); p += n) + if ((p == list || p[-1] == ',') && (p[n] == ',' || !p[n])) return 1; + return 0; +} + static void record_local_rom(const char *path, int rom_id, const profile_map *m, const cJSON *srv, const char *md5, const local_rom *r) { const char *name = jget_str(srv, "name", ""), *fs_name = jget_str(srv, "fs_name", ""); @@ -280,6 +313,84 @@ static void record_local_rom(const char *path, int rom_id, const profile_map *m, else cJSON_AddItemToObject(e, "stems", stems); cJSON_DeleteItemFromObjectCaseSensitive(e, "miss_at"); + cJSON_DeleteItemFromObjectCaseSensitive(e, "miss_dirs"); +} + +/* Renames files in dir (and its folders one level down, RetroArch's per-core + * ones) named "." to ".", unless that's taken. */ +static void rename_by_stem(const char *dir, const char *old_stem, const char *new_stem, int depth) { + DIR *d = opendir(dir); + struct dirent *de; + size_t ol = strlen(old_stem); + while (d && (de = readdir(d))) { + if (de->d_name[0] == '.') continue; + char from[PATH_MAX_LEN], to[PATH_MAX_LEN + 256]; + path_join(from, sizeof from, dir, de->d_name); + if (dir_exists(from)) { + if (depth == 0) rename_by_stem(from, old_stem, new_stem, 1); + continue; + } + if (strncmp(de->d_name, old_stem, ol) != 0 || de->d_name[ol] != '.') continue; + snprintf(to, sizeof to, "%s/%s%s", dir, new_stem, de->d_name + ol); + if (file_exists(to) || rename(from, to) != 0) continue; + LOGI("renamed %s -> %s", from, to); + state_lock(); + static const char *const sections[] = {"saves", "states"}; + for (int i = 0; i < 2; i++) { + cJSON *sec = state_section(sections[i]), *e = cJSON_DetachItemFromObjectCaseSensitive(sec, from); + if (e) cJSON_AddItemToObject(sec, to, e); + } + state_unlock(); + } + if (d) closedir(d); +} + +/* Version 1.1.1 downloaded a game RomM keeps as a folder holding one file as + * a single file named after the folder, with no extension, which emulators + * don't list. It becomes that folder with the file inside under its own + * name, as downloads do now; its saves and states take the file's name, which + * is what the emulator looks for. Returns 1 when it moved the game. */ +static int fix_bare_rom(const profile_map *m, const char *path, int rom_id) { + char api[64]; + long st = 0; + snprintf(api, sizeof api, "/api/roms/%d", rom_id); + cJSON *rom = romm_call("GET", api, NULL, &st); + const cJSON *files = cJSON_GetObjectItemCaseSensitive(rom, "files"); + const char *fn = path_basename(jget_str(cJSON_GetArrayItem(files, 0), "file_name", "")); + int done = 0; + if (st == 200 && cJSON_GetArraySize(files) == 1 && path_ext(fn)[0] && + !strcmp(jget_str(rom, "fs_name", ""), path_basename(path))) { + char tmp[PATH_MAX_LEN + 8], inside[PATH_MAX_LEN + 256]; + snprintf(tmp, sizeof tmp, "%s.moving", path); + snprintf(inside, sizeof inside, "%s/%s", path, fn); + if (rename(path, tmp) == 0) { + if (mkdir_p(path) == 0 && rename(tmp, inside) == 0) { + char old_stem[256], new_stem[256]; + str_copy(old_stem, sizeof old_stem, path_basename(path)); + path_stem(new_stem, sizeof new_stem, fn); + LOGI("moved %s into %s, so the emulator lists it", path, inside); + state_lock(); + cJSON *e = state_entry("roms", path); + if (e) { + cJSON_DeleteItemFromObjectCaseSensitive(e, "stems"); + cJSON *stems = cJSON_AddArrayToObject(e, "stems"); + cJSON_AddItemToArray(stems, cJSON_CreateString(old_stem)); + cJSON_AddItemToArray(stems, cJSON_CreateString(new_stem)); + } + state_unlock(); + if (strcmp(old_stem, new_stem) != 0) { + rename_by_stem(m->save_dir, old_stem, new_stem, 0); + if (strcmp(m->state_dir, m->save_dir) != 0) rename_by_stem(m->state_dir, old_stem, new_stem, 0); + } + done = 1; + } else { + rmdir(path); + rename(tmp, path); /* put it back as it was */ + } + } + } + cJSON_Delete(rom); + return done; } static void scan_rom_dir(scan_ctx *c, const profile_map *m) { @@ -309,12 +420,29 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) { add_stem(&r, de->d_name); DIR *sd = opendir(path); struct dirent *se; + char only[256] = ""; + int files = 0; while (sd && (se = readdir(sd))) { if (se->d_name[0] == '.') continue; path_stem(stem, sizeof stem, se->d_name); if (ext_in_list(se->d_name, ".cue,.m3u,.gdi,.chd,.ccd,.iso,.pbp")) add_stem(&r, stem); + if (files++ == 0) str_copy(only, sizeof only, stem); } if (sd) closedir(sd); + /* One file in the folder (RomM keeps some single-file games so): + * emulators name its saves after that file. */ + if (files == 1) { + /* The emulator names saves after the file inside, so new + * saves take its name (the folder's still matches old ones). */ + add_stem(&r, only); + for (int k = 1; k < r.nstems; k++) + if (!strcmp(r.stems[k], only)) { + char first[sizeof r.stems[0]]; + str_copy(first, sizeof first, r.stems[0]); + str_copy(r.stems[0], sizeof r.stems[0], only); + str_copy(r.stems[k], sizeof r.stems[k], first); + } + } } else { path_stem(stem, sizeof stem, de->d_name); add_stem(&r, stem); @@ -324,6 +452,34 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) { cJSON *e = state_entry("roms", path); int rom_id = e ? (int)jget_num(e, "rom_id", 0) : 0; time_t miss_at = e ? (time_t)jget_num(e, "miss_at", 0) : 0; + /* A folder that holds several of the emulator's systems (Porpoise: + * GameCube and Wii): a game is the system's it matched for, and a + * miss for one system doesn't stop the others asking. */ + const char *was_dir = e ? jget_str(e, "platform_dir", "") : ""; + const int other_system = e && rom_id && was_dir[0] && !strcmp(jget_str(e, "profile", ""), m->profile_id) && + strcmp(was_dir, m->platform_dir) != 0; + if (miss_at && !list_has(jget_str(e, "miss_dirs", ""), m->platform_dir)) miss_at = 0; + if (other_system) { + state_unlock(); + continue; + } + /* Seen under this emulator now (an older version named its profile otherwise). */ + if (e && rom_id && strcmp(jget_str(e, "profile", ""), m->profile_id) != 0) { + int stale = 1; /* no profile has that name now */ + for (int k = 0; k < c->nmaps && stale; k++) stale = strcmp(c->maps[k].profile_id, jget_str(e, "profile", "")) != 0; + /* In a folder shared by systems, the system it was recorded for takes it. */ + int theirs = 0; + for (int k = 0; k < c->nmaps && stale && was_dir[0] && strcmp(was_dir, m->platform_dir) != 0; k++) + theirs |= !strcmp(c->maps[k].profile_id, m->profile_id) && !strcmp(c->maps[k].platform_dir, was_dir); + if (theirs) { + state_unlock(); + continue; + } + if (stale) { + jset_str(e, "profile", m->profile_id); + jset_str(e, "platform_dir", m->platform_dir); + } + } if (e) { const cJSON *s; cJSON_ArrayForEach(s, cJSON_GetObjectItemCaseSensitive(e, "stems")) @@ -344,8 +500,13 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) { LOGI("matched %s -> RomM rom %d", path, rom_id); } else { cJSON *ne = state_entry_ensure("roms", path); + char dirs[256]; + const char *had = jget_str(ne, "miss_dirs", ""); + if (list_has(had, m->platform_dir)) str_copy(dirs, sizeof dirs, had); + else snprintf(dirs, sizeof dirs, "%s%s%s", had, had[0] ? "," : "", m->platform_dir); jset_num(ne, "rom_id", 0); jset_num(ne, "miss_at", (double)now); + jset_str(ne, "miss_dirs", dirs); jset_str(ne, "profile", m->profile_id); } state_unlock(); @@ -357,6 +518,18 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) { state_unlock(); continue; } + if (!is_dir && !path_ext(de->d_name)[0] && fix_bare_rom(m, path, rom_id)) { + char fixed[PATH_MAX_LEN]; + str_copy(fixed, sizeof fixed, path); /* the folder, now */ + DIR *sd = opendir(fixed); + struct dirent *se; + while (sd && (se = readdir(sd))) + if (se->d_name[0] != '.') { + path_stem(stem, sizeof stem, se->d_name); + add_stem(&r, stem); + } + if (sd) closedir(sd); + } r.rom_id = rom_id; if (m->save_stems[0] || m->state_stems[0] || map_uses(m, "{rom_stem_safe}")) { state_lock(); @@ -377,8 +550,19 @@ static void scan_rom_dir(scan_ctx *c, const profile_map *m) { /* The folder the emulator reads this game's saves (or states) from. Returns 0 * when saves are sorted by core and the core name isn't known. */ +static int rom_game_id(const local_rom *r, char *out, size_t n); + static int expected_dir(const local_rom *r, int states, char *out, size_t n) { const rom_rules *ru = &r->rules; + /* A folder per game, by its id (Porpoise's states/GAFE01). */ + const char *at = strstr(states ? ru->state_dir : ru->save_dir, "{game_id}"); + if (at) { + char id[16]; + if (rom_game_id(r, id, sizeof id) != 0) return 0; + const char *dir = states ? ru->state_dir : ru->save_dir; + snprintf(out, n, "%.*s%s%s", (int)(at - dir), dir, id, at + 9); + return 1; + } char content_dir[PATH_MAX_LEN]; str_copy(content_dir, sizeof content_dir, r->path); if (!dir_exists(r->path)) { /* multi-file games are folders themselves */ @@ -411,8 +595,16 @@ static const local_rom *match_rom(const scan_ctx *c, const char *file_dir, const size_t best_len = 0, best_active_len = 0; for (int i = 0; i < c->nroms; i++) { const local_rom *r = &c->roms[i]; - if (!r->rules.sync_saves || (!states && r->map->save_layout[0])) continue; + if (!r->rules.sync_saves || (states && !r->map->sync_states) || (!states && r->map->save_layout[0])) continue; if (!ext_in_list(file_name, states ? r->map->state_exts : r->map->save_exts)) continue; + if (strstr(states ? r->rules.state_dir : r->rules.save_dir, "{game_id}")) { + char exp[PATH_MAX_LEN]; + if (expected_dir(r, states, exp, sizeof exp) && !strcmp(exp, file_dir)) { + *active = 1; + return r; + } + continue; + } for (int s = 0; s < r->nstems; s++) { size_t l = strlen(r->stems[s]); if (strncmp(file_name, r->stems[s], l) != 0 || file_name[l] != '.') continue; @@ -485,6 +677,21 @@ static void add_stray(const local_rom *r, const char *path, int states) { state_unlock(); } +/* Saves on a PS1 memory card (directory frames starting one), or -1 when + * it isn't one. */ +static int psx_card_saves(const profile_map *m, const char *path) { + if (strcmp(m->save_format, "psx-card") != 0) return -1; + unsigned char dir[16 * 128]; + FILE *fp = fopen(path, "rb"); + if (!fp) return -1; + size_t n = fread(dir, 1, sizeof dir, fp); + fclose(fp); + if (n < sizeof dir || dir[0] != 'M' || dir[1] != 'C') return -1; + int used = 0; + for (int i = 1; i < 16; i++) used += dir[i * 128] == 0x51; + return used; +} + static void scan_file_dir(scan_ctx *c, const char *dir, int depth, int states) { DIR *d = opendir(dir); if (!d) return; @@ -525,9 +732,22 @@ static void scan_file_dir(scan_ctx *c, const char *dir, int depth, int states) { for (int i = 0; i < c->nsaves; i++) { local_file *o = &c->saves[i]; if (o->rom_id == f.rom_id && !strcmp(o->slot, f.slot)) { - LOGW("two local saves for rom %d slot %s: %s, %s (using newest)", f.rom_id, - f.slot, o->path, f.path); - if (f.mtime > o->mtime) { + /* Said once per pair while running, not at every sync. */ + static cJSON *told; + char key[2 * PATH_MAX_LEN + 8]; + snprintf(key, sizeof key, "%s|%s", o->path, f.path); + state_lock(); + if (!told) told = cJSON_CreateObject(); + const int first = !cJSON_GetObjectItemCaseSensitive(told, key); + if (first) cJSON_AddTrueToObject(told, key); + state_unlock(); + if (first) + LOGW("two local saves for rom %d slot %s: %s, %s (using newest)", f.rom_id, f.slot, o->path, f.path); + /* A blank card (an emulator made a fresh one under another + * name) never beats one with saves on it. */ + int fo = psx_card_saves(r->map, f.path), oo = psx_card_saves(r->map, o->path); + int take = fo == 0 && oo > 0 ? 0 : oo == 0 && fo > 0 ? 1 : f.mtime > o->mtime; + if (take) { hash_cached("saves", &f); *o = f; } @@ -558,6 +778,9 @@ static void base_dir(const local_rom *r, int states, char *out, size_t n) { } } else { str_copy(out, n, states ? r->rules.state_dir : r->rules.save_dir); + /* A folder per game ("states/{game_id}"): scan from its parent. */ + char *id = strstr(out, "/{game_id}"); + if (id) *id = 0; } } @@ -815,6 +1038,203 @@ static int rom_game_id(const local_rom *r, char *out, size_t n) { return 0; } +/* ---- GameCube: the form RomM keeps a game's saves in ---- + * RomM is the authority: a game's saves go up in the form they're there in. + * zip the game's .gci files zipped (this app's own form, and the default) + * gci a single .gci, as other apps send it + * card a whole memory card (.raw), as Dolphin keeps one: this console's + * saves for the game are put on a copy of RomM's card (kept in + * gccards/.raw), so the card's other saves stay as they are. */ + +static const char *gc_form_of_name(const char *file_name) { + if (str_ends_with_ci(file_name, ".gci")) return "gci"; + if (ext_in_list(file_name, ".raw,.gcp,.mcp")) return "card"; + return "zip"; +} + +static void gc_card_cache(int rom_id, char *out, size_t n) { + char dir[PATH_MAX_LEN]; + path_join(dir, sizeof dir, plat_data_dir(), "gccards"); + snprintf(out, n, "%s/%d.raw", dir, rom_id); +} + +static void gc_form_get(const local_rom *r, char *form, size_t n, char *name, size_t nn) { + state_lock(); + cJSON *e = state_entry("roms", r->path); + str_copy(form, n, e ? jget_str(e, "gc_form", "zip") : "zip"); + if (name) str_copy(name, nn, e ? jget_str(e, "gc_name", "") : ""); + state_unlock(); +} + +static void gc_form_set(const local_rom *r, const char *form, const char *name) { + state_lock(); + cJSON *e = state_entry("roms", r->path); + if (e) { + jset_str(e, "gc_form", form); + if (name && name[0]) jset_str(e, "gc_name", name); + } + state_unlock(); +} + +/* Fetches a save's content without telling RomM this console has it. */ +static int gc_fetch(int save_id, const char *dest) { + char api[64]; + snprintf(api, sizeof api, "/api/saves/%d/content", save_id); + http_resp resp; + mkdir_parent(dest); + if (romm_download(api, dest, NULL, NULL, &resp) != 0) { + LOGW("save %d: %s", save_id, resp.error); + unlink(dest); + return -1; + } + return 0; +} + +static int gc_is_card_file(const char *path) { + size_t len = 0; + char *data = read_file(path, &len); + gc_card card; + int ok = data && gc_card_open(&card, (const unsigned char *)data, len) == 0; + if (ok) gc_card_free(&card); + free(data); + return ok; +} + +/* Which form RomM has the game's saves in: its newest one in this console's + * slot, or else its newest one anywhere. For a card, a copy of it is kept + * here so this console's saves can be put on it. */ +static void gc_learn_form(const scan_ctx *c, const local_rom *r) { + char api[64]; + snprintf(api, sizeof api, "/api/saves?rom_id=%d", r->rom_id); + long st = 0; + cJSON *all = romm_call("GET", api, NULL, &st); + const cJSON *s, *mine = NULL, *any = NULL; + cJSON_ArrayForEach(s, (st == 200 ? all : NULL)) { + if ((int)jget_num(s, "rom_id", 0) != r->rom_id) continue; + const char *up = jget_str(s, "updated_at", ""); + if (!any || strcmp(up, jget_str(any, "updated_at", "")) > 0) any = s; + if (!strcmp(jget_str(s, "slot", ""), c->slot) && (!mine || strcmp(up, jget_str(mine, "updated_at", "")) > 0)) + mine = s; + } + const cJSON *pick = mine ? mine : any; + if (!pick && st == 200) { /* nothing on RomM: this app's own form, no card kept */ + char cache[PATH_MAX_LEN]; + gc_card_cache(r->rom_id, cache, sizeof cache); + unlink(cache); + gc_form_set(r, "zip", NULL); + } + if (pick) { + /* RomM stamps the names it keeps: "MemoryCardA.USA [2026-10-14_16-39-01].raw". */ + char name[256]; + str_copy(name, sizeof name, jget_str(pick, "file_name", "")); + char *open = strrchr(name, '['), *close = open ? strchr(open, ']') : NULL; + if (open && close && open > name && open[-1] == ' ') memmove(open - 1, close + 1, strlen(close + 1) + 1); + const char *form = gc_form_of_name(name); + char was[8]; + gc_form_get(r, was, sizeof was, NULL, 0); + if (strcmp(was, form) != 0) LOGI("%s: RomM keeps its saves as %s (%s)", path_basename(r->path), form, name); + gc_form_set(r, form, name); + char cache[PATH_MAX_LEN]; + gc_card_cache(r->rom_id, cache, sizeof cache); + if (strcmp(form, "card") != 0) unlink(cache); /* RomM keeps no card for it now */ + if (!strcmp(form, "card") && !file_exists(cache) && gc_fetch((int)jget_num(pick, "id", 0), cache) == 0 && + !gc_is_card_file(cache)) { + LOGW("%s: RomM's save %s isn't a GameCube memory card", path_basename(r->path), name); + unlink(cache); + } + } + cJSON_Delete(all); +} + +/* .gci files taken off a card. */ +typedef struct { + unsigned char *g[32]; + size_t n[32]; + int count; +} gc_list; + +static void gc_keep(const unsigned char *g, size_t n, void *ud) { + gc_list *l = ud; + if (l->count >= 32 || !(l->g[l->count] = malloc(n))) return; + memcpy(l->g[l->count], g, n); + l->n[l->count++] = n; +} + +static void gc_list_free(gc_list *l) { + for (int k = 0; k < l->count; k++) free(l->g[k]); + l->count = 0; +} + +/* RomM's card with this console's saves for the game on it, in place of the + * card's own. Unchanged saves leave the card exactly as RomM has it. */ +static int gc_virtual_card(int rom_id, const bundle *b, unsigned char **out, size_t *out_n) { + char cache[PATH_MAX_LEN]; + gc_card_cache(rom_id, cache, sizeof cache); + size_t len = 0; + char *raw = read_file(cache, &len); + gc_card card; + if (!raw || gc_card_open(&card, (const unsigned char *)raw, len) != 0) { + free(raw); + return -1; + } + free(raw); + /* Same saves, same bytes (where each sits on the card aside)? */ + gc_list on_card = {0}; + gc_card_saves(&card, b->game_id, gc_keep, &on_card); + int same = on_card.count == b->n; + for (int i = 0; same && i < b->n; i++) { + size_t gl = 0; + char *g = read_file(b->files[i].path, &gl); + int match = 0; + for (int k = 0; g && k < on_card.count && !match; k++) + match = on_card.n[k] == gl && !memcmp(on_card.g[k], g, 0x36) && + !memcmp(on_card.g[k] + 0x38, g + 0x38, gl - 0x38); + same = match; + free(g); + } + gc_list_free(&on_card); + int rc = 0; + if (!same) { + gc_card_remove(&card, b->game_id); + for (int i = 0; i < b->n && rc == 0; i++) { + size_t gl = 0; + char *g = read_file(b->files[i].path, &gl); + if (!g || gc_card_put(&card, (const unsigned char *)g, gl) != 0) { + LOGW("%s doesn't fit on RomM's memory card", b->files[i].name); + rc = -1; + } + free(g); + } + } + if (rc == 0) { + *out = card.data; + *out_n = card.size; + card.data = NULL; + } + gc_card_free(&card); + return rc; +} + +/* The one .gci that goes up when RomM keeps a single one: the same save as + * RomM's, or the newest. */ +static int gc_pick_gci(const bundle *b, const char *server_name, char *out, size_t n) { + time_t best = 0; + out[0] = 0; + for (int i = 0; i < b->n; i++) + if (server_name && !strcmp(b->files[i].name, server_name)) { + str_copy(out, n, b->files[i].path); + return 0; + } + for (int i = 0; i < b->n; i++) { + time_t t = file_mtime(b->files[i].path); + if (!out[0] || t > best) { + best = t; + str_copy(out, n, b->files[i].path); + } + } + return out[0] ? 0 : -1; +} + static void fill_bundle_file(local_file *f, const scan_ctx *c, const local_rom *r, const bundle *b) { memset(f, 0, sizeof *f); snprintf(f->path, sizeof f->path, "%s#%s", b->dir, b->game_id); @@ -834,6 +1254,26 @@ static void fill_bundle_file(local_file *f, const scan_ctx *c, const local_rom * path_stem(stem, sizeof stem, r->path); snprintf(f->upload_name, sizeof f->upload_name, "%s.zip", stem); } + if (strcmp(r->map->save_layout, "gci") != 0) return; + /* In the form RomM has it: the hash RomM would give what we'd send. */ + char form[8], name[256]; + gc_form_get(r, form, sizeof form, name, sizeof name); + if (!strcmp(form, "gci") && gc_pick_gci(b, name, f->gc_file, sizeof f->gc_file) == 0) { + str_copy(f->gc_form, sizeof f->gc_form, "gci"); + md5_file_hex(f->gc_file, f->hash); + str_copy(f->upload_name, sizeof f->upload_name, name[0] ? name : path_basename(f->gc_file)); + } else if (!strcmp(form, "card")) { + unsigned char *card = NULL; + size_t n = 0; + str_copy(f->gc_form, sizeof f->gc_form, "card"); + if (gc_virtual_card(r->rom_id, b, &card, &n) == 0) { + md5_hex(card, n, f->hash); + free(card); + } else { + f->hash[0] = 0; /* no card to put them on: not sent (scan_bundles) */ + } + str_copy(f->upload_name, sizeof f->upload_name, name[0] ? name : "MemoryCardA.raw"); + } } static void scan_bundles(scan_ctx *c) { @@ -846,15 +1286,78 @@ static void scan_bundles(scan_ctx *c) { state_lock(); cJSON_AddItemToArray(g_bundle_roots, cJSON_CreateString(b.dir)); state_unlock(); + if (!strcmp(r->map->save_layout, "gci")) gc_learn_form(c, r); if (b.n > 0 && !find_save(c, r->rom_id, c->slot)) { local_file f; fill_bundle_file(&f, c, r, &b); - add_file(&c->saves, &c->nsaves, &c->capsaves, &f); + if (f.hash[0]) add_file(&c->saves, &c->nsaves, &c->capsaves, &f); + else LOGW("%s: its saves can't go on RomM's memory card yet", path_basename(r->path)); } bundle_free(&b); } } +/* A GameCube save from RomM that's a memory card or a single .gci: the + * game's saves on it replace the game's .gci files here. 1 when placed, 0 + * when it's neither (a zip), -1 when it can't be used. The bundle is listed + * again. */ +static int gc_place(const local_rom *r, bundle *b, const char *file) { + size_t len = 0; + char *data = read_file(file, &len); + if (!data) return -1; + const unsigned char *u = (const unsigned char *)data; + gc_card card; + int rc = 0; + if (gc_card_open(&card, u, len) == 0) { + gc_list on_card = {0}; + gc_card_saves(&card, b->game_id, gc_keep, &on_card); + if (on_card.count) { + for (int i = 0; i < b->n; i++) unlink(b->files[i].path); + mkdir_p(b->dir); + } else { + LOGI("RomM's memory card has no saves for %s: keeping this console's", b->game_id); + } + for (int k = 0; k < on_card.count; k++) { + char name[256], path[PATH_MAX_LEN]; + gc_gci_name(on_card.g[k], name, sizeof name); + path_join(path, sizeof path, b->dir, name); + if (write_file_atomic(path, on_card.g[k], on_card.n[k]) != 0) rc = -1; + } + gc_list_free(&on_card); + gc_card_free(&card); + char cache[PATH_MAX_LEN]; + gc_card_cache(r->rom_id, cache, sizeof cache); + mkdir_parent(cache); + if (rc == 0) rc = copy_file(file, cache) == 0 ? 1 : -1; + if (rc > 0) gc_form_set(r, "card", NULL); + } else if (gc_gci_valid(u, len)) { + /* Over the same save here, or beside the others. */ + char path[PATH_MAX_LEN] = ""; + for (int i = 0; i < b->n && !path[0]; i++) { + size_t gl = 0; + char *g = read_file(b->files[i].path, &gl); + if (g && gl >= GC_DENTRY && gc_gci_same_save((const unsigned char *)g, u)) str_copy(path, sizeof path, b->files[i].path); + free(g); + } + if (!path[0]) { + char name[256]; + gc_gci_name(u, name, sizeof name); + mkdir_p(b->dir); + path_join(path, sizeof path, b->dir, name); + } + rc = write_file_atomic(path, u, len) == 0 ? 1 : -1; + if (rc > 0) gc_form_set(r, "gci", NULL); + } + free(data); + if (rc > 0) { + char id[16]; + str_copy(id, sizeof id, b->game_id); + bundle_free(b); + if (bundle_for_rom(r, id, b) != 0) rc = -1; + } + return rc; +} + /* Downloads a save zip and unpacks it over the game's current files. With * record set, the result counts as synced; a manual restore leaves it to upload. */ static int do_download_bundle(const scan_ctx *c, int save_id, const local_rom *r, const char *slot, int session_id, @@ -889,7 +1392,9 @@ static int do_download_bundle(const scan_ctx *c, int save_id, const local_rom *r if (bundle_zip(&b, cur) == 0) backup_file(c, cur, r->rom_id); unlink(cur); } - if (bundle_replace(&b, zip, tmpdir) != 0) { + int placed = !strcmp(r->map->save_layout, "gci") ? gc_place(r, &b, zip) : 0; + if (placed < 0) goto out; + if (!placed && bundle_replace(&b, zip, tmpdir) != 0) { LOGE("save %d is not a usable zip", save_id); goto out; } @@ -929,7 +1434,26 @@ static int do_upload(const scan_ctx *c, const local_file *f, int session_id, int free(dev); long st = 0; cJSON *j; - if (f->is_bundle) { + char card_up[PATH_MAX_LEN] = ""; + if (!strcmp(f->gc_form, "gci")) { + j = romm_upload("POST", path, "saveFile", f->gc_file, f->upload_name, &st); + } else if (!strcmp(f->gc_form, "card")) { + /* RomM's card with this console's saves for the game on it. */ + bundle b; + unsigned char *card = NULL; + size_t n = 0; + snprintf(card_up, sizeof card_up, "%s/tmp/upload-%d.raw", plat_data_dir(), f->rom_id); + if (bundle_reopen(f, &b) != 0 || gc_virtual_card(f->rom_id, &b, &card, &n) != 0 || + write_file_atomic(card_up, card, n) != 0) { + bundle_free(&b); + free(card); + if (status_out) *status_out = 0; + return -1; + } + bundle_free(&b); + free(card); + j = romm_upload("POST", path, "saveFile", card_up, f->upload_name, &st); + } else if (f->is_bundle) { char zip[PATH_MAX_LEN]; bundle b; snprintf(zip, sizeof zip, "%s/tmp/upload-%d.zip", plat_data_dir(), f->rom_id); @@ -946,6 +1470,12 @@ static int do_upload(const scan_ctx *c, const local_file *f, int session_id, int } if (status_out) *status_out = st; int ok = (st == 200 || st == 201) && j; + if (card_up[0]) { /* the card RomM has now is the one kept here */ + char cache[PATH_MAX_LEN]; + gc_card_cache(f->rom_id, cache, sizeof cache); + if (ok) move_file(card_up, cache); + else unlink(card_up); + } if (ok) { record_save(f, (int)jget_num(j, "id", 0), jget_str(j, "content_hash", f->hash)); LOGI("uploaded %s (rom %d, slot %s)", f->path, f->rom_id, f->slot); @@ -955,6 +1485,23 @@ static int do_upload(const scan_ctx *c, const local_file *f, int session_id, int } /* Where a downloaded save goes when the game has no local save yet. */ +static void active_core(const profile_map *m, char *out, size_t n); + +/* The save name the emulator uses with the core it runs now. */ +static void save_name_now(const profile_map *m, char *out, size_t n) { + str_copy(out, n, m->save_name); + if (!m->save_names_by_core[0]) return; + char core[64], list[256], *save = NULL; + active_core(m, core, sizeof core); + str_copy(list, sizeof list, m->save_names_by_core); + for (char *t = strtok_r(list, ";", &save); t; t = strtok_r(NULL, ";", &save)) { + char *eq = strchr(t, '='); + if (!eq) continue; + *eq = 0; + if (!strcmp(t, core)) str_copy(out, n, eq + 1); + } +} + static void download_target(const local_rom *r, const char *server_name, const char *slot, const scan_ctx *c, char *out, size_t n) { char ext[32]; @@ -971,8 +1518,9 @@ static void download_target(const local_rom *r, const char *server_name, const c } const char *vars[13]; name_vars(r, ext, vars); - char name[512], dir[PATH_MAX_LEN]; - str_template(name, sizeof name, r->map->save_name, vars); + char name[512], dir[PATH_MAX_LEN], tmpl[128]; + save_name_now(r->map, tmpl, sizeof tmpl); + str_template(name, sizeof name, tmpl, vars); /* A name from a value this game lacks (no serial read) falls back to its own. */ if (!name[0] || name[0] == '_' || name[0] == '.' || strchr(name, '{')) str_template(name, sizeof name, "{rom_stem}{ext}", vars); @@ -980,9 +1528,11 @@ static void download_target(const local_rom *r, const char *server_name, const c path_join(out, n, dir, name); } +/* Returns 0, -1 when it failed, or -2 when it would put a blank PS1 card over + * one with saves on it (only with guard; the user choosing RomM's copy isn't). */ static int do_download(const scan_ctx *c, int save_id, const char *dest, const local_file *f, int rom_id, const char *slot, int session_id, const char *server_hash, - const char *server_updated_at) { + const char *server_updated_at, int guard) { char path[512], tmp[PATH_MAX_LEN]; char *dev = url_q(c->device_id); int o = snprintf(path, sizeof path, "/api/saves/%d/content?device_id=%s&optimistic=false", save_id, dev); @@ -995,6 +1545,11 @@ static int do_download(const scan_ctx *c, int save_id, const char *dest, const l unlink(tmp); return -1; } + if (guard && f && psx_card_saves(f->map, tmp) == 0 && psx_card_saves(f->map, dest) > 0) { + LOGW("save %d is a blank memory card and %s has saves on it: not replacing it", save_id, dest); + unlink(tmp); + return -2; + } backup_file(c, dest, rom_id); if (move_file(tmp, dest) != 0) { LOGE("cannot write %s", dest); @@ -1043,6 +1598,248 @@ static const local_rom *find_rom(const scan_ctx *c, int rom_id, const char *emul return any; } +/* The core an emulator runs now, from its own settings file + * ("state_core_setting"); the first listed value when the file says nothing. */ +static void active_core(const profile_map *m, char *out, size_t n) { + out[0] = 0; + char value[32] = ""; + char *txt = m->core_key[0] ? read_file(m->core_file, NULL) : NULL; + for (char *l = txt; l && *l;) { + size_t kl = strlen(m->core_key); + if (!strncmp(l, m->core_key, kl) && l[kl] == '=') { + size_t v = 0; + for (l += kl + 1; *l && *l != '\n' && *l != '\r' && v + 1 < sizeof value; l++) value[v++] = *l; + value[v] = 0; + break; + } + l = strchr(l, '\n'); + if (l) l++; + } + free(txt); + char list[256]; + str_copy(list, sizeof list, m->core_values); + char *save = NULL; + for (char *t = strtok_r(list, ",", &save); t; t = strtok_r(NULL, ",", &save)) { + char *eq = strchr(t, '='); + if (!eq) continue; + *eq = 0; + if (!out[0] || !strcmp(t, value)) str_copy(out, n, eq + 1); + if (!strcmp(t, value)) break; + } +} + +/* Whether an emulator can load a state RomM says `emulator` made. */ +static int map_takes_state(const profile_map *m, const char *emulator) { + char want[64], key[64]; + emulator_key(emulator ? emulator : "", want, sizeof want); + if (!want[0]) return 0; + if (m->core_values[0]) { + char core[64]; + active_core(m, core, sizeof core); + emulator_key(core, key, sizeof key); + if (strcmp(key, want) != 0) return 0; + } + char list[256]; + str_copy(list, sizeof list, m->state_cores); + char *save = NULL; + for (char *t = strtok_r(list, ",", &save); t; t = strtok_r(NULL, ",", &save)) { + emulator_key(t, key, sizeof key); + if (!strcmp(key, want)) return 1; + } + return 0; +} + +/* The local copy of a game whose emulator can load a state from `emulator`. */ +static const local_rom *find_rom_for_state(const scan_ctx *c, int rom_id, const char *emulator) { + for (int i = 0; i < c->nroms; i++) { + const local_rom *r = &c->roms[i]; + if (r->rom_id == rom_id && r->rules.sync_saves && r->map->sync_states && map_takes_state(r->map, emulator)) return r; + } + return NULL; +} + +/* The emulator as the log names it: "RetroArch (mGBA)", "PSXS5". */ +static const char *emu_name(const profile_map *m) { + static __thread char out[160]; + if (m->core_name[0]) snprintf(out, sizeof out, "%s (%s)", m->profile_name, m->core_name); + else str_copy(out, sizeof out, m->profile_name); + return out; +} + +/* Saves from before RomM's sync slots (no slot), or kept in another device's + * slot: this console's sync never brings them. + * A game here with no save of its own gets the newest one, and from then on + * syncs it in this console's slot. Never over a file that's there. */ +static int fetch_slotless_saves(scan_ctx *c, int only_rom) { + int want = 0; + for (int i = 0; i < c->nroms && !want; i++) { + const local_rom *r = &c->roms[i]; + want = r->rules.sync_saves && !r->map->save_layout[0] && (!only_rom || r->rom_id == only_rom) && + !find_save(c, r->rom_id, c->slot); + } + if (!want) return 0; + long st = 0; + char api[64] = "/api/saves"; + if (only_rom) snprintf(api, sizeof api, "/api/saves?rom_id=%d", only_rom); + cJSON *all = romm_call("GET", api, NULL, &st); + int n = 0; + for (int i = 0; st == 200 && i < c->nroms; i++) { + const local_rom *r = &c->roms[i]; + if (!r->rules.sync_saves || r->map->save_layout[0] || (only_rom && r->rom_id != only_rom) || + find_save(c, r->rom_id, c->slot)) + continue; + int done_before = 0; /* another emulator's copy of the game took it */ + for (int k = 0; k < i; k++) done_before |= c->roms[k].rom_id == r->rom_id; + if (done_before) continue; + /* The newest of each kind: the save, and files beside it such as a + * real-time clock (.rtc), each in the slot it would sync in. */ + struct { char slot[64]; const cJSON *s; } picks[4]; + int np = 0, slotted = 0; + const cJSON *s; + cJSON_ArrayForEach(s, all) { + if ((int)jget_num(s, "rom_id", 0) != r->rom_id) continue; + if (our_slot(c, jget_str(s, "slot", ""))) { /* the sync's own */ + slotted = 1; + continue; + } + const char *name = jget_str(s, "file_name", ""); + if (!ext_in_list(name, r->map->save_exts)) continue; + char sl[64]; + slot_for(c, r->map, name, sl, sizeof sl); + int at = -1; + for (int k = 0; k < np; k++) + if (!strcmp(picks[k].slot, sl)) at = k; + if (at < 0 && np < 4) { + at = np++; + str_copy(picks[at].slot, sizeof picks[at].slot, sl); + picks[at].s = s; + } else if (at >= 0 && strcmp(jget_str(s, "updated_at", ""), jget_str(picks[at].s, "updated_at", "")) > 0) { + picks[at].s = s; + } + } + if (slotted) continue; /* the sync's own job */ + for (int k = 0; k < np; k++) { + if (find_save(c, r->rom_id, picks[k].slot)) continue; + char dest[PATH_MAX_LEN]; + download_target(r, jget_str(picks[k].s, "file_name", ""), picks[k].slot, c, dest, sizeof dest); + if (file_exists(dest)) continue; + mkdir_parent(dest); + if (do_download(c, (int)jget_num(picks[k].s, "id", 0), dest, NULL, r->rom_id, picks[k].slot, 0, "", + jget_str(picks[k].s, "updated_at", ""), 0) == 0) { + n++; + events_add("save", "downloaded", r->rom_id, dest, emu_name(r->map), + jget_str(picks[k].s, "slot", "")[0] ? "From another device" : "Older save with no slot"); + } + } + } + cJSON_Delete(all); + return n; +} + +/* Saves synced before whose file is gone from this console (deleted, or lost + * with the emulator): RomM sees nothing new, so negotiate never offers them. + * The newest version of that slot comes back to where it was. To get rid of a + * save for good, delete it on RomM too. */ +static int restore_missing_saves(scan_ctx *c, int only_rom) { + typedef struct { char path[PATH_MAX_LEN]; char slot[64]; int rom_id, save_id; } missing; + missing *list = NULL; + int nlist = 0, cap = 0; + state_lock(); + const cJSON *e; + cJSON_ArrayForEach(e, state_section("saves")) { + const char *path = e->string; + int rom_id = (int)jget_num(e, "rom_id", 0); + if (!rom_id || (only_rom && rom_id != only_rom) || strchr(path, '#') || file_exists(path)) continue; + if (nlist == cap) { + cap = cap ? cap * 2 : 16; + missing *grown = realloc(list, (size_t)cap * sizeof *list); + if (!grown) break; + list = grown; + } + str_copy(list[nlist].path, sizeof list[nlist].path, path); + str_copy(list[nlist].slot, sizeof list[nlist].slot, jget_str(e, "slot", "")); + list[nlist].save_id = (int)jget_num(e, "save_id", 0); + list[nlist++].rom_id = rom_id; + } + state_unlock(); + int n = 0; + for (int i = 0; i < nlist; i++) { + const missing *m = &list[i]; + const local_rom *r = NULL; + for (int k = 0; k < c->nroms && !r; k++) + if (c->roms[k].rom_id == m->rom_id && c->roms[k].rules.sync_saves && !c->roms[k].map->save_layout[0]) + r = &c->roms[k]; + if (!r || find_save(c, m->rom_id, m->slot) || file_exists(m->path)) continue; /* the game's gone, or it has a save */ + int done_before = 0; /* one file per slot: another name of the same save came back already */ + for (int k = 0; k < i; k++) done_before |= list[k].rom_id == m->rom_id && !strcmp(list[k].slot, m->slot) && file_exists(list[k].path); + if (done_before) continue; + char api[64]; + snprintf(api, sizeof api, "/api/saves?rom_id=%d", m->rom_id); + long st = 0; + cJSON *all = romm_call("GET", api, NULL, &st); + const cJSON *s, *best = NULL; + cJSON_ArrayForEach(s, (st == 200 ? all : NULL)) + if ((int)jget_num(s, "rom_id", 0) == m->rom_id && !strcmp(jget_str(s, "slot", ""), m->slot) && + (!best || strcmp(jget_str(s, "updated_at", ""), jget_str(best, "updated_at", "")) > 0)) + best = s; + /* Two names for one slot (a PS1 card by serial and by file name): the + * one that had RomM's newest version gets it. */ + int other = 0; + const int best_id = best ? (int)jget_num(best, "id", 0) : 0; + for (int k = i + 1; best && m->save_id != best_id && k < nlist; k++) + other |= list[k].rom_id == m->rom_id && !strcmp(list[k].slot, m->slot) && list[k].save_id == best_id; + if (best && !other) { + mkdir_parent(m->path); + if (do_download(c, (int)jget_num(best, "id", 0), m->path, NULL, m->rom_id, m->slot, 0, + jget_str(best, "content_hash", ""), jget_str(best, "updated_at", ""), 0) == 0) { + n++; + events_add("save", "downloaded", m->rom_id, m->path, emu_name(r->map), "Missing here"); + } + } + cJSON_Delete(all); + } + free(list); + return n; +} + +/* A game whose saves are a folder (GameCube, PSP, Wii) with none of them on + * this console while RomM has some: a new emulator, or files deleted. RomM's + * newest in this console's slot (or else anywhere) comes down. */ +static int restore_missing_bundles(scan_ctx *c, int only_rom) { + int n = 0; + for (int i = 0; i < c->nroms; i++) { + const local_rom *r = &c->roms[i]; + if (!r->rules.sync_saves || !bundle_supported(r->map->save_layout) || (only_rom && r->rom_id != only_rom)) continue; + int done_before = 0; /* another emulator's copy of the game */ + for (int k = 0; k < i; k++) done_before |= c->roms[k].rom_id == r->rom_id && c->roms[k].rules.sync_saves && bundle_supported(c->roms[k].map->save_layout); + char id[16]; + bundle b = {0}; + if (done_before || rom_game_id(r, id, sizeof id) != 0 || bundle_for_rom(r, id, &b) != 0) continue; + const int empty = b.n == 0; + bundle_free(&b); + if (!empty) continue; + char api[64]; + snprintf(api, sizeof api, "/api/saves?rom_id=%d", r->rom_id); + long st = 0; + cJSON *all = romm_call("GET", api, NULL, &st); + const cJSON *s, *mine = NULL, *any = NULL; + cJSON_ArrayForEach(s, (st == 200 ? all : NULL)) { + if ((int)jget_num(s, "rom_id", 0) != r->rom_id) continue; + const char *up = jget_str(s, "updated_at", ""); + if (!any || strcmp(up, jget_str(any, "updated_at", "")) > 0) any = s; + if (!strcmp(jget_str(s, "slot", ""), c->slot) && (!mine || strcmp(up, jget_str(mine, "updated_at", "")) > 0)) + mine = s; + } + const cJSON *pick = mine ? mine : any; + if (pick && do_download_bundle(c, (int)jget_num(pick, "id", 0), r, c->slot, 0, jget_str(pick, "content_hash", ""), 1) == 0) { + n++; + events_add("save", "downloaded", r->rom_id, b.dir, emu_name(r->map), "Missing here"); + } + cJSON_Delete(all); + } + return n; +} + static void record_conflict(const local_file *f, const cJSON *op) { state_lock(); int is_new = state_entry("conflicts", f->path) == NULL; @@ -1056,14 +1853,86 @@ static void record_conflict(const local_file *f, const cJSON *op) { jset_num(e, "local_mtime", (double)f->mtime); jset_num(e, "detected_at", (double)time(NULL)); state_unlock(); - if (is_new) LOGW("conflict for %s: %s", f->path, jget_str(op, "reason", "")); + if (is_new) { + LOGW("conflict for %s: %s", f->path, jget_str(op, "reason", "")); + events_add("save", "conflict", f->rom_id, f->path, emu_name(f->map), "Changed in both places"); + } } -static int sync_states_up(scan_ctx *c) { +/* Server states already brought down or sent up, by id, with the updated_at + * we saw. Caller holds state_lock. */ +static int same_time(const char *a, const char *b) { + time_t ta = iso8601_parse(a), tb = iso8601_parse(b); + return ta > 0 ? ta == tb : !strcmp(a, b); +} + +static void mark_seen(int id, const char *updated_at) { + char key[16]; + snprintf(key, sizeof key, "%d", id); + cJSON *seen = state_section("states_seen"); + cJSON_DeleteItemFromObjectCaseSensitive(seen, key); + cJSON_AddStringToObject(seen, key, updated_at); +} + +static int was_seen(int id, const char *updated_at) { + char key[16]; + snprintf(key, sizeof key, "%d", id); + const char *at = jget_str(state_section("states_seen"), key, NULL); + return at && same_time(at, updated_at); +} + +/* The local file holding server state id, if we have one. */ +static cJSON *state_record_for(int id, char *path, size_t n) { + cJSON *e; + cJSON_ArrayForEach(e, state_section("states")) { + if ((int)jget_num(e, "state_id", 0) == id) { + str_copy(path, n, e->string); + return e; + } + } + return NULL; +} + +/* Name a state goes up under: the local name with this console's name in it, + * so other devices' states (and RomM, which replaces a state of the same + * name) never meet it. "Game.state1" -> "Game [PlayStation 5].state1". */ +static void state_upload_name(const char *path, char *out, size_t n) { + char device[64]; + config_lock(); + str_copy(device, sizeof device, g_cfg.device_name[0] ? g_cfg.device_name : "RommPS"); + config_unlock(); + const char *base = path_basename(path), *ext = path_ext(base); + snprintf(out, n, "%.*s [%s]%s", (int)(ext - base), base, device, ext); +} + +/* A local state as RomM's web player loads it, in a temp file: RetroArch's + * container, uncompressed. *core becomes the core a bare state comes from + * when its signature says (PSXS5 runs several). */ +static int state_for_romm(const char *path, char *tmp, size_t n, const char **core) { + size_t len = 0, out_n = 0; + unsigned char *data = (unsigned char *)read_file(path, &len); + if (!data) return -1; + const char *sig = state_core_of(data, len); + if (sig && core) *core = sig; + unsigned char *out = state_to_rastate(data, len, &out_n); + free(data); + if (!out) { + LOGW("can't read the state %s", path); + return -1; + } + static int seq; + snprintf(tmp, n, "%s/tmp/state-up-%d.bin", plat_data_dir(), __sync_add_and_fetch(&seq, 1)); + int rc = write_file_atomic(tmp, out, out_n); + free(out); + return rc; +} + +static int sync_states_up(scan_ctx *c, int only_rom) { int uploaded = 0; for (int i = 0; i < c->nstates; i++) { local_file *f = &c->states[i]; + if (only_rom && f->rom_id != only_rom) continue; if (file_mtime_ns(f->path) != f->mtime_ns) { /* replaced by the download pass */ f->mtime = file_mtime(f->path); f->mtime_ns = file_mtime_ns(f->path); @@ -1074,24 +1943,39 @@ static int sync_states_up(scan_ctx *c) { cJSON *e = state_entry("states", f->path); int same = e && !strcmp(jget_str(e, "hash", ""), f->hash); int state_id = e ? (int)jget_num(e, "state_id", 0) : 0; + int mine = e && jget_num(e, "mine", 0); state_unlock(); if (same) continue; char path[512]; long st = 0; cJSON *j = NULL; - if (state_id) { - snprintf(path, sizeof path, "/api/states/%d", state_id); - j = romm_upload("PUT", path, "stateFile", f->path, path_basename(f->path), &st); + const char *core = f->map->emulator; /* or the core a bare state's signature names */ + /* Only a state this console made is replaced on RomM. One that came + * from elsewhere and was then saved over here goes up as a new one. */ + if (state_id && mine) { + char tmp[PATH_MAX_LEN]; + if (state_for_romm(f->path, tmp, sizeof tmp, &core) == 0) { + snprintf(path, sizeof path, "/api/states/%d", state_id); + j = romm_upload("PUT", path, "stateFile", tmp, path_basename(f->path), &st); + unlink(tmp); + } } - if (!state_id || st == 404) { + if (!state_id || !mine || st == 404) { cJSON_Delete(j); - char *emu = url_q(f->map->emulator); - snprintf(path, sizeof path, "/api/states?rom_id=%d&emulator=%s", f->rom_id, emu); - free(emu); - j = romm_upload("POST", path, "stateFile", f->path, path_basename(f->path), &st); + j = NULL; + char name[300], tmp[PATH_MAX_LEN]; + state_upload_name(f->path, name, sizeof name); + if (state_for_romm(f->path, tmp, sizeof tmp, &core) == 0) { + char *emu = url_q(core); + snprintf(path, sizeof path, "/api/states?rom_id=%d&emulator=%s", f->rom_id, emu); + free(emu); + j = romm_upload("POST", path, "stateFile", tmp, name, &st); + unlink(tmp); + } } if ((st == 200 || st == 201) && j) { + int id = (int)jget_num(j, "id", state_id); state_lock(); cJSON *ne = state_entry_ensure("states", f->path); jset_num(ne, "rom_id", f->rom_id); @@ -1101,12 +1985,15 @@ static int sync_states_up(scan_ctx *c) { snprintf(ns, sizeof ns, "%lld", (long long)file_mtime_ns(f->path)); jset_str(ne, "mtime_ns", ns); jset_num(ne, "size", (double)f->size); - jset_num(ne, "state_id", jget_num(j, "id", state_id)); + jset_num(ne, "state_id", id); + jset_num(ne, "mine", 1); jset_str(ne, "server_updated_at", jget_str(j, "updated_at", "")); jset_num(ne, "synced_at", (double)time(NULL)); + mark_seen(id, jget_str(j, "updated_at", "")); state_unlock(); uploaded++; LOGI("uploaded state %s", f->path); + events_add("state", "uploaded", f->rom_id, f->path, emu_name(f->map), NULL); } cJSON_Delete(j); } @@ -1124,22 +2011,69 @@ static void state_local_name(const local_rom *r, const char *server_name, char * return; } } - const char *vars[13]; - name_vars(r, path_ext(server_name), vars); + const char *ext = path_ext(server_name), *vars[13]; + if (!strcmp(ext, ".state")) ext = r->map->state_slot0; + name_vars(r, ext, vars); str_template(out, n, r->map->state_name, vars); + /* A name from a value this game lacks (no hash read): none at all. */ + if (out[0] == '.' || out[0] == '_' || strchr(out, '{')) out[0] = 0; +} + +/* A file there already, or one another server state is kept in. */ +static int state_path_taken(const char *path, int id) { + if (file_exists(path)) return 1; + state_lock(); + const cJSON *e = state_entry("states", path); + int taken = e && (int)jget_num(e, "state_id", 0) != id; + state_unlock(); + return taken; +} + +/* The next free numbered slot beside path ("Game.state" -> "Game.state1"), + * for an emulator whose states are numbered. 0 if there's none. */ +static int free_state_slot(const local_rom *r, char *path, size_t n, int id) { + if (!strchr(r->map->state_exts, '*')) return 0; + char base[PATH_MAX_LEN]; + str_copy(base, sizeof base, path); + char *dot = strrchr(base, '.'); + if (!dot || strncmp(dot, ".state", 6) != 0) return 0; + dot[6] = 0; /* "Game.state" */ + for (int slot = 1; slot < 100; slot++) { + char p[PATH_MAX_LEN + 8]; + snprintf(p, sizeof p, "%s%d", base, slot); + if (!state_path_taken(p, id)) { + str_copy(path, n, p); + return 1; + } + } + return 0; } /* Downloads a state into place (backing up what was there) and records it. */ -static int fetch_state(const scan_ctx *c, const cJSON *s, const char *path, int rom_id) { +static int fetch_state(const scan_ctx *c, const cJSON *s, const char *path, const local_rom *r) { + int rom_id = r->rom_id; char api[128], tmp[PATH_MAX_LEN]; int id = (int)jget_num(s, "id", 0); snprintf(api, sizeof api, "/api/states/%d/content", id); snprintf(tmp, sizeof tmp, "%s/tmp/state-%d.bin", plat_data_dir(), id); - http_resp r; - if (romm_download(api, tmp, NULL, NULL, &r) != 0) { + http_resp resp; + if (romm_download(api, tmp, NULL, NULL, &resp) != 0) { unlink(tmp); return -1; } + if (!strcmp(r->map->state_format, "raw")) { + size_t len = 0, raw_n = 0; + unsigned char *data = (unsigned char *)read_file(tmp, &len); + unsigned char *raw = data ? state_to_raw(data, len, &raw_n) : NULL; + free(data); + if (!raw || write_file_atomic(tmp, raw, raw_n) != 0) { + LOGW("state %d: can't unpack it for %s", id, path); + free(raw); + unlink(tmp); + return -1; + } + free(raw); + } backup_file(c, path, rom_id); if (move_file(tmp, path) != 0) return -1; time_t t = iso8601_parse(jget_str(s, "updated_at", "")); @@ -1156,44 +2090,158 @@ static int fetch_state(const scan_ctx *c, const cJSON *s, const char *path, int jset_str(e, "mtime_ns", ns); jset_num(e, "size", (double)file_size(path)); jset_num(e, "state_id", id); + jset_num(e, "mine", 0); jset_str(e, "server_updated_at", jget_str(s, "updated_at", "")); jset_num(e, "synced_at", (double)time(NULL)); + mark_seen(id, jget_str(s, "updated_at", "")); state_unlock(); LOGI("downloaded state %s", path); return 0; } -/* Pulls states that changed on the server since we last saw them. A state - * that also changed here is kept; the upload pass sends it. */ -static int sync_states_down(scan_ctx *c) { +/* Logs, once per version, a state no emulator here can load: made by another + * core, or by an app whose states only it reads. */ +static void note_skipped_state(const scan_ctx *c, const cJSON *s) { + int rom_id = (int)jget_num(s, "rom_id", 0), local = 0; + for (int i = 0; i < c->nroms && !local; i++) + local = c->roms[i].rom_id == rom_id && c->roms[i].rules.sync_saves && c->roms[i].map->sync_states; + if (!local) return; /* game isn't on this console, or its emulator doesn't sync states */ + char key[16]; + snprintf(key, sizeof key, "%d", (int)jget_num(s, "id", 0)); + const char *up = jget_str(s, "updated_at", ""); + state_lock(); + cJSON *sk = state_section("states_skipped"); + int known = !strcmp(jget_str(sk, key, ""), up); + if (!known) { + cJSON_DeleteItemFromObjectCaseSensitive(sk, key); + cJSON_AddStringToObject(sk, key, up); + } + state_unlock(); + if (!known) { + const char *by = jget_str(s, "emulator", "")[0] ? jget_str(s, "emulator", "") : "an unknown emulator"; + LOGI("state %s skipped: made by %s, which no emulator here for rom %d can load", jget_str(s, "file_name", "?"), by, + rom_id); + events_add("state", "skipped", rom_id, jget_str(s, "file_name", ""), "", "No suitable emulator on console"); + } +} + +/* Pulls states that are new or changed on the server. Each server state keeps + * one local file; a new one never replaces a file that's there (the player's + * own slot, or another server state) but takes the next free slot. A state + * changed here since it came down is kept; the upload pass sends it. */ +static int sync_states_down(scan_ctx *c, int only_rom) { long st = 0; cJSON *list = romm_call("GET", "/api/states", NULL, &st); int n = 0; const cJSON *s; cJSON_ArrayForEach(s, list) { - const local_rom *r = find_rom(c, (int)jget_num(s, "rom_id", 0), jget_str(s, "emulator", NULL)); - char dir[PATH_MAX_LEN], name[256], path[PATH_MAX_LEN]; - if (!r || !expected_dir(r, 1, dir, sizeof dir)) continue; - const char *server_name = jget_str(s, "file_name", ""); - if (!ext_in_list(server_name, r->map->state_exts)) continue; - state_local_name(r, server_name, name, sizeof name); - path_join(path, sizeof path, dir, name); - + if (only_rom && (int)jget_num(s, "rom_id", 0) != only_rom) continue; + int id = (int)jget_num(s, "id", 0); const char *server_up = jget_str(s, "updated_at", ""); + char path[PATH_MAX_LEN], rec_hash[33] = "", rec_up[64] = ""; state_lock(); - cJSON *e = state_entry("states", path); - int known = e && !strcmp(jget_str(e, "server_updated_at", ""), server_up); - char rec_hash[33]; - str_copy(rec_hash, sizeof rec_hash, e ? jget_str(e, "hash", "") : ""); - state_unlock(); - if (known) continue; - - if (file_exists(path)) { - char hash[33]; - md5_file_hex(path, hash); - if (rec_hash[0] ? strcmp(hash, rec_hash) != 0 : file_mtime(path) >= iso8601_parse(server_up)) continue; + int seen = was_seen(id, server_up); + cJSON *e = state_record_for(id, path, sizeof path); + /* One we kept whose file is gone from here comes back. */ + const int missing = e && !file_exists(path); + if (missing) seen = 0; + if (e && !seen && !missing) { + str_copy(rec_hash, sizeof rec_hash, jget_str(e, "hash", "")); + str_copy(rec_up, sizeof rec_up, jget_str(e, "server_updated_at", "")); + /* Recorded before states_seen existed. */ + if (same_time(rec_up, server_up)) { + mark_seen(id, server_up); + seen = 1; + } } - if (fetch_state(c, s, path, r->rom_id) == 0) n++; + state_unlock(); + if (seen) continue; + + const char *emulator = jget_str(s, "emulator", ""); + const local_rom *r = NULL; + if (e) { /* a state kept here: the emulator whose folder it's in (two can play the game) */ + char folder[PATH_MAX_LEN], exp[PATH_MAX_LEN]; + str_copy(folder, sizeof folder, path); + char *slash = strrchr(folder, '/'); + if (slash) *slash = 0; + for (int k = 0; k < c->nroms && !r; k++) + if (c->roms[k].rom_id == (int)jget_num(s, "rom_id", 0) && c->roms[k].rules.sync_saves && + c->roms[k].map->sync_states && map_takes_state(c->roms[k].map, emulator) && + expected_dir(&c->roms[k], 1, exp, sizeof exp) && !strcmp(exp, folder)) + r = &c->roms[k]; + } + if (!r) r = find_rom_for_state(c, (int)jget_num(s, "rom_id", 0), emulator); + if (!r) { + note_skipped_state(c, s); + continue; + } + char dir[PATH_MAX_LEN], name[256]; + if (!expected_dir(r, 1, dir, sizeof dir)) continue; + const char *server_name = jget_str(s, "file_name", ""); + /* A folder per game renames what comes down (slot1.state): any state will do. */ + const int per_game = strstr(r->rules.state_dir, "{game_id}") != NULL; + const char *sx = path_ext(server_name); + if (per_game ? strncmp(sx, ".state", 6) != 0 : !ext_in_list(server_name, r->map->state_exts)) continue; + + if (e) { /* a newer version of a state we keep */ + if (file_exists(path)) { + char hash[33]; + md5_file_hex(path, hash); + if (strcmp(hash, rec_hash) != 0) continue; + } + } else if (strstr(r->rules.state_dir, "{game_id}")) { + /* A folder per game holding slot1.state, slot2.state...: the first free. */ + int k = 1; + for (; k < 10; k++) { + snprintf(name, sizeof name, "slot%d.state", k); + path_join(path, sizeof path, dir, name); + if (!state_path_taken(path, id)) break; + } + if (k == 10) { + LOGI("state %d skipped: no free slot in %s", id, dir); + continue; + } + } else { + state_local_name(r, server_name, name, sizeof name); + if (!name[0]) continue; + path_join(path, sizeof path, dir, name); + if (state_path_taken(path, id) && !free_state_slot(r, path, sizeof path, id)) { + LOGI("state %d skipped: no free slot beside %s", id, path); + continue; + } + } + if (missing) mkdir_parent(path); + if (fetch_state(c, s, path, r) == 0) { + n++; + events_add("state", "downloaded", r->rom_id, path, emu_name(r->map), missing ? "Missing here" : NULL); + } else { + events_add("state", "failed", r->rom_id, path, emu_name(r->map), "Download failed"); + } + } + /* Forget states that are gone from the server, and the records of states + * gone from both sides. */ + if (st == 200 && cJSON_IsArray(list)) { + static const char *const sections[] = {"states_seen", "states_skipped"}; + state_lock(); + cJSON *recs = state_section("states"), *rec = recs->child; + while (rec) { + cJSON *next = rec->next; + int id = (int)jget_num(rec, "state_id", 0), there = 0; + cJSON_ArrayForEach(s, list) if ((int)jget_num(s, "id", 0) == id) there = 1; + if (!there && !file_exists(rec->string)) cJSON_Delete(cJSON_DetachItemViaPointer(recs, rec)); + rec = next; + } + for (int i = 0; i < 2; i++) { + cJSON *sec = state_section(sections[i]), *k = sec->child; + while (k) { + cJSON *next = k->next; + int there = 0; + cJSON_ArrayForEach(s, list) if ((int)jget_num(s, "id", 0) == atoi(k->string)) there = 1; + if (!there) cJSON_Delete(cJSON_DetachItemViaPointer(sec, k)); + k = next; + } + } + state_unlock(); } cJSON_Delete(list); return n; @@ -1208,11 +2256,13 @@ typedef struct { * (save_format) has one save: the newest of their copies goes to the others, * the copies replaced backed up first. Runs before the saves are scanned (so * one save goes up) and after the downloads (so a new one reaches them all). */ +static int share_bundles(scan_ctx *c); + static int share_saves(scan_ctx *c) { - int copied = 0; + int copied = share_bundles(c); for (int i = 0; i < c->nroms; i++) { const local_rom *a = &c->roms[i]; - if (!a->map->save_format[0] || a->map->own_slot || !a->rules.sync_saves) continue; + if (!a->map->save_format[0] || a->map->own_slot || !a->rules.sync_saves || a->map->save_layout[0]) continue; /* The first member of each group does the work. */ int first = 1; for (int k = 0; k < i && first; k++) @@ -1251,6 +2301,57 @@ static int share_saves(scan_ctx *c) { return copied; } +/* The same for a GameCube game two Dolphins play (GCI folders): the game's + * .gci files from the one that saved last go to the other's folder. */ +static int share_bundles(scan_ctx *c) { + int copied = 0; + for (int i = 0; i < c->nroms; i++) { + const local_rom *a = &c->roms[i]; + if (strcmp(a->map->save_layout, "gci") != 0 || a->map->own_slot || !a->rules.sync_saves) continue; + int first = 1; + for (int k = 0; k < i && first; k++) + if (c->roms[k].rom_id == a->rom_id && !strcmp(c->roms[k].map->save_layout, "gci") && + !c->roms[k].map->own_slot && c->roms[k].rules.sync_saves) + first = 0; + char id[16]; + if (!first || rom_game_id(a, id, sizeof id) != 0) continue; + bundle bs[4]; + int n = 0, newest = -1; + for (int k = i; k < c->nroms && n < 4; k++) { + const local_rom *r = &c->roms[k]; + if (r->rom_id != a->rom_id || strcmp(r->map->save_layout, "gci") || r->map->own_slot || !r->rules.sync_saves) + continue; + if (bundle_for_rom(r, id, &bs[n]) != 0) continue; + if (bs[n].n && (newest < 0 || bs[n].mtime > bs[newest].mtime)) newest = n; + n++; + } + char want[33] = ""; + if (n >= 2 && newest >= 0) bundle_hash(&bs[newest], want); + for (int k = 0; want[0] && k < n; k++) { + char have[33]; + bundle_hash(&bs[k], have); + if (k == newest || !strcmp(have, want)) continue; + if (bs[k].n) { /* what was there, kept as a zip */ + char cur[PATH_MAX_LEN]; + snprintf(cur, sizeof cur, "%s/tmp/%s-shared.zip", plat_data_dir(), id); + if (bundle_zip(&bs[k], cur) == 0) backup_file(c, cur, a->rom_id); + unlink(cur); + } + for (int f = 0; f < bs[k].n; f++) unlink(bs[k].files[f].path); + mkdir_p(bs[k].dir); + for (int f = 0; f < bs[newest].n; f++) { + char to[PATH_MAX_LEN]; + path_join(to, sizeof to, bs[k].dir, bs[newest].files[f].name); + if (copy_file(bs[newest].files[f].path, to) == 0) set_file_mtime(to, file_mtime(bs[newest].files[f].path)); + } + LOGI("shared save: %s's GameCube saves -> %s", id, bs[k].dir); + copied++; + } + for (int k = 0; k < n; k++) bundle_free(&bs[k]); + } + return copied; +} + /* Game ids, save ids and what was synced belong to one RomM server (and one * device on it). Paired with another, they mean nothing there: they're * forgotten, and the games are matched again by name. */ @@ -1296,7 +2397,8 @@ static void forget_other_server(void) { state_unlock(); } -static void run_sync(const char *reason, sync_result *res) { +/* only_rom limits the transfers to one game; the rest wait for the next sync. */ +static void run_sync(const char *reason, int only_rom, sync_result *res) { memset(res, 0, sizeof *res); forget_other_server(); scan_ctx c; @@ -1342,7 +2444,7 @@ static void run_sync(const char *reason, sync_result *res) { int session_id = (int)jget_num(neg, "session_id", 0); /* A running game would overwrite a downloaded save, so downloads wait until * it closes. The sync right after launch still downloads. */ - int upload_only = g_game_running && strcmp(reason, "game started") != 0; + int upload_only = g_game_running && strcmp(reason, "game started") != 0 && !only_rom; const cJSON *op; int nops = cJSON_GetArraySize(cJSON_GetObjectItemCaseSensitive(neg, "operations")), k = 0; @@ -1355,6 +2457,8 @@ static void run_sync(const char *reason, sync_result *res) { int rom_id = (int)jget_num(op, "rom_id", 0); int save_id = (int)jget_num(op, "save_id", 0); const char *slot = jget_str(op, "slot", ""); + if (only_rom && rom_id != only_rom) continue; + if (!our_slot(&c, slot)) continue; /* another device's slot: its saves stay its own */ const local_file *f = find_save(&c, rom_id, slot); const char *server_hash = jget_str(op, "server_content_hash", ""); @@ -1424,12 +2528,14 @@ static void run_sync(const char *reason, sync_result *res) { res->uploaded++; if (last_rom != rom_id) roms_touched++; last_rom = rom_id; + events_add("save", "uploaded", rom_id, f->path, emu_name(f->map), overwrite ? "Kept this console's" : NULL); } else if (ust == 409) { /* The server copy changed since negotiate. */ record_conflict(f, op); res->conflicts++; } else { res->failed++; + events_add("save", "failed", rom_id, f->path, emu_name(f->map), "Upload failed"); } } else if (want_down && upload_only) { res->deferred++; @@ -1448,27 +2554,39 @@ static void run_sync(const char *reason, sync_result *res) { str_copy(dest, sizeof dest, f->path); else download_target(r, jget_str(op, "file_name", ""), slot, &c, dest, sizeof dest); - ok = do_download(&c, save_id, dest, f, rom_id, slot, session_id, jget_str(op, "server_content_hash", ""), - jget_str(op, "server_updated_at", "")) == 0; + int rc = do_download(&c, save_id, dest, f, rom_id, slot, session_id, jget_str(op, "server_content_hash", ""), + jget_str(op, "server_updated_at", ""), 1); + if (rc == -2) { + record_conflict(f, op); + res->conflicts++; + events_add("save", "conflict", rom_id, dest, emu_name(r->map), "RomM's card is blank"); + continue; + } + ok = rc == 0; } + const char *where = r->map->save_layout[0] ? r->path : dest; if (ok) { res->downloaded++; + events_add("save", "downloaded", rom_id, where, emu_name(r->map), is_conflict ? "Kept RomM's" : NULL); } else { res->failed++; + events_add("save", "failed", rom_id, where, emu_name(r->map), "Download failed"); } } else { res->skipped++; } } cJSON_Delete(neg); + if (!upload_only) res->downloaded += restore_missing_saves(&c, only_rom) + restore_missing_bundles(&c, only_rom); + if (!upload_only) res->downloaded += fetch_slotless_saves(&c, only_rom); set_phase("finishing"); cJSON *done = cJSON_CreateObject(); cJSON_AddNumberToObject(done, "operations_completed", res->uploaded + res->downloaded + res->noop); cJSON_AddNumberToObject(done, "operations_failed", res->failed); pthread_mutex_lock(&g_req_lock); - cJSON *ps = g_play_sessions; - g_play_sessions = NULL; + cJSON *ps = only_rom ? NULL : g_play_sessions; + if (!only_rom) g_play_sessions = NULL; pthread_mutex_unlock(&g_req_lock); if (ps) { /* If one game's save changed, that's the game that was played. */ @@ -1493,11 +2611,11 @@ static void run_sync(const char *reason, sync_result *res) { config_unlock(); if (do_states) { set_phase("syncing save states"); - if (do_states == 2 && !upload_only) res->states_down = sync_states_down(&c); - res->states_up = sync_states_up(&c); + if (do_states == 2 && !upload_only) res->states_down = sync_states_down(&c, only_rom); + res->states_up = sync_states_up(&c, only_rom); } - res->cards = memcard_backup(); + if (!only_rom) res->cards = memcard_backup(); /* A save that just came down reaches the other emulators that share it. */ share_saves(&c); @@ -1507,6 +2625,47 @@ static void run_sync(const char *reason, sync_result *res) { scan_free(&c); } +/* Runs a sync and adds it to the history. */ +static void run_and_record(const char *reason, int only_rom, sync_result *r) { + pthread_mutex_lock(&g_run_lock); + events_set_sync(reason); + g_running = 1; + run_sync(reason, only_rom, r); + g_running = 0; + g_last_run = time(NULL); + set_phase("idle"); + pthread_mutex_unlock(&g_run_lock); + events_save(); + + cJSON *h = cJSON_CreateObject(); + g_sync_count++; + cJSON_AddNumberToObject(h, "time", (double)g_last_run); + cJSON_AddStringToObject(h, "reason", reason); + cJSON_AddNumberToObject(h, "uploaded", r->uploaded); + cJSON_AddNumberToObject(h, "downloaded", r->downloaded); + cJSON_AddNumberToObject(h, "conflicts", r->conflicts); + cJSON_AddNumberToObject(h, "skipped", r->skipped); + cJSON_AddNumberToObject(h, "failed", r->failed); + cJSON_AddNumberToObject(h, "states_uploaded", r->states_up); + cJSON_AddNumberToObject(h, "states_downloaded", r->states_down); + cJSON_AddNumberToObject(h, "deferred", r->deferred); + cJSON_AddNumberToObject(h, "cards_uploaded", r->cards); + if (r->error[0]) cJSON_AddStringToObject(h, "error", r->error); + state_lock(); + state_history_add(h); + state_save(); + state_unlock(); +} + +static int sync_summary(const sync_result *r, char *out, size_t n); + +static int notify_on(void) { + config_lock(); + int notify = g_cfg.notify; + config_unlock(); + return notify; +} + static void *worker(void *arg) { (void)arg; for (;;) { @@ -1518,65 +2677,99 @@ static void *worker(void *arg) { pthread_mutex_unlock(&g_req_lock); if (!config_is_paired()) continue; - /* Only manual syncs run until setup is finished. */ - int ready; + /* Only manual syncs run until setup is finished, and none at all + * with save sync off (RomM is then only where games come from). */ + int ready, on; config_lock(); ready = g_cfg.setup_complete; + on = g_cfg.save_sync; config_unlock(); + if (!on) { + LOGI("sync (%s) skipped: save sync is off", reason); + continue; + } if (!ready && strcmp(reason, "manual") != 0 && strcmp(reason, "first sync") != 0) { LOGI("sync (%s) skipped: setup not finished", reason); continue; } - pthread_mutex_lock(&g_run_lock); - g_running = 1; sync_result r; - run_sync(reason, &r); - g_running = 0; - g_last_run = time(NULL); - set_phase("idle"); - pthread_mutex_unlock(&g_run_lock); + const unsigned mark = events_seq(); + run_and_record(reason, 0, &r); - cJSON *h = cJSON_CreateObject(); - g_sync_count++; - cJSON_AddNumberToObject(h, "time", (double)g_last_run); - cJSON_AddStringToObject(h, "reason", reason); - cJSON_AddNumberToObject(h, "uploaded", r.uploaded); - cJSON_AddNumberToObject(h, "downloaded", r.downloaded); - cJSON_AddNumberToObject(h, "conflicts", r.conflicts); - cJSON_AddNumberToObject(h, "skipped", r.skipped); - cJSON_AddNumberToObject(h, "failed", r.failed); - cJSON_AddNumberToObject(h, "states_uploaded", r.states_up); - cJSON_AddNumberToObject(h, "states_downloaded", r.states_down); - cJSON_AddNumberToObject(h, "deferred", r.deferred); - cJSON_AddNumberToObject(h, "cards_uploaded", r.cards); - if (r.error[0]) cJSON_AddStringToObject(h, "error", r.error); - state_lock(); - state_history_add(h); - state_save(); - state_unlock(); - - int notify; - config_lock(); - notify = g_cfg.notify; - config_unlock(); + int notify = notify_on(); if (r.error[0]) { LOGE("sync failed: %s", r.error); - if (notify) plat_notify("RomM Sync: sync failed: %s", r.error); + if (notify) plat_notify("RommPS: couldn't sync. %s", r.error); } else { - LOGI("sync done: %d up, %d down, %d conflicts, %d failed, %d states", r.uploaded, - r.downloaded, r.conflicts, r.failed, r.states_up); - if (notify && (r.uploaded || r.downloaded || r.conflicts || r.failed)) - plat_notify("RomM Sync: %d uploaded, %d downloaded%s", r.uploaded, r.downloaded, - r.conflicts ? ", conflicts to resolve" : ""); + /* Issue #10 - Added notification and log file for memory card operations. */ + LOGI("sync done: %d up, %d down, %d conflicts, %d failed, %d states, %d cards", r.uploaded, + r.downloaded, r.conflicts, r.failed, r.states_up, r.cards); + char text[200], games[400]; + if (notify && sync_summary(&r, text, sizeof text)) { + if (events_games(mark, 3, games, sizeof games)) plat_notify("RommPS: %s\n%s", text, games); + else plat_notify("RommPS: %s", text); + } } } return NULL; } +/* What a sync did, for its notification: "2 saves up, 1 state down, 1 + * conflict to resolve". 0 when it did nothing worth telling. */ +static int sync_summary(const sync_result *r, char *out, size_t n) { + struct { int count; const char *one, *many; } parts[] = { + {r->uploaded, "save up", "saves up"}, {r->downloaded, "save down", "saves down"}, + {r->states_up, "state up", "states up"}, {r->states_down, "state down", "states down"}, + {r->cards, "memory card up", "memory cards up"}, + {r->conflicts, "conflict to resolve", "conflicts to resolve"}, {r->failed, "failed", "failed"}}; + size_t o = 0; + out[0] = 0; + for (size_t i = 0; i < sizeof parts / sizeof parts[0] && o < n; i++) + if (parts[i].count > 0) + o += (size_t)snprintf(out + o, n - o, "%s%d %s", o ? ", " : "", parts[i].count, + parts[i].count == 1 ? parts[i].one : parts[i].many); + return o > 0; +} + void sync_init(void) { thread_start(worker, NULL); } +static void plural(char *out, size_t n, int count, const char *what) { + snprintf(out, n, "%d %s%s", count, what, count == 1 ? "" : "s"); +} + +int sync_game(int rom_id, const char *name, sync_game_result *out) { + memset(out, 0, sizeof *out); + config_lock(); + int ready = g_cfg.setup_complete && g_cfg.save_sync; + config_unlock(); + if (!config_is_paired() || !ready) return -1; + + sync_result r; + run_and_record("game downloaded", rom_id, &r); + out->saves = r.uploaded + r.downloaded; + out->states = r.states_up + r.states_down; + out->conflicts = r.conflicts; + out->failed = r.failed || r.error[0]; + LOGI("saves for %s: %d saves, %d states, %d conflicts%s", name, out->saves, out->states, out->conflicts, + out->failed ? ", some failed" : ""); + if (!notify_on()) return 0; + if (out->failed) { + plat_notify("RommPS: %s downloaded. Its saves couldn't sync", name); + } else if (out->conflicts) { + plat_notify("RommPS: %s downloaded. A save conflict needs you", name); + } else if (out->saves || out->states) { + char saves[32] = "", states[32] = ""; + if (out->saves) plural(saves, sizeof saves, out->saves, "save"); + if (out->states) plural(states, sizeof states, out->states, "state"); + plat_notify("RommPS: %s downloaded with %s%s%s", name, saves, out->saves && out->states ? " and " : "", states); + } else { + plat_notify("RommPS: %s downloaded", name); + } + return 0; +} + void sync_request(const char *reason) { pthread_mutex_lock(&g_req_lock); if (!g_req_reason[0]) str_copy(g_req_reason, sizeof g_req_reason, reason); @@ -1669,7 +2862,7 @@ int sync_resolve_conflict(const char *path, const char *keep, char *err, int en) if (rc) snprintf(err, (size_t)en, "download failed"); } else { rc = do_download(&c, (int)jget_num(e, "save_id", 0), path, f, f->rom_id, f->slot, 0, jget_str(e, "server_hash", ""), - jget_str(e, "server_updated_at", "")); + jget_str(e, "server_updated_at", ""), 0); if (rc) snprintf(err, (size_t)en, "download failed"); } if (rc == 0) { @@ -1677,6 +2870,9 @@ int sync_resolve_conflict(const char *path, const char *keep, char *err, int en) state_entry_remove("conflicts", path); state_save(); state_unlock(); + events_set_sync("conflict resolved"); + events_add("save", "resolved", f->rom_id, path, emu_name(f->map), local ? "Kept this console's" : "Kept RomM's"); + events_save(); } done: cJSON_Delete(e); @@ -1799,6 +2995,7 @@ cJSON *sync_preview(char *err, int en) { const char *action = jget_str(op, "action", ""); int rom_id = (int)jget_num(op, "rom_id", 0); const char *slot = jget_str(op, "slot", ""); + if (!our_slot(&c, slot)) continue; const local_file *f = find_save(&c, rom_id, slot); char game[256] = ""; if (f) rom_display_name(f->rom, game, sizeof game); @@ -1947,7 +3144,13 @@ int sync_restore(const char *kind, int id, char *err, int en) { } scan_saves(&c); int rom_id = (int)jget_num(meta, "rom_id", 0); - const local_rom *r = find_rom(&c, rom_id, jget_str(meta, "emulator", NULL)); + const local_rom *r = is_state ? find_rom_for_state(&c, rom_id, jget_str(meta, "emulator", "")) + : find_rom(&c, rom_id, jget_str(meta, "emulator", NULL)); + if (!r && is_state && find_rom(&c, rom_id, NULL)) { + snprintf(err, (size_t)en, "made by %s, which this console's emulator can't load", + jget_str(meta, "emulator", "")[0] ? jget_str(meta, "emulator", "") : "an unknown emulator"); + goto done; + } if (!r) { snprintf(err, (size_t)en, "the game isn't on this console"); goto done; @@ -1960,7 +3163,7 @@ int sync_restore(const char *kind, int id, char *err, int en) { } state_local_name(r, jget_str(meta, "file_name", ""), name, sizeof name); path_join(dest, sizeof dest, dir, name); - rc = fetch_state(&c, meta, dest, rom_id); + rc = fetch_state(&c, meta, dest, r); if (rc) snprintf(err, (size_t)en, "download failed"); } else if (r->map->save_layout[0]) { const char *slot = jget_str(meta, "slot", c.slot); @@ -1988,7 +3191,10 @@ int sync_restore(const char *kind, int id, char *err, int en) { if (rc) snprintf(err, (size_t)en, "could not write %s", dest); /* Not recorded as synced: the next sync uploads it as the newest version. */ } - if (rc == 0) LOGI("restored %s %d to %s", kind, id, dest); + if (rc == 0) { + LOGI("restored %s %d to %s", kind, id, dest); + events_add(is_state ? "state" : "save", "restored", rom_id, dest, r ? emu_name(r->map) : "", "Older version"); + } done: scan_free(&c); pthread_mutex_unlock(&g_run_lock); @@ -1996,3 +3202,115 @@ done: if (rc == 0) sync_request("restore"); return rc; } + +/* One tracked file for sync_tracked. */ +static void tracked_file(cJSON *games, const cJSON *roms, const profile_map *maps, int nmaps, const char *kind, + const cJSON *e, const cJSON *conflicts) { + int rom_id = (int)jget_num(e, "rom_id", 0); + /* The game's record, one whose file is here first. */ + const cJSON *game = NULL, *g; + int here = 0; + cJSON_ArrayForEach(g, roms) if ((int)jget_num(g, "rom_id", -1) == rom_id && rom_id) { + const int exists = file_exists(g->string) || dir_exists(g->string); + if (!game || (exists && !here)) game = g; + here |= exists; + } + const char *platform = game ? jget_str(game, "platform_dir", "") : ""; + const char *profile = game ? jget_str(game, "profile", "") : ""; + const char *emulator = ""; + for (int i = 0; i < nmaps; i++) + if (!strcmp(maps[i].profile_id, profile) && (!platform[0] || !strcmp(maps[i].platform_dir, platform))) + emulator = maps[i].profile_name; + /* A record from an older version names its profile differently: by platform. */ + for (int i = 0; i < nmaps && !emulator[0] && platform[0]; i++) + if (!strcmp(maps[i].platform_dir, platform)) emulator = maps[i].profile_name; + + char key[16]; + snprintf(key, sizeof key, "%d", rom_id); + cJSON *entry = cJSON_GetObjectItemCaseSensitive(games, key); + if (!entry) { + entry = cJSON_AddObjectToObject(games, key); + cJSON_AddNumberToObject(entry, "rom_id", rom_id); + cJSON_AddStringToObject(entry, "name", game ? jget_str(game, "name", "") : ""); + cJSON_AddStringToObject(entry, "platform", platform); + cJSON_AddStringToObject(entry, "emulator", emulator); + cJSON_AddArrayToObject(entry, "files"); + } + const char *path = e->string; + const char *status = "synced"; + if (cJSON_GetObjectItemCaseSensitive(conflicts, path)) { + status = "conflict"; + } else if (!here) { /* the game isn't on this console any more */ + status = "no_game"; + } else if (strchr(path, '#')) { /* a save folder synced as a zip */ + status = "synced"; + } else if (!file_exists(path)) { + status = "missing"; + } else { + char ns[24]; + snprintf(ns, sizeof ns, "%lld", (long long)file_mtime_ns(path)); + /* A newer time with the same content (emulators rewrite saves) is still in sync. */ + char hash[33] = ""; + if ((strcmp(ns, jget_str(e, "mtime_ns", "")) != 0 || file_size(path) != (int64_t)jget_num(e, "size", -1)) && + (md5_file_hex(path, hash) != 0 || strcmp(hash, jget_str(e, "hash", "")) != 0)) + status = "changed"; + } + cJSON *f = cJSON_CreateObject(); + cJSON_AddStringToObject(f, "kind", kind); + cJSON_AddStringToObject(f, "path", path); + cJSON_AddStringToObject(f, "status", status); + cJSON_AddNumberToObject(f, "synced_at", jget_num(e, "synced_at", 0)); + cJSON_AddNumberToObject(f, "size", jget_num(e, "size", 0)); + if (!strcmp(kind, "save")) { + cJSON_AddStringToObject(f, "slot", jget_str(e, "slot", "")); + cJSON_AddNumberToObject(f, "server_id", jget_num(e, "save_id", 0)); + } else { + cJSON_AddNumberToObject(f, "server_id", jget_num(e, "state_id", 0)); + cJSON_AddBoolToObject(f, "from_here", jget_num(e, "mine", 0) != 0); + } + cJSON_AddItemToArray(cJSON_GetObjectItemCaseSensitive(entry, "files"), f); +} + +cJSON *sync_tracked(int only_rom) { + profile_map *maps = NULL; + config_lock(); + int nmaps = profiles_expand(g_cfg.profiles, &maps); + config_unlock(); + cJSON *games = cJSON_CreateObject(); + state_lock(); + const cJSON *roms = state_section("roms"), *conflicts = state_section("conflicts"), *e; + static const char *const kinds[][2] = {{"saves", "save"}, {"states", "state"}}; + for (int k = 0; k < 2; k++) + cJSON_ArrayForEach(e, state_section(kinds[k][0])) { + if (only_rom && (int)jget_num(e, "rom_id", 0) != only_rom) continue; + tracked_file(games, roms, maps, nmaps, kinds[k][1], e, conflicts); + } + state_unlock(); + free(maps); + + /* {"platforms": [{"platform": "gba", "games": [...]}], "saves": n, "states": n, "conflicts": n} */ + cJSON *out = cJSON_CreateObject(), *plats = cJSON_AddArrayToObject(out, "platforms"), *gm; + int n[3] = {0, 0, 0}; + while ((gm = games->child)) { + cJSON_DetachItemViaPointer(games, gm); + const char *pl = jget_str(gm, "platform", ""); + cJSON *p = NULL, *x; + cJSON_ArrayForEach(x, plats) if (!strcmp(jget_str(x, "platform", ""), pl)) p = x; + if (!p) { + p = cJSON_CreateObject(); + cJSON_AddStringToObject(p, "platform", pl); + cJSON_AddArrayToObject(p, "games"); + cJSON_AddItemToArray(plats, p); + } + cJSON_ArrayForEach(x, cJSON_GetObjectItemCaseSensitive(gm, "files")) { + n[!strcmp(jget_str(x, "kind", ""), "state")]++; + if (!strcmp(jget_str(x, "status", ""), "conflict")) n[2]++; + } + cJSON_AddItemToArray(cJSON_GetObjectItemCaseSensitive(p, "games"), gm); + } + cJSON_Delete(games); + cJSON_AddNumberToObject(out, "saves", n[0]); + cJSON_AddNumberToObject(out, "states", n[1]); + cJSON_AddNumberToObject(out, "conflicts", n[2]); + return out; +} diff --git a/src/sync.h b/src/sync.h index db92220..84c27f3 100644 --- a/src/sync.h +++ b/src/sync.h @@ -27,6 +27,14 @@ void sync_add_play_session(time_t start, time_t end, const char *title_id); void sync_set_game_running(int running); int sync_game_running(void); +/* Syncs one game's saves and states now, e.g. once it's downloaded, and + * notifies what came down. Waits for a sync already running. -1 when + * syncing isn't set up yet. */ +typedef struct { + int saves, states, conflicts, failed; +} sync_game_result; +int sync_game(int rom_id, const char *name, sync_game_result *out); + /* What a sync would do, without transferring anything. */ cJSON *sync_preview(char *err, int en); @@ -35,6 +43,10 @@ cJSON *sync_history(int rom_id, char *err, int en); /* Restores a save version or a state ("save" or "state") into place. */ int sync_restore(const char *kind, int id, char *err, int en); +/* The saves and states this console keeps in sync, by platform and game, each + * with its status (synced, changed, conflict, missing); only_rom 0 for all. */ +cJSON *sync_tracked(int only_rom); + /* Save folders the last sync looked in, for the watcher. any_file marks * bundle folders, where every file counts. */ int sync_scanned_dirs(char dirs[][1024], int *any_file, int max); diff --git a/src/update.c b/src/update.c index f0c3d71..0f5b07a 100644 --- a/src/update.c +++ b/src/update.c @@ -140,7 +140,7 @@ static void *check_thread(void *arg) { LOGI("update check: latest release %s, running %s", g_latest, APP_VERSION); if (notify && available && strcmp(g_notified, g_latest) != 0) { str_copy(g_notified, sizeof g_notified, g_latest); - if (plat_info()->self_update) plat_notify("RomM Sync %s is available. Update it from Settings.", g_latest); + if (plat_info()->self_update) plat_notify("RommPS %s is available. Update it from Settings.", g_latest); else plat_notify("RommPS %s is available. Update it from ProsperoStore.", g_latest); } } @@ -278,7 +278,7 @@ static void *install_thread(void *arg) { } free(elf); LOGI("update: %s installed, starts with the next jailbreak", want); - plat_notify("RomM Sync %s is installed and starts with the next jailbreak", want); + plat_notify("RommPS %s is installed and starts with the next jailbreak", want); pthread_mutex_lock(&g_lock); g_state = U_PENDING; str_copy(g_pending, sizeof g_pending, want); @@ -288,7 +288,7 @@ static void *install_thread(void *arg) { } set_message(U_RESTARTING, persisted ? "Restarting" : "Restarting (until the next reboot, autostart isn't set up)"); - plat_notify("Updating RomM Sync to %s", want); + plat_notify("Updating RommPS to %s", want); sleep(1); /* lets the UI see the restart coming */ if (plat_launch_elf(elf, elf_len) != 0) { snprintf(err, sizeof err, "couldn't start the new version (%s).%s", strerror(errno), diff --git a/src/util.c b/src/util.c index bf1f902..1100fe1 100644 --- a/src/util.c +++ b/src/util.c @@ -194,8 +194,9 @@ int write_file_atomic(const char *path, const void *data, size_t len) { return rename(tmp, path); } -int copy_file(const char *src, const char *dst) { +int copy_file_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud) { char tmp[PATH_MAX_LEN]; + int64_t total = file_size(src), done = 0; FILE *in = fopen(src, "rb"); if (!in) return -1; char *buf = malloc(64 * 1024); @@ -218,6 +219,11 @@ int copy_file(const char *src, const char *dst) { rc = -1; break; } + done += (int64_t)n; + if (cb && cb(ud, done, total)) { + rc = -1; + break; + } } fclose(in); free(buf); @@ -229,6 +235,10 @@ int copy_file(const char *src, const char *dst) { return rc; } +int copy_file(const char *src, const char *dst) { + return copy_file_progress(src, dst, NULL, NULL); +} + static void parse_version(const char *s, int v[3]) { v[0] = v[1] = v[2] = 0; if (!s) return; @@ -447,7 +457,7 @@ int md5_file_hex(const char *path, char out[33]) { } /* A hard link to src at dst (a copy across drives); folders file by file. */ -int link_or_copy(const char *src, const char *dst) { +int link_or_copy_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud) { if (dir_exists(src)) { if (mkdir_p(dst) != 0) return -1; DIR *d = opendir(src); @@ -459,7 +469,7 @@ int link_or_copy(const char *src, const char *dst) { char a[PATH_MAX_LEN], b[PATH_MAX_LEN]; path_join(a, sizeof a, src, de->d_name); path_join(b, sizeof b, dst, de->d_name); - if (link_or_copy(a, b) != 0) rc = -1; + if (link_or_copy_progress(a, b, cb, ud) != 0) rc = -1; } closedir(d); return rc; @@ -469,7 +479,12 @@ int link_or_copy(const char *src, const char *dst) { unlink(dst); } if (link(src, dst) == 0) return 0; - return copy_file(src, dst); + LOGI("can't link %s to %s (%s), copying instead", src, dst, strerror(errno)); + return copy_file_progress(src, dst, cb, ud); +} + +int link_or_copy(const char *src, const char *dst) { + return link_or_copy_progress(src, dst, NULL, NULL); } int fnv1a64_file_hex(const char *path, char out[17]) { diff --git a/src/util.h b/src/util.h index 7661e48..16b5f29 100644 --- a/src/util.h +++ b/src/util.h @@ -8,7 +8,7 @@ #define APP_NAME "romm-sync" #ifndef APP_VERSION -#define APP_VERSION "1.1.1" +#define APP_VERSION "1.2.0" #endif #ifndef APP_BUILD /* git describe of the build, from the Makefile */ #define APP_BUILD "unknown" @@ -43,7 +43,10 @@ int64_t file_mtime_ns(const char *path); /* nanoseconds, 0 if missing */ int set_file_mtime(const char *path, time_t t); char *read_file(const char *path, size_t *len); /* malloc'd, NUL-terminated */ int write_file_atomic(const char *path, const void *data, size_t len); +/* Tells us how far along a copy is; Nonzero return will stop it. */ +typedef int (*copy_progress_fn)(void *ud, int64_t done, int64_t total); int copy_file(const char *src, const char *dst); +int copy_file_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud); /* -1, 0 or 1 as a is older, the same as or newer than b ("1.2.3", a leading * "v" and anything after the patch ignored). Unreadable counts as 0.0.0. */ @@ -52,6 +55,7 @@ int version_cmp(const char *a, const char *b); * 1.1.0 on), or "" if it has none. */ void payload_version(const void *elf, size_t len, char *out, size_t n); int link_or_copy(const char *src, const char *dst); /* hard link, a copy across drives; folders too */ +int link_or_copy_progress(const char *src, const char *dst, copy_progress_fn cb, void *ud); int move_file(const char *src, const char *dst); /* rename, falls back to copy+unlink */ void remove_tree(const char *path); /* rm -rf */ const char *path_basename(const char *path); diff --git a/src/watch.c b/src/watch.c index 6bc3ec2..0b955e3 100644 --- a/src/watch.c +++ b/src/watch.c @@ -1,6 +1,8 @@ /* Polls the save and state folders once a second and asks for a sync when a * changed file has been left alone for SETTLE_SEC. Files that changed before - * startup are left to the startup sync. */ + * startup are left to the startup sync. While a game runs, a file goes up at + * most every PLAYING_GAP_SEC: some emulators rewrite the save every few + * seconds, and the sync after the game closes sends the last version. */ #include "watch.h" #include @@ -21,6 +23,7 @@ #define POLL_SEC 1 #define SETTLE_SEC 2 +#define PLAYING_GAP_SEC 300 #define RELOAD_DIRS_SEC 60 #define MAX_DEPTH 3 @@ -31,6 +34,7 @@ typedef struct { int64_t synced_mtime; /* last version handed to a sync */ off_t synced_size; time_t changed_at; /* when an unsynced change was seen, 0 if none */ + time_t sent_at; /* when it last asked for a sync */ int seen; } watched; @@ -190,7 +194,7 @@ static void *watch_thread(void *arg) { time_t now = time(NULL); int enabled; config_lock(); - enabled = g_cfg.sync_on_change && g_cfg.setup_complete; + enabled = g_cfg.sync_on_change && g_cfg.setup_complete && g_cfg.save_sync; config_unlock(); if (!config_is_paired() || !enabled) { baseline = 1; @@ -218,11 +222,13 @@ static void *watch_thread(void *arg) { for (int i = 0; i < g_nfiles; i++) { watched *w = &g_files[i]; if (!w->changed_at || now - w->changed_at < SETTLE_SEC) continue; + if (sync_game_running() && w->sent_at && now - w->sent_at < PLAYING_GAP_SEC) continue; /* later */ w->changed_at = 0; if (w->mtime == w->synced_mtime && w->size == w->synced_size) continue; w->synced_mtime = w->mtime; w->synced_size = w->size; if (written_by_sync(w)) continue; + w->sent_at = now; if (!first) first = w->path; ready++; } diff --git a/src/web.c b/src/web.c index 9e7b46e..1cd9114 100644 --- a/src/web.c +++ b/src/web.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include "config.h" #include "covers.h" #include "detect.h" +#include "events.h" #include "http.h" #include "library.h" #include "memcard.h" @@ -29,6 +31,7 @@ #include "ui_index.h" /* generated from ui/index.html */ #include "update.h" #include "util.h" +#include "zip.h" #define MAX_HEADER (16 * 1024) #define MAX_BODY (2 * 1024 * 1024) @@ -264,6 +267,7 @@ static cJSON *status_json(void) { config_lock(); cJSON_AddBoolToObject(j, "paired", config_is_paired()); cJSON_AddBoolToObject(j, "setup_complete", g_cfg.setup_complete); + cJSON_AddBoolToObject(j, "save_sync", g_cfg.save_sync); cJSON_AddStringToObject(j, "server_url", g_cfg.server_url); cJSON_AddStringToObject(j, "username", g_cfg.username); cJSON_AddStringToObject(j, "server_version", g_cfg.server_version); @@ -318,6 +322,14 @@ static cJSON *systems_json(const cJSON *profiles) { return out; } +/* Whether a list in obj ("on": [roots]) names root. */ +static int lists_root(const cJSON *obj, const char *list, const char *root) { + const cJSON *x; + cJSON_ArrayForEach(x, cJSON_GetObjectItemCaseSensitive(obj, list)) + if (cJSON_IsString(x) && !strcmp(x->valuestring, root)) return 1; + return 0; +} + static int plays_system(const cJSON *profile, const char *key) { const cJSON *e; cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(profile, "platforms")) @@ -405,7 +417,7 @@ static char *diagnostics_text(void) { char line[512], about[256], now_iso[40], path[PATH_MAX_LEN]; plat_describe(about, sizeof about); iso8601_utc(time(NULL), now_iso, sizeof now_iso); - snprintf(line, sizeof line, "RomM Sync diagnostics\nversion %s, build %s, %s\n%s\nclock %s, up %lds\n", + snprintf(line, sizeof line, "RommPS diagnostics\nversion %s, build %s, %s\n%s\nclock %s, up %lds\n", APP_VERSION, APP_BUILD, plat_name(), about, now_iso, (long)(time(NULL) - g_started)); tb_str(&b, line); tb_json(&b, "status", status_json()); @@ -426,6 +438,286 @@ static char *diagnostics_text(void) { return b.s; } +/* Diagnostic bundle: a zip with the report, the logs, the sync log, the + * settings and emulators found, the emulators' own logs and the kernel log + * when it can be read. Nothing in it names the person: the server, user + * name, device name and ids, tokens, IP and MAC addresses, emails and + * console user ids are replaced before anything is written. */ +typedef struct { char from[600]; const char *to; } redaction; + +static size_t redactions(redaction *r, size_t max) { + size_t n = 0; + config_lock(); + const struct { const char *v; const char *to; } src[] = { + {g_cfg.token, ""}, {g_cfg.server_url, ""}, {g_cfg.username, ""}, + {g_cfg.device_name, ""}, {g_cfg.device_id, ""}, + {g_cfg.client_device_identifier, ""}}; + for (size_t i = 0; i < sizeof src / sizeof src[0] && n < max; i++) + if (strlen(src[i].v) >= 3 && strcmp(src[i].v, "PlayStation 5")) { + str_copy(r[n].from, sizeof r[n].from, src[i].v); + r[n++].to = src[i].to; + } + /* The server's host alone, as it shows in errors. */ + const char *h = strstr(g_cfg.server_url, "://"); + if (h && n < max) { + h += 3; + size_t l = strcspn(h, ":/"); + if (l >= 3 && l < sizeof r[n].from) { + memcpy(r[n].from, h, l); + r[n].from[l] = 0; + r[n++].to = ""; + } + } + config_unlock(); + return n; +} + +static int is_hex(char c) { return (c >= '0' && c <= '9') || ((c | 32) >= 'a' && (c | 32) <= 'f'); } +static int is_digit(char c) { return c >= '0' && c <= '9'; } +static int is_alnum(char c) { return is_digit(c) || ((c | 32) >= 'a' && (c | 32) <= 'z'); } +static int is_word(char c) { return is_hex(c) || ((c | 32) >= 'a' && (c | 32) <= 'z') || c == '_' || c == '.' || c == '-' || c == '+'; } + +/* Length of a dotted IPv4 address at s, or 0. */ +static size_t ipv4_at(const char *s) { + size_t i = 0; + for (int part = 0; part < 4; part++) { + size_t d = 0; + while (is_digit(s[i + d]) && d < 4) d++; + if (!d || d > 3) return 0; + i += d; + if (part < 3) { + if (s[i] != '.') return 0; + i++; + } + } + return is_digit(s[i]) || (s[i] == '.' && is_digit(s[i + 1])) ? 0 : i; +} + +/* Length of a MAC address (aa:bb:cc:dd:ee:ff or with -) at s, or 0. */ +static size_t mac_at(const char *s) { + for (int i = 0; i < 6; i++) { + if (!is_hex(s[i * 3]) || !is_hex(s[i * 3 + 1])) return 0; + if (i < 5 && s[i * 3 + 2] != ':' && s[i * 3 + 2] != '-') return 0; + } + return is_hex(s[17]) ? 0 : 17; +} + +/* A copy of txt with everything that could name the person replaced. */ +static char *redact(const char *txt, const redaction *r, size_t nr) { + textbuf b = {0}; + tb_str(&b, ""); + for (const char *p = txt; *p;) { + size_t m = 0; + const char *to = NULL; + const int start = p == txt || !is_word(p[-1]); + for (size_t i = 0; i < nr && !m; i++) { /* whole words only */ + size_t l = strlen(r[i].from); + if ((p == txt || !is_alnum(p[-1])) && !strncasecmp(p, r[i].from, l) && !is_alnum(p[l])) m = l, to = r[i].to; + } + if (!m && start && (m = ipv4_at(p)) && (!strncmp(p, "127.", 4) || !strncmp(p, "0.0.0.0", 7))) m = 0; + if (m && !to) to = ""; + if (!m && start && (m = mac_at(p))) to = ""; + if (!m && !strncmp(p, "/user/home/", 11)) { /* a console user's id */ + size_t l = 0; + while (is_hex(p[11 + l])) l++; + if (l) m = 11 + l, to = "/user/home/"; + } + static const char *const keys[] = {"onlineid", "online_id", "accountid", "account_id", "npid", "userid", "user_id", "username", "user_name", "email"}; + for (size_t i = 0; !m && start && i < sizeof keys / sizeof keys[0]; i++) { /* key=value, key: "value" */ + size_t l = strlen(keys[i]); + if (strncasecmp(p, keys[i], l) || is_alnum(p[l])) continue; + size_t v = l; + while (p[v] == ' ' || p[v] == '=' || p[v] == ':' || p[v] == '"' || p[v] == '\'' || p[v] == '\t') v++; + if (v == l || !is_word(p[v])) continue; + tb_add(&b, p, v); + m = v; + while (is_word(p[m])) m++; + m -= v; + p += v; + to = ""; + } + if (!m && *p == '@' && p > txt && is_word(p[-1]) && is_word(p[1])) { /* an email: drop both sides */ + while (b.len && is_word(b.s[b.len - 1])) b.s[--b.len] = 0; + m = 1; + while (is_word(p[m])) m++; + to = ""; + } + if (m) { + tb_str(&b, to); + p += m; + } else tb_add(&b, p++, 1); + } + return b.s; +} + +/* Writes name into dir with its text redacted; adds it to the members. */ +static void bundle_add(const char *dir, const char *name, const char *txt, const redaction *r, size_t nr, + zip_member *mem, char (*paths)[PATH_MAX_LEN], int *n, int max) { + if (!txt || *n >= max) return; + char *clean = redact(txt, r, nr); + path_join(paths[*n], PATH_MAX_LEN, dir, name); + if (clean && !write_file_atomic(paths[*n], clean, strlen(clean))) { + mem[*n].path = paths[*n]; + mem[*n].name = strrchr(paths[*n], '/') + 1; + (*n)++; + } + free(clean); +} + +/* The last max bytes of a file, from the start of a line, or NULL. */ +static char *file_tail(const char *path, size_t max) { + size_t len = 0; + char *txt = read_file(path, &len); + if (!txt || len <= max) return txt; + const char *start = txt + len - max, *nl = strchr(start, '\n'); + if (nl) start = nl + 1; + memmove(txt, start, strlen(start) + 1); + return txt; +} + +/* What the kernel log holds right now, without waiting for more. */ +static char *kernel_log(void) { + int fd = open("/dev/klog", O_RDONLY | O_NONBLOCK); + if (fd < 0) return NULL; + textbuf b = {0}; + char buf[4096]; + time_t until = time(NULL) + 2; + while (b.len < 512 * 1024 && time(NULL) <= until) { + ssize_t got = read(fd, buf, sizeof buf); + if (got <= 0) break; + tb_add(&b, buf, (size_t)got); + } + close(fd); + return b.s; +} + +/* The emulators' own logs: *.log files in each emulator's folder. */ +static void emulator_logs(const char *dir, const redaction *r, size_t nr, zip_member *mem, + char (*paths)[PATH_MAX_LEN], int *n, int max) { + cJSON *found = detect_emulators(), *c; + char seen[16][PATH_MAX_LEN]; + int nseen = 0; + cJSON_ArrayForEach(c, found) { + char roots[2][PATH_MAX_LEN] = {"", ""}; + str_copy(roots[0], PATH_MAX_LEN, jget_str(c, "root", "")); + const char *tid = jget_str(c, "title_id", ""); + if (tid[0]) snprintf(roots[1], PATH_MAX_LEN, "/data/homebrew/%s", tid); + for (int k = 0; k < 2; k++) { + int dup = !roots[k][0]; + for (int i = 0; i < nseen && !dup; i++) dup = !strcmp(seen[i], roots[k]); + if (dup || nseen >= 16) continue; + str_copy(seen[nseen++], PATH_MAX_LEN, roots[k]); + DIR *d = opendir(roots[k]); + struct dirent *de; + while (d && (de = readdir(d))) { + if (de->d_name[0] == '.' || !str_ends_with_ci(de->d_name, ".log")) continue; + char path[PATH_MAX_LEN], name[256]; + path_join(path, sizeof path, roots[k], de->d_name); + if (dir_exists(path)) continue; + const char *base = strrchr(roots[k], '/'); + snprintf(name, sizeof name, "emulators-%s-%s", base ? base + 1 : roots[k], de->d_name); + char *txt = file_tail(path, 256 * 1024); + bundle_add(dir, name, txt, r, nr, mem, paths, n, max); + free(txt); + } + if (d) closedir(d); + } + } + cJSON_Delete(found); +} + +/* Builds the bundle at zip_path. 0 on success. */ +static int diagnostics_bundle(const char *zip_path) { + enum { kMax = 48 }; + redaction r[8]; + size_t nr = redactions(r, 8); + char dir[PATH_MAX_LEN], path[PATH_MAX_LEN]; + path_join(dir, sizeof dir, plat_data_dir(), "diagnostics.tmp"); + remove_tree(dir); + mkdir_p(dir); + zip_member mem[kMax]; + char (*paths)[PATH_MAX_LEN] = malloc(kMax * sizeof *paths); /* console threads have small stacks */ + int n = 0; + if (!paths) return -1; + + char *txt = diagnostics_text(); + bundle_add(dir, "report.txt", txt, r, nr, mem, paths, &n, kMax); + free(txt); + const char *logs[][2] = {{"romm-sync.log", "romm-sync.log"}, {"romm-sync.log.1", "romm-sync.previous.log"}, + {"events.json", "sync-log.json"}, {"boot.log", "boot.log"}, {"launcher.log", "launcher.log"}}; + for (size_t i = 0; i < sizeof logs / sizeof logs[0]; i++) { + path_join(path, sizeof path, plat_data_dir(), logs[i][0]); + txt = read_file(path, NULL); + bundle_add(dir, logs[i][1], txt, r, nr, mem, paths, &n, kMax); + free(txt); + } + cJSON *j = detect_emulators(); + txt = cJSON_Print(j); + cJSON_Delete(j); + bundle_add(dir, "emulators-found.json", txt, r, nr, mem, paths, &n, kMax); + free(txt); + config_lock(); + txt = cJSON_Print(g_cfg.profiles); + config_unlock(); + bundle_add(dir, "emulator-settings.json", txt, r, nr, mem, paths, &n, kMax); + free(txt); + emulator_logs(dir, r, nr, mem, paths, &n, kMax); + txt = kernel_log(); + bundle_add(dir, "kernel.log", txt ? txt : "The kernel log couldn't be read (another tool may be reading it).\n", r, + nr, mem, paths, &n, kMax); + free(txt); + + int rc = zip_write(zip_path, mem, n); + remove_tree(dir); + free(paths); + LOGI("diagnostic bundle %s: %d files%s", zip_path, n, rc ? ", failed" : ""); + return rc; +} + +/* Saves a bundle where the person can get at it: a USB drive when one is in, + * and the payload's folder. The newest three are kept there. */ +static cJSON *diagnostics_save(void) { + char stamp[32], name[64], path[PATH_MAX_LEN], dir[PATH_MAX_LEN]; + time_t now = time(NULL); + struct tm tm; + localtime_r(&now, &tm); + strftime(stamp, sizeof stamp, "%Y%m%d-%H%M%S", &tm); + snprintf(name, sizeof name, "rommps-diagnostics-%s.zip", stamp); + path_join(dir, sizeof dir, plat_data_dir(), "diagnostics"); + mkdir_p(dir); + path_join(path, sizeof path, dir, name); + if (diagnostics_bundle(path)) return NULL; + /* Older ones go: names sort by time. */ + DIR *d = opendir(dir); + struct dirent *de; + char (*old)[64] = malloc(64 * sizeof *old); + int n = 0; + while (old && d && (de = readdir(d)) && n < 64) + if (!strncmp(de->d_name, "rommps-diagnostics-", 19)) str_copy(old[n++], sizeof old[0], de->d_name); + if (d) closedir(d); + if (old) { + qsort(old, (size_t)n, sizeof old[0], (int (*)(const void *, const void *))strcmp); + for (int i = 0; i + 3 < n; i++) { + char p[PATH_MAX_LEN]; + path_join(p, sizeof p, dir, old[i]); + unlink(p); + } + free(old); + } + cJSON *j = cJSON_CreateObject(); + cJSON_AddStringToObject(j, "path", path); + const char *usb[] = {"/mnt/usb0", "/mnt/usb1", "/mnt/usb2", "/mnt/usb3"}; + for (size_t i = 0; i < sizeof usb / sizeof usb[0]; i++) { + char to[PATH_MAX_LEN]; + path_join(to, sizeof to, usb[i], name); + if (dir_exists(usb[i]) && !copy_file(path, to)) { + cJSON_AddStringToObject(j, "usb", to); + break; + } + } + return j; +} + /* Covers and platform icons, from the console's cache or RomM (covers.h). */ static void proxy_asset(const request *r) { char p[1024]; @@ -562,10 +854,20 @@ static void route(request *r) { return; } if (is_get && !strcmp(r->path, "/api/diagnostics")) { - char *txt = diagnostics_text(); - send_response(fd, 200, "text/plain; charset=utf-8", txt ? txt : "", txt ? strlen(txt) : 0, - "Content-Disposition: attachment; filename=\"romm-sync-diagnostics.txt\"\r\n"); - free(txt); + cJSON *j = diagnostics_save(); + size_t len = 0; + char *zip = j ? read_file(jget_str(j, "path", ""), &len) : NULL; + if (zip) send_response(fd, 200, "application/zip", zip, len, + "Content-Disposition: attachment; filename=\"rommps-diagnostics.zip\"\r\n"); + else send_error(fd, 500, "couldn't make the bundle"); + free(zip); + cJSON_Delete(j); + return; + } + if (is_post && !strcmp(r->path, "/api/diagnostics")) { + cJSON *j = diagnostics_save(); + if (j) send_json(fd, 200, j); + else send_error(fd, 500, "couldn't make the bundle"); return; } if (is_get && !strcmp(r->path, "/api/config")) { @@ -682,25 +984,45 @@ static void route(request *r) { * system several of them play to the root of its main emulator; * "also" lists, per system, the other emulators that keep it as * extras (downloads linked in, a save shared where the format is the - * same); the rest exclude it. */ + * same); the rest exclude it. "systems" does the same per system, + * and wins over both: {"ps2": {"main": root, "on": [roots], + * "folders": {root: {"rom_dir", "save_dir", "state_dir"}}}}. An + * emulator not "on" excludes it; folders go on that system's entry. */ cJSON *j = body_json(r), *found = detect_emulators(), *profiles = cJSON_CreateArray(); const cJSON *root, *c, *choices = cJSON_GetObjectItemCaseSensitive(j, "choices"); const cJSON *also = cJSON_GetObjectItemCaseSensitive(j, "also"); + const cJSON *systems = cJSON_GetObjectItemCaseSensitive(j, "systems"); cJSON_ArrayForEach(root, cJSON_GetObjectItemCaseSensitive(j, "roots")) { cJSON_ArrayForEach(c, found) { if (!cJSON_IsString(root) || strcmp(jget_str(c, "root", ""), root->valuestring) != 0) continue; if (cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(c, "ready"))) continue; /* no preset: "custom" below */ cJSON *p = cJSON_Duplicate(cJSON_GetObjectItemCaseSensitive(c, "profile"), 1), *ex = cJSON_CreateArray(), *extra = cJSON_CreateArray(); - const cJSON *e, *x; + cJSON *e; cJSON_ArrayForEach(e, cJSON_GetObjectItemCaseSensitive(p, "platforms")) { const char *key = profiles_system_key(e), *keeper = jget_str(choices, key, NULL); + const cJSON *sys = cJSON_GetObjectItemCaseSensitive(systems, key); + if (sys) { + if (!lists_root(sys, "on", root->valuestring)) { + cJSON_AddItemToArray(ex, cJSON_CreateString(key)); + continue; + } + if (strcmp(jget_str(sys, "main", ""), root->valuestring) != 0) /* on, not the main one */ + cJSON_AddItemToArray(extra, cJSON_CreateString(key)); + const cJSON *f = cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(sys, "folders"), + root->valuestring); + static const char *const dirs[] = {"rom_dir", "save_dir", "state_dir"}; + for (int k = 0; f && k < 3; k++) { + const char *d = jget_str(f, dirs[k], ""); + if (d[0] != '/') continue; /* empty: the default */ + cJSON_DeleteItemFromObjectCaseSensitive(e, dirs[k]); + cJSON_AddStringToObject(e, dirs[k], d); + } + continue; + } if (!keeper || !strcmp(keeper, root->valuestring)) continue; /* "also": {"psx": [roots]} keeps the system on those as extras. */ - int kept = 0; - cJSON_ArrayForEach(x, cJSON_GetObjectItemCaseSensitive(also, key)) - if (cJSON_IsString(x) && !strcmp(x->valuestring, root->valuestring)) kept = 1; - cJSON_AddItemToArray(kept ? extra : ex, cJSON_CreateString(key)); + cJSON_AddItemToArray(lists_root(also, key, root->valuestring) ? extra : ex, cJSON_CreateString(key)); } if (cJSON_GetArraySize(ex)) cJSON_AddItemToObject(p, "exclude", ex); else cJSON_Delete(ex); @@ -757,6 +1079,35 @@ static void route(request *r) { } cJSON_AddItemToArray(profiles, p); } + /* Save sync per platform, on each platform entry ("sync": "off" or + * "saves"; none is everything): "systems"{key}{"sync"} from the app, + * else "sync"{root} (the web UI, per emulator), else what it was. */ + const cJSON *modes = cJSON_GetObjectItemCaseSensitive(j, "sync"); + config_lock(); + cJSON *pr; + cJSON_ArrayForEach(pr, profiles) { + const char *at = jget_str(pr, "root", ""); + const cJSON *old = NULL, *o; + cJSON_ArrayForEach(o, g_cfg.profiles) if (!strcmp(jget_str(o, "root", ""), at) && + !strcmp(jget_str(o, "id", ""), jget_str(pr, "id", ""))) + old = o; + cJSON *pe; + cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(pr, "platforms")) { + const char *key = profiles_system_key(pe); + const char *mode = jget_str(cJSON_GetObjectItemCaseSensitive(systems, key), "sync", NULL); + if (!mode) mode = jget_str(modes, at, NULL); + const cJSON *oe; + cJSON_ArrayForEach(oe, cJSON_GetObjectItemCaseSensitive(old, "platforms")) if (!mode && !strcmp(profiles_system_key(oe), key)) + mode = jget_str(oe, "sync", NULL); + if (!mode) mode = jget_str(old, "sync", NULL); + char keep[8] = ""; + if (mode && (!strcmp(mode, "off") || !strcmp(mode, "saves"))) str_copy(keep, sizeof keep, mode); + cJSON_DeleteItemFromObjectCaseSensitive(pe, "sync"); + if (keep[0]) cJSON_AddStringToObject(pe, "sync", keep); + } + cJSON_DeleteItemFromObjectCaseSensitive(pr, "sync"); + } + config_unlock(); cJSON_Delete(found); cJSON_Delete(j); cJSON *cfg = cJSON_CreateObject(); @@ -778,6 +1129,38 @@ static void route(request *r) { } return; } + if (is_post && !strcmp(r->path, "/api/emulators/sync")) { + /* One emulator's save sync, for every platform it plays: {"root", "sync": "off" | "saves" | "all"}. */ + cJSON *j = body_json(r), *p; + const char *at = jget_str(j, "root", ""), *mode = jget_str(j, "sync", ""); + int found = 0, rc = -1; + if (at[0] && (!strcmp(mode, "off") || !strcmp(mode, "saves") || !strcmp(mode, "all"))) { + config_lock(); + cJSON_ArrayForEach(p, g_cfg.profiles) { + if (strcmp(jget_str(p, "root", ""), at) != 0) continue; + found = 1; + cJSON_DeleteItemFromObjectCaseSensitive(p, "sync"); + cJSON *pe; /* every platform it plays */ + cJSON_ArrayForEach(pe, cJSON_GetObjectItemCaseSensitive(p, "platforms")) { + cJSON_DeleteItemFromObjectCaseSensitive(pe, "sync"); + if (strcmp(mode, "all") != 0) cJSON_AddStringToObject(pe, "sync", mode); + } + } + if (found) { + g_cfg.profiles_custom = 1; + rc = config_save(); + } + 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); + } + return; + } if (is_get && !strcmp(r->path, "/api/setup/preview")) { cJSON *j = sync_preview(err, sizeof err); if (j) send_json(fd, 200, j); @@ -833,6 +1216,14 @@ static void route(request *r) { else send_ok(fd); return; } + if (is_get && !strcmp(r->path, "/api/events")) { + send_json(fd, 200, events_json(qint(r, "rom_id", 0), qint(r, "limit", 200))); + return; + } + if (is_get && !strcmp(r->path, "/api/tracked")) { + send_json(fd, 200, sync_tracked(qint(r, "rom_id", 0))); + return; + } if (is_get && !strcmp(r->path, "/api/history")) { cJSON *j = sync_history(qint(r, "rom_id", 0), err, sizeof err); if (j) send_json(fd, 200, j); @@ -893,7 +1284,7 @@ static void route(request *r) { } if (is_get && !strcmp(r->path, "/api/installed")) { int limit = qint(r, "limit", 20); - send_json(fd, 200, library_installed(limit < 1 || limit > 200 ? 20 : limit)); + send_json(fd, 200, library_installed(limit < 1 ? 20 : limit > 5000 ? 5000 : limit)); return; } if (is_post && !strcmp(r->path, "/api/download")) { @@ -928,8 +1319,9 @@ static void route(request *r) { cJSON_AddStringToObject(o, "platforms", maps[i].romm_slugs); cJSON_AddStringToObject(o, "emulator", maps[i].emulator); cJSON_AddStringToObject(o, "rom_dir", maps[i].rom_dir); + cJSON_AddStringToObject(o, "root", maps[i].root); cJSON_AddStringToObject(o, "save_dir", maps[i].sync_saves ? maps[i].save_dir : ""); - cJSON_AddStringToObject(o, "state_dir", maps[i].sync_saves ? maps[i].state_dir : ""); + cJSON_AddStringToObject(o, "state_dir", maps[i].sync_saves && maps[i].sync_states ? maps[i].state_dir : ""); cJSON_AddStringToObject(o, "bios_dir", maps[i].bios_dir); cJSON_AddStringToObject(o, "sync_note", maps[i].sync_note); cJSON_AddStringToObject(o, "save_layout", maps[i].save_layout); diff --git a/tests/autostart.sh b/tests/autostart.sh index e9641da..bd1220d 100755 --- a/tests/autostart.sh +++ b/tests/autostart.sh @@ -109,6 +109,6 @@ stop; : > "$T/daemon.log"; SELF_UPDATE=0 APP_INSTALLED=1 start; sleep 1 stop; : > "$T/daemon.log"; SELF_UPDATE=0 APP_INSTALLED=1 APP_VERSION=1.1.0 start; sleep 1 grep -q "NOTIFY.*RommPS 1.1.0 is out of date" "$T/daemon.log" || fail "no hint to update an older RommPS: $(grep NOTIFY "$T/daemon.log")" stop; : > "$T/daemon.log"; SELF_UPDATE=0 start; sleep 1 -grep -q "NOTIFY.*Install RommPS from ProsperoStore" "$T/daemon.log" || fail "no hint to install RommPS: $(grep NOTIFY "$T/daemon.log")" +grep -q "NOTIFY.*Install the RommPS app from ProsperoStore" "$T/daemon.log" || fail "no hint to install RommPS: $(grep NOTIFY "$T/daemon.log")" echo PASS diff --git a/tests/e2e.sh b/tests/e2e.sh index 80c7496..e2a004a 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -8,7 +8,7 @@ MOCK_PORT=8899 WEB_PORT=8781 API="http://127.0.0.1:${WEB_PORT}" trap 'kill $(jobs -p) 2>/dev/null || true; rm -rf "$T"' EXIT -fail() { echo "FAIL: $*"; echo "--- daemon log"; tail -30 "$T/daemon.log"; exit 1; } +fail() { echo "FAIL: $*"; echo "--- daemon log"; tail -80 "$T/daemon.log"; exit 1; } post() { curl -sf -X POST "$API$1" -d "${2:-{\}}"; } wait_sync() { # waits for the history length to reach $1 for _ in $(seq 1 40); do @@ -62,6 +62,12 @@ ROMM_SYNC_HOMEBREW="$T/homebrew" ROMM_SYNC_DATA="$T/data" ROMM_SYNC_AUTOSTART="$ 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 +echo "web UI script parses" +if command -v node >/dev/null; then + python3 -c "import re,sys; print('\n'.join(re.findall(r'', open(sys.argv[1]).read(), re.S)))" "$ROOT/ui/index.html" > "$T/ui.js" + node --check "$T/ui.js" || fail "the web UI's script doesn't parse" +fi + echo "0. self-signed HTTPS needs certificate checks turned off" r=$(curl -s -X POST "$API/api/pair/start" -d "{\"server_url\":\"https://127.0.0.1:$TLS_PORT\"}") [[ "$r" == *"doesn't trust"* ]] || fail "untrusted certificate not reported: $r" @@ -86,20 +92,41 @@ post /api/setup/finish '{"conflict_policy":"ask"}' >/dev/null wait_sync 1 [[ "$(last reason)" == "first sync" && "$(last uploaded)" == 1 ]] || fail "expected the first sync to upload the local save" -echo "2. game download, then its server save" +echo "2. game download brings its server save along" +curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":11,"slot":"autosave","file_name":"Super Metroid (USA).srm","content":"server-save"}' >/dev/null post /api/download '{"rom_id":11,"name":"Super Metroid"}' >/dev/null [[ "$(curl -sf "$API/api/downloads" | python3 -c "import json,sys; print([d['name'] for d in json.load(sys.stdin) if d.get('rom_id') == 11][-1])")" == "Super Metroid" ]] \ || fail "a download must show its name from the start" -sleep 1 +wait_sync 2 [[ -f "$RA/roms/snes/Super Metroid (USA).sfc" ]] || fail "ROM not downloaded" -curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":11,"slot":"autosave","file_name":"Super Metroid (USA).srm","content":"server-save"}' >/dev/null -post /api/sync >/dev/null; wait_sync 2 +[[ "$(last reason)" == "game downloaded" ]] || fail "a finished download must sync the game's saves" [[ "$(cat "$RA/saves/Snes9x/Super Metroid (USA).srm")" == "server-save" ]] || fail "server save not downloaded" +for _ in $(seq 1 20); do + dl=$(curl -sf "$API/api/downloads" | python3 -c "import json,sys; d=[d for d in json.load(sys.stdin) if d.get('rom_id') == 11][-1]; print(d['status'], d.get('saves'))") + [[ "$dl" == "done 1" ]] && break; sleep 0.25 +done +[[ "$dl" == "done 1" ]] || fail "the download must end done, with the save it synced: $dl" + +echo "2b. folder roms: one iso per folder, and a cue+bin set" +post /api/download '{"rom_id":22,"name":"Downhill Domination"}' >/dev/null +post /api/download '{"rom_id":25,"name":"War of the Monsters"}' >/dev/null +dl_wait() { # wait for a downloaded path to exist + for _ in $(seq 1 30); do [[ -e "$1" ]] && return 0; sleep 0.5; done; fail "not downloaded: $1" +} +D="$RA/roms/ps2/Downhill Domination (USA)" +W="$RA/roms/ps2/War of the Monsters (USA)" +dl_wait "$D/Downhill Domination (USA).iso"; dl_wait "$W/War of the Monsters (USA).cue" +[[ -d "$D" && -d "$W" ]] || fail "a folder rom must land in a folder named after its fs_name" +[[ "$(cat "$D/Downhill Domination (USA).iso")" == "ROM:Downhill Domination (USA).iso" ]] || fail "iso content wrong" +[[ -f "$W/War of the Monsters (USA).bin" ]] || fail "cue's bin not downloaded" +[[ "$(cat "$W/War of the Monsters (USA).bin")" == "ROM:War of the Monsters (USA).bin" ]] || fail "bin content wrong" +[[ ! -f "$RA/roms/ps2/Downhill Domination (USA)" ]] || fail "folder rom written as a single extensionless file" ci=$(curl -sf "$API/api/roms?platform_id=1" | python3 -c "import json,sys; print(sorted(json.load(sys.stdin)['char_index'].items()))") [[ "$ci" == "[('c', 0), ('e', 1), ('m', 2), ('s', 3)]" ]] || fail "library char_index for the A-Z ribbon: $ci" -inst=$(curl -sf "$API/api/installed" | python3 -c "import json,sys; print([(g['id'], g['name'], g['platform_id'] > 0) for g in json.load(sys.stdin)])") -[[ "$inst" == "[(11, 'Super Metroid', True), (10, 'Chrono Trigger', True)]" ]] || fail "the games on this console, for the app: $inst" +inst=$(curl -sf "$API/api/installed" | python3 -c "import json,sys; print(sorted([(g['id'], g['name'], g['platform_id'] > 0) for g in json.load(sys.stdin)]))") +[[ "$inst" == "[(10, 'Chrono Trigger', True), (11, 'Super Metroid', True), (22, 'Downhill Domination', True), (25, 'War of the Monsters', True)]" ]] \ + || fail "the games on this console, for the app: $inst" all=$(curl -sf "$API/api/roms?platform_id=0&search=metroid" | python3 -c "import json,sys; print([(g['name'], g['platform_id']) for g in json.load(sys.stdin)['items']])") [[ "$all" == "[('Super Metroid', 1)]" ]] || fail "a search across every platform: $all" @@ -181,9 +208,43 @@ n=$(dump | python3 -c "import json,sys; print(sum(1 for x in json.load(sys.stdin echo "10. states download, save history and restore" post /api/config '{"states":"sync"}' >/dev/null -curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"file_name":"Super Metroid (USA).state1","content":"srv-state"}' >/dev/null +curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"emulator":"snes9x","file_name":"Super Metroid (USA).state1","content":"srv-state"}' >/dev/null +# Made by another core (or with no emulator at all): no emulator here can load it. +curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"emulator":"bsnes","file_name":"Super Metroid (USA) [bsnes].state5","content":"other-core"}' >/dev/null +curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"file_name":"Super Metroid (USA) [unknown].state6","content":"no-emulator"}' >/dev/null syncnow +! grep -rqs "other-core\|no-emulator" "$RA/states" || fail "a state from another core came down" +grep -q "skipped: made by bsnes" "$T/daemon.log" || fail "a skipped state must say why" [[ "$(cat "$RA/states/Super Metroid (USA).state1" 2>/dev/null)" == "srv-state" ]] || fail "server state not downloaded" +# Two web player states for one game: each gets its own file, and they don't +# come down again on the next sync. +for ts in "2026-10-14 01-00-00-000" "2026-10-14 01-00-05-000"; do + curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":11,\"emulator\":\"snes9x\",\"file_name\":\"Super Metroid (USA) [$ts].state\",\"content\":\"web-$ts\"}" >/dev/null +done +syncnow +[[ "$(cat "$RA/states/Super Metroid (USA).state")" == "web-2026-10-14 01-00-00-000" ]] || fail "first web state should take slot 0" +[[ "$(cat "$RA/states/Super Metroid (USA).state2")" == "web-2026-10-14 01-00-05-000" ]] || fail "second web state should take the next free slot" +[[ "$(cat "$RA/states/Super Metroid (USA).state1")" == "srv-state" ]] || fail "a downloaded state replaced another one" +syncnow +[[ "$(last states_downloaded)" == 0 ]] || fail "states came down again: $(last states_downloaded)" +# Saving over a state that came from elsewhere sends a new one; RomM's copy +# of the web state stays as it was. +echo "played-on-console" > "$RA/states/Super Metroid (USA).state" +syncnow +web=$(dump | python3 -c "import json,sys; print([s['file_name'] for s in json.load(sys.stdin)['states'] if s['rom_id']==11 and s['file_name'].endswith('.state')])") +[[ "$web" == *"Super Metroid (USA) [2026-10-14 01-00-00-000].state"* && "$web" == *"Super Metroid (USA) [PlayStation 5].state"* ]] \ + || fail "a state saved over a web state must go up as a new one: $web" +orig=$(curl -sf "$API/api/history?rom_id=11" >/dev/null; dump | python3 -c "import json,sys; print([s['file_size_bytes'] for s in json.load(sys.stdin)['states'] if s['file_name']=='Super Metroid (USA) [2026-10-14 01-00-00-000].state'][0])") +[[ "$orig" == 27 ]] || fail "the web state on RomM was changed (size $orig)" +syncnow +[[ "$(last states_downloaded)$(last states_uploaded)" == 00 ]] || fail "an uploaded state came back down or went up again" +# The player's own slot is never taken by a state from elsewhere. +echo "my-own-slot" > "$RA/states/Chrono Trigger (USA).state" +syncnow +curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":10,"emulator":"snes9x","file_name":"Chrono Trigger (USA) [2026-10-14 02-00-00-000].state","content":"web-ct"}' >/dev/null +syncnow +[[ "$(cat "$RA/states/Chrono Trigger (USA).state")" == "my-own-slot" ]] || fail "a web state replaced the player's own slot" +[[ "$(cat "$RA/states/Chrono Trigger (USA).state1")" == "web-ct" ]] || fail "the web state should land in the next free slot" old=$(curl -sf "$API/api/history?rom_id=10" | python3 -c "import json,sys; s=sorted(json.load(sys.stdin)['saves'], key=lambda x: x['updated_at']); print(s[0]['id'])") want="$(curl -sf "http://127.0.0.1:$MOCK_PORT/_admin/save_content?id=$old")" post /api/restore "{\"kind\":\"save\",\"id\":$old}" >/dev/null || fail "restore failed" @@ -239,6 +300,64 @@ syncnow [[ -f "$SD/ULUS10336GAMEDATA/BIG.BIN" ]] || fail "game data folder must be left alone" ls "$T/data/backups/30/" | grep -q "ULUS10336.zip" || fail "no backup of the PSP save" +echo "12b. GameCube saves stay in the form RomM has them: one .gci, or a whole memory card" +GC="python3 $ROOT/tests/gccard.py" +admin_save() { # rom_id file_name path + python3 -c "import base64,json,sys; print(json.dumps({'rom_id':int(sys.argv[1]),'slot':'autosave','file_name':sys.argv[2],'content_b64':base64.b64encode(open(sys.argv[3],'rb').read()).decode()}))" "$1" "$2" "$3" > "$T/admin.json" + curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d @"$T/admin.json" >/dev/null +} +newest() { # rom_id: writes RomM's newest save for it to $T/newest, prints its name + dump | python3 -c " +import json,sys,re +s=sorted([x for x in json.load(sys.stdin)['saves'] if x['rom_id']==$1], key=lambda x: (x['updated_at'], x['id'])) +open('$T/newest','wb').write(s[-1]['content'].encode('latin1')); print(re.sub(r' \[[^]]*\]', '', s[-1]['file_name']))"; } +blocks_md5() { python3 -c "import hashlib,sys; print(hashlib.md5(open(sys.argv[1],'rb').read()[0x40:]).hexdigest())" "$1"; } +for g in "41 Sunshine GMSE01" "42 Melee GALE01"; do + set -- $g + curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/rom" -d "{\"id\":$1,\"platform_id\":4,\"name\":\"$2\",\"fs_name\":\"$2 (USA).iso\",\"files\":[]}" >/dev/null + python3 "$ROOT/tests/make_disc.py" gc "$RA/roms/ngc/$2 (USA).iso" "$3" +done +# One .gci on RomM (another app's): it comes down into the GCI folder, and goes back up as one .gci. +$GC gci "$T/sms.gci" GMSE01 super_mario_sunshine 2 0x11 +admin_save 41 "01-GMSE-super_mario_sunshine.gci" "$T/sms.gci" +syncnow +cmp -s "$T/sms.gci" "$CARD/01-GMSE-super_mario_sunshine.gci" || fail "RomM's .gci must land in the GCI folder" +syncnow +[[ "$(last uploaded)" == 0 ]] || fail "an unchanged .gci must not go up again" +sleep 1.1; $GC gci "$CARD/01-GMSE-super_mario_sunshine.gci" GMSE01 super_mario_sunshine 3 0x22 +syncnow +[[ "$(newest 41)" == "01-GMSE-super_mario_sunshine.gci" ]] || fail "a .gci on RomM must go back up as a .gci: $(newest 41)" +cmp -s "$T/newest" "$CARD/01-GMSE-super_mario_sunshine.gci" || fail "the .gci RomM got isn't this console's" +# A whole card on RomM with Melee's save and another game's: Melee's comes +# down; this console's change goes back onto RomM's card, the other save untouched. +$GC blank "$T/cardA.raw" 64 +$GC gci "$T/melee.gci" GALE01 SuperSmashBros0110290334 3 0x33; $GC put "$T/cardA.raw" "$T/melee.gci" +$GC gci "$T/other.gci" GXXE01 other_game 2 0x44; $GC put "$T/cardA.raw" "$T/other.gci" +admin_save 42 "MemoryCardA.USA.raw" "$T/cardA.raw" +syncnow +cmp -s "$T/melee.gci" "$CARD/01-GALE-SuperSmashBros0110290334.gci" 2>/dev/null || + [[ "$(blocks_md5 "$T/melee.gci")" == "$(blocks_md5 "$CARD/01-GALE-SuperSmashBros0110290334.gci" 2>/dev/null)" ]] || fail "Melee's save must come off RomM's card" +[[ ! -e "$CARD/01-GXXE-other_game.gci" ]] || fail "only the game's own saves come off the card" +syncnow +[[ "$(last uploaded)" == 0 ]] || fail "an unchanged save must leave RomM's card alone" +other_md5=$($GC saves "$T/cardA.raw" GXXE01) +sleep 1.1; $GC gci "$CARD/01-GALE-SuperSmashBros0110290334.gci" GALE01 SuperSmashBros0110290334 4 0x55 +syncnow +[[ "$(newest 42)" == "MemoryCardA.USA.raw" ]] || fail "a card on RomM must go back up as the card: $(newest 42)" +dump | python3 -c "import json,sys; n=[x['file_name'] for x in json.load(sys.stdin)['saves'] if x['rom_id'] in (41,42)]; sys.exit(any(f.count('[') > 1 for f in n))" || fail "a name sent back must not pile up RomM's stamps" +$GC check "$T/newest" || fail "the card sent to RomM must be one Dolphin reads" +[[ "$($GC saves "$T/newest" GALE01 | cut -d' ' -f2)" == "$(blocks_md5 "$CARD/01-GALE-SuperSmashBros0110290334.gci")" ]] || fail "this console's Melee save must be on the card" +[[ "$($GC saves "$T/newest" GXXE01)" == "$other_md5" ]] || fail "the card's other saves must stay as they were" +# Another device changes the card: it comes down, and this console's file follows. +cp "$T/newest" "$T/cardB.raw"; $GC gci "$T/melee2.gci" GALE01 SuperSmashBros0110290334 2 0x66; $GC put "$T/cardB.raw" "$T/melee2.gci" +sleep 1.1; admin_save 42 "MemoryCardA.USA.raw" "$T/cardB.raw" +syncnow +[[ "$(blocks_md5 "$CARD/01-GALE-SuperSmashBros0110290334.gci")" == "$(blocks_md5 "$T/melee2.gci")" ]] || fail "a newer card on RomM must update the save here" +ls "$T/data/backups/42/" 2>/dev/null | grep -q . || fail "no backup before the card's save replaced this console's" +grep -q "Sunshine (USA).iso: RomM keeps its saves as gci (01-GMSE-super_mario_sunshine.gci)" "$T/daemon.log" && + grep -q "Melee (USA).iso: RomM keeps its saves as card (MemoryCardA.USA.raw)" "$T/daemon.log" || + fail "each game's form must be the one RomM has" + echo "13. in-app update: only a correctly signed release installs" SIGN="$ROOT/build/host/release-sign" upd() { curl -sf "$API/api/update" | python3 -c "import json,sys; u=json.load(sys.stdin); print(u['$1'])"; } @@ -279,11 +398,24 @@ cmp -s "$REL/romm-sync.elf" "$T/data/launched.elf" || fail "the new payload was cmp -s "$REL/romm-sync.elf" "$T/etaHEN/payloads/romm-sync.elf" || fail "the installed payload was not replaced" [[ ! -e "$T/etaHEN/payloads/romm-sync.elf.bak" ]] || fail "the update left a .bak of the old payload behind" -echo "14. diagnostics report leaves out the token" -curl -sf "$API/api/diagnostics" > "$T/diag.txt" || fail "no diagnostics report" -grep -q "RomM Sync diagnostics" "$T/diag.txt" || fail "diagnostics report is empty" -tok="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['token'])" "$T/data/config.json")" -[[ -n "$tok" ]] && ! grep -qF "$tok" "$T/diag.txt" || fail "the diagnostics report contains the token" +echo "14. diagnostic bundle: the logs, nothing that names the person" +echo "connecting to 10.0.16.84 from aa:bb:cc:dd:ee:ff, mail me@example.com, /user/home/1a2b3c4d/x, onlineId=SomePlayer99" >> "$T/data/romm-sync.log" +curl -sf "$API/api/diagnostics" > "$T/diag.zip" || fail "no diagnostic bundle" +python3 - "$T/diag.zip" "$T/data/config.json" <<'PY' || fail "diagnostic bundle" +import json, sys, zipfile +z = zipfile.ZipFile(sys.argv[1]); cfg = json.load(open(sys.argv[2])) +names = z.namelist() +for want in ("report.txt", "romm-sync.log", "sync-log.json", "emulators-found.json", "emulator-settings.json", "kernel.log"): + assert want in names, (want, names) +assert "RommPS diagnostics" in z.read("report.txt").decode() +text = "\n".join(z.read(n).decode("utf-8", "replace") for n in names) +host = cfg["server_url"].split("://")[1].split("/")[0].split(":")[0] +for secret in (cfg["token"], cfg["server_url"], host, cfg["device_id"], "10.0.16.84", "aa:bb:cc:dd:ee:ff", "me@example.com", "1a2b3c4d", "SomePlayer99"): + assert secret and secret not in text, secret +assert "" in text and "" in text and "" in text and "" in text +PY +out=$(curl -sf -X POST "$API/api/diagnostics") || fail "saving a bundle refused" +[[ -f "$(echo "$out" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")" ]] || fail "the saved bundle isn't there: $out" echo "15. one emulator per system when several play it" kinds=$(curl -sf "$API/api/setup/detect" | python3 -c "import json,sys; print([(c['kind'], c['title_id']) for c in json.load(sys.stdin)])") @@ -312,6 +444,10 @@ cat > "$T/catalog.json" < "$T/catalog.json" < "$T/psxemu/saves/SLUS-12345_1.mcd" -printf 'state-from-psx' > "$T/psxemu/states/Test_Quest__USA_.state0" +printf 'STv4 PCSXra test-build' > "$T/psxemu/states/Test_Quest__USA_.state0" # a bare PCSX-ReARMed state +printf 'emulator=0\n' > "$T/psxemu/psxs5.ini" printf 'ROM:Test Racer (U).z64' > "$T/n64emu/games/Test Racer (U).z64" cp "$T/n64emu/games/Test Racer (U).z64" "$T/n64b/roms/Test Racer (U).z64" fnv=$(python3 -c " @@ -346,11 +483,11 @@ post /api/setup/emulators "{\"roots\":[\"$HB\",\"$T/psxemu\",\"$T/n64emu\",\"$T/ for _ in $(seq 1 20); do [[ -f "$T/n64b/roms/Old Racer (U).z64" ]] && break; sleep 0.25; done [[ -f "$T/n64b/roms/Old Racer (U).z64" ]] || fail "turning an extra on must give it the games already there" for _ in $(seq 1 20); do grep -q "NOTIFY.*now also sync with" "$T/daemon.log" && break; sleep 0.25; done -grep -q "NOTIFY\] RomM Sync: [0-9]* N64 games\{0,1\} now also sync with" "$T/daemon.log" || fail "no notice that the games now also sync with the extra" +grep -q "NOTIFY\] RommPS: [0-9]* N64 games\{0,1\} now also sync with" "$T/daemon.log" || fail "no notice that the games now also sync with the extra" dirs=$(curl -sf "$API/api/paths" | python3 -c "import json,sys; print(sorted(m['rom_dir'] for m in json.load(sys.stdin)))") [[ "$dirs" != *"$T/nope"* ]] || fail "an emulator not supported yet got a profile: $dirs" post /api/config '{"states":"upload"}' >/dev/null -post /api/sync >/dev/null; wait_sync 16 +syncnow dump() { curl -sf "http://127.0.0.1:$MOCK_PORT/_admin/dump"; } saves=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted((s['rom_id'], s['file_name'], s['slot']) for s in (d['saves'].values() if isinstance(d['saves'], dict) else d['saves']) if s['rom_id'] in (50, 60)))") # (RomM puts the upload's time in the name: "SLUS-12345_1 [2026-...].mcd".) @@ -360,11 +497,50 @@ saves=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted [[ "$(cat "$T/n64b/saves/Test Racer (U).srm" 2>/dev/null)" == "n64-save" ]] || fail "the shared save didn't reach the second emulator" states=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted(s['file_name'] for s in (d['states'].values() if isinstance(d['states'], dict) else d['states']) if s['rom_id'] == 50))") [[ "$states" == "['Test_Quest__USA_"*".state0']" ]] || fail "the state named by the safe name didn't sync: $states" +# A bare state goes up in RetroArch's container, as the core that made it, so +# RomM's web player can load it. +up=$(dump | python3 -c "import json,sys; print([(s['emulator'], s['file_size_bytes']) for s in json.load(sys.stdin)['states'] if s['rom_id'] == 50])") +[[ "$up" == "[('pcsx_rearmed', 48)]" ]] || fail "a PSXS5 state must go up wrapped, as pcsx_rearmed: $up" c2=$(dump | python3 -c "import json,sys; print([c['versions'] for c in json.load(sys.stdin)['cards'] if c['emulator'] == 'ps2like' and c['name'].endswith('PS2-like slot 1')])") [[ "$c2" == "[1]" ]] || fail "the shared PS2 card wasn't backed up: $c2" +# Web player states come down bare, into a slot of their own, and only for the +# core the emulator runs now. +post /api/config '{"states":"sync"}' >/dev/null +rast() { python3 -c "import json,sys,struct; m=sys.argv[1].encode(); print(json.dumps((b'RASTATE\x01MEM '+struct.pack('/dev/null +curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":50,\"emulator\":\"mednafen_psx_hw\",\"file_name\":\"Test Quest (USA) [beetle].state\",\"content\":$(rast 'MDFNSVST beetle')}" >/dev/null +syncnow +[[ "$(cat "$T/psxemu/states/Test_Quest__USA_.state1")" == "STv4 PCSXra web-build" ]] || fail "a web state must come down bare, in the next free slot" +[[ ! -e "$T/psxemu/states/Test_Quest__USA_.state2" ]] || fail "a Beetle state came down while the emulator runs PCSX-ReARMed" +printf 'emulator=2\n' > "$T/psxemu/psxs5.ini" +syncnow +[[ "$(cat "$T/psxemu/states/Test_Quest__USA_.state2")" == "MDFNSVST beetle" ]] || fail "switched to Beetle, its state must come down" +# With Beetle the card is named after the game file: still the game's card. +sleep 1; printf 'card-beetle' > "$T/psxemu/saves/Test Quest (USA)_1.mcd" +syncnow +card=$(dump | python3 -c "import json,sys; s=sorted([x for x in json.load(sys.stdin)['saves'] if x['rom_id']==50], key=lambda x: x['updated_at']); print(s[-1]['content'])") +[[ "$card" == "card-beetle" ]] || fail "the card Beetle names after the game file must sync: $card" +# A blank card an emulator just made under the other name never beats one with a save on it. +mkcard() { python3 -c "import sys; d=bytearray(131072); d[0:2]=b'MC'; d[128:128*16]=bytes([0xA0])*(128*15) +if sys.argv[2]=='save': d[128]=0x51; d[0x2000:0x2004]=b'GAME' +open(sys.argv[1],'wb').write(bytes(d))" "$1" "$2"; } +mkcard "$T/psxemu/saves/SLUS-12345_1.mcd" save; sleep 1; mkcard "$T/psxemu/saves/Test Quest (USA)_1.mcd" blank +syncnow +kind=$(dump | python3 -c "import json,sys; s=sorted([x for x in json.load(sys.stdin)['saves'] if x['rom_id']==50], key=lambda x: x['updated_at']); print('save' if 'GAME' in s[-1]['content'] else 'blank')") +[[ "$kind" == "save" ]] || fail "a blank card was uploaded over a card with a save" +# Nor does a blank card from RomM replace one with saves: that's a conflict to settle. +python3 -c "import base64,json,sys; d=bytearray(131072); d[0:2]=b'MC'; d[128:128*16]=bytes([0xA0])*(128*15) +json.dump({'rom_id': 50, 'slot': 'autosave', 'file_name': 'SLUS-12345_1.mcd', 'content_b64': base64.b64encode(bytes(d)).decode()}, open(sys.argv[1], 'w'))" "$T/blank.json" +sleep 1; curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d @"$T/blank.json" >/dev/null +syncnow +python3 -c "import sys; d=open(sys.argv[1],'rb').read(); sys.exit(0 if d[128]==0x51 else 1)" "$T/psxemu/saves/SLUS-12345_1.mcd" \ + || fail "a blank card from RomM replaced a card with saves" +[[ "$(last conflicts)" -ge 1 ]] || fail "a blank card from RomM over one with saves must be a conflict" + +post /api/config '{"states":"upload"}' >/dev/null # A save from another device reaches both N64 emulators. curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":60,"slot":"autosave","file_name":"Test Racer (U).srm","content":"n64-from-elsewhere","emulator":"other"}' >/dev/null -post /api/sync >/dev/null; wait_sync 17 +syncnow [[ "$(cat "$T/n64emu/saves/$fnv.sav")" == "n64-from-elsewhere" && "$(cat "$T/n64b/saves/Test Racer (U).srm")" == "n64-from-elsewhere" ]] \ || fail "a downloaded save must reach every emulator that shares it" # A download goes to the main emulator and is linked into the extra. @@ -373,6 +549,57 @@ post /api/download '{"rom_id":61}' >/dev/null; sleep 2 python3 -c 'import os,sys; sys.exit(not os.path.samefile(sys.argv[1], sys.argv[2]))' \ "$T/n64emu/games/Other Racer (U).z64" "$T/n64b/roms/Other Racer (U).z64" || fail "the extra copy should be a hard link" +echo "16b. two Dolphins for GameCube keep one save: the main one's .gci files reach the extra's folder" +mkdir -p "$T/gcA/games" "$T/gcB/ngc" +python3 - "$T/catalog.json" "$T" <<'PY' +import json, sys +cat = json.load(open(sys.argv[1])) +# Dolphin A keeps GameCube and Wii games in one folder, like Porpoise; +# Dolphin B a folder for each. +# Their folders for GameCube are named differently ("gamecube", "ngc"), as +# Porpoise's and RetroArch's are. +for gid, name, roms, gc_dir in (("gcA", "Dolphin A", "{root}/games", "gamecube"), ("gcB", "Dolphin B", "{root}/{platform}", "ngc")): + cat.append({"id": gid, "name": name, "detect": [f"{sys.argv[2]}/{gid}"], + "profile": {"root": f"{sys.argv[2]}/{gid}", "rom_dir": roms, "save_dir": "{root}/User", + "platforms": [{"romm": ["ngc"], "dir": gc_dir, "emulator": "dolphin", + "save_layout": "gci", "bundle_dirs": "{root}/User/GC", + **({"state_dir": "{root}/states/{game_id}", "state_exts": ".state", + "state_format": "raw"} if gid == "gcA" else {})}, + {"romm": ["wii"], "dir": "wii", "emulator": "dolphin", + "save_layout": "wii", "bundle_dirs": "{root}/User/Wii"}]}}) +json.dump(cat, open(sys.argv[1], "w")) +PY +python3 "$ROOT/tests/make_disc.py" gc "$T/gcA/games/Sunshine (USA).iso" GMSE01 +python3 "$ROOT/tests/make_disc.py" gc "$T/gcB/ngc/Sunshine (USA).iso" GMSE01 +post /api/setup/emulators "{\"roots\":[\"$HB\",\"$T/psxemu\",\"$T/n64emu\",\"$T/n64b\",\"$T/ps2emu\",\"$T/nope\",\"$T/gcA\",\"$T/gcB\"], + \"choices\":{\"psx\":\"$T/psxemu\",\"n64\":\"$T/n64emu\",\"ps2\":\"$T/ps2emu\",\"ngc\":\"$T/gcA\",\"wii\":\"$T/gcA\"}, + \"also\":{\"n64\":[\"$T/n64b\"],\"ngc\":[\"$T/gcB\"],\"wii\":[\"$T/gcB\"]}}" >/dev/null \ + || fail "setup with two Dolphins refused" +A="$T/gcA/User/GC/USA/Card A"; Bc="$T/gcB/User/GC/USA/Card A" +syncnow +sleep 1; syncnow +[[ ! -e "$T/gcB/wii/Sunshine (USA).iso" ]] || fail "a GameCube game in a folder shared with Wii must not go to the extra's Wii folder" +cmp -s "$A/01-GMSE-super_mario_sunshine.gci" "$Bc/01-GMSE-super_mario_sunshine.gci" || fail "RomM's save must reach both Dolphins" +sleep 1.1; $GC gci "$Bc/01-GMSE-super_mario_sunshine.gci" GMSE01 super_mario_sunshine 2 0x77 +syncnow +cmp -s "$Bc/01-GMSE-super_mario_sunshine.gci" "$A/01-GMSE-super_mario_sunshine.gci" || fail "a save made in the extra must reach the main Dolphin" +[[ "$(last uploaded)" == 1 ]] || fail "two Dolphins sharing a save must upload it once: $(last uploaded)" +[[ "$(newest 41)" == "01-GMSE-super_mario_sunshine.gci" ]] && cmp -s "$T/newest" "$A/01-GMSE-super_mario_sunshine.gci" || fail "RomM must get the newer save, as one .gci" +ls "$T/data/backups/41/" 2>/dev/null | grep -q "GMSE01-shared.zip" || fail "the save replaced in the main Dolphin must be backed up" +# Porpoise's states: a folder per game id holding slot1.state, slot2.state... +post /api/config '{"states":"sync"}' >/dev/null +mkdir -p "$T/gcA/states/GMSE01"; printf 'dolphin-bare-state' > "$T/gcA/states/GMSE01/slot1.state" +syncnow +up=$(dump | python3 -c "import json,sys; print([(s['file_name'].split(' [')[0], s['emulator'], s['file_size_bytes']) for s in json.load(sys.stdin)['states'] if s['rom_id']==41])") +[[ "$up" == "[('slot1', 'dolphin', 48)]" ]] || fail "a state in a folder per game must go up, wrapped: $up" +curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":41,\"emulator\":\"dolphin\",\"file_name\":\"Sunshine (USA).state3\",\"content\":$(rast 'dolphin-from-retroarch')}" >/dev/null +syncnow +[[ "$(cat "$T/gcA/states/GMSE01/slot2.state" 2>/dev/null)" == "dolphin-from-retroarch" ]] || fail "a state from RomM must land unwrapped in the game's next free slot" +[[ "$(cat "$T/gcA/states/GMSE01/slot1.state")" == "dolphin-bare-state" ]] || fail "the state already there must be left alone" +rm "$T/gcA/states/GMSE01/slot1.state" +syncnow +[[ "$(cat "$T/gcA/states/GMSE01/slot1.state" 2>/dev/null)" == "dolphin-bare-state" ]] || fail "a deleted state must come back bare, in its own emulator's form" + echo "17. paired with another server, the old server's game and save ids are forgotten" hist() { curl -sf "$API/api/status" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['sync']['history']))"; } n=$(hist) @@ -412,6 +639,24 @@ import json,sys for p in json.load(sys.stdin)['profiles']: if p['id'].startswith('custom-'): print(p['id'], p.get('extra'), p['rom_dir'] == '$T/porp/games')") [[ "$porp" == "custom-newemu-snes ['snes'] True" ]] || fail "an emulator with no preset should be an extra next to RetroArch: $porp" +# Each system's folders: detection says where an emulator keeps them, and folders +# picked for one system in setup are used for it alone. +found=$(curl -sf "$API/api/setup/detect" | python3 -c " +import json,sys +for c in json.load(sys.stdin): + if c['root']=='$HB': print(c['dirs']['snes']['rom_dir'] != '', 'fixed' in c)") +[[ "$found" == "True True" ]] || fail "detection must give each system's folders: $found" +mkdir -p "$T/mine/snes" "$T/mine/snessaves" +post /api/setup/emulators "{\"roots\":[\"$HB\"],\"systems\":{\"snes\":{\"main\":\"$HB\",\"on\":[\"$HB\"], + \"folders\":{\"$HB\":{\"rom_dir\":\"$T/mine/snes\",\"save_dir\":\"$T/mine/snessaves\",\"state_dir\":\"\"}}}}}" >/dev/null \ + || fail "setup with a system's own folders refused" +sn=$(curl -sf "$API/api/paths" | python3 -c " +import json,sys +for m in json.load(sys.stdin): + if 'snes' in m['platforms'].split(','): print(m['rom_dir'], m['save_dir']) + elif m['rom_dir'].startswith('$T/mine/snes'): print('leaked', m['platforms'])") +grep -q "^$T/mine/snes $T/mine/snessaves$" <<<"$sn" && ! grep -q "^leaked" <<<"$sn" || fail "a system's picked folders must apply to it alone: $sn" +post /api/setup/emulators "{\"roots\":[\"$HB\"],\"custom\":[{\"id\":\"newemu-snes\",\"slugs\":[\"snes\",\"sfam\"],\"name\":\"NewEmu (SNES)\",\"rom_dir\":\"$T/porp/games\",\"save_dir\":\"$T/porp/saves\"}]}" >/dev/null echo "19. every cover kept on the console, then only the missing and changed ones fetched" # Paired with the HTTPS mock since scenario 17. @@ -448,4 +693,146 @@ post /api/download '{"rom_id":13}' >/dev/null; sleep 1 grep -q "systeminfo.txt isn't a game" "$T/daemon.log" || fail "a file that isn't a game was downloaded" [[ -z "$(find "$T" -name systeminfo.txt -not -path '*/data/*')" ]] || fail "systeminfo.txt reached an emulator's folder" +echo "21. one-file folder games: saves named after the file sync; old extensionless downloads are moved into the folder" +curl -sfk -X POST "$TM/_admin/rom" -d '{"id":70,"platform_id":1,"name":"Folder Game","fs_name":"Folder Game", + "files":[{"id":701,"file_name":"Folder Game (USA).sfc","content":"ROM:Folder Game (USA).sfc"}]}' >/dev/null +post /api/download '{"rom_id":70}' >/dev/null +for _ in $(seq 1 20); do got=$(find "$T" -path "*/Folder Game/Folder Game (USA).sfc" -not -path '*/data/*' | head -1); [[ -n "$got" ]] && break; sleep 0.25; done +[[ -n "$got" ]] || fail "a one-file folder game must download into its folder" +gdir=$(dirname "$(dirname "$got")") +sdir=$(curl -sf "$API/api/paths" | python3 -c "import json,sys; print([m['save_dir'] for m in json.load(sys.stdin) if m['rom_dir']=='$gdir'][0])") +[[ -n "$sdir" ]] || fail "no save folder for $gdir" +mkdir -p "$sdir"; printf 'folder-save' > "$sdir/Folder Game (USA).srm" +syncnow +saved=$(curl -sfk "$TM/_admin/dump" | python3 -c "import json,sys; s=[x for x in json.load(sys.stdin)['saves'] if x['rom_id']==70]; print(s[-1]['content'] if s else 'none')") +[[ "$saved" == "folder-save" ]] || fail "a save named after the file inside a one-file folder must sync: $saved" +# What 1.1.1 left behind: the file named after the folder, no extension. +curl -sfk -X POST "$TM/_admin/rom" -d '{"id":71,"platform_id":1,"name":"Bare Game","fs_name":"Bare Game", + "files":[{"id":711,"file_name":"Bare Game (USA).sfc","content":"ROM:Bare Game (USA).sfc"}]}' >/dev/null +printf 'ROM:Bare Game (USA).sfc' > "$gdir/Bare Game"; printf 'bare-save' > "$sdir/Bare Game.srm" +syncnow +[[ -f "$gdir/Bare Game/Bare Game (USA).sfc" ]] || fail "an extensionless game must be moved into its folder under the file's name" +[[ "$(cat "$sdir/Bare Game (USA).srm" 2>/dev/null)" == "bare-save" && ! -e "$sdir/Bare Game.srm" ]] || fail "its save must take the file's name" + +# A one-file folder game's save from RomM is named after the file inside, as the emulator names it. +curl -sfk -X POST "$TM/_admin/rom" -d '{"id":76,"platform_id":1,"name":"Folder Two","fs_name":"Folder Two", + "files":[{"id":761,"file_name":"Folder Two (USA).sfc","content":"ROM:Folder Two (USA).sfc"}]}' >/dev/null +curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":76,"slot":"autosave","file_name":"Folder Two (USA).srm","content":"folder-two-save"}' >/dev/null +post /api/download '{"rom_id":76}' >/dev/null +for _ in $(seq 1 40); do [[ -f "$sdir/Folder Two (USA).srm" ]] && break; sleep 0.25; done +[[ "$(cat "$sdir/Folder Two (USA).srm" 2>/dev/null)" == "folder-two-save" ]] || fail "a one-file folder game's save must take the file's name: $(ls "$sdir" | grep -i 'folder two')" +[[ ! -e "$sdir/Folder Two.srm" ]] || fail "not the folder's name" + +# A save from before RomM's sync slots (no slot) comes down with its game. +curl -sfk -X POST "$TM/_admin/rom" -d '{"id":72,"platform_id":1,"name":"Old Save Game","fs_name":"Old Save Game (USA).sfc", + "files":[{"id":721,"file_name":"Old Save Game (USA).sfc","content":"ROM:Old Save Game (USA).sfc"}]}' >/dev/null +curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":72,"slot":null,"file_name":"Old Save Game (USA) [2026-08-01].srm","content":"from-before-slots"}' >/dev/null +curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":72,"slot":null,"file_name":"Old Save Game (USA) [2026-08-01].rtc","content":"clock"}' >/dev/null +post /api/download '{"rom_id":72}' >/dev/null +for _ in $(seq 1 40); do [[ -f "$sdir/Old Save Game (USA).srm" ]] && break; sleep 0.25; done +[[ "$(cat "$sdir/Old Save Game (USA).srm" 2>/dev/null)" == "from-before-slots" ]] || fail "a save with no slot must come down with its game" +if [[ "$(curl -sf "$API/api/paths" | python3 -c "import json,sys; print([m for m in json.load(sys.stdin) if m['rom_dir']=='$gdir'][0].get('save_exts', '.srm,.rtc'))")" == *rtc* ]]; then + [[ "$(cat "$sdir/Old Save Game (USA).rtc" 2>/dev/null)" == "clock" ]] || fail "the clock file beside it must come down too" +fi +syncnow +up=$(curl -sfk "$TM/_admin/dump" | python3 -c "import json,sys; print(sorted(str(s['slot']) for s in json.load(sys.stdin)['saves'] if s['rom_id']==72))") +[[ "$up" == "['None', 'None', 'autosave', 'autosave-rtc']" ]] || fail "they must then sync in this console's slots: $up" + +# Another device's slot: never mixed into this console's save. A game with +# none of its own takes the newest from elsewhere, then syncs in its own slot. +for g in "74 Phone Game" "75 Both Game"; do + set -- $g + curl -sfk -X POST "$TM/_admin/rom" -d "{\"id\":$1,\"platform_id\":1,\"name\":\"$2 $3\",\"fs_name\":\"$2 $3 (USA).sfc\", + \"files\":[{\"id\":${1}1,\"file_name\":\"$2 $3 (USA).sfc\",\"content\":\"ROM:$2 $3 (USA).sfc\"}]}" >/dev/null +done +curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":74,"slot":"phone","file_name":"Phone Game (USA).srm","content":"from-the-phone"}' >/dev/null +curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":75,"slot":"phone","file_name":"Both Game (USA).srm","content":"phone-copy"}' >/dev/null +sleep 1.1 +curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":75,"slot":"autosave","file_name":"Both Game (USA).srm","content":"this-slot"}' >/dev/null +post /api/download '{"rom_id":74}' >/dev/null; post /api/download '{"rom_id":75}' >/dev/null +for _ in $(seq 1 40); do [[ -f "$sdir/Phone Game (USA).srm" && -f "$sdir/Both Game (USA).srm" ]] && break; sleep 0.25; done +syncnow +[[ "$(cat "$sdir/Phone Game (USA).srm" 2>/dev/null)" == "from-the-phone" ]] || fail "a game with only another device's save must get it" +[[ "$(cat "$sdir/Both Game (USA).srm" 2>/dev/null)" == "this-slot" ]] || fail "another device's slot must never replace this console's save" +curl -sf "$API/api/events?rom_id=74" | grep -q "From another device" || fail "the sync log must say where it came from" + +echo "22. the sync log and the tracked saves" +ev=$(curl -sf "$API/api/events?limit=500" | python3 -c " +import json,sys; L=json.load(sys.stdin) +print(sorted({(e['kind'], e['action']) for e in L})) +print(next((e['game'] for e in L if e['rom_id']==70 and e['action']=='uploaded'), 'none'))") +grep -q "('save', 'uploaded')" <<<"$ev" && grep -q "('state', 'downloaded')" <<<"$ev" && grep -q "('state', 'skipped')" <<<"$ev" \ + || fail "the sync log is missing kinds of entries: $ev" +[[ "$(tail -1 <<<"$ev")" == "Folder Game" ]] || fail "a log entry must name its game: $ev" +one=$(curl -sf "$API/api/events?rom_id=70" | python3 -c "import json,sys; print({e['rom_id'] for e in json.load(sys.stdin)})") +[[ "$one" == "{70}" ]] || fail "the log for one game holds only that game: $one" +tr=$(curl -sf "$API/api/tracked" | python3 -c " +import json,sys; t=json.load(sys.stdin) +g={x['rom_id']: x for p in t['platforms'] for x in p['games']} +print(t['saves'] > 0, t['states'] > 0, g[70]['name'], [f['status'] for f in g[70]['files']])") +[[ "$tr" == "True True Folder Game ['synced']" ]] || fail "tracked saves by platform: $tr" +printf 'changed-again' > "$sdir/Folder Game (USA).srm" +st=$(curl -sf "$API/api/tracked?rom_id=70" | python3 -c "import json,sys; print([f['status'] for p in json.load(sys.stdin)['platforms'] for g in p['games'] for f in g['files']])") +[[ "$st" == "['changed']" ]] || fail "a save changed here must say so: $st" + +echo "23. save sync off: RomM only as a game source" +post /api/config '{"save_sync":false}' >/dev/null +n=$(curl -sf "$API/api/status" | python3 -c "import json,sys; print(json.load(sys.stdin)['sync']['sync_count'])") +curl -sfk -X POST "$TM/_admin/rom" -d '{"id":73,"platform_id":1,"name":"No Sync Game","fs_name":"No Sync Game (USA).sfc", + "files":[{"id":731,"file_name":"No Sync Game (USA).sfc","content":"ROM:No Sync Game (USA).sfc"}]}' >/dev/null +curl -sfk -X POST "$TM/_admin/save" -d '{"rom_id":73,"slot":"autosave","file_name":"No Sync Game (USA).srm","content":"stays-on-romm"}' >/dev/null +post /api/download '{"rom_id":73}' >/dev/null; post /api/sync >/dev/null; sleep 3 +[[ -n "$(find "$T" -name "No Sync Game (USA).sfc" -not -path '*/data/*')" ]] || fail "games must still download with save sync off" +[[ ! -e "$sdir/No Sync Game (USA).srm" ]] || fail "no save may come down with save sync off" +[[ "$(curl -sf "$API/api/status" | python3 -c "import json,sys; print(json.load(sys.stdin)['sync']['sync_count'])")" == "$n" ]] || fail "no sync may run with save sync off" +grep -q "skipped: save sync is off" "$T/daemon.log" || fail "a skipped sync must say why" +post /api/config '{"save_sync":true}' >/dev/null +syncnow +[[ "$(cat "$sdir/No Sync Game (USA).srm" 2>/dev/null)" == "stays-on-romm" ]] || fail "turned back on, the save must come down" +grep -q "\[NOTIFY\] RommPS: No Sync Game downloaded$" "$T/daemon.log" || fail "a download with save sync off must still say it's done" +grep -qE "\[NOTIFY\] RommPS: ([0-9]+ (saves?|states?) (up|down)(, )?)+$" "$T/daemon.log" || fail "a sync's notification must say what moved" +! grep -q "\[NOTIFY\] RommPS: 0 " "$T/daemon.log" || fail "a notification must not count nothing" +grep -A1 -E "\[NOTIFY\] RommPS: [0-9]+ saves? (up|down)" "$T/daemon.log" | grep -q "No Sync Game" || fail "a sync's notification must name the games" + +echo "24. save sync per emulator and platform: saves only, then off, then everything" +path_of() { curl -sf "$API/api/paths" | python3 -c "import json,sys; print([m['$1'] for m in json.load(sys.stdin) if m['rom_dir']=='$gdir'][0])"; } +emu_root=$(path_of root); stdir=$(path_of state_dir) +[[ -n "$emu_root" && -n "$stdir" ]] || fail "no emulator folders for $gdir" +post /api/config '{"states":"sync"}' >/dev/null +post /api/emulators/sync "{\"root\":\"$emu_root\",\"sync\":\"saves\"}" >/dev/null || fail "choosing saves only refused" +[[ -z "$(path_of state_dir)" && -n "$(path_of save_dir)" ]] || fail "saves only: states must show as not synced" +curl -sfk -X POST "$TM/_admin/state" -d '{"rom_id":73,"emulator":"snes9x","file_name":"No Sync Game (USA).state1","content":"emu-state"}' >/dev/null +printf 'saves-only' > "$sdir/No Sync Game (USA).srm" +syncnow +[[ ! -e "$stdir/No Sync Game (USA).state1" ]] || fail "saves only: a state came down" +last73() { curl -sfk "$TM/_admin/dump" | python3 -c "import json,sys; s=[x for x in json.load(sys.stdin)['saves'] if x['rom_id']==73]; print(s[-1]['content'] if s else 'none')"; } +[[ "$(last73)" == "saves-only" ]] || fail "saves only: the save must still go up: $(last73)" +post /api/emulators/sync "{\"root\":\"$emu_root\",\"sync\":\"off\"}" >/dev/null +sleep 1.1; printf 'not-synced' > "$sdir/No Sync Game (USA).srm" +syncnow +[[ "$(last73)" == "saves-only" ]] || fail "off: a save went up" +[[ -z "$(path_of save_dir)" ]] || fail "off: saves must show as not synced" +post /api/setup/emulators "{\"roots\":[\"$emu_root\"]}" >/dev/null || true +[[ "$(curl -sf "$API/api/config" | python3 -c "import json,sys; print([e.get('sync','all') for p in json.load(sys.stdin)['profiles'] if p.get('root')=='$emu_root' for e in p['platforms']])")" == *"'off'"* ]] || fail "setting up again must keep each platform's choice" +post /api/emulators/sync "{\"root\":\"$emu_root\",\"sync\":\"all\"}" >/dev/null +syncnow +[[ "$(last73)" == "not-synced" ]] || fail "back on: the save must go up: $(last73)" +[[ "$(cat "$stdir/No Sync Game (USA).state1" 2>/dev/null)" == "emu-state" ]] || fail "back on: the state must come down" +curl -s -X POST "$API/api/emulators/sync" -d '{"root":"/nope","sync":"off"}' | grep -q error || fail "an unknown emulator must be refused" +# Per platform, the way the app sets it: only this system's saves stop. +post /api/setup/emulators "{\"roots\":[\"$emu_root\"],\"systems\":{\"snes\":{\"main\":\"$emu_root\",\"on\":[\"$emu_root\"],\"sync\":\"off\"}}}" >/dev/null +modes=$(curl -sf "$API/api/config" | python3 -c "import json,sys; print(sorted((e['romm'][0], e.get('sync','all')) for p in json.load(sys.stdin)['profiles'] if p.get('root')=='$emu_root' for e in p['platforms']))") +[[ "$modes" == *"('snes', 'off')"* && "$modes" == *"('ps2', 'all')"* ]] || fail "save sync must be set for one platform only: $modes" +[[ -z "$(path_of save_dir)" ]] || fail "a platform with save sync off must show its saves as not synced" +post /api/setup/emulators "{\"roots\":[\"$emu_root\"],\"systems\":{\"snes\":{\"main\":\"$emu_root\",\"on\":[\"$emu_root\"],\"sync\":\"all\"}}}" >/dev/null + +echo "25. a save or state deleted from the console comes back from RomM" +rm -f "$sdir/No Sync Game (USA).srm" +syncnow +[[ "$(cat "$sdir/No Sync Game (USA).srm" 2>/dev/null)" == "not-synced" ]] || fail "a deleted save must come back" +curl -sf "$API/api/events?rom_id=73" | grep -q "Missing here" || fail "the sync log must say why it came back" +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 "PASS" diff --git a/tests/gccard.py b/tests/gccard.py new file mode 100644 index 0000000..9bb8384 --- /dev/null +++ b/tests/gccard.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""GameCube memory cards for the e2e test, written apart from src/gcmem.c so +each checks the other. + + gccard.py blank CARD NBLOCKS a formatted, empty card + gccard.py gci OUT ID FILE BLOCKS FILL a .gci: ID "GZLE01", FILE its name on the card + gccard.py put CARD GCI puts a .gci on the card + gccard.py saves CARD ID " " per save + gccard.py check CARD exits 1 unless the card is one Dolphin reads +""" +import hashlib +import struct +import sys + +B = 0x2000 + + +def be16(b, o): + return struct.unpack_from(">H", b, o)[0] + + +def sums(b): + a = c = 0 + for i in range(0, len(b), 2): + w = be16(b, i) + a = (a + w) & 0xFFFF + c = (c + (w ^ 0xFFFF)) & 0xFFFF + return (0 if a == 0xFFFF else a), (0 if c == 0xFFFF else c) + + +def seal(card, blk, is_dir): + o = blk * B + a, c = sums(card[o:o + 0x1FFC]) if is_dir else sums(card[o + 4:o + B]) + struct.pack_into(">HH", card, o + (0x1FFC if is_dir else 0), a, c) + + +def current(card): + def ok(blk, is_dir): + o = blk * B + a, c = sums(card[o:o + 0x1FFC]) if is_dir else sums(card[o + 4:o + B]) + return (a, c) == struct.unpack_from(">HH", card, o + (0x1FFC if is_dir else 0)) + + def newer(x, y, at): + cx, cy = ok(x, at == 0x1FFA), ok(y, at == 0x1FFA) + if cx and cy: + sx, sy = (struct.unpack_from(">h", card, z * B + at)[0] for z in (x, y)) + return y if sy > sx else x + return x if cx else y if cy else None + + return newer(1, 2, 0x1FFA), newer(3, 4, 4) + + +def entries(card, d): + for i in range(127): + o = d * B + i * 0x40 + if card[o:o + 4] != b"\xff" * 4: + yield i, o + + +def chain(card, t, first, count): + blk, out = first, [] + for _ in range(count): + out.append(blk) + blk = be16(card, t * B + 0xA + (blk - 5) * 2) + return out + + +def blank(nblocks): + card = bytearray(b"\xff" * (nblocks * B)) + card[0:0x26] = bytes(0x26) + struct.pack_into(">H", card, 0x22, nblocks // 16) + a, c = sums(card[0:0x1FC]) + struct.pack_into(">HH", card, 0x1FC, a, c) + for k in (1, 2): + struct.pack_into(">H", card, k * B + 0x1FFA, k - 1) + seal(card, k, True) + for k in (3, 4): + card[k * B:(k + 1) * B] = bytes(B) + struct.pack_into(">HHH", card, k * B + 4, k - 3, nblocks - 5, 4) + seal(card, k, False) + return card + + +def put(card, g): + """Like Dolphin's import: the same save is dropped, new blocks follow the + last one handed out, and the edited copies replace the older ones.""" + d, t = current(card) + nd, nt = (2 if d == 1 else 1), (4 if t == 3 else 3) + card[nd * B:(nd + 1) * B] = card[d * B:(d + 1) * B] + card[nt * B:(nt + 1) * B] = card[t * B:(t + 1) * B] + nblocks = len(card) // B + for i, o in list(entries(card, nd)): + if card[o:o + 6] == g[0:6] and card[o + 8:o + 40] == g[8:40]: + for blk in chain(card, nt, be16(card, o + 0x36), be16(card, o + 0x38)): + struct.pack_into(">H", card, nt * B + 0xA + (blk - 5) * 2, 0) + struct.pack_into(">H", card, nt * B + 6, be16(card, nt * B + 6) + be16(card, o + 0x38)) + card[o:o + 0x40] = b"\xff" * 0x40 + count = be16(g, 0x38) + start = be16(card, nt * B + 8) + 1 + if not 5 <= start < nblocks: + start = 5 + free = [b for b in (5 + (start - 5 + k) % (nblocks - 5) for k in range(nblocks - 5)) + if be16(card, nt * B + 0xA + (b - 5) * 2) == 0][:count] + slot = next(i for i in range(127) if card[nd * B + i * 0x40:nd * B + i * 0x40 + 4] == b"\xff" * 4) + for k, blk in enumerate(free): + card[blk * B:(blk + 1) * B] = g[0x40 + k * B:0x40 + (k + 1) * B] + struct.pack_into(">H", card, nt * B + 0xA + (blk - 5) * 2, free[k + 1] if k + 1 < count else 0xFFFF) + struct.pack_into(">H", card, nt * B + 6, be16(card, nt * B + 6) - count) + struct.pack_into(">H", card, nt * B + 8, free[-1]) + o = nd * B + slot * 0x40 + card[o:o + 0x40] = g[0:0x40] + struct.pack_into(">H", card, o + 0x36, free[0]) + for blk, at in ((nd, 0x1FFA), (nt, 4)): + struct.pack_into(">H", card, blk * B + at, (be16(card, blk * B + at) + 1) & 0xFFFF) + seal(card, nd, True) + seal(card, nt, False) + + +def saves(card, gid): + d, t = current(card) + for i, o in entries(card, d): + if card[o:o + 6] == gid.encode(): + data = b"".join(card[b * B:(b + 1) * B] for b in chain(card, t, be16(card, o + 0x36), be16(card, o + 0x38))) + yield card[o + 8:o + 40].rstrip(b"\0").decode(), hashlib.md5(data).hexdigest() + + +def check(card): + d, t = current(card) + assert d and t, "no directory or table checks out" + nblocks, used = len(card) // B, set() + for i, o in entries(card, d): + blocks = chain(card, t, be16(card, o + 0x36), be16(card, o + 0x38)) + assert all(5 <= b < nblocks for b in blocks), "a chain leaves the card" + assert not used & set(blocks), "a block in two saves" + used |= set(blocks) + zeros = sum(1 for b in range(5, nblocks) if be16(card, t * B + 0xA + (b - 5) * 2) == 0) + assert zeros == be16(card, t * B + 6) == nblocks - 5 - len(used), "free count is off" + + +if __name__ == "__main__": + cmd, a = sys.argv[1], sys.argv[2:] + if cmd == "blank": + open(a[0], "wb").write(blank(int(a[1]))) + elif cmd == "gci": + gid, name, blocks, fill = a[1].encode(), a[2].encode(), int(a[3]), int(a[4], 0) + head = bytearray(b"\xff" * 0x40) + head[0:6], head[8:40] = gid, name.ljust(32, b"\0") + struct.pack_into(">HH", head, 0x36, 0, blocks) + open(a[0], "wb").write(bytes(head) + b"".join(bytes([(fill + k) & 0xFF]) * B for k in range(blocks))) + elif cmd == "put": + card = bytearray(open(a[0], "rb").read()) + put(card, open(a[1], "rb").read()) + open(a[0], "wb").write(card) + elif cmd == "saves": + for name, md5 in saves(bytearray(open(a[0], "rb").read()), a[1]): + print(name, md5) + elif cmd == "check": + check(bytearray(open(a[0], "rb").read())) diff --git a/tests/gcmem_test.c b/tests/gcmem_test.c new file mode 100644 index 0000000..ca8d66f --- /dev/null +++ b/tests/gcmem_test.c @@ -0,0 +1,201 @@ +/* GameCube memory cards: saves go on, come off and are replaced, and the + * card stays one Dolphin reads (checksums, chains, free count). + * cc -Isrc tests/gcmem_test.c src/gcmem.c -o build/host/gcmem_test */ +#include +#include +#include + +#include "gcmem.h" + +static int failures; +#define CHECK(x) \ + do { \ + if (!(x)) { \ + fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); \ + failures++; \ + } \ + } while (0) + +static unsigned be16(const unsigned char *p) { return (unsigned)p[0] << 8 | p[1]; } +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)(v >> 8), p[1] = (unsigned char)v; } + +static void sums(const unsigned char *p, size_t n, unsigned *a, unsigned *b) { + unsigned x = 0, y = 0; + for (size_t i = 0; i < n; i += 2) x = (x + be16(p + i)) & 0xFFFF, y = (y + (be16(p + i) ^ 0xFFFF)) & 0xFFFF; + *a = x == 0xFFFF ? 0 : x, *b = y == 0xFFFF ? 0 : y; +} + +/* A blank card as Dolphin formats one: header, two empty directories, two + * tables with every data block free. */ +static unsigned char *blank(int nblocks) { + unsigned char *c = malloc((size_t)nblocks * GC_BLOCK); + memset(c, 0xFF, (size_t)nblocks * GC_BLOCK); + memset(c, 0, 0x26); + put16(c + 0x22, (unsigned)nblocks / 16); + unsigned a, b; + sums(c, 0x1FC, &a, &b); + put16(c + 0x1FC, a), put16(c + 0x1FE, b); + for (int k = 1; k <= 2; k++) { + unsigned char *d = c + k * GC_BLOCK; + put16(d + 0x1FFA, (unsigned)k - 1); + sums(d, 0x1FFC, &a, &b); + put16(d + 0x1FFC, a), put16(d + 0x1FFE, b); + } + for (int k = 3; k <= 4; k++) { + unsigned char *t = c + k * GC_BLOCK; + memset(t, 0, GC_BLOCK); + put16(t + 4, (unsigned)k - 3); + put16(t + 6, (unsigned)nblocks - 5); + put16(t + 8, 4); + sums(t + 4, GC_BLOCK - 4, &a, &b); + put16(t, a), put16(t + 2, b); + } + return c; +} + +static unsigned char *gci(const char *id, const char *file, int blocks, unsigned char fill, size_t *size) { + *size = GC_DENTRY + (size_t)blocks * GC_BLOCK; + unsigned char *g = malloc(*size); + memset(g, 0xFF, GC_DENTRY); + memcpy(g, id, 6); + memset(g + 8, 0, 32); + memcpy(g + 8, file, strlen(file)); + put16(g + 0x36, 0x1234); /* where it sat on another card: ignored */ + put16(g + 0x38, (unsigned)blocks); + for (int k = 0; k < blocks; k++) memset(g + GC_DENTRY + (size_t)k * GC_BLOCK, fill + k, GC_BLOCK); + return g; +} + +/* What Dolphin checks, from the current copies: checksums, every chain + * inside the card and no block in two, the free count right. */ +static void check_card(const gc_card *c) { + const unsigned char *d = c->data + (size_t)c->dir * GC_BLOCK, *t = c->data + (size_t)c->bat * GC_BLOCK; + unsigned a, b; + sums(d, 0x1FFC, &a, &b); + CHECK(a == be16(d + 0x1FFC) && b == be16(d + 0x1FFE)); + sums(t + 4, GC_BLOCK - 4, &a, &b); + CHECK(a == be16(t) && b == be16(t + 2)); + char used[4096] = {0}; + int in_use = 0; + for (int i = 0; i < 127; i++) { + const unsigned char *e = d + i * GC_DENTRY; + if (e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF && e[3] == 0xFF) continue; + unsigned blk = be16(e + 0x36), n = be16(e + 0x38); + for (unsigned k = 0; k < n; k++) { + CHECK(blk >= 5 && blk < (unsigned)c->nblocks); + if (blk < 5 || blk >= (unsigned)c->nblocks) break; + CHECK(!used[blk]); + used[blk] = 1; + in_use++; + unsigned next = be16(t + 0xA + (blk - 5) * 2); + if (k + 1 == n) CHECK(next == 0xFFFF); + blk = next; + } + } + int zeros = 0; + for (int blk = 5; blk < c->nblocks; blk++) { + unsigned m = be16(t + 0xA + (blk - 5) * 2); + zeros += m == 0; + CHECK((m == 0) == !used[blk]); + } + CHECK(be16(t + 6) == (unsigned)zeros); + CHECK(zeros + in_use == c->nblocks - 5); +} + +typedef struct { unsigned char *g[8]; size_t size[8]; int n; } got; +static void keep(const unsigned char *g, size_t size, void *ud) { + got *x = ud; + x->g[x->n] = malloc(size); + memcpy(x->g[x->n], g, size); + x->size[x->n++] = size; +} +static void got_free(got *x) { for (int i = 0; i < x->n; i++) free(x->g[i]); x->n = 0; } + +int main(void) { + const int nblocks = 64; /* a 59-block card */ + unsigned char *raw = blank(nblocks); + gc_card c; + CHECK(gc_card_open(&c, raw, (size_t)nblocks * GC_BLOCK) == 0); + check_card(&c); + + size_t za, zb, zc; + unsigned char *a = gci("GZLE01", "gczelda", 3, 0x10, &za), *b = gci("GMSE01", "super_mario_sunshine", 7, 0x40, &zb); + CHECK(gc_gci_valid(a, za) && gc_gci_valid(b, zb)); + CHECK(gc_card_put(&c, a, za) == 0); + CHECK(gc_card_put(&c, b, zb) == 0); + check_card(&c); + + got x = {0}; + CHECK(gc_card_saves(&c, "GZLE01", keep, &x) == 1); + /* The same save comes back, at its place on this card. */ + CHECK(x.n == 1 && x.size[0] == za && !memcmp(x.g[0] + GC_DENTRY, a + GC_DENTRY, za - GC_DENTRY)); + CHECK(x.n == 1 && !memcmp(x.g[0], a, 0x36) && !memcmp(x.g[0] + 0x38, a + 0x38, 8)); + got_free(&x); + + /* Same save again, bigger: replaced, not added. */ + unsigned char *a2 = gci("GZLE01", "gczelda", 5, 0x70, &zc); + CHECK(gc_card_put(&c, a2, zc) == 0); + check_card(&c); + CHECK(gc_card_saves(&c, "GZLE01", keep, &x) == 1); + CHECK(x.n == 1 && x.size[0] == zc && !memcmp(x.g[0] + GC_DENTRY, a2 + GC_DENTRY, zc - GC_DENTRY)); + got_free(&x); + CHECK(gc_card_saves(&c, "GMSE01", keep, &x) == 1); + CHECK(x.n == 1 && !memcmp(x.g[0] + GC_DENTRY, b + GC_DENTRY, zb - GC_DENTRY)); + got_free(&x); + + /* A second file of the same game sits beside the first. */ + size_t zd; + unsigned char *d = gci("GZLE01", "gczelda_opt", 1, 0x90, &zd); + CHECK(gc_card_put(&c, d, zd) == 0); + CHECK(gc_card_saves(&c, "GZLE01", NULL, NULL) == 2); + check_card(&c); + + /* Removing a game frees its blocks and leaves the others. */ + CHECK(gc_card_remove(&c, "GZLE01") == 2); + CHECK(gc_card_saves(&c, "GZLE01", NULL, NULL) == 0); + CHECK(gc_card_saves(&c, "GMSE01", NULL, NULL) == 1); + check_card(&c); + + /* Too big for what's left: refused, card untouched. */ + size_t zbig; + unsigned char *big = gci("GBIG01", "big", 60, 0x01, &zbig); + unsigned char *before = malloc(c.size); + memcpy(before, c.data, c.size); + CHECK(gc_card_put(&c, big, zbig) == -1); + CHECK(!memcmp(before, c.data, c.size)); + + /* The card reopens from its bytes, with the newer copies current. */ + gc_card again; + CHECK(gc_card_open(&again, c.data, c.size) == 0); + CHECK(again.dir == c.dir && again.bat == c.bat); + CHECK(gc_card_saves(&again, "GMSE01", NULL, NULL) == 1); + check_card(&again); + + /* A spoilt current directory: the previous copy is used. */ + again.data[(size_t)again.dir * GC_BLOCK + 100] ^= 0xFF; + gc_card fallback; + CHECK(gc_card_open(&fallback, again.data, again.size) == 0); + CHECK(fallback.dir != again.dir); + gc_card_free(&fallback); + + char name[128]; + gc_gci_name(a, name, sizeof name); + CHECK(!strcmp(name, "01-GZLE-gczelda.gci")); + size_t zq; + unsigned char *q = gci("GQQE01", "a/b?", 1, 0, &zq); + gc_gci_name(q, name, sizeof name); + CHECK(!strcmp(name, "01-GQQE-a__2f__b__3f__.gci")); + + CHECK(gc_card_open(&fallback, raw, 1000) == -1); + CHECK(!gc_gci_valid(a, za - 1)); + + gc_card_free(&again); + gc_card_free(&c); + free(raw), free(a), free(a2), free(b), free(d), free(big), free(before), free(q); + if (failures) { + fprintf(stderr, "%d failed\n", failures); + return 1; + } + puts("gcmem: ok"); + return 0; +} diff --git a/tests/mock_romm.py b/tests/mock_romm.py index 61c3f83..9d1f811 100755 --- a/tests/mock_romm.py +++ b/tests/mock_romm.py @@ -58,6 +58,14 @@ DB = { 14: {"id": 14, "platform_id": 1, "name": "Mega Test", "fs_name": "Mega Test (USA).md"}, 20: {"id": 20, "platform_id": 2, "name": "Okami", "fs_name": "Okami.iso"}, 21: {"id": 21, "platform_id": 2, "name": "Other", "fs_name": "Other.iso"}, + 22: {"id": 22, "platform_id": 2, "name": "Downhill Domination", "fs_name": "Downhill Domination (USA)", + "files": [{"id": 23, "file_name": "Downhill Domination (USA).iso", + "content": b"ROM:Downhill Domination (USA).iso"}]}, + 25: {"id": 25, "platform_id": 2, "name": "War of the Monsters", "fs_name": "War of the Monsters (USA)", + "files": [{"id": 26, "file_name": "War of the Monsters (USA).cue", + "content": b'FILE "War of the Monsters (USA).bin" BINARY\n'}, + {"id": 27, "file_name": "War of the Monsters (USA).bin", + "content": b"ROM:War of the Monsters (USA).bin"}]}, 30: {"id": 30, "platform_id": 3, "name": "Crisis Core", "fs_name": "Crisis Core (USA).iso"}, 40: {"id": 40, "platform_id": 4, "name": "Wind Waker", "fs_name": "Wind Waker (USA).iso"}, 50: {"id": 50, "platform_id": 5, "name": "Test Quest", "fs_name": "Test Quest (USA).bin"}, @@ -87,14 +95,24 @@ def next_id(): def rom_public(r): p = next(p for p in DB["platforms"] if p["id"] == r["platform_id"]) - stem = r["fs_name"].rsplit(".", 1)[0] + stem = r["fs_name"].rsplit(".", 1)[0] if "." in r["fs_name"] else r["fs_name"] + cover = "/assets/romm/resources/roms/%d/%d/cover/small.png?ts=%s" % ( + r["platform_id"], r["id"], DB["cover_ts"].get(r["id"], "2026-01-01 00:00:00")) + if r.get("files"): + files = [{"id": f["id"], "file_name": f["file_name"], "file_size_bytes": len(f["content"]), + "category": f.get("category", "content")} for f in r["files"]] + return {**{k: v for k, v in r.items() if k != "files"}, "fs_name_no_ext": stem, + "fs_size_bytes": sum(f["file_size_bytes"] for f in files), + "platform_slug": p["slug"], "platform_fs_slug": p["fs_slug"], + "has_multiple_files": len(files) > 1, "files": files, + "md5_hash": hashlib.md5(b"".join(f["content"] for f in r["files"])).hexdigest(), + "path_cover_small": cover} content = ("ROM:" + r["fs_name"]).encode() return {**r, "fs_name_no_ext": stem, "fs_size_bytes": len(content), "platform_slug": p["slug"], "platform_fs_slug": p["fs_slug"], "has_multiple_files": False, "files": [], "md5_hash": hashlib.md5(content).hexdigest(), # Like RomM's: the cover's path carries a stamp that changes with it. - "path_cover_small": "/assets/romm/resources/roms/%d/%d/cover/small.png?ts=%s" % ( - r["platform_id"], r["id"], DB["cover_ts"].get(r["id"], "2026-01-01 00:00:00"))} + "path_cover_small": cover} def save_public(s): @@ -230,6 +248,12 @@ class H(BaseHTTPRequestHandler): raw = base64.b64decode(b["content_b64"]) if "content_b64" in b else b["content"].encode() s = new_save(b["rom_id"], b.get("slot"), b.get("emulator", "other-emu"), b.get("file_name", "x.srm"), raw) return self.reply(200, save_public(s)) + if p == "/_admin/rom" and method == "POST": # e.g. a game RomM keeps as a folder holding one file + b = self.json_body() + for f in b.get("files", []): + f["content"] = f["content"].encode() + DB["roms"][b["id"]] = b + return self.reply(200, rom_public(b)) if p == "/_admin/state" and method == "POST": b = self.json_body() sid = next_id() @@ -275,7 +299,25 @@ class H(BaseHTTPRequestHandler): return self.reply(200, rom_public(DB["roms"][int(parts[2])])) if len(parts) == 5 and parts[:2] == ["api", "roms"] and parts[3] == "content": r = DB["roms"][int(parts[2])] + if r.get("files"): + if len(r["files"]) == 1: + return self.reply(200, raw=r["files"][0]["content"], ctype="application/octet-stream") + import io, zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for f in r["files"]: + zf.writestr(f["file_name"], f["content"]) + zf.writestr(r["fs_name"] + ".m3u", + "\n".join(f["file_name"] for f in r["files"]).encode()) + return self.reply(200, raw=buf.getvalue(), ctype="application/zip") return self.reply(200, raw=("ROM:" + r["fs_name"]).encode(), ctype="application/octet-stream") + if len(parts) == 6 and parts[:2] == ["api", "roms"] and parts[3:5] == ["files", "content"]: + # One file of a folder rom, by its file id. + for r in DB["roms"].values(): + for f in r.get("files") or []: + if f["id"] == int(parts[2]): + return self.reply(200, raw=f["content"], ctype="application/octet-stream") + return self.reply(404, {"detail": "file not found"}) if p == "/api/firmware": pid = int(qs("platform_id", 0)) return self.reply(200, [{"id": f["id"], "file_name": f["file_name"], "file_size_bytes": len(f["content"])} diff --git a/tests/test-hardlinks.c b/tests/test-hardlinks.c new file mode 100644 index 0000000..2175c32 --- /dev/null +++ b/tests/test-hardlinks.c @@ -0,0 +1,103 @@ +/* Where hard links work on the console, and why they fail where they don't. */ +#include +#include +#include +#include +#include +#include +#include + +static const char *dirs[] = { + "/user/data", + "/data", + "/data/PCSX2/games", + "/data/PSXS5/games", + "/data/homebrew", + "/data/homebrew/PPSA99169/content/ps2", + NULL, +}; + +/* From folder, to folder, what it tests. */ +static const char *pairs[][3] = { + {"/data/PCSX2/games", "/data/PCSX2/games", "A: same folder"}, + {"/data/PCSX2/games", "/data/PSXS5/games", "B: two folders under /data"}, + {"/data/homebrew/PPSA99169/content/ps2", "/data/homebrew/PPSA99169/content/ps2", "C: inside the homebrew mount"}, + {"/data/homebrew/PPSA99169/content/ps2", "/data/PCSX2/games", "D: across the mount (what RommPS does)"}, + {"/user/data/PCSX2/games", "/user/data/PCSX2/games", "E1: Same folder through underlying path"}, + {"/user/data/homebrew/PPSA99169/content/ps2", "/user/data/PCSX2/games", "E2: What we need to do through underlying path"}, + {NULL, NULL, NULL}, +}; + +static void show_mount(const char *dir) { + struct statfs fs; + struct stat st; + if (statfs(dir, &fs) != 0 || stat(dir, &st) != 0) { + printf("%s: %s\n", dir, strerror(errno)); + return; + } + printf("%s\n device %llu: %s mounted on %s (%s)\n", dir, (unsigned long long)st.st_dev, fs.f_mntfromname, + fs.f_mntonname, fs.f_fstypename); +} + +static void try_link(const char *from_dir, const char *to_dir, const char *what) { + char from[512], to[512]; + snprintf(from, sizeof from, "%s/rommps-linktest-a", from_dir); + snprintf(to, sizeof to, "%s/rommps-linktest-b", to_dir); + printf("%s\n %s -> %s\n", what, from_dir, to_dir); + int fd = open(from, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + printf(" can't create the test file: %s\n", strerror(errno)); + return; + } + if (write(fd, "test\n", 5) != 5) printf(" short write\n"); + close(fd); + unlink(to); + if (link(from, to) == 0) { + struct stat a, b; + stat(from, &a); + stat(to, &b); + printf(" linked: inodes %llu and %llu, %u links\n", (unsigned long long)a.st_ino, + (unsigned long long)b.st_ino, (unsigned)a.st_nlink); + } else { + printf(" failed: %s (errno %d)\n", strerror(errno), errno); + } + unlink(to); + unlink(from); +} + +static void try_symlink(const char *dir) { + char target[512], link_path[512]; + snprintf(target, sizeof target, "%s/rommps-linktest-a", dir); + snprintf(link_path, sizeof link_path, "%s/rommps-linktest-sym", dir); + printf("S: symlink in %s\n", dir); + int fd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + printf(" can't create the test file: %s\n", strerror(errno)); + return; + } + close(fd); + unlink(link_path); + if (symlink(target, link_path) == 0) { + struct stat st; + printf(" made; lstat says %s, stat %s\n", lstat(link_path, &st) == 0 && S_ISLNK(st.st_mode) ? "a link" : "not a link", + stat(link_path, &st) == 0 ? "reaches the file" : "can't reach the file"); + } else { + printf(" failed: %s (errno %d)\n", strerror(errno), errno); + } + unlink(link_path); + unlink(target); +} + +int main(void) { + printf("== running as uid %d, euid %d\n", (int)getuid(), (int)geteuid()); + printf("== mounts\n"); + for (int i = 0; dirs[i]; i++) show_mount(dirs[i]); + printf("== links\n"); + for (int i = 0; pairs[i][0]; i++) try_link(pairs[i][0], pairs[i][1], pairs[i][2]); + try_symlink("/data/PCSX2/games"); + try_symlink("/user/data/PCSX2/games"); + try_symlink("/system_tmp"); + printf("== done\n"); + fflush(stdout); + return 0; +} \ No newline at end of file diff --git a/tests/upgrade.sh b/tests/upgrade.sh new file mode 100755 index 0000000..13c85ae --- /dev/null +++ b/tests/upgrade.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Upgrade test: each released version sets up and syncs a console against +# tests/mock_romm.py, then this build takes over its data folder. Nothing may +# move that didn't change, the settings and emulators stay, and syncing goes on. +# make host && tests/upgrade.sh [old-binary ...] +# With no arguments, the releases are built from their tags first (git worktrees +# in build/upgrade/). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +NEW="$ROOT/build/host/romm-sync" +MOCK_PORT=8897 WEB_PORT=8783 +API="http://127.0.0.1:${WEB_PORT}" + +olds=("$@") +if [[ ${#olds[@]} -eq 0 ]]; then + mkdir -p "$ROOT/build/upgrade" + for t in $(git -C "$ROOT" tag --sort=version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$'); do + w="$ROOT/build/upgrade/$t" + [[ -d "$w" ]] || git -C "$ROOT" worktree add -q --detach "$w" "$t" + [[ -x "$w/build/host/romm-sync" ]] || make -C "$w" host >/dev/null + olds+=("$w/build/host/romm-sync") + done +fi + +T="" +cleanup() { kill $(jobs -p) 2>/dev/null || true; if [[ -n "$T" ]]; then rm -rf "$T"; fi; } +trap cleanup EXIT +fail() { echo "FAIL ($label): $*"; echo "--- log"; tail -40 "$T/daemon.log"; exit 1; } +post() { curl -sf -X POST "$API$1" -d "${2:-{\}}"; } +status() { curl -sf "$API/api/status"; } +count() { status | python3 -c "import json,sys; s=json.load(sys.stdin)['sync']; print(s['sync_count'] if not s['running'] else -1)"; } +syncnow() { + local n; n=$(count); post /api/sync >/dev/null + for _ in $(seq 1 60); do [[ "$(count)" -gt "$n" ]] && return 0; sleep 0.5; done; fail "the sync didn't finish" +} +last() { status | python3 -c "import json,sys; h=json.load(sys.stdin)['sync']['history'][-1]; print(h.get('$1', h.get('error','')))"; } +up() { for _ in $(seq 1 40); do curl -sf "$API/api/status" >/dev/null 2>&1 && return 0; sleep 0.25; done; fail "the web UI didn't come up"; } +dump() { curl -sf "http://127.0.0.1:$MOCK_PORT/_admin/dump"; } +snapshot() { # every save and state here, with its hash + (cd "$RA" && find saves states roms -type f -exec md5sum {} + 2>/dev/null | sort) +} +start() { # binary + ROMM_SYNC_DATA="$T/data" ROMM_SYNC_HOMEBREW="$T/none" ROMM_SYNC_AUTOSTART="$T/autostart" ROMM_SYNC_APP_DIRS="$T/none" \ + ROMM_SYNC_CATALOG="$T/catalog.json" ROMM_SYNC_FS_ROOTS="$T" "$1" >>"$T/daemon.log" 2>&1 & + PID=$! + up +} + +for OLD in "${olds[@]}"; do + label="$(basename "$(dirname "$(dirname "$(dirname "$OLD")")")")" # build/upgrade//build/host/romm-sync + T="$(mktemp -d)" + python3 "$ROOT/tests/mock_romm.py" $MOCK_PORT 2>"$T/mock.log" & + MOCK=$! + sleep 0.5 + RA="$T/RetroArch"; mkdir -p "$RA/roms/snes" "$RA/saves/Snes9x" "$RA/states/Snes9x" "$T/data" "$T/autostart" "$T/none" + echo '[]' > "$T/catalog.json" + printf 'savefile_directory = ":/saves"\nsort_savefiles_enable = "true"\nsavestate_directory = ":/states"\nsort_savestates_enable = "true"\n' > "$RA/retroarch.cfg" + cat > "$T/data/config.json" < "$RA/roms/snes/Chrono Trigger (USA).sfc" + echo "local-save-v1" > "$RA/saves/Snes9x/Chrono Trigger (USA).srm" + printf 'local-state-1' > "$RA/states/Snes9x/Chrono Trigger (USA).state1" + echo "ROM" > "$RA/roms/snes/Super Metroid (USA).sfc" + # GameCube: a game with a .gci save (synced as a zip), from RomM's catalog of the mock. + mkdir -p "$RA/roms/ngc" "$RA/saves/dolphin-emu/User/GC/USA/Card A" + python3 "$ROOT/tests/make_disc.py" gc "$RA/roms/ngc/Wind Waker (USA).iso" GZLE01 + python3 "$ROOT/tests/gccard.py" gci "$RA/saves/dolphin-emu/User/GC/USA/Card A/01-GZLE-gczelda.gci" GZLE01 gczelda 1 0x10 + # A state on RomM the old version brings down. + curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d '{"rom_id":11,"emulator":"snes9x","file_name":"Super Metroid (USA).state1","content":"srv-state"}' >/dev/null + curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/save" -d '{"rom_id":11,"slot":"autosave","file_name":"Super Metroid (USA).srm","content":"server-save"}' >/dev/null + + # The old version: paired, set up, synced twice. + start "$OLD" + post /api/pair/start "{\"server_url\":\"http://127.0.0.1:$MOCK_PORT\"}" >/dev/null || fail "pairing refused" + for _ in $(seq 1 20); do [[ "$(status | python3 -c "import json,sys; print(json.load(sys.stdin)['paired'])")" == True ]] && break; sleep 0.5; done + post /api/setup/finish '{"conflict_policy":"ask"}' >/dev/null 2>&1 || true + syncnow; syncnow + [[ "$(cat "$RA/saves/Snes9x/Super Metroid (USA).srm" 2>/dev/null)" == "server-save" ]] || fail "the old version didn't sync (setup)" + [[ -n "$(find "$RA/states" -name 'Super Metroid*' 2>/dev/null)" ]] || fail "the old version didn't bring the state down (setup)" + dump | python3 -c "import json,sys; sys.exit(0 if any(s['rom_id']==40 for s in json.load(sys.stdin)['saves']) else 1)" || + fail "the old version didn't send the GameCube save (setup)" + before=$(snapshot) + saves_before=$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d['saves']), len(d['states']))") + kill "$PID"; wait "$PID" 2>/dev/null || true + + # This build takes over. + start "$NEW" + for _ in $(seq 1 40); do [[ "$(count)" -ge 1 ]] && break; sleep 0.5; done # its startup sync + syncnow + v=$(status | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])") + [[ "$(snapshot)" == "$before" ]] || fail "files here changed on upgrade: $(diff <(echo "$before") <(snapshot) | head -5)" + [[ "$(dump | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d['saves']), len(d['states']))")" == "$saves_before" ]] || + fail "RomM got something new on upgrade" + h=$(status | python3 -c "import json,sys; print([(h['reason'], h['uploaded'], h['downloaded'], h['conflicts'], h['states_uploaded'], h['states_downloaded']) for h in json.load(sys.stdin)['sync']['history'][-2:]])") + [[ "$h" != *", 1"* && "$h" != *"conflict"* ]] || true + python3 - "$h" <<'PY' || fail "a sync after the upgrade moved something: $h" +import ast, sys +for reason, up, down, conflicts, sup, sdown in ast.literal_eval(sys.argv[1]): + assert up == down == conflicts == sup == sdown == 0 +PY + cfg=$(curl -sf "$API/api/config" | python3 -c " +import json,sys; c=json.load(sys.stdin) +print(c['conflict_policy'], c['states'], c['save_sync'], c['notify'], c['keep_backups'], [p['id'] for p in c['profiles']])") + [[ "$cfg" == "ask sync True False 3 ['retroarch']" ]] || fail "settings changed on upgrade: $cfg" + tr=$(curl -sf "$API/api/tracked" | python3 -c " +import json,sys; t=json.load(sys.stdin) +print(sorted((f['status']) for p in t['platforms'] for g in p['games'] for f in g['files']))") + [[ "$tr" != *"changed"* && "$tr" != *"conflict"* && "$tr" == *"synced"* ]] || fail "tracked saves after the upgrade: $tr" + curl -sf "$API/api/events" | python3 -c "import json,sys; assert isinstance(json.load(sys.stdin), list)" || fail "no sync log" + # And it keeps syncing. + sleep 1.1; echo "local-save-v2" > "$RA/saves/Snes9x/Chrono Trigger (USA).srm" + syncnow + [[ "$(last uploaded)" == 1 ]] || fail "a changed save after the upgrade must go up" + echo "ok $label -> $v" + kill "$PID" "$MOCK" 2>/dev/null || true; wait 2>/dev/null || true + rm -rf "$T"; T="" +done +echo "PASS" diff --git a/ui/index.html b/ui/index.html index e5c5738..50698b4 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,7 +3,7 @@ -RomM Sync +RommPS diff --git a/ui/style.css b/ui/style.css index a32c228..18c7175 100644 --- a/ui/style.css +++ b/ui/style.css @@ -1,4 +1,4 @@ -/* RomM Sync web UI: layout and components. Colours, shapes and effects come +/* RommPS web UI: layout and components. Colours, shapes and effects come * from the theme tokens in ui/themes/ (classic.css lists them all). */ * { box-sizing: border-box; } body { margin: 0; background-color: var(--page); background-image: var(--backdrop); background-attachment: fixed; color: var(--text); font: 18px/1.45 var(--font); } @@ -103,3 +103,8 @@ a { color: var(--accent-text); } .ribbon button:disabled { opacity: .3; } .ribbon, [data-rom] { scroll-margin-top: 90px; } /* clear of the sticky header */ #updBody details { margin-top: 14px; } +.tplat { border-top: var(--hairline); padding: 8px 0; } +.tplat summary { cursor: pointer; padding: 4px 0; } +.tgame { margin: 10px 0 10px 14px; } +.tfile { margin: 4px 0 0 10px; word-break: break-all; } +.noSaves .syncCol { display: none; } diff --git a/ui/themes/classic.css b/ui/themes/classic.css index 230543f..c7ffbd1 100644 --- a/ui/themes/classic.css +++ b/ui/themes/classic.css @@ -1,4 +1,4 @@ -/* Classic: RomM Sync's original dark look. Also the defaults, so this file +/* Classic: RommPS's original dark look. Also the defaults, so this file * lists every token ui/style.css uses; other themes override what they change * under [data-theme=""]. */ :root {