Merge pull request #16 from s0liton/release/v1.2.0

v1.2.0 - tested and validated.
This commit is contained in:
Pedro Santos authored and GitHub committed 2026-10-14 18:30:09 -04:00
commit ceebe293aa
56 files changed
+5791 -533

No files matched your search

+10 -2
View File
@@ -19,7 +19,7 @@
CHDR_SRCS := $(filter-out %microflac.cpp,$(wildcard third_party/libchdr/src/*.c)) \
third_party/libchdr/deps/lzma/src/LzmaDec.c third_party/libchdr/deps/zstd/zstddeclib.c
SRCS := src/main.c src/util.c src/http.c src/romm.c src/config.c src/state.c \
src/profiles.c src/sync.c src/watch.c src/detect.c src/autostart.c src/memcard.c src/zip.c src/bundle.c src/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
+18 -4
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

+25
View File
@@ -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.
@@ -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<int>(recent.size()));
ui::text(list, fonts.semibold, here, kMargin, 190, 24, section_ == 0 ? look.text : look.muted);
list.push_clip({0, 150, 1920, 330});
for (int i = 0; i < static_cast<int>(recent.size()); ++i)
{
@@ -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
{
@@ -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<cJSON, void (*)(cJSON *)> 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<int>(num(d, "saves"));
dl.states = static_cast<int>(num(d, "states"));
dl.save_conflicts = static_cast<int>(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);
}
@@ -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<float>(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
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cstdio>
#include <cctype>
namespace rommps
{
@@ -35,13 +36,28 @@ RowEvent RowList::handle(const std::vector<Row> &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<std::size_t>(focus_)].kind == RowKind::header)
{
int at = focus_;
while (at < count && rows[static_cast<std::size_t>(at)].kind == RowKind::header)
++at;
if (at == count)
for (at = focus_; at > 0 && rows[static_cast<std::size_t>(at)].kind == RowKind::header;)
--at;
focus_ = at;
}
const Row &row = rows[static_cast<std::size_t>(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<std::size_t>(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<float>(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<char>(std::toupper(static_cast<unsigned char>(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();
@@ -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<const Named> options, const std::string &id);
@@ -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<SheetAction> 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<SheetAction> actions = sheet_actions();
@@ -412,11 +474,18 @@ class RommPS final : public app::Concept
}
}
action_ = std::clamp(action_, 0, count - 1);
if (actions[static_cast<std::size_t>(action_)].kind != 3)
remove_hold_ = -1;
if (actions[static_cast<std::size_t>(action_)].kind == 1)
{
if (hold_to_cancel(input, dt, app_.download_for(sheet_game_.id), feedback))
sheet_open_ = false;
}
else if (actions[static_cast<std::size_t>(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<std::size_t>(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<int>(app_.downloads().size()))
{
const rommps::Download &d = app_.downloads()[static_cast<std::size_t>(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<ui::Hint> hints = view_.hints();
ui::draw_hints(list, fonts, style, hints.data(), static_cast<int>(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<ui::Hint> 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<int>(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<int>(downloads.size()) &&
downloads[static_cast<std::size_t>(download_row_)].active();
downloads[static_cast<std::size_t>(download_row_)].cancellable();
const bool any_finished = std::any_of(downloads.begin(), downloads.end(),
[](const rommps::Download &d) { return d.finished(); });
std::vector<ui::Hint> 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_;
@@ -3,10 +3,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "settings_page.hpp"
#include "cJSON.h"
#include "core/tween.hpp"
#include <algorithm>
#include <cstdio>
#include <memory>
namespace rommps
{
@@ -48,23 +50,25 @@ void SettingsPage::enter(App &app)
on_credits_ = false;
}
std::vector<Row> SettingsPage::rows(const App &app) const
std::vector<Row> SettingsPage::rows(const App &app, std::vector<int> *ids) const
{
const Config &c = app.config();
const Update &u = app.update();
std::vector<Row> 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<Row> 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<Row> 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<Row> 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<std::size_t>(armed_)].value = "Press again to confirm";
if (!c.known)
for (int i = kInterval; i <= kCovers; ++i)
r[static_cast<std::size_t>(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<Row> out;
if (ids)
ids->clear();
for (const auto &section : 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<std::size_t>(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<Row> r = rows(app);
const Row &row = r[static_cast<std::size_t>(std::clamp(list_.focus(), 0, kCount - 1))];
const Row &row = r[static_cast<std::size_t>(std::clamp(list_.focus(), 0, static_cast<int>(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<hui::gfx::Renderer *>(rommps_renderer()));
const std::vector<Row> r = rows(app);
std::vector<int> ids;
const std::vector<Row> r = rows(app, &ids);
const int count = static_cast<int>(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::size_t>(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<std::size_t>(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<cJSON, void (*)(cJSON *)> 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();
@@ -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 <string>
#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<Row> rows(const App &app) const;
// The rows as shown, sections included; ids[i] is row i's Id.
std::vector<Row> rows(const App &app, std::vector<int> *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;
};
File diff suppressed because it is too large. Load diff
@@ -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 <array>
#include <map>
#include <set>
#include <string>
@@ -72,9 +74,14 @@ class SetupPage
std::vector<std::string> cores;
std::vector<System> 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<std::string, std::array<std::string, 3>> dirs;
std::map<std::string, int> games; // games already in its folder, by system
std::set<std::string> 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<Conflict> conflicts() const;
// The root of the emulator that keeps a system: the one picked, else RetroArch, else the first.
std::vector<Conflict> conflicts() const; // systems several emulators on play
std::vector<Conflict> 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<Row> emulator_rows(const App &app, std::vector<RowRef> *refs = nullptr) const;
std::vector<Row> preview_rows() const;
std::vector<Row> 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<Row> leave_rows() const;
// ids: what each row is (the steps' rows depend on save_sync_).
std::vector<Row> preview_rows(std::vector<int> *ids = nullptr) const;
std::vector<Row> finish_rows(std::vector<int> *ids = nullptr) const;
bool has_ps2() const;
// The server's systems no ticked emulator plays, in the server's order.
std::vector<std::string> 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<Emulator> emulators_;
bool emulators_loaded_ = false;
std::map<std::string, std::string> 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<std::string, std::string> choices_; // system key -> the root of its main emulator ("": off)
std::map<std::string, std::string> 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<std::string, std::set<std::string>> 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<std::string, std::map<std::string, std::array<std::string, 3>>> 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<std::string, Own> own_; // by system key (first slug)
std::vector<std::string> own_order_; // the server's order
std::vector<std::string> 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<std::string> 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_;
@@ -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 <algorithm>
#include <cstdio>
#include <ctime>
#include <memory>
namespace rommps
{
namespace
{
namespace ui = hui::ui;
using hui::Action;
using hui::audio::Cue;
using hui::gfx::Rect;
using Json = std::unique_ptr<cJSON, void (*)(cJSON *)>;
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<std::time_t>(t));
char text[32];
if (s < 60)
return "just now";
if (s < 3600)
std::snprintf(text, sizeof text, "%d min ago", static_cast<int>(s / 60));
else if (s < 86400)
std::snprintf(text, sizeof text, "%d h ago", static_cast<int>(s / 3600));
else
std::snprintf(text, sizeof text, s < 2 * 86400 ? "%d day ago" : "%d days ago", static_cast<int>(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<std::time_t>(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<char>(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<char>(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<int>(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<Plat> 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<int>(num(j.get(), "saves")),
static_cast<int>(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<const cJSON *> 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<int>(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<int>(i));
}
if (items_.empty())
add({RowKind::info, "No conflicts", "", ""});
}
std::vector<Row> SyncView::rows() const
{
std::vector<Row> out;
out.reserve(items_.size());
for (const Item &it : items_)
out.push_back(it.row);
if (armed_ >= 0 && armed_ < static_cast<int>(out.size()))
out[static_cast<std::size_t>(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<Row> 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<int>(r.size()), kRows.h);
if (e.row < 0 || !e.activated || busy_)
return;
const Item it = items_[static_cast<std::size_t>(e.row)];
switch (it.action)
{
case kPickConflict:
{
const Conflict &c = conflicts_[static_cast<std::size_t>(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<std::size_t>(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<hui::ui::Hint> SyncView::hints() const
{
std::vector<hui::ui::Hint> h;
const int f = list_.focus();
const bool can = f >= 0 && f < static_cast<int>(items_.size()) && items_[static_cast<std::size_t>(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
@@ -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 <string>
#include <vector>
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<hui::ui::Hint> 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<Row> rows() const;
bool active_ = false;
Kind kind_ = kActivity;
int rom_id_ = 0;
std::string name_;
std::string title_, subtitle_;
std::vector<Item> items_;
std::vector<Conflict> 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
@@ -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": [
+6
View File
@@ -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;
}
+27 -11
View File
@@ -9,6 +9,7 @@
#include <sys/time.h>
#include <unistd.h>
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
@@ -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<int> 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<std::mutex> 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<std::mutex> g(lock_);
in_flight_ = 0;
finished_.push_back(std::move(job));
+3 -1
View File
@@ -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();
+33 -12
View File
@@ -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"}
]
}
}
]
+4 -1
View File
@@ -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);
+1
View File
@@ -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 */
+34 -2
View File
@@ -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);
+93
View File
@@ -13,6 +13,7 @@
#include "detect.h"
#include <dirent.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -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++;
+4
View File
@@ -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
+134
View File
@@ -0,0 +1,134 @@
#include "events.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#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;
}
+36
View File
@@ -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
+241
View File
@@ -0,0 +1,241 @@
#include "gcmem.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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);
}
+56
View File
@@ -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 <stddef.h>
#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
+112 -18
View File
@@ -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;
}
+8 -6
View File
@@ -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);
}
}
+6
View File
@@ -1,5 +1,7 @@
#include "memcard.h"
#include "events.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -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;
}
+1 -1
View File
@@ -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");
}
+67 -2
View File
@@ -4,6 +4,7 @@
#include <stdlib.h>
#include <string.h>
#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;
+28 -1
View File
@@ -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 = <dir>/<game folder>/,
* by_core = <dir>/<core_name>/. Both sort flags nest content then core. */
+155
View File
@@ -0,0 +1,155 @@
#include "statefmt.h"
#include <ctype.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <zlib.h>
#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]);
}
+38
View File
@@ -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 <stddef.h>
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
+1420 -102
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -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);
+3 -3
View File
@@ -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),
+19 -4
View File
@@ -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]) {
+5 -1
View File
@@ -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);
+8 -2
View File
@@ -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 <dirent.h>
@@ -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++;
}
+405 -13
View File
@@ -3,6 +3,7 @@
#include <arpa/inet.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdio.h>
@@ -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, "<token>"}, {g_cfg.server_url, "<server>"}, {g_cfg.username, "<user>"},
{g_cfg.device_name, "<device>"}, {g_cfg.device_id, "<device-id>"},
{g_cfg.client_device_identifier, "<device-id>"}};
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 = "<server>";
}
}
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 = "<ip>";
if (!m && start && (m = mac_at(p))) to = "<mac>";
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/<user>";
}
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 = "<id>";
}
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 = "<email>";
}
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);
+1 -1
View File
@@ -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
+404 -17
View File
@@ -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'<script>(.*?)</script>', 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 "<ip>" in text and "<mac>" in text and "<email>" in text and "<server>" 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" <<EOF
"profile": {"root": "$T/psxemu", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/states",
"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", "2": "mednafen_psx_hw"}},
"platforms": [{"romm": ["psx", "ps1"], "dir": "psx", "save_format": "psx-card"}]}},
{"id": "n64like", "name": "N64-like", "detect": ["$T/n64emu"],
"profile": {"root": "$T/n64emu", "rom_dir": "{root}/games", "save_dir": "{root}/saves", "state_dir": "{root}/saves",
@@ -328,7 +464,8 @@ cat > "$T/catalog.json" <<EOF
EOF
python3 "$ROOT/tests/make_disc.py" psxbin "$T/psxemu/games/Test Quest (USA).bin" SLUS_123.45
printf 'card-from-psx' > "$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('<I',len(m))+m+b'\0'*(-len(m)%8)+b'END \0\0\0\0').decode('latin1')))" "$1"; }
curl -sf -X POST "http://127.0.0.1:$MOCK_PORT/_admin/state" -d "{\"rom_id\":50,\"emulator\":\"pcsx_rearmed\",\"file_name\":\"Test Quest (USA) [web].state\",\"content\":$(rast 'STv4 PCSXra web-build')}" >/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"
+158
View File
@@ -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 "<file name> <md5 of the save's blocks>" 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()))
+201
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}
+45 -3
View File
@@ -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"])}
+103
View File
@@ -0,0 +1,103 @@
/* Where hard links work on the console, and why they fail where they don't. */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <unistd.h>
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;
}
+125
View File
@@ -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/<tag>/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" <<EOF
{"web_port": $WEB_PORT, "sync_interval_min": 0, "exit_delay_sec": 1, "profiles_custom": true, "conflict_policy": "ask",
"states": "sync", "notify": false, "keep_backups": 3,
"profiles": [{"id":"retroarch","name":"RetroArch","enabled":true,"root":"$RA",
"rom_dir":"{root}/roms/{platform}","save_dir":"{root}/saves","state_dir":"{root}/states",
"bios_dir":"{root}/system","save_exts":".srm","state_exts":".state*","retroarch_cfg":"{root}/retroarch.cfg",
"platforms":[{"romm":["snes"],"dir":"snes","emulator":"snes9x","core_name":"Snes9x"},
{"romm":["ngc"],"dir":"ngc","emulator":"dolphin","core_name":"Dolphin","save_layout":"gci"}]}]}
EOF
echo "ROM" > "$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"
+164 -51
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RomM Sync</title>
<title>RommPS</title>
<script>
// The consoles' browsers can't blur fast enough: themes drop the blur there.
if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className += " low-gpu";
@@ -17,7 +17,7 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
</head>
<body>
<header>
<h1>RomM <span>Sync</span></h1>
<h1>Romm<span>PS</span></h1>
<nav id="tabs">
<button class="tab active" data-tab="home">Status</button>
<button class="tab" data-tab="library">Library</button>
@@ -38,7 +38,7 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
<p class="muted">RomM 5.2.0 or newer.</p>
<label for="srv">Server address</label>
<input id="srv" placeholder="https://romm.example.com or 192.168.1.10:8080" autocomplete="off">
<div class="toggle"><input type="checkbox" id="wzInsecure"><label for="wzInsecure">Skip certificate checks (for self-signed HTTPS)</label></div>
<div class="toggle"><input type="checkbox" id="wzInsecure"><label for="wzInsecure">Don't verify TLS certificate</label></div>
<div class="row" style="margin-top:14px"><button class="primary" id="pairStart">Continue</button></div>
<details style="margin-top:18px">
<summary class="muted">Use a pairing code instead</summary>
@@ -91,7 +91,10 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
<div class="wz" id="wz4">
<h2>First sync</h2>
<p class="muted">Nothing has been transferred yet. This is what the first sync will do.</p>
<div class="toggle"><input type="checkbox" id="wzSaveSync" checked><label for="wzSaveSync">Sync saves with RomM</label></div>
<p class="muted" id="wzGamesOnly" style="display:none">RomM is only for games. Turn save sync on any time in Settings.</p>
<div id="wzSyncPart">
<p class="muted">What the first sync will do.</p>
<div id="preview"><p class="muted">Checking</p></div>
<label for="wzPolicy">If a save is different here and on RomM</label>
<select id="wzPolicy">
@@ -101,19 +104,22 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
<option value="server">Keep the one on RomM</option>
</select>
<div class="notice">
<b>Back up your saves first.</b> Before the first sync, copy your save folders somewhere safe (FTP or a USB drive). RomM Sync keeps local backups before it replaces a file, but a copy of your own is the only guarantee.
<b>Back up your saves first.</b> Before the first sync, copy your save folders somewhere safe (FTP or a USB drive). RommPS keeps local backups before it replaces a file, but a copy of your own is the only guarantee.
<div class="toggle" style="margin-top:10px"><input type="checkbox" id="wzBackedUp"><label for="wzBackedUp">I have backed up my saves</label></div>
</div>
</div>
<div class="row"><button id="wzBack4">Back</button><button id="wzRefresh">Check again</button><button class="primary" id="wzPreviewOk" disabled>Continue</button></div>
</div>
<div class="wz" id="wz5">
<h2>Background sync</h2>
<div id="wzSyncOpts">
<div class="toggle"><input type="checkbox" id="wzChange" checked><label for="wzChange">Upload saves as soon as they change</label></div>
<div class="toggle"><input type="checkbox" id="wzExit" checked><label for="wzExit">Sync when a game closes</label></div>
<div class="toggle"><input type="checkbox" id="wzStart" checked><label for="wzStart">Download new saves when a game starts</label></div>
<div class="toggle"><input type="checkbox" id="wzNotify" checked><label for="wzNotify">Notifications</label></div>
<label for="wzInterval">Sync every (minutes, 0 to turn off)</label><input id="wzInterval" type="number" min="0" value="15">
</div>
<div class="toggle"><input type="checkbox" id="wzNotify" checked><label for="wzNotify">Notifications</label></div>
<h3>Start with the console</h3>
<div id="autostart"><p class="muted">Checking autostart</p></div>
<div class="row" style="margin-top:16px"><button id="wzBack5">Back</button><button class="primary" id="wzFinish">Finish</button></div>
@@ -124,7 +130,8 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
<section id="home" class="active">
<div class="notice" id="updNotice" style="display:none"></div>
<div class="card">
<div class="card" id="syncOff" style="display:none"><h2 style="margin:0">Save sync is off</h2><p class="muted">RomM is only for games. Turn it on in Settings.</p></div>
<div class="card syncCard">
<div class="row">
<h2 style="margin:0">Saves</h2>
<button class="primary" id="syncNow">Sync now</button>
@@ -137,10 +144,24 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
<p class="muted">Changed here and on RomM. The one you don't keep is backed up.</p>
<table><thead><tr><th>Game</th><th>This console</th><th>RomM</th><th></th></tr></thead><tbody id="conflicts"></tbody></table>
</div>
<div class="card">
<div class="card syncCard">
<h2>Recent syncs</h2>
<table><thead><tr><th>When</th><th>Reason</th><th>Uploaded</th><th>Downloaded</th><th>States</th><th>Result</th></tr></thead><tbody id="history"></tbody></table>
</div>
<div class="card syncCard">
<div class="row">
<h2 style="margin:0">Activity</h2>
<select id="actFilter" class="fixed" aria-label="Show">
<option value="">Everything</option><option value="save">Saves</option><option value="state">Save states</option><option value="problem">Problems</option>
</select>
</div>
<table><thead><tr><th>When</th><th>Game</th><th>Result</th><th>Details</th></tr></thead><tbody id="activity"></tbody></table>
</div>
<div class="card syncCard">
<h2>Tracked saves and states</h2>
<p class="muted" id="trackedSum"></p>
<div id="tracked"></div>
</div>
<div class="card" id="strayCard" style="display:none">
<h2 class="warn">Saves in the wrong folder</h2>
<p class="muted">The emulator doesn't read these, so they aren't synced.</p>
@@ -205,13 +226,16 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
</div>
<div class="card">
<h2>Sync</h2>
<div class="toggle"><input type="checkbox" id="saveSync"><label for="saveSync">Sync saves and states with RomM</label></div>
<label for="devname">Device name</label><input id="devname">
<div class="toggle"><input type="checkbox" id="notify"><label for="notify">Notifications</label></div>
<div class="toggle"><input type="checkbox" id="tls"><label for="tls">Verify TLS certificate</label></div>
<label for="concurrency">Downloads at once</label><select id="concurrency"></select>
<div id="syncOpts">
<label for="interval">Sync every (minutes, 0 to turn off)</label><input id="interval" type="number" min="0">
<div class="toggle"><input type="checkbox" id="onchange"><label for="onchange">Upload saves as soon as they change</label></div>
<div class="toggle"><input type="checkbox" id="onexit"><label for="onexit">Sync when a game closes</label></div>
<div class="toggle"><input type="checkbox" id="onstart"><label for="onstart">Download new saves when a game starts</label></div>
<div class="toggle"><input type="checkbox" id="notify"><label for="notify">Notifications</label></div>
<div class="toggle"><input type="checkbox" id="tls"><label for="tls">Verify TLS certificates</label></div>
<label for="policy">If a save is different here and on RomM</label>
<select id="policy">
<option value="ask">Ask me each time</option>
@@ -221,14 +245,15 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
</select>
<label for="states">Save states</label>
<select id="states">
<option value="off">Don't sync</option>
<option value="upload">Upload only</option>
<option value="sync">Upload and download</option>
<option value="off">Off</option>
<option value="upload">Back up to RomM</option>
<option value="sync">Back up and restore</option>
</select>
<p class="muted">States only load where they were made.</p>
<label for="serverVersions">Versions kept on RomM per save (0 keeps all)</label><input id="serverVersions" type="number" min="0" max="100">
<label for="slot">RomM save slot</label><input id="slot">
<label for="concurrency">Downloads at once</label><select id="concurrency"></select>
<label for="backups">Local backups per save</label><input id="backups" type="number" min="0">
</div>
<div class="row" style="margin-top:16px"><button class="primary" id="saveCfg">Save</button></div>
</div>
<div class="card" id="ps2Card" style="display:none">
@@ -242,13 +267,13 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
</div>
<div class="card" id="systemsCard" style="display:none">
<h2>Emulator for each system</h2>
<p class="muted">These systems can be played by more than one of your emulators. Games download to, and saves sync with, the one picked here.</p>
<p class="muted">Pick the emulator for each system.</p>
<div id="systems"></div>
</div>
<div class="card" data-collapse="folders">
<div class="row"><h2 style="margin:0">Folders on this console</h2><button class="small fixed" data-toggle>Expand</button></div>
<div class="collapsible">
<table style="margin-top:12px"><thead><tr><th>Platforms</th><th>Games</th><th>Saves</th><th>States</th><th>BIOS</th></tr></thead><tbody id="paths"></tbody></table>
<table id="pathsTable" style="margin-top:12px"><thead><tr><th>Platforms</th><th>Games</th><th class="syncCol">Saves</th><th class="syncCol">States</th><th>BIOS</th></tr></thead><tbody id="paths"></tbody></table>
</div>
</div>
<div class="card" data-collapse="profiles">
@@ -261,8 +286,8 @@ if (/PlayStation/.test(navigator.userAgent)) document.documentElement.className
</div>
</section>
<section id="log"><div class="card"><div class="row"><h2 style="margin:0">Log</h2><button id="refreshLog">Refresh</button><button id="diagBtn">Download diagnostics</button></div>
<p class="muted">The diagnostics file has the console details, settings and full log, without your RomM credentials. Attach it when reporting a problem.</p><pre id="logText"></pre></div></section>
<section id="log"><div class="card"><div class="row"><h2 style="margin:0">Log</h2><button id="refreshLog">Refresh</button><button id="diagBtn">Diagnostic bundle</button></div>
<p class="muted">Logs and settings for a bug report. Your server, names and addresses are left out.</p><pre id="logText"></pre></div></section>
</main>
<div id="modal"><div class="card">
<div class="row"><h2 style="margin:0" id="histTitle">History</h2><button id="histClose">Close</button></div>
@@ -311,7 +336,7 @@ function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString() : "never";
function ago(t) {
if (!t) return "never";
var s = Math.round(Date.now() / 1000 - t);
if (s < 60) return s + "s ago"; if (s < 3600) return Math.round(s / 60) + " min ago"; if (s < 86400) return Math.round(s / 3600) + " h ago";
if (s < 120) return s + "s ago"; if (s < 7200) return Math.round(s / 60) + " min ago"; if (s < 86400) return Math.round(s / 3600) + " h ago";
return fmtTime(t);
}
function fmtEta(sec) {
@@ -350,6 +375,9 @@ function renderStatus(s) {
}
if (document.querySelector("#pair.active") && !wz.editing) showTab("home");
var sy = s.sync;
$("syncOff").style.display = s.save_sync === false ? "" : "none";
each("#home .syncCard", function (x) { x.style.display = s.save_sync === false ? "none" : ""; });
if (sy.sync_count !== state.shownSyncs && !sy.running) { state.shownSyncs = sy.sync_count; loadActivity(); loadTracked(); }
$("syncNow").disabled = sy.running;
$("syncPhase").textContent = sy.running ? "Syncing: " + sy.phase : "Last sync " + ago(sy.last_run) + (s.foreground ? ", playing " + s.foreground : "");
var last = sy.history.length ? sy.history[sy.history.length - 1] : null;
@@ -366,12 +394,12 @@ function renderStatus(s) {
'<td><button class="small" data-keep="local" data-path="' + esc(c.path) + '">Keep this one</button> <button class="small" data-keep="server" data-path="' + esc(c.path) + '">Keep RomM</button></td></tr>';
}).join("");
$("history").innerHTML = sy.history.slice().reverse().slice(0, 10).map(function (h) {
$("history").innerHTML = sy.history.slice().sort(function (a, b) { return b.time - a.time; }).slice(0, 10).map(function (h) {
var result = h.error ? '<span class="err">' + esc(h.error) + "</span>"
: h.conflicts ? '<span class="warn">' + h.conflicts + " conflict" + (h.conflicts > 1 ? "s" : "") + "</span>"
: '<span class="ok">OK</span>';
if (h.failed) result += ' <span class="err">' + h.failed + " failed</span>";
if (h.cards_uploaded) result += " " + pill("memory card backed up");
if (h.cards_uploaded) result += " " + pill("memory card up");
return "<tr><td>" + ago(h.time) + "</td><td>" + esc(h.reason) + "</td><td>" + h.uploaded + "</td><td>" + h.downloaded +
(h.deferred ? ' <small class="muted">(' + h.deferred + " waiting for the game to close)</small>" : "") + "</td><td>" + h.states_uploaded + "</td><td>" + result + "</td></tr>";
}).join("") || '<tr><td colspan=6 class="muted">No syncs yet.</td></tr>';
@@ -388,7 +416,7 @@ function renderStatus(s) {
function poll() {
poll.n = (poll.n || 0) + 1;
if (poll.n % 10 === 0) loadUpdate();
api("GET", "/api/status").then(renderStatus).catch(function () { $("conn").innerHTML = '<span class="err">&#9679;</span> Can\'t reach RomM Sync'; });
api("GET", "/api/status").then(renderStatus).catch(function () { $("conn").innerHTML = '<span class="err">&#9679;</span> Can\'t reach RommPS'; });
if (document.querySelector("#downloads.active")) loadDownloads();
if (document.querySelector("#library.active") && $("romCard").style.display !== "none") pollLibraryDownloads();
}
@@ -425,6 +453,7 @@ function wzLoadSettings() {
api("GET", "/api/config").then(function (c) {
$("wzPolicy").value = c.conflict_policy; $("wzChange").checked = c.sync_on_change; $("wzExit").checked = c.sync_on_game_exit;
$("wzStart").checked = c.sync_on_game_start; $("wzNotify").checked = c.notify; $("wzInterval").value = c.sync_interval_min;
$("wzSaveSync").checked = c.save_sync !== false; wzSaveSyncShown();
}).catch(function () {});
}
function renderQr(url) {
@@ -477,10 +506,14 @@ function emuCard(c) {
}).join("");
var on = wz.editing ? !!wz.current[c.root] : true;
return '<div class="emu"><label class="head"><input type="checkbox" data-root="' + esc(c.root) + '"' + (on ? " checked" : "") + "><b>" + esc(c.name) + "</b>" +
(c.title_id ? pill(c.title_id, "off") : "") + (wz.editing && !wz.current[c.root] ? pill("new", "ok") : "") + "</label>" +
(c.title_id ? pill(c.title_id, "off") : "") + "</label>" +
'<div class="path">' + esc(c.root) + "</div>" +
(c.cores.length ? '<div class="group"><small>Cores</small><div>' + c.cores.map(function (x) { return pill(x); }).join("") + "</div></div>" : "") +
(plats.length ? '<div class="group"><small>Platforms</small><div>' + platPills + "</div></div>" : "") + "</div>";
(plats.length ? '<div class="group"><small>Platforms</small><div>' + platPills + "</div></div>" : "") +
($("wzSaveSync").checked ? '<div class="group"><small>Save sync</small><div><select data-sync="' + esc(c.root) + '">' +
[["all", "Saves and states"], ["saves", "Saves"], ["off", "Off"]].concat((wz.sync || {})[c.root] === "mixed" ? [["mixed", "Per platform"]] : []).map(function (o) {
return '<option value="' + o[0] + '"' + (((wz.sync || {})[c.root] || "all") === o[0] ? " selected" : "") + ">" + o[1] + "</option>";
}).join("") + "</select></div></div>" : "") + "</div>";
}
// Names for system keys (the first RomM slug of a platform), until RomM's own load.
var SYSTEM_NAMES = { psx: "PlayStation", ps2: "PlayStation 2", psp: "PSP", n64: "Nintendo 64", nds: "Nintendo DS",
@@ -504,7 +537,8 @@ function emuConflicts() {
if (!key) return;
if (!by[key]) { by[key] = []; order.push(key); }
if (by[key].some(function (o) { return o.root === c.root; })) return;
by[key].push({ root: c.root, label: c.name + (p.core_name ? " (" + p.core_name + ")" : ""), retroarch: c.kind === "retroarch" });
by[key].push({ root: c.root, label: c.name + (p.core_name ? " (" + p.core_name + ")" : ""), retroarch: c.kind === "retroarch",
games: (c.dirs && c.dirs[key] && c.dirs[key].games) || 0 });
});
});
return order.filter(function (k) { return by[k].length > 1; }).map(function (k) { return { key: k, options: by[k] }; });
@@ -514,11 +548,13 @@ function renderEmuChoices() {
if (!list.length) { $("emuChoices").innerHTML = ""; return; }
var also = wz.also || {};
$("emuChoices").innerHTML = "<h3>Which emulator plays what</h3>" +
'<p class="muted">Some systems have more than one emulator here. Pick the main one for each, and tick any others that should get the same games. You can change this later in Settings.</p>' +
'<p class="muted">Pick the main emulator for each system. Tick others that should get the same games.</p>' +
'<div class="row"><button id="allRetroarch">Use RetroArch for all</button></div>' +
list.map(function (s) {
/* Not picked yet: where the system's games already are (most), else RetroArch. */
var most = s.options.reduce(function (b, o) { return o.games > (b ? b.games : 0) || (b && o.games === b.games && o.games > 0 && o.retroarch) ? o : b; }, null);
var pick = prev[s.key] && s.options.some(function (o) { return o.root === prev[s.key]; }) ? prev[s.key]
: (s.options.filter(function (o) { return o.retroarch; })[0] || s.options[0]).root;
: (most || s.options.filter(function (o) { return o.retroarch; })[0] || s.options[0]).root;
return '<div class="emu"><div class="head"><b>' + esc(systemName(s.key)) + "</b></div><small>Main</small>" + s.options.map(function (o) {
return '<label class="toggle"><input type="radio" name="sys-' + esc(s.key) + '" data-sys="' + esc(s.key) + '" value="' + esc(o.root) + '"' +
(o.root === pick ? " checked" : "") + ">" + esc(o.label) + "</label>";
@@ -572,7 +608,7 @@ function renderOwn() {
wz.own = wz.own || {};
var systems = uncoveredSystems().length, all = ownEntries();
if (!all.length) { $("ownSystems").innerHTML = ""; return; }
var names = ["Games", "Saves", "States"];
var names = $("wzSaveSync").checked ? ["Games", "Saves", "States"] : ["Games"]; /* save sync off: only games folders */
var card = function (e) {
var o = wz.own[e.key] = wz.own[e.key] || { on: false, folders: ["", "", ""] };
return '<div class="emu"><label class="head"><input type="checkbox" data-own="' + esc(e.key) + '"' + (o.on ? " checked" : "") + "><b>" +
@@ -584,10 +620,10 @@ function renderOwn() {
};
$("ownSystems").innerHTML =
(systems ? "<h3>No emulator found for these</h3>" +
'<p class="muted">Turn one on to pick where its games, saves and states go, or leave it off for now.</p>' +
'<p class="muted">Turn one on to pick its folders.</p>' +
all.slice(0, systems).map(card).join("") : "") +
(all.length > systems ? "<h3>Emulators with no preset yet</h3>" +
'<p class="muted">RomM Sync doesn\'t know where these keep their files yet. If yours works, turn it on and pick its folders.</p>' +
'<p class="muted">Folders unknown. Turn one on to pick them.</p>' +
all.slice(systems).map(card).join("") : "");
each("#ownSystems input[data-own]", function (x) { x.onchange = function () { wz.own[x.dataset.own].on = x.checked; renderOwn(); }; });
each("#ownSystems button[data-pick]", function (b) { b.onclick = function () { openPicker(b.dataset.pick, +b.dataset.which); }; });
@@ -638,7 +674,7 @@ function loadEmus() {
$("emus").innerHTML = '<p class="muted">Looking for emulators</p>';
var current = wz.editing ? api("GET", "/api/config").then(function (cfg) {
// From Settings: what's set up now (which emulators, their main systems and extras).
wz.current = {}; wz.choices = {}; wz.also = {}; wz.own = {};
wz.current = {}; wz.choices = {}; wz.also = {}; wz.own = {}; wz.sync = {};
(cfg.profiles || []).forEach(function (p) {
if (p.enabled === false) return;
if ((p.id || "").indexOf("custom-") === 0) {
@@ -648,6 +684,9 @@ function loadEmus() {
return;
}
wz.current[p.root] = true;
// One setting for the emulator, or "mixed" when its platforms differ.
var modes = (p.platforms || []).map(function (e) { return e.sync || p.sync || "all"; });
wz.sync[p.root] = modes.every(function (m) { return m === modes[0]; }) ? (modes[0] || "all") : "mixed";
(p.platforms || []).forEach(function (e) {
var key = (e.romm || [])[0];
if (!key) return;
@@ -659,7 +698,7 @@ function loadEmus() {
current.then(function () { return api("GET", "/api/setup/detect"); }).then(function (list) {
wz.loaded[3] = true;
if (!list.length) {
$("emus").innerHTML = '<p class="warn">No emulators found.</p><p class="muted">Turn on the systems below to choose their folders, or continue with the default RetroArch profile and change folders later in Settings.</p>';
$("emus").innerHTML = '<p class="warn">No emulators found.</p><p class="muted">Turn on systems to pick their folders, or keep RetroArch\'s defaults.</p>';
$("ps2Choice").style.display = "none";
wz.emus = [];
api("GET", "/api/platforms").then(function (p) { state.platforms = wz.platforms = p; renderOwn(); }).catch(function () {});
@@ -687,7 +726,10 @@ $("wzEmus").onclick = function () {
each("#emus input[data-root]", function (x) { if (x.checked) roots.push(x.dataset.root); });
var custom = ownCustom(), missing = custom.filter(function (c) { return !c.rom_dir; })[0];
if (missing) { $("pairErr").textContent = "Choose a games folder for " + missing.name + ", or turn it off."; return; }
api("POST", "/api/setup/emulators", { roots: roots, choices: emuChoices(), also: emuAlso(), custom: custom })
var sync = {};
// Only what was changed here, so per-platform settings made in the app stay.
each("#emus select[data-sync]", function (x) { if (x.value !== ((wz.sync || {})[x.dataset.sync] || "all")) sync[x.dataset.sync] = x.value; });
api("POST", "/api/setup/emulators", { roots: roots, choices: emuChoices(), also: emuAlso(), custom: custom, sync: sync })
.then(function () { return api("POST", "/api/config", { ps2_cards: ps2Mode() }); })
.then(function () {
if (!wz.editing) { wzGo(4); return; }
@@ -717,6 +759,13 @@ function loadPreview() {
}).catch(function (e) { $("preview").innerHTML = '<p class="err">' + esc(e.message) + "</p>"; });
}
$("wzBackedUp").onchange = function () { $("wzPreviewOk").disabled = !this.checked; };
function wzSaveSyncShown() {
var on = $("wzSaveSync").checked;
$("wzSyncPart").style.display = on ? "" : "none"; $("wzGamesOnly").style.display = on ? "none" : "";
$("wzSyncOpts").style.display = on ? "" : "none"; $("wzFinish").textContent = on ? "Finish" : "Finish";
$("wzPreviewOk").disabled = on && !$("wzBackedUp").checked;
}
$("wzSaveSync").onchange = wzSaveSyncShown;
$("wzBack4").onclick = function () { wz.loaded[3] = false; wzGo(3); };
$("wzRefresh").onclick = loadPreview;
$("wzPreviewOk").onclick = function () { wzGo(5); };
@@ -746,9 +795,9 @@ $("wzBack5").onclick = function () { wzGo(4); };
$("wzFinish").onclick = function () {
$("wzFinish").disabled = true;
api("POST", "/api/setup/finish", {
conflict_policy: $("wzPolicy").value, sync_on_change: $("wzChange").checked, sync_on_game_exit: $("wzExit").checked,
save_sync: $("wzSaveSync").checked, conflict_policy: $("wzPolicy").value, sync_on_change: $("wzChange").checked, sync_on_game_exit: $("wzExit").checked,
sync_on_game_start: $("wzStart").checked, notify: $("wzNotify").checked, sync_interval_min: +$("wzInterval").value
}).then(function () { $("wzFinish").disabled = false; toast("First sync started"); state.status.setup_complete = true; showTab("home"); poll(); })
}).then(function () { $("wzFinish").disabled = false; toast($("wzSaveSync").checked ? "All set. Let the first sync finish before you play" : "All set"); state.status.setup_complete = true; showTab("home"); poll(); })
.catch(function (e) { $("wzFinish").disabled = false; $("pairErr").textContent = e.message; });
};
// Settings' Emulators: just that step, from what's set up now, without pausing sync.
@@ -810,18 +859,29 @@ $("viewGrid").onclick = function () { setView("grid"); };
$("viewTable").onclick = function () { setView("table"); };
var CLOCK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
function romJob(g) { var j = state.dl[g.id]; return j && (j.status === "queued" || j.status === "downloading") ? j : null; }
function romJob(g) { var j = state.dl[g.id]; return j && (j.status === "queued" || j.status === "downloading" || j.status === "syncing") ? j : null; }
function jobPct(j) { return j.total ? Math.min(100, Math.floor(j.done * 100 / j.total)) : 0; }
function jobLabel(j) { return j.status === "queued" ? "Queued" : j.status === "syncing" ? "Syncing saves" : jobPct(j) + "%"; }
function plural(n, what) { return n + " " + what + (n === 1 ? "" : "s"); }
/* What the sync after a download brought. */
function jobSaves(d) {
if (d.saves_failed) return '<span class="err">saves could not sync</span>';
if (d.save_conflicts) return '<span class="warn">save conflict to resolve</span>';
var got = [];
if (d.saves) got.push(plural(d.saves, "save"));
if (d.states) got.push(plural(d.states, "state"));
return got.length ? got.join(" and ") + " synced" : "";
}
function romButton(g) {
if (!state.platform.profile) return "";
var j = romJob(g);
if (j) return "<button disabled>" + (j.status === "queued" ? "Queued" : jobPct(j) + "%") + "</button>";
if (j) return "<button disabled>" + jobLabel(j) + "</button>";
return g.installed ? '<button class="icon" data-hist="' + g.id + '" title="History" aria-label="History">' + CLOCK_ICON + '</button> <button class="danger" data-del="' + g.id + '">Remove</button>'
: '<button class="primary" data-dl="' + g.id + '" data-name="' + esc(g.name || "") + '">Download</button>';
}
function romStatus(g) {
var j = romJob(g);
if (j) return pill(j.status === "queued" ? "Queued" : jobPct(j) + "%", "warn");
if (j) return pill(jobLabel(j), "warn");
return g.installed ? pill("Downloaded", "ok") : "";
}
// Updates the open platform as downloads progress, keeping focus on the same game.
@@ -854,7 +914,7 @@ function renderRoms() {
$("roms").innerHTML = '<div class="grid">' + items.map(function (g) {
var cover = g.cover ? 'style="background-image:url(\'' + coverUrl(g) + '\')"' : "";
var j = romJob(g);
var badge = j ? '<span class="badge dl">' + (j.status === "queued" ? "Queued" : jobPct(j) + "%") + "</span>" : g.installed ? '<span class="badge">Downloaded</span>' : "";
var badge = j ? '<span class="badge dl">' + jobLabel(j) + "</span>" : g.installed ? '<span class="badge">Downloaded</span>' : "";
return '<div class="rom" data-rom="' + g.id + '"><div class="cover" ' + cover + ">" + badge + '</div><div class="t">' + esc(g.name) + '</div><div class="s">' + (g.version ? esc(g.version) + " · " : "") + fmtSize(g.size) + "</div>" + (g.installed && !romJob(g) ? '<div class="btns">' + romButton(g) + "</div>" : romButton(g)) + "</div>";
}).join("") + "</div>";
}
@@ -934,8 +994,12 @@ function showHistory(romId) {
$("histTitle").textContent = g ? g.name : "History";
$("histBody").innerHTML = '<p class="muted">Loading</p>';
$("modal").classList.add("on");
api("GET", "/api/history?rom_id=" + romId).then(function (h) {
$("histBody").innerHTML = "<h3>Saves</h3>" + historyTable("save", h.saves) + "<h3>Save states</h3>" + historyTable("state", h.states);
Promise.all([api("GET", "/api/history?rom_id=" + romId), api("GET", "/api/tracked?rom_id=" + romId), api("GET", "/api/events?limit=30&rom_id=" + romId)]).then(function (r) {
var h = r[0], here = [], log = (r[2] || []).slice().sort(function (a, b) { return b.t - a.t; });
r[1].platforms.forEach(function (p) { p.games.forEach(function (g) { here = here.concat(g.files); }); });
$("histBody").innerHTML = "<h3>On this console</h3>" + (here.length ? trackedFiles(here) : '<p class="muted">No saves or states here yet.</p>') +
"<h3>Saves on RomM</h3>" + historyTable("save", h.saves) + "<h3>Save states on RomM</h3>" + historyTable("state", h.states) +
"<h3>Log</h3>" + (log.length ? "<table><tbody>" + log.map(function (e) { return eventRow(e, false); }).join("") + "</tbody></table>" : '<p class="muted">Nothing synced yet.</p>');
var first = $("histBody").querySelector("button"); if (first) first.focus();
}).catch(function (e) { $("histBody").innerHTML = '<p class="err">' + esc(e.message) + "</p>"; });
}
@@ -948,6 +1012,51 @@ $("histBody").onclick = function (ev) {
api("POST", "/api/restore", { kind: kind, id: +b.dataset.restoreId }).then(function () { toast("Restored"); $("modal").classList.remove("on"); }).catch(function (e) { b.disabled = false; toast(e.message, 1); });
};
// Activity and tracked saves
var ACTION_PILL = { uploaded: "ok", downloaded: "ok", restored: "ok", resolved: "ok", "backed up": "ok", conflict: "warn", skipped: "off", failed: "err" };
var STATUS_LABEL = { synced: ["In sync", "ok"], changed: ["Changed here", "warn"], conflict: ["Conflict", "warn"], missing: ["Missing", "err"], no_game: ["Game not here", "off"] };
function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; }
function eventRow(e, withGame) {
var what = [e.kind === "card" ? "Memory card" : cap(e.kind)];
if (e.detail) what.push(e.detail);
if (e.emulator && e.action !== "skipped") what.push(e.emulator);
return "<tr><td>" + ago(e.t) + "</td>" +
(withGame ? "<td>" + esc(e.game || (e.kind === "card" ? "Memory card" : "Game " + e.rom_id)) + "</td>" : "") +
"<td>" + pill(cap(e.action), ACTION_PILL[e.action]) + '</td><td title="' + esc(e.path) + '">' + esc(what.join(" · ")) + "</td></tr>";
}
function loadActivity() {
var f = $("actFilter").value;
api("GET", "/api/events?limit=300").then(function (list) {
if (f === "problem") list = list.filter(function (e) { return e.action === "failed" || e.action === "conflict" || e.action === "skipped"; });
else if (f) list = list.filter(function (e) { return e.kind === f; });
list.sort(function (a, b) { return b.t - a.t; }); /* newest first */
$("activity").innerHTML = list.slice(0, 100).map(function (e) { return eventRow(e, true); }).join("") ||
'<tr><td colspan=4 class="muted">Nothing yet.</td></tr>';
}).catch(function () {});
}
$("actFilter").onchange = loadActivity;
function trackedFiles(files) {
return files.map(function (x) {
var st = STATUS_LABEL[x.status] || [x.status, ""];
return '<div class="tfile" title="' + esc(x.path.replace(/#.*$/, "")) + '">' + pill(cap(x.kind), "off") + " " + pill(st[0], st[1]) +
' <small class="muted">' + (x.synced_at ? "synced " + ago(x.synced_at) : "") + (x.kind === "state" && !x.from_here && x.server_id ? " · from another device" : "") + "</small></div>";
}).join("");
}
function loadTracked() {
api("GET", "/api/tracked").then(function (t) {
$("trackedSum").textContent = t.saves + " saves, " + t.states + " states" + (t.conflicts ? ", " + t.conflicts + " in conflict" : "");
t.platforms.sort(function (a, b) { return systemName(a.platform) < systemName(b.platform) ? -1 : 1; });
$("tracked").innerHTML = t.platforms.map(function (p) {
p.games.sort(function (a, b) { return a.name < b.name ? -1 : 1; });
var files = p.games.reduce(function (n, g) { return n + g.files.length; }, 0);
return '<details class="tplat"><summary><b>' + esc(systemName(p.platform) || "Other") + '</b> <small class="muted">' + p.games.length + " game" + (p.games.length === 1 ? "" : "s") + ", " + files + " file" + (files === 1 ? "" : "s") + "</small></summary>" +
p.games.map(function (g) {
return '<div class="tgame"><b>' + esc(g.name || ("Game " + g.rom_id)) + "</b>" + (g.emulator ? ' <small class="muted">' + esc(g.emulator) + "</small>" : "") + trackedFiles(g.files) + "</div>";
}).join("") + "</details>";
}).join("") || '<p class="muted">Nothing tracked yet.</p>';
}).catch(function () {});
}
function pollLibraryDownloads() { api("GET", "/api/downloads").then(syncLibraryDownloads).catch(function () {}); }
// Downloads
@@ -963,14 +1072,15 @@ function downloadRow(d) {
var pct = d.total ? Math.min(100, Math.round(d.done * 100 / d.total)) : (d.status === "done" ? 100 : 0);
var meta;
if (d.status === "error") meta = '<span class="err">' + esc(d.error) + "</span>";
else if (d.status === "done") meta = '<span class="ok">Done</span> ' + fmtSize(d.total);
else if (d.status === "done") meta = '<span class="ok">Done</span> ' + fmtSize(d.total) + (jobSaves(d) ? ", " + jobSaves(d) : "");
else if (d.status === "syncing") meta = "Syncing saves";
else if (d.status === "cancelled") meta = '<span class="muted">Cancelled</span>';
else if (d.status === "cancelling") meta = "Cancelling";
else if (d.status === "queued") meta = "Queued";
else meta = fmtSize(d.done) + (d.total ? " of " + fmtSize(d.total) : "") + (d.speed > 0 ? ", " + fmtSize(d.speed) + "/s" + (d.total ? ", " + fmtEta((d.total - d.done) / d.speed) : "") : "");
else meta = (d.note ? esc(d.note) + ": " : "") + fmtSize(d.done) + (d.total ? " of " + fmtSize(d.total) : "") + (d.speed > 0 ? ", " + fmtSize(d.speed) + "/s" + (d.total ? ", " + fmtEta((d.total - d.done) / d.speed) : "") : "");
return '<div class="dl-item"><div class="row"><div>' + (d.platform ? pill(d.platform) : "") + (d.type === "firmware" ? pill("BIOS", "off") : "") + "<b>" + esc(d.name) + "</b>" +
'<div class="meta">' + meta + "</div></div>" + (active ? '<button class="small" data-cancel="' + d.id + '">Cancel</button>' : "") + "</div>" +
(active || d.status === "cancelling" ? '<div class="bar"><i style="width:' + pct + '%"></i></div>' : "") + "</div>";
(active || d.status === "cancelling" || d.status === "syncing" ? '<div class="bar"><i style="width:' + pct + '%"></i></div>' : "") + "</div>";
}
function loadDownloads() {
api("GET", "/api/downloads").then(function (list) {
@@ -986,6 +1096,8 @@ function loadSettings() {
'<br><small class="muted">Device ' + esc(c.device_id) + "</small>";
$("feed4").textContent = c.server_url + "/api/feeds/fpkgi/ps4";
$("feed5").textContent = c.server_url + "/api/feeds/fpkgi/ps5";
$("saveSync").checked = c.save_sync !== false; $("syncOpts").style.display = $("saveSync").checked ? "" : "none";
$("pathsTable").classList.toggle("noSaves", !$("saveSync").checked);
$("devname").value = c.device_name; $("interval").value = c.sync_interval_min;
$("onchange").checked = c.sync_on_change; $("onexit").checked = c.sync_on_game_exit; $("onstart").checked = c.sync_on_game_start;
$("states").value = c.states; $("serverVersions").value = c.server_versions; $("notify").checked = c.notify; $("tls").checked = c.tls_verify;
@@ -1003,8 +1115,8 @@ function loadSettings() {
var saves = !m.save_dir ? pill("not synced", "off") + (m.sync_note ? '<br><small class="warn">' + esc(m.sync_note) + "</small>" : "")
: m.save_layout ? pill(layouts[m.save_layout] || m.save_layout, "ok") + "<br><small class=muted>synced as one zip per game</small>"
: "<code>" + esc(m.save_dir) + "</code>";
return "<tr><td>" + esc(m.platforms.split(",")[0]) + "<br><small class=muted>" + esc(m.emulator) + "</small></td><td><code>" + esc(m.rom_dir) + "</code></td><td>" + saves +
"</td><td>" + (m.state_dir ? "<code>" + esc(m.state_dir) + "</code>" : "") + "</td><td><code>" + esc(m.bios_dir) + "</code></td></tr>";
return "<tr><td>" + esc(m.platforms.split(",")[0]) + "<br><small class=muted>" + esc(m.emulator) + "</small></td><td><code>" + esc(m.rom_dir) + "</code></td><td class=syncCol>" + saves +
"</td><td class=syncCol>" + (m.state_dir ? "<code>" + esc(m.state_dir) + "</code>" : "") + "</td><td><code>" + esc(m.bios_dir) + "</code></td></tr>";
}).join("");
var ps2 = list.filter(function (m) { return m.emulator === "pcsx2"; })[0];
loadPs2(ps2);
@@ -1060,12 +1172,13 @@ $("ps2Status").onclick = function (ev) {
b.disabled = true;
api("POST", "/api/memcards/restore", { slot: +b.dataset.restore }).then(function () { toast("Memory card restored"); loadSettings(); }).catch(function (e) { b.disabled = false; toast(e.message, 1); });
};
$("saveSync").onchange = function () { $("syncOpts").style.display = this.checked ? "" : "none"; $("pathsTable").classList.toggle("noSaves", !this.checked); };
$("saveCfg").onclick = function () {
var form = {
device_name: $("devname").value, sync_interval_min: +$("interval").value, sync_on_change: $("onchange").checked,
sync_on_game_exit: $("onexit").checked, sync_on_game_start: $("onstart").checked, states: $("states").value, server_versions: +$("serverVersions").value,
notify: $("notify").checked, tls_verify: $("tls").checked, conflict_policy: $("policy").value, slot: $("slot").value,
keep_backups: +$("backups").value, download_concurrency: +$("concurrency").value
keep_backups: +$("backups").value, download_concurrency: +$("concurrency").value, save_sync: $("saveSync").checked
};
// Only what was changed here: RommPS may have changed the rest since this page loaded.
var changed = {}, any = false, was = state.cfg || {};
@@ -1095,7 +1208,7 @@ function renderUpdate(u) {
h += "<p>" + esc(u.message || u.state) + (u.state === "downloading" && u.total ? " (" + pct + "%)" : "") + "</p>";
if (u.state === "downloading") h += '<div class="bar"><i style="width:' + pct + '%"></i></div>';
} else if (u.available && u.self_update === false) {
h += '<p class="ok">RommPS ' + esc(u.latest) + " is available. Update it in ProsperoStore, and RomM Sync comes with it.</p>";
h += '<p class="ok">RommPS ' + esc(u.latest) + " is available. Update it in ProsperoStore.</p>";
} else if (u.available) {
h += '<p class="ok">Version ' + esc(u.latest) + " is available.</p>";
if (!u.autostart) h += '<p class="warn">' + (u.autostart_supported ? "Autostart isn't set up, so the" : "The") + " update only lasts until the console restarts.</p>";
@@ -1108,7 +1221,7 @@ function renderUpdate(u) {
$("updBody").innerHTML = h;
if ($("updInstall")) $("updInstall").onclick = installUpdate;
$("updNotice").style.display = u.available && !busy ? "" : "none";
$("updNotice").innerHTML = "<b>" + (u.self_update === false ? "RommPS " : "RomM Sync ") + esc(u.latest) + ' is available.</b> <button class="small" id="updGo">See what\'s new</button>';
$("updNotice").innerHTML = "<b>RommPS " + esc(u.latest) + ' is available.</b> <button class="small" id="updGo">See what\'s new</button>';
$("updGo").onclick = function () { showTab("settings"); };
}
function loadUpdate() { return api("GET", "/api/update").then(renderUpdate).catch(function () {}); }
@@ -1124,7 +1237,7 @@ function watchUpdate() {
});
}
function installUpdate() {
if (!confirm("Update RomM Sync to " + state.update.latest + "? It restarts once no sync, download or game is running.")) return;
if (!confirm("Update RommPS to " + state.update.latest + "? It restarts once no sync, download or game is running.")) return;
api("POST", "/api/update/install").then(function (u) { renderUpdate(u); watchUpdate(); }).catch(function (e) { toast(e.message, 1); });
}
$("updCheck").onclick = function () { api("POST", "/api/update/check").then(function (u) { renderUpdate(u); watchUpdate(); }).catch(function (e) { toast(e.message, 1); }); };
@@ -1201,7 +1314,7 @@ api("GET", "/api/status").then(function (s) {
if (!ready) { wzGo(s.paired ? 3 : 1); wizardFromStatus(s); }
api("GET", "/api/config").then(function (c) { $("wzInsecure").checked = !c.tls_verify; }).catch(function () {});
loadUpdate().then(function () { if (state.update && updBusy(state.update)) watchUpdate(); });
}).catch(function () { $("conn").textContent = "Can't reach RomM Sync"; });
}).catch(function () { $("conn").textContent = "Can't reach RommPS"; });
setInterval(poll, 3000);
</script>
</body>
+6 -1
View File
@@ -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; }
+1 -1
View File
@@ -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="<id>"]. */
:root {