mirror of
https://github.com/s0liton/ps5-romm.git
synced 2026-10-29 02:01:50 +01:00
- Game lists drop the members nothing here reads (RetroAchievements and scraper metadata, screenshots) as they stream in (src/jsonlean.c): a page of 16 PS1 games goes from 1.3 MB to 60 KB, so the PS4 payload, which shares GoldHEN's process, no longer runs out of memory browsing - The emulator table waits up to 2 s for memory instead of giving up, and a download that still can't get it says so instead of "no emulator is set up" - Free memory is logged at the end of each sync and with any shortage - Settings: Interface sounds can be turned off (rumble stays) - Unit tests: the JSON filter against 3000 random documents
3455 lines
142 KiB
C
3455 lines
142 KiB
C
#include "sync.h"
|
|
|
|
#include <ctype.h>
|
|
#include <dirent.h>
|
|
#include <pthread.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
#include "config.h"
|
|
#include "bundle.h"
|
|
#include "gcmem.h"
|
|
#include "gameid.h"
|
|
#include "http.h"
|
|
#include "memcard.h"
|
|
#include "platform.h"
|
|
#include "profiles.h"
|
|
#include "romm.h"
|
|
#include "events.h"
|
|
#include "firmware.h"
|
|
#include "state.h"
|
|
#include "statefmt.h"
|
|
#include "util.h"
|
|
#include "zip.h"
|
|
|
|
/* Never treated as games. */
|
|
static const char *IGNORED_EXTS = ".part,.tmp,.png,.jpg,.jpeg,.txt,.nfo,.xml,.json,.cfg,.opt,.srm,.state*,.sav";
|
|
|
|
typedef struct {
|
|
char path[PATH_MAX_LEN];
|
|
int rom_id;
|
|
const profile_map *map;
|
|
char stems[8][256];
|
|
int nstems;
|
|
char md5[33];
|
|
/* For profiles that name files by them (save_stems, state_stems): */
|
|
char serial[24]; /* a PS1 disc's, "SCUS-94194" */
|
|
char fnv64[17]; /* FNV-1a 64 of the game file */
|
|
char safe_stem[256]; /* stems[0], non-alphanumerics as "_" */
|
|
char game_id[24]; /* a disc's, "GAFE01" (DolphinPS4 names states by it), or a title's */
|
|
rom_rules rules;
|
|
} local_rom;
|
|
|
|
static int rom_game_id(const local_rom *r, char *out, size_t n);
|
|
|
|
typedef struct {
|
|
char path[PATH_MAX_LEN];
|
|
int rom_id;
|
|
char slot[48];
|
|
char hash[33];
|
|
time_t mtime;
|
|
int64_t mtime_ns;
|
|
int64_t size;
|
|
const profile_map *map;
|
|
const local_rom *rom;
|
|
int is_bundle; /* several files synced as one zip, see bundle.h */
|
|
char game_id[24];
|
|
char upload_name[256];
|
|
/* GameCube saves in a GCI folder keep the form RomM has them in (gc_form):
|
|
* "zip" (the folder's files for the game), "gci" (one of them, gc_file)
|
|
* or "card" (a whole memory card with them on it). */
|
|
char gc_form[8];
|
|
char gc_file[PATH_MAX_LEN];
|
|
} local_file;
|
|
|
|
typedef struct {
|
|
profile_map *maps;
|
|
int nmaps;
|
|
local_rom *roms;
|
|
int nroms, caproms;
|
|
local_file *saves;
|
|
int nsaves, capsaves;
|
|
local_file *states;
|
|
int nstates, capstates;
|
|
cJSON *platforms;
|
|
long server_status; /* of the platform list; 0 when the server never answered */
|
|
char slot[32], device_id[64];
|
|
int keep_backups;
|
|
int server_versions;
|
|
conflict_policy policy;
|
|
} scan_ctx;
|
|
|
|
static pthread_mutex_t g_run_lock = PTHREAD_MUTEX_INITIALIZER; /* one sync at a time */
|
|
static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
|
|
static pthread_cond_t g_req_cond = PTHREAD_COND_INITIALIZER;
|
|
static char g_req_reason[64];
|
|
static volatile int g_running;
|
|
static volatile time_t g_last_run;
|
|
static char g_phase[128] = "idle";
|
|
static cJSON *g_play_sessions; /* guarded by g_req_lock */
|
|
static cJSON *g_unmatched; /* guarded by state_lock */
|
|
static cJSON *g_strays; /* guarded by state_lock */
|
|
static cJSON *g_roots; /* folders scanned by the last sync, guarded by state_lock */
|
|
static cJSON *g_bundle_roots; /* bundle folders, watched for any file, guarded by state_lock */
|
|
static volatile int g_game_running;
|
|
static volatile int g_sync_count;
|
|
|
|
static void set_phase(const char *p) { str_copy(g_phase, sizeof g_phase, p); }
|
|
|
|
|
|
static char *url_q(const char *s) { return http_escape(s ? s : ""); }
|
|
|
|
/* This console's slots: its own ("autosave"), and the ones derived from it
|
|
* ("autosave-rtc", "autosave-<emulator>"). Other slots are other devices'. */
|
|
static int our_slot(const scan_ctx *c, const char *slot) {
|
|
size_t n = strlen(c->slot);
|
|
return !strncmp(slot, c->slot, n) && (slot[n] == 0 || slot[n] == '-');
|
|
}
|
|
|
|
static const char *slot_for(const scan_ctx *c, const profile_map *m, const char *file,
|
|
char *buf, size_t n) {
|
|
/* The main save uses the shared slot so other RomM clients pair with it.
|
|
* Extra files such as .rtc get a slot of their own. */
|
|
char first[32];
|
|
str_copy(first, sizeof first, m->save_exts);
|
|
char *comma = strchr(first, ',');
|
|
if (comma) *comma = 0;
|
|
const char *ext = path_ext(file);
|
|
char base[64];
|
|
/* An extra emulator whose saves can't be shared keeps them apart. */
|
|
if (m->own_slot) snprintf(base, sizeof base, "%s-%s", c->slot, m->profile_id);
|
|
else str_copy(base, sizeof base, c->slot);
|
|
if (!first[0] || str_ieq(ext, first)) {
|
|
str_copy(buf, n, base);
|
|
} else {
|
|
snprintf(buf, n, "%s-%s", base, ext[0] == '.' ? ext + 1 : ext);
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
static int ignored_rom_file(const char *name, const profile_map *m) {
|
|
if (name[0] == '.') return 1;
|
|
if (ext_in_list(name, IGNORED_EXTS)) return 1;
|
|
if (ext_in_list(name, m->save_exts) || ext_in_list(name, m->state_exts)) return 1;
|
|
return 0;
|
|
}
|
|
|
|
static void add_stem(local_rom *r, const char *stem) {
|
|
if (!stem[0] || r->nstems >= 8) return;
|
|
for (int i = 0; i < r->nstems; i++)
|
|
if (!strcmp(r->stems[i], stem)) return;
|
|
str_copy(r->stems[r->nstems++], sizeof r->stems[0], stem);
|
|
}
|
|
|
|
|
|
/* The disc or file of a game that a serial or hash is read from: the game
|
|
* itself, or for a folder its first .cue, .chd, .iso or .bin. */
|
|
static int game_file(const local_rom *r, char *out, size_t n) {
|
|
if (!dir_exists(r->path)) {
|
|
str_copy(out, n, r->path);
|
|
return 0;
|
|
}
|
|
static const char *const order[] = {".cue", ".chd", ".iso", ".bin"};
|
|
for (int k = 0; k < 4; k++) {
|
|
DIR *d = opendir(r->path);
|
|
struct dirent *de;
|
|
while (d && (de = readdir(d))) {
|
|
if (de->d_name[0] != '.' && str_ends_with_ci(de->d_name, order[k])) {
|
|
path_join(out, n, r->path, de->d_name);
|
|
closedir(d);
|
|
return 0;
|
|
}
|
|
}
|
|
if (d) closedir(d);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static int map_uses(const profile_map *m, const char *var) {
|
|
return strstr(m->save_stems, var) || strstr(m->state_stems, var) || strstr(m->save_name, var) ||
|
|
strstr(m->save_names_by_core, var) ||
|
|
strstr(m->state_name, var);
|
|
}
|
|
|
|
/* The template variables a game's file names can use. */
|
|
static void name_vars(const local_rom *r, const char *ext, const char *vars[15]) {
|
|
const char *v[] = {"rom_stem", r->stems[0], "ext", ext ? ext : "", "rom_md5", r->md5, "serial", r->serial,
|
|
"rom_fnv64", r->fnv64, "rom_stem_safe", r->safe_stem, "game_id", r->game_id, NULL};
|
|
memcpy(vars, v, sizeof v);
|
|
}
|
|
|
|
/* Works out what the profile's extra names need (cached per file in the
|
|
* state's roms entry, keyed by size and mtime), then adds those names. */
|
|
static void add_derived_stems(local_rom *r, cJSON *e) {
|
|
const profile_map *m = r->map;
|
|
size_t k = 0;
|
|
for (const char *p = r->stems[0]; *p && k + 1 < sizeof r->safe_stem; p++)
|
|
r->safe_stem[k++] = isalnum((unsigned char)*p) ? *p : '_';
|
|
r->safe_stem[k] = 0;
|
|
char file[PATH_MAX_LEN];
|
|
int need_serial = map_uses(m, "{serial}"), need_fnv = map_uses(m, "{rom_fnv64}");
|
|
if ((need_serial || need_fnv) && game_file(r, file, sizeof file) == 0) {
|
|
double size = (double)file_size(file), mtime = (double)file_mtime(file);
|
|
int fresh = e && jget_num(e, "id_size", -1) == size && jget_num(e, "id_mtime", -1) == mtime;
|
|
if (need_serial) {
|
|
if (fresh && cJSON_GetObjectItemCaseSensitive(e, "serial"))
|
|
str_copy(r->serial, sizeof r->serial, jget_str(e, "serial", ""));
|
|
else if (gameid_psx(file, r->serial, sizeof r->serial) != 0)
|
|
r->serial[0] = 0;
|
|
}
|
|
if (need_fnv) {
|
|
if (fresh && cJSON_GetObjectItemCaseSensitive(e, "fnv64"))
|
|
str_copy(r->fnv64, sizeof r->fnv64, jget_str(e, "fnv64", ""));
|
|
else if (fnv1a64_file_hex(file, r->fnv64) != 0)
|
|
r->fnv64[0] = 0;
|
|
}
|
|
if (e) {
|
|
jset_num(e, "id_size", size);
|
|
jset_num(e, "id_mtime", mtime);
|
|
if (need_serial) jset_str(e, "serial", r->serial);
|
|
if (need_fnv) jset_str(e, "fnv64", r->fnv64);
|
|
}
|
|
}
|
|
const char *vars[15];
|
|
name_vars(r, "", vars);
|
|
for (int pass = 0; pass < 2; pass++) {
|
|
char list[256], *save = NULL;
|
|
str_copy(list, sizeof list, pass ? m->state_stems : m->save_stems);
|
|
for (char *t = strtok_r(list, ",", &save); t; t = strtok_r(NULL, ",", &save)) {
|
|
char stem[256];
|
|
str_template(stem, sizeof stem, t, vars);
|
|
/* A template whose value is unknown (no serial) names nothing. */
|
|
if (stem[0] && !strchr(stem, '{') && strcmp(stem, "_1") != 0 && stem[0] != '_') add_stem(r, stem);
|
|
}
|
|
}
|
|
/* Names other cores of the emulator give the save ("core=template;..."). */
|
|
char list[256], *save = NULL;
|
|
str_copy(list, sizeof list, m->save_names_by_core);
|
|
for (char *t = strtok_r(list, ";", &save); t; t = strtok_r(NULL, ";", &save)) {
|
|
char *eq = strchr(t, '='), stem[256];
|
|
if (!eq) continue;
|
|
str_template(stem, sizeof stem, eq + 1, vars);
|
|
if (stem[0] && !strchr(stem, '{') && stem[0] != '_') add_stem(r, stem);
|
|
}
|
|
}
|
|
|
|
static int platform_ids_for_map(const scan_ctx *c, const profile_map *m, int *ids, int max) {
|
|
int n = 0;
|
|
const cJSON *p;
|
|
cJSON_ArrayForEach(p, c->platforms) {
|
|
if (n >= max) break;
|
|
if (profile_map_handles(m, jget_str(p, "slug", NULL), jget_str(p, "fs_slug", NULL)))
|
|
ids[n++] = (int)jget_num(p, "id", 0);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
struct server_rom_match {
|
|
const char *name, *stem;
|
|
cJSON *exact, *by_stem; /* copies of the matching games */
|
|
};
|
|
|
|
/* An exact file name match wins; otherwise the first game with the same stem. */
|
|
static int match_server_rom(const cJSON *it, const cJSON *page, int first, void *ctx) {
|
|
struct server_rom_match *m = ctx;
|
|
if (!it) return 0;
|
|
if (str_ieq(jget_str(it, "fs_name", ""), m->name)) {
|
|
m->exact = cJSON_Duplicate(it, 1);
|
|
return 1;
|
|
}
|
|
if (!m->by_stem && str_ieq(jget_str(it, "fs_name_no_ext", ""), m->stem)) m->by_stem = cJSON_Duplicate(it, 1);
|
|
return 0;
|
|
}
|
|
|
|
/* The RomM game whose file name matches a local file or folder. */
|
|
static cJSON *resolve_rom_on_server(const scan_ctx *c, const profile_map *m, const char *name,
|
|
int is_dir) {
|
|
int ids[16];
|
|
int nids = platform_ids_for_map(c, m, ids, 16);
|
|
if (nids == 0) return NULL;
|
|
char stem[256], path[2048];
|
|
if (is_dir)
|
|
str_copy(stem, sizeof stem, name);
|
|
else
|
|
path_stem(stem, sizeof stem, name);
|
|
char *q = url_q(stem);
|
|
int o = snprintf(path, sizeof path, "/api/roms?search_term=%s&with_total=false&with_char_index=false" ROMS_LEAN, q);
|
|
free(q);
|
|
for (int i = 0; i < nids; i++) o += snprintf(path + o, sizeof path - (size_t)o, "&platform_ids=%d", ids[i]);
|
|
|
|
struct server_rom_match match = {name, stem, NULL, NULL};
|
|
long st = 0;
|
|
romm_list(path, 0, 50, match_server_rom, &match, &st);
|
|
if (match.exact) {
|
|
cJSON_Delete(match.by_stem);
|
|
return match.exact;
|
|
}
|
|
return match.by_stem;
|
|
}
|
|
|
|
/* Whether a comma separated list has item. */
|
|
static int list_has(const char *list, const char *item) {
|
|
size_t n = strlen(item);
|
|
for (const char *p = list; n && (p = strstr(p, item)); p += n)
|
|
if ((p == list || p[-1] == ',') && (p[n] == ',' || !p[n])) return 1;
|
|
return 0;
|
|
}
|
|
|
|
static void record_local_rom(const char *path, int rom_id, const profile_map *m, const cJSON *srv,
|
|
const char *md5, const local_rom *r) {
|
|
const char *name = jget_str(srv, "name", ""), *fs_name = jget_str(srv, "fs_name", "");
|
|
cJSON *e = state_entry_ensure("roms", path);
|
|
jset_num(e, "rom_id", rom_id);
|
|
/* For the app's "on this console" row: the cover and the platform. */
|
|
jset_num(e, "platform_id", jget_num(srv, "platform_id", 0));
|
|
jset_str(e, "cover", jget_str(srv, "path_cover_small", ""));
|
|
jset_str(e, "profile", m->profile_id);
|
|
jset_str(e, "platform_dir", m->platform_dir);
|
|
jset_str(e, "name", name);
|
|
jset_str(e, "fs_name", fs_name);
|
|
if (md5 && md5[0]) jset_str(e, "md5", md5);
|
|
cJSON *stems = cJSON_CreateArray();
|
|
for (int i = 0; i < r->nstems; i++) cJSON_AddItemToArray(stems, cJSON_CreateString(r->stems[i]));
|
|
if (cJSON_GetObjectItemCaseSensitive(e, "stems"))
|
|
cJSON_ReplaceItemInObjectCaseSensitive(e, "stems", stems);
|
|
else
|
|
cJSON_AddItemToObject(e, "stems", stems);
|
|
cJSON_DeleteItemFromObjectCaseSensitive(e, "miss_at");
|
|
cJSON_DeleteItemFromObjectCaseSensitive(e, "miss_dirs");
|
|
}
|
|
|
|
/* Renames files in dir (and its folders one level down, RetroArch's per-core
|
|
* ones) named "<old>.<ext>" to "<new>.<ext>", unless that's taken. */
|
|
static void rename_by_stem(const char *dir, const char *old_stem, const char *new_stem, int depth) {
|
|
DIR *d = opendir(dir);
|
|
struct dirent *de;
|
|
size_t ol = strlen(old_stem);
|
|
while (d && (de = readdir(d))) {
|
|
if (de->d_name[0] == '.') continue;
|
|
char from[PATH_MAX_LEN], to[PATH_MAX_LEN + 256];
|
|
path_join(from, sizeof from, dir, de->d_name);
|
|
if (dir_exists(from)) {
|
|
if (depth == 0) rename_by_stem(from, old_stem, new_stem, 1);
|
|
continue;
|
|
}
|
|
if (strncmp(de->d_name, old_stem, ol) != 0 || de->d_name[ol] != '.') continue;
|
|
snprintf(to, sizeof to, "%s/%s%s", dir, new_stem, de->d_name + ol);
|
|
if (file_exists(to) || rename(from, to) != 0) continue;
|
|
LOGI("renamed %s -> %s", from, to);
|
|
state_lock();
|
|
static const char *const sections[] = {"saves", "states"};
|
|
for (int i = 0; i < 2; i++) {
|
|
cJSON *sec = state_section(sections[i]), *e = cJSON_DetachItemFromObjectCaseSensitive(sec, from);
|
|
if (e) cJSON_AddItemToObject(sec, to, e);
|
|
}
|
|
state_unlock();
|
|
}
|
|
if (d) closedir(d);
|
|
}
|
|
|
|
/* Version 1.1.1 downloaded a game RomM keeps as a folder holding one file as
|
|
* a single file named after the folder, with no extension, which emulators
|
|
* don't list. It becomes that folder with the file inside under its own
|
|
* name, as downloads do now; its saves and states take the file's name, which
|
|
* is what the emulator looks for. Returns 1 when it moved the game. */
|
|
static int fix_bare_rom(const profile_map *m, const char *path, int rom_id) {
|
|
char api[64];
|
|
long st = 0;
|
|
snprintf(api, sizeof api, "/api/roms/%d", rom_id);
|
|
cJSON *rom = romm_call("GET", api, NULL, &st);
|
|
const cJSON *files = cJSON_GetObjectItemCaseSensitive(rom, "files");
|
|
const char *fn = path_basename(jget_str(cJSON_GetArrayItem(files, 0), "file_name", ""));
|
|
int done = 0;
|
|
if (st == 200 && cJSON_GetArraySize(files) == 1 && path_ext(fn)[0] &&
|
|
!strcmp(jget_str(rom, "fs_name", ""), path_basename(path))) {
|
|
char tmp[PATH_MAX_LEN + 8], inside[PATH_MAX_LEN + 256];
|
|
snprintf(tmp, sizeof tmp, "%s.moving", path);
|
|
snprintf(inside, sizeof inside, "%s/%s", path, fn);
|
|
if (rename(path, tmp) == 0) {
|
|
if (mkdir_p(path) == 0 && rename(tmp, inside) == 0) {
|
|
char old_stem[256], new_stem[256];
|
|
str_copy(old_stem, sizeof old_stem, path_basename(path));
|
|
path_stem(new_stem, sizeof new_stem, fn);
|
|
LOGI("moved %s into %s, so the emulator lists it", path, inside);
|
|
state_lock();
|
|
cJSON *e = state_entry("roms", path);
|
|
if (e) {
|
|
cJSON_DeleteItemFromObjectCaseSensitive(e, "stems");
|
|
cJSON *stems = cJSON_AddArrayToObject(e, "stems");
|
|
cJSON_AddItemToArray(stems, cJSON_CreateString(old_stem));
|
|
cJSON_AddItemToArray(stems, cJSON_CreateString(new_stem));
|
|
}
|
|
state_unlock();
|
|
if (strcmp(old_stem, new_stem) != 0) {
|
|
rename_by_stem(m->save_dir, old_stem, new_stem, 0);
|
|
if (strcmp(m->state_dir, m->save_dir) != 0) rename_by_stem(m->state_dir, old_stem, new_stem, 0);
|
|
}
|
|
done = 1;
|
|
} else {
|
|
rmdir(path);
|
|
rename(tmp, path); /* put it back as it was */
|
|
}
|
|
}
|
|
}
|
|
cJSON_Delete(rom);
|
|
return done;
|
|
}
|
|
|
|
static void scan_rom_dir(scan_ctx *c, const profile_map *m) {
|
|
DIR *d = opendir(m->rom_dir);
|
|
if (!d) return;
|
|
struct dirent *de;
|
|
time_t now = time(NULL);
|
|
while ((de = readdir(d))) {
|
|
if (ignored_rom_file(de->d_name, m)) continue;
|
|
char path[PATH_MAX_LEN];
|
|
path_join(path, sizeof path, m->rom_dir, de->d_name);
|
|
int is_dir = dir_exists(path);
|
|
if (!is_dir && !file_exists(path)) continue;
|
|
|
|
/* Several platforms of one emulator can share a folder. Other
|
|
* emulators playing the same folder (an extra with a mount, or one
|
|
* set to the main one's games) each have the game, for their saves. */
|
|
int dup = 0;
|
|
for (int i = 0; i < c->nroms; i++)
|
|
if (!strcmp(c->roms[i].path, path) && !strcmp(c->roms[i].map->profile_id, m->profile_id)) dup = 1;
|
|
if (dup) continue;
|
|
|
|
local_rom r;
|
|
memset(&r, 0, sizeof r);
|
|
str_copy(r.path, sizeof r.path, path);
|
|
r.map = m;
|
|
char stem[256];
|
|
if (is_dir) {
|
|
add_stem(&r, de->d_name);
|
|
DIR *sd = opendir(path);
|
|
struct dirent *se;
|
|
char only[256] = "";
|
|
int files = 0;
|
|
while (sd && (se = readdir(sd))) {
|
|
if (se->d_name[0] == '.') continue;
|
|
path_stem(stem, sizeof stem, se->d_name);
|
|
if (ext_in_list(se->d_name, ".cue,.m3u,.gdi,.chd,.ccd,.iso,.pbp")) add_stem(&r, stem);
|
|
if (files++ == 0) str_copy(only, sizeof only, stem);
|
|
}
|
|
if (sd) closedir(sd);
|
|
/* One file in the folder (RomM keeps some single-file games so):
|
|
* emulators name its saves after that file. */
|
|
if (files == 1) {
|
|
/* The emulator names saves after the file inside, so new
|
|
* saves take its name (the folder's still matches old ones). */
|
|
add_stem(&r, only);
|
|
for (int k = 1; k < r.nstems; k++)
|
|
if (!strcmp(r.stems[k], only)) {
|
|
char first[sizeof r.stems[0]];
|
|
str_copy(first, sizeof first, r.stems[0]);
|
|
str_copy(r.stems[0], sizeof r.stems[0], only);
|
|
str_copy(r.stems[k], sizeof r.stems[k], first);
|
|
}
|
|
}
|
|
} else {
|
|
path_stem(stem, sizeof stem, de->d_name);
|
|
add_stem(&r, stem);
|
|
}
|
|
|
|
state_lock();
|
|
cJSON *e = state_entry("roms", path);
|
|
int rom_id = e ? (int)jget_num(e, "rom_id", 0) : 0;
|
|
time_t miss_at = e ? (time_t)jget_num(e, "miss_at", 0) : 0;
|
|
/* A folder that holds several of the emulator's systems (Porpoise:
|
|
* GameCube and Wii): a game is the system's it matched for, and a
|
|
* miss for one system doesn't stop the others asking. */
|
|
const char *was_dir = e ? jget_str(e, "platform_dir", "") : "";
|
|
const int other_system = e && rom_id && was_dir[0] && !strcmp(jget_str(e, "profile", ""), m->profile_id) &&
|
|
strcmp(was_dir, m->platform_dir) != 0;
|
|
if (miss_at && !list_has(jget_str(e, "miss_dirs", ""), m->platform_dir)) miss_at = 0;
|
|
if (other_system) {
|
|
state_unlock();
|
|
continue;
|
|
}
|
|
/* Seen under this emulator now (an older version named its profile otherwise). */
|
|
if (e && rom_id && strcmp(jget_str(e, "profile", ""), m->profile_id) != 0) {
|
|
int stale = 1; /* no profile has that name now */
|
|
for (int k = 0; k < c->nmaps && stale; k++) stale = strcmp(c->maps[k].profile_id, jget_str(e, "profile", "")) != 0;
|
|
/* In a folder shared by systems, the system it was recorded for takes it. */
|
|
int theirs = 0;
|
|
for (int k = 0; k < c->nmaps && stale && was_dir[0] && strcmp(was_dir, m->platform_dir) != 0; k++)
|
|
theirs |= !strcmp(c->maps[k].profile_id, m->profile_id) && !strcmp(c->maps[k].platform_dir, was_dir);
|
|
if (theirs) {
|
|
state_unlock();
|
|
continue;
|
|
}
|
|
if (stale) {
|
|
jset_str(e, "profile", m->profile_id);
|
|
jset_str(e, "platform_dir", m->platform_dir);
|
|
}
|
|
}
|
|
if (e) {
|
|
const cJSON *s;
|
|
cJSON_ArrayForEach(s, cJSON_GetObjectItemCaseSensitive(e, "stems"))
|
|
if (cJSON_IsString(s)) add_stem(&r, s->valuestring);
|
|
str_copy(r.md5, sizeof r.md5, jget_str(e, "md5", ""));
|
|
}
|
|
state_unlock();
|
|
|
|
/* Ask RomM about new files. Misses are retried after a day. */
|
|
if (!rom_id && (!miss_at || now - miss_at > 86400)) {
|
|
cJSON *srv = resolve_rom_on_server(c, m, de->d_name, is_dir);
|
|
state_lock();
|
|
if (srv) {
|
|
rom_id = (int)jget_num(srv, "id", 0);
|
|
add_stem(&r, jget_str(srv, "fs_name_no_ext", ""));
|
|
str_copy(r.md5, sizeof r.md5, jget_str(srv, "md5_hash", ""));
|
|
record_local_rom(path, rom_id, m, srv, r.md5, &r);
|
|
LOGI("matched %s -> RomM rom %d", path, rom_id);
|
|
} else {
|
|
cJSON *ne = state_entry_ensure("roms", path);
|
|
char dirs[256];
|
|
const char *had = jget_str(ne, "miss_dirs", "");
|
|
if (list_has(had, m->platform_dir)) str_copy(dirs, sizeof dirs, had);
|
|
else snprintf(dirs, sizeof dirs, "%s%s%s", had, had[0] ? "," : "", m->platform_dir);
|
|
jset_num(ne, "rom_id", 0);
|
|
jset_num(ne, "miss_at", (double)now);
|
|
jset_str(ne, "miss_dirs", dirs);
|
|
jset_str(ne, "profile", m->profile_id);
|
|
}
|
|
state_unlock();
|
|
cJSON_Delete(srv);
|
|
}
|
|
if (!rom_id) {
|
|
state_lock();
|
|
cJSON_AddItemToArray(g_unmatched, cJSON_CreateString(path));
|
|
state_unlock();
|
|
continue;
|
|
}
|
|
if (!is_dir && !path_ext(de->d_name)[0] && fix_bare_rom(m, path, rom_id)) {
|
|
char fixed[PATH_MAX_LEN];
|
|
str_copy(fixed, sizeof fixed, path); /* the folder, now */
|
|
DIR *sd = opendir(fixed);
|
|
struct dirent *se;
|
|
while (sd && (se = readdir(sd)))
|
|
if (se->d_name[0] != '.') {
|
|
path_stem(stem, sizeof stem, se->d_name);
|
|
add_stem(&r, stem);
|
|
}
|
|
if (sd) closedir(sd);
|
|
}
|
|
r.rom_id = rom_id;
|
|
if (map_uses(m, "{game_id}") && rom_game_id(&r, r.game_id, sizeof r.game_id) != 0) r.game_id[0] = 0;
|
|
if (m->save_stems[0] || m->state_stems[0] || map_uses(m, "{rom_stem_safe}")) {
|
|
state_lock();
|
|
add_derived_stems(&r, state_entry("roms", path));
|
|
state_save();
|
|
state_unlock();
|
|
}
|
|
profile_rules_for(m, path, &r.rules);
|
|
if (c->nroms == c->caproms) {
|
|
c->caproms = c->caproms ? c->caproms * 2 : 64;
|
|
c->roms = realloc(c->roms, sizeof *c->roms * (size_t)c->caproms);
|
|
}
|
|
c->roms[c->nroms++] = r;
|
|
}
|
|
closedir(d);
|
|
}
|
|
|
|
|
|
/* The folder the emulator reads this game's saves (or states) from. Returns 0
|
|
* when saves are sorted by core and the core name isn't known. */
|
|
static int expected_dir(const local_rom *r, int states, char *out, size_t n) {
|
|
const rom_rules *ru = &r->rules;
|
|
/* A folder per game, by its id (Porpoise's states/GAFE01). */
|
|
const char *at = strstr(states ? ru->state_dir : ru->save_dir, "{game_id}");
|
|
if (at) {
|
|
char id[24];
|
|
if (rom_game_id(r, id, sizeof id) != 0) return 0;
|
|
const char *dir = states ? ru->state_dir : ru->save_dir;
|
|
snprintf(out, n, "%.*s%s%s", (int)(at - dir), dir, id, at + 9);
|
|
return 1;
|
|
}
|
|
char content_dir[PATH_MAX_LEN];
|
|
str_copy(content_dir, sizeof content_dir, r->path);
|
|
if (!dir_exists(r->path)) { /* multi-file games are folders themselves */
|
|
char *slash = strrchr(content_dir, '/');
|
|
if (slash) *slash = 0;
|
|
}
|
|
if (ru->in_content[states]) {
|
|
str_copy(out, n, content_dir);
|
|
return 1;
|
|
}
|
|
char tmp[PATH_MAX_LEN];
|
|
str_copy(out, n, states ? ru->state_dir : ru->save_dir);
|
|
if (ru->by_content[states]) {
|
|
path_join(tmp, sizeof tmp, out, path_basename(content_dir));
|
|
str_copy(out, n, tmp);
|
|
}
|
|
if (ru->by_core[states]) {
|
|
if (!r->map->core_name[0]) return 0;
|
|
path_join(tmp, sizeof tmp, out, r->map->core_name);
|
|
str_copy(out, n, tmp);
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
/* The game a save belongs to, by name: "Game" owns "Game.srm", "Game.1a2b.sav"
|
|
* and "Game.state3". *active is 0 when the file isn't where the emulator reads it. */
|
|
static const local_rom *match_rom(const scan_ctx *c, const char *file_dir, const char *file_name,
|
|
int states, int *active) {
|
|
const local_rom *best = NULL, *best_active = NULL;
|
|
size_t best_len = 0, best_active_len = 0;
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
const local_rom *r = &c->roms[i];
|
|
if (!r->rules.sync_saves || (states && !r->map->sync_states) || (!states && r->map->save_layout[0])) continue;
|
|
if (!ext_in_list(file_name, states ? r->map->state_exts : r->map->save_exts)) continue;
|
|
if (strstr(states ? r->rules.state_dir : r->rules.save_dir, "{game_id}")) {
|
|
char exp[PATH_MAX_LEN];
|
|
if (expected_dir(r, states, exp, sizeof exp) && !strcmp(exp, file_dir)) {
|
|
*active = 1;
|
|
return r;
|
|
}
|
|
continue;
|
|
}
|
|
for (int s = 0; s < r->nstems; s++) {
|
|
size_t l = strlen(r->stems[s]);
|
|
if (strncmp(file_name, r->stems[s], l) != 0 || file_name[l] != '.') continue;
|
|
char exp[PATH_MAX_LEN];
|
|
int known = expected_dir(r, states, exp, sizeof exp);
|
|
/* Unknown core folder: accept it anywhere below the base folder. */
|
|
int here = known ? !strcmp(exp, file_dir) : 1;
|
|
if (here && l > best_active_len) {
|
|
best_active = r;
|
|
best_active_len = l;
|
|
}
|
|
if (l > best_len) {
|
|
best = r;
|
|
best_len = l;
|
|
}
|
|
}
|
|
}
|
|
*active = best_active != NULL;
|
|
return best_active ? best_active : best;
|
|
}
|
|
|
|
static void add_file(local_file **arr, int *n, int *cap, const local_file *f) {
|
|
if (*n == *cap) {
|
|
*cap = *cap ? *cap * 2 : 64;
|
|
*arr = realloc(*arr, sizeof **arr * (size_t)*cap);
|
|
}
|
|
(*arr)[(*n)++] = *f;
|
|
}
|
|
|
|
/* Reuse the recorded hash only if the file is untouched, down to the
|
|
* nanosecond. Seconds weren't good enough: SRAM saves never change size, and
|
|
* emulators are happy to rewrite them twice in the same second. */
|
|
static void hash_cached(const char *section, local_file *f) {
|
|
char ns[24];
|
|
snprintf(ns, sizeof ns, "%lld", (long long)f->mtime_ns);
|
|
state_lock();
|
|
cJSON *e = state_entry(section, f->path);
|
|
if (e && !strcmp(jget_str(e, "mtime_ns", ""), ns) && (int64_t)jget_num(e, "size", -1) == f->size)
|
|
str_copy(f->hash, sizeof f->hash, jget_str(e, "hash", ""));
|
|
state_unlock();
|
|
if (!f->hash[0]) md5_file_hex(f->path, f->hash);
|
|
}
|
|
|
|
static int already_seen(const scan_ctx *c, const char *path) {
|
|
for (int i = 0; i < c->nsaves; i++)
|
|
if (!strcmp(c->saves[i].path, path)) return 1;
|
|
for (int i = 0; i < c->nstates; i++)
|
|
if (!strcmp(c->states[i].path, path)) return 1;
|
|
const cJSON *e;
|
|
cJSON_ArrayForEach(e, g_strays)
|
|
if (!strcmp(jget_str(e, "path", ""), path)) return 1;
|
|
return 0;
|
|
}
|
|
|
|
static void add_stray(const local_rom *r, const char *path, int states) {
|
|
char exp_dir[PATH_MAX_LEN], exp[PATH_MAX_LEN];
|
|
expected_dir(r, states, exp_dir, sizeof exp_dir);
|
|
path_join(exp, sizeof exp, exp_dir, path_basename(path));
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "path", path);
|
|
cJSON_AddStringToObject(o, "expected_path", exp);
|
|
cJSON_AddBoolToObject(o, "expected_exists", file_exists(exp));
|
|
cJSON_AddBoolToObject(o, "state", states);
|
|
cJSON_AddNumberToObject(o, "rom_id", r->rom_id);
|
|
cJSON_AddStringToObject(o, "emulator", r->map->profile_name);
|
|
state_lock();
|
|
cJSON *e = state_entry("roms", r->path);
|
|
cJSON_AddStringToObject(o, "rom_name", e ? jget_str(e, "name", "") : "");
|
|
cJSON_AddItemToArray(g_strays, o);
|
|
state_unlock();
|
|
}
|
|
|
|
/* Saves on a PS1 memory card (directory frames starting one), or -1 when
|
|
* it isn't one. */
|
|
static int psx_card_saves(const profile_map *m, const char *path) {
|
|
if (strcmp(m->save_format, "psx-card") != 0) return -1;
|
|
unsigned char dir[16 * 128];
|
|
FILE *fp = fopen(path, "rb");
|
|
if (!fp) return -1;
|
|
size_t n = fread(dir, 1, sizeof dir, fp);
|
|
fclose(fp);
|
|
if (n < sizeof dir || dir[0] != 'M' || dir[1] != 'C') return -1;
|
|
int used = 0;
|
|
for (int i = 1; i < 16; i++) used += dir[i * 128] == 0x51;
|
|
return used;
|
|
}
|
|
|
|
static void scan_file_dir(scan_ctx *c, const char *dir, int depth, int states) {
|
|
DIR *d = opendir(dir);
|
|
if (!d) return;
|
|
struct dirent *de;
|
|
while ((de = readdir(d))) {
|
|
if (de->d_name[0] == '.') continue;
|
|
char path[PATH_MAX_LEN];
|
|
path_join(path, sizeof path, dir, de->d_name);
|
|
if (dir_exists(path)) {
|
|
/* Sorted layouts go two levels deep. */
|
|
if (depth < 2) scan_file_dir(c, path, depth + 1, states);
|
|
continue;
|
|
}
|
|
if (str_ends_with_ci(de->d_name, ".tmp") || str_ends_with_ci(de->d_name, ".part")) continue;
|
|
if (already_seen(c, path)) continue;
|
|
int active = 0;
|
|
const local_rom *r = match_rom(c, dir, de->d_name, states, &active);
|
|
if (!r) continue;
|
|
if (!active) {
|
|
/* Possibly an old copy. Report it and leave it alone. */
|
|
add_stray(r, path, states);
|
|
continue;
|
|
}
|
|
|
|
local_file f;
|
|
memset(&f, 0, sizeof f);
|
|
str_copy(f.path, sizeof f.path, path);
|
|
f.rom_id = r->rom_id;
|
|
f.map = r->map;
|
|
f.rom = r;
|
|
f.mtime = file_mtime(path);
|
|
f.mtime_ns = file_mtime_ns(path);
|
|
f.size = file_size(path);
|
|
if (!states) {
|
|
slot_for(c, r->map, de->d_name, f.slot, sizeof f.slot);
|
|
/* One file per game and slot; the newest wins. */
|
|
int replaced = 0;
|
|
for (int i = 0; i < c->nsaves; i++) {
|
|
local_file *o = &c->saves[i];
|
|
if (o->rom_id == f.rom_id && !strcmp(o->slot, f.slot)) {
|
|
/* Said once per pair while running, not at every sync. */
|
|
static cJSON *told;
|
|
char key[2 * PATH_MAX_LEN + 8];
|
|
snprintf(key, sizeof key, "%s|%s", o->path, f.path);
|
|
state_lock();
|
|
if (!told) told = cJSON_CreateObject();
|
|
const int first = !cJSON_GetObjectItemCaseSensitive(told, key);
|
|
if (first) cJSON_AddTrueToObject(told, key);
|
|
state_unlock();
|
|
if (first)
|
|
LOGW("two local saves for rom %d slot %s: %s, %s (using newest)", f.rom_id, f.slot, o->path, f.path);
|
|
/* A blank card (an emulator made a fresh one under another
|
|
* name) never beats one with saves on it. */
|
|
int fo = psx_card_saves(r->map, f.path), oo = psx_card_saves(r->map, o->path);
|
|
int take = fo == 0 && oo > 0 ? 0 : oo == 0 && fo > 0 ? 1 : f.mtime > o->mtime;
|
|
if (take) {
|
|
hash_cached("saves", &f);
|
|
*o = f;
|
|
}
|
|
replaced = 1;
|
|
break;
|
|
}
|
|
}
|
|
if (replaced) continue;
|
|
hash_cached("saves", &f);
|
|
add_file(&c->saves, &c->nsaves, &c->capsaves, &f);
|
|
} else {
|
|
hash_cached("states", &f);
|
|
add_file(&c->states, &c->nstates, &c->capstates, &f);
|
|
}
|
|
}
|
|
closedir(d);
|
|
}
|
|
|
|
static void scan_bundles(scan_ctx *c);
|
|
|
|
/* Base folder a game's saves (or states) live under, before sorting. */
|
|
static void base_dir(const local_rom *r, int states, char *out, size_t n) {
|
|
if (r->rules.in_content[states]) {
|
|
str_copy(out, n, r->path);
|
|
if (!dir_exists(r->path)) {
|
|
char *slash = strrchr(out, '/');
|
|
if (slash) *slash = 0;
|
|
}
|
|
} else {
|
|
str_copy(out, n, states ? r->rules.state_dir : r->rules.save_dir);
|
|
/* A folder per game ("states/{game_id}"): scan from its parent. */
|
|
char *id = strstr(out, "/{game_id}");
|
|
if (id) *id = 0;
|
|
}
|
|
}
|
|
|
|
static void scan_saves(scan_ctx *c) {
|
|
state_lock();
|
|
cJSON_Delete(g_strays);
|
|
g_strays = cJSON_CreateArray();
|
|
cJSON_Delete(g_roots);
|
|
g_roots = cJSON_CreateArray();
|
|
cJSON_Delete(g_bundle_roots);
|
|
g_bundle_roots = cJSON_CreateArray();
|
|
state_unlock();
|
|
for (int pass = 0; pass < 2; pass++) {
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
if (!c->roms[i].rules.sync_saves) continue;
|
|
char dir[PATH_MAX_LEN];
|
|
base_dir(&c->roms[i], pass, dir, sizeof dir);
|
|
int seen = 0;
|
|
for (int k = 0; k < i && !seen; k++) {
|
|
char other[PATH_MAX_LEN];
|
|
if (!c->roms[k].rules.sync_saves) continue;
|
|
base_dir(&c->roms[k], pass, other, sizeof other);
|
|
seen = !strcmp(other, dir);
|
|
}
|
|
if (seen || !dir[0]) continue;
|
|
scan_file_dir(c, dir, 0, pass);
|
|
state_lock();
|
|
cJSON_AddItemToArray(g_roots, cJSON_CreateString(dir));
|
|
state_unlock();
|
|
}
|
|
}
|
|
scan_bundles(c);
|
|
}
|
|
|
|
|
|
static int scan_prepare(scan_ctx *c) {
|
|
memset(c, 0, sizeof *c);
|
|
config_lock();
|
|
c->nmaps = profiles_expand(g_cfg.profiles, &c->maps);
|
|
str_copy(c->slot, sizeof c->slot, g_cfg.slot);
|
|
str_copy(c->device_id, sizeof c->device_id, g_cfg.device_id);
|
|
c->keep_backups = g_cfg.keep_backups;
|
|
c->server_versions = g_cfg.server_versions;
|
|
c->policy = g_cfg.conflicts;
|
|
config_unlock();
|
|
|
|
long st = 0;
|
|
c->platforms = romm_call("GET", "/api/platforms", NULL, &st);
|
|
c->server_status = st;
|
|
if (st != 200 || !cJSON_IsArray(c->platforms)) {
|
|
LOGE("could not list platforms (HTTP %ld)", st);
|
|
return -1;
|
|
}
|
|
state_lock();
|
|
cJSON_Delete(g_unmatched);
|
|
g_unmatched = cJSON_CreateArray();
|
|
state_unlock();
|
|
for (int i = 0; i < c->nmaps; i++) scan_rom_dir(c, &c->maps[i]);
|
|
return 0;
|
|
}
|
|
|
|
static void scan_free(scan_ctx *c) {
|
|
free(c->maps);
|
|
free(c->roms);
|
|
free(c->saves);
|
|
free(c->states);
|
|
cJSON_Delete(c->platforms);
|
|
}
|
|
|
|
void sync_scan_local_roms(void) {
|
|
if (!config_is_paired()) return;
|
|
pthread_mutex_lock(&g_run_lock);
|
|
scan_ctx c;
|
|
if (scan_prepare(&c) == 0) {
|
|
state_lock();
|
|
state_save();
|
|
state_unlock();
|
|
}
|
|
scan_free(&c);
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
}
|
|
|
|
|
|
static void backup_file(const scan_ctx *c, const char *path, int rom_id) {
|
|
if (c->keep_backups <= 0 || !file_exists(path)) return;
|
|
char dir[PATH_MAX_LEN], dst[PATH_MAX_LEN], sub[32];
|
|
snprintf(sub, sizeof sub, "backups/%d", rom_id);
|
|
path_join(dir, sizeof dir, plat_data_dir(), sub);
|
|
mkdir_p(dir);
|
|
char ts[32];
|
|
time_t now = time(NULL);
|
|
struct tm tm;
|
|
localtime_r(&now, &tm);
|
|
strftime(ts, sizeof ts, "%Y%m%d-%H%M%S", &tm);
|
|
snprintf(dst, sizeof dst, "%s/%s.%s", dir, path_basename(path), ts);
|
|
if (copy_file(path, dst) == 0) LOGI("backed up %s -> %s", path, dst);
|
|
|
|
/* Keep the newest keep_backups copies. */
|
|
DIR *d = opendir(dir);
|
|
if (!d) return;
|
|
char names[64][256];
|
|
int n = 0;
|
|
struct dirent *de;
|
|
size_t bl = strlen(path_basename(path));
|
|
while ((de = readdir(d)) && n < 64)
|
|
if (!strncmp(de->d_name, path_basename(path), bl) && de->d_name[bl] == '.')
|
|
str_copy(names[n++], sizeof names[0], de->d_name);
|
|
closedir(d);
|
|
for (int i = 0; i < n; i++)
|
|
for (int k = i + 1; k < n; k++)
|
|
if (strcmp(names[k], names[i]) < 0) {
|
|
char t[256];
|
|
memcpy(t, names[i], sizeof t);
|
|
memcpy(names[i], names[k], sizeof t);
|
|
memcpy(names[k], t, sizeof t);
|
|
}
|
|
for (int i = 0; i < n - c->keep_backups; i++) {
|
|
char p[PATH_MAX_LEN];
|
|
path_join(p, sizeof p, dir, names[i]);
|
|
unlink(p);
|
|
}
|
|
}
|
|
|
|
static void record_save(const local_file *f, int save_id, const char *hash) {
|
|
state_lock();
|
|
cJSON *e = state_entry_ensure("saves", f->path);
|
|
jset_num(e, "rom_id", f->rom_id);
|
|
jset_str(e, "slot", f->slot);
|
|
jset_str(e, "hash", hash ? hash : f->hash);
|
|
jset_num(e, "mtime", (double)(f->is_bundle ? f->mtime : file_mtime(f->path)));
|
|
jset_num(e, "size", (double)(f->is_bundle ? f->size : file_size(f->path)));
|
|
if (!f->is_bundle) {
|
|
char ns[24];
|
|
snprintf(ns, sizeof ns, "%lld", (long long)file_mtime_ns(f->path));
|
|
jset_str(e, "mtime_ns", ns);
|
|
}
|
|
if (save_id) jset_num(e, "save_id", save_id);
|
|
jset_num(e, "synced_at", (double)time(NULL));
|
|
state_entry_remove("conflicts", f->path);
|
|
state_unlock();
|
|
}
|
|
|
|
static const local_file *find_save(const scan_ctx *c, int rom_id, const char *slot);
|
|
|
|
#define Z32 "00000000000000000000000000000000" /* Azahar's SD card and console ids */
|
|
|
|
/* Candidate folders for a game's bundled saves. */
|
|
static int bundle_bases(const local_rom *r, char bases[][PATH_MAX_LEN], int max) {
|
|
static const char *defaults[][3] = {
|
|
/* {save_path} follows RetroArch's sorting. {save_root} is the bare save
|
|
* folder, because Dolphin ignores sort-by-core entirely and drops
|
|
* dolphin-emu/User right in savefiles/. Thanks, Dolphin. */
|
|
{"psp", "{save_path}/PSP/SAVEDATA,{save_root}/PPSSPP/PSP/SAVEDATA,{bios}/PPSSPP/PSP/SAVEDATA", NULL},
|
|
{"gci", "{save_root}/dolphin-emu/User/GC,{save_path}/User/GC,{save_path}/dolphin-emu/User/GC,{bios}/dolphin-emu/User/GC", NULL},
|
|
{"wii", "{save_root}/dolphin-emu/User/Wii,{save_path}/User/Wii,{save_path}/dolphin-emu/User/Wii,{bios}/dolphin-emu/User/Wii", NULL},
|
|
/* RetroArch's Azahar keeps its user folder in savefiles/Azahar, or
|
|
* in system/Azahar without a save folder. */
|
|
{"3ds", "{save_root}/Azahar/sdmc/Nintendo 3DS/" Z32 "/" Z32 "/title,{bios}/Azahar/sdmc/Nintendo 3DS/" Z32 "/" Z32 "/title", NULL},
|
|
};
|
|
const char *tpl = r->map->bundle_dirs[0] ? r->map->bundle_dirs : NULL;
|
|
for (size_t i = 0; !tpl && i < sizeof defaults / sizeof defaults[0]; i++)
|
|
if (!strcmp(defaults[i][0], r->map->save_layout)) tpl = defaults[i][1];
|
|
if (!tpl) return 0;
|
|
char save_path[PATH_MAX_LEN], list[2048], *save = NULL;
|
|
if (!expected_dir(r, 0, save_path, sizeof save_path)) str_copy(save_path, sizeof save_path, r->rules.save_dir);
|
|
const char *vars[] = {"save_path", save_path, "save_root", r->rules.save_dir, "bios", r->map->bios_dir, "root", r->map->root, NULL};
|
|
str_template(list, sizeof list, tpl, vars);
|
|
int n = 0;
|
|
for (char *t = strtok_r(list, ",", &save); t && n < max; t = strtok_r(NULL, ",", &save))
|
|
str_copy(bases[n++], PATH_MAX_LEN, t);
|
|
return n;
|
|
}
|
|
|
|
static int bundle_for_rom(const local_rom *r, const char *game_id, bundle *b) {
|
|
char bases[6][PATH_MAX_LEN];
|
|
int n = bundle_bases(r, bases, 6);
|
|
return n ? bundle_open(b, r->map->save_layout, game_id, bases, n) : -1;
|
|
}
|
|
|
|
static int bundle_reopen(const local_file *f, bundle *b) { return bundle_for_rom(f->rom, f->game_id, b); }
|
|
|
|
static int norm_eq(const char *a, const char *b) {
|
|
char x[256], y[256];
|
|
size_t i = 0, j = 0;
|
|
for (; *a && i < sizeof x - 1; a++) if (isalnum((unsigned char)*a)) x[i++] = (char)tolower((unsigned char)*a);
|
|
for (; *b && j < sizeof y - 1; b++) if (isalnum((unsigned char)*b)) y[j++] = (char)tolower((unsigned char)*b);
|
|
x[i] = y[j] = 0;
|
|
return i >= 4 && j >= 4 && (strstr(x, y) || strstr(y, x));
|
|
}
|
|
|
|
/* PSP saves name their game in PARAM.SFO; used when the disc can't be read. */
|
|
static int psp_id_from_saves(const local_rom *r, const char *name, char *out, size_t n) {
|
|
char bases[6][PATH_MAX_LEN];
|
|
int nb = bundle_bases(r, bases, 6);
|
|
for (int i = 0; i < nb; i++) {
|
|
DIR *d = opendir(bases[i]);
|
|
struct dirent *de;
|
|
while (d && (de = readdir(d))) {
|
|
char sfo[PATH_MAX_LEN], title[256];
|
|
snprintf(sfo, sizeof sfo, "%s/%s/PARAM.SFO", bases[i], de->d_name);
|
|
if (strlen(de->d_name) < 9 || sfo_file_get(sfo, "TITLE", title, sizeof title) != 0) continue;
|
|
if (!norm_eq(title, name)) continue;
|
|
snprintf(out, n, "%.9s", de->d_name);
|
|
closedir(d);
|
|
return 0;
|
|
}
|
|
if (d) closedir(d);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/* The game's id: from the disc image, RomM (title_id, 5.3+), or its PSP saves. */
|
|
static int rom_game_id(const local_rom *r, char *out, size_t n) {
|
|
out[0] = 0;
|
|
char name[256] = "";
|
|
state_lock();
|
|
cJSON *e = state_entry("roms", r->path);
|
|
if (e) {
|
|
str_copy(out, n, jget_str(e, "game_id", ""));
|
|
str_copy(name, sizeof name, jget_str(e, "name", ""));
|
|
}
|
|
state_unlock();
|
|
if (out[0]) return 0;
|
|
|
|
/* A front end that lists its games' ids (PSFlyCast), then the disc
|
|
* ("disc_id": "dreamcast"). RomM has no title id for these, so it isn't asked. */
|
|
if (r->map->game_ids[0] || !strcmp(r->map->disc_id, "dreamcast")) {
|
|
char disc[PATH_MAX_LEN] = "";
|
|
int ok = r->map->game_ids[0] && gameid_from_list(r->map->game_ids, r->map->root, r->path, out, n) == 0;
|
|
if (!ok && dir_exists(r->path)) {
|
|
DIR *d = opendir(r->path);
|
|
struct dirent *de;
|
|
while (d && (de = readdir(d)) && !disc[0])
|
|
if (ext_in_list(de->d_name, ".gdi,.chd,.cue,.iso")) path_join(disc, sizeof disc, r->path, de->d_name);
|
|
if (d) closedir(d);
|
|
} else if (!ok) {
|
|
str_copy(disc, sizeof disc, r->path);
|
|
}
|
|
if (!ok && (!disc[0] || gameid_dreamcast(disc, out, n) != 0)) {
|
|
out[0] = 0;
|
|
return -1;
|
|
}
|
|
state_lock();
|
|
e = state_entry("roms", r->path);
|
|
if (e) jset_str(e, "game_id", out);
|
|
state_unlock();
|
|
LOGI("game id for %s is %s", path_basename(r->path), out);
|
|
return 0;
|
|
}
|
|
|
|
char disc[PATH_MAX_LEN];
|
|
str_copy(disc, sizeof disc, r->path);
|
|
if (dir_exists(r->path)) { /* multi-file game: use its first disc image */
|
|
DIR *d = opendir(r->path);
|
|
struct dirent *de;
|
|
disc[0] = 0;
|
|
while (d && (de = readdir(d)) && !disc[0])
|
|
if (ext_in_list(de->d_name, ".iso,.cso,.rvz,.wia,.gcm,.ciso,.wbfs,.cci,.3ds,.cxi,.cia")) path_join(disc, sizeof disc, r->path, de->d_name);
|
|
if (d) closedir(d);
|
|
}
|
|
const char *layout = r->map->save_layout;
|
|
const int title = !strcmp(layout, "3ds") || !strcmp(layout, "wiiu");
|
|
int ok = 0;
|
|
if (title) {
|
|
/* The front end's list first (a .wux can't be read without its disc
|
|
* key), then the game file's header. */
|
|
char lib[PATH_MAX_LEN];
|
|
path_join(lib, sizeof lib, r->map->root, "library.json");
|
|
ok = gameid_from_library(lib, layout, r->path, out, n) == 0 ||
|
|
(!strcmp(layout, "3ds") && disc[0] && gameid_3ds(disc, out, n) == 0);
|
|
} else {
|
|
ok = disc[0] && (!strcmp(layout, "psp") ? gameid_psp(disc, out, n) : gameid_dolphin(disc, out, n)) == 0;
|
|
}
|
|
if (!ok) {
|
|
char api[64];
|
|
long st = 0;
|
|
snprintf(api, sizeof api, "/api/roms/%d", r->rom_id);
|
|
cJSON *rom = romm_call("GET", api, NULL, &st);
|
|
const char *tid = jget_str(rom, "title_id", "");
|
|
size_t o = 0;
|
|
for (; *tid && o + 1 < n; tid++) if (isalnum((unsigned char)*tid)) out[o++] = (char)toupper((unsigned char)*tid);
|
|
out[o] = 0;
|
|
ok = title ? o == 16 : o >= 4;
|
|
cJSON_Delete(rom);
|
|
}
|
|
if (!ok && !strcmp(r->map->save_layout, "psp")) {
|
|
if (!name[0]) path_stem(name, sizeof name, r->path);
|
|
ok = psp_id_from_saves(r, name, out, n) == 0;
|
|
}
|
|
if (!ok) {
|
|
out[0] = 0;
|
|
return -1;
|
|
}
|
|
state_lock();
|
|
e = state_entry("roms", r->path);
|
|
if (e) jset_str(e, "game_id", out);
|
|
state_unlock();
|
|
LOGI("game id for %s is %s", path_basename(r->path), out);
|
|
return 0;
|
|
}
|
|
|
|
/* ---- GameCube: the form RomM keeps a game's saves in ----
|
|
* RomM is the authority: a game's saves go up in the form they're there in.
|
|
* zip the game's .gci files zipped (this app's own form, and the default)
|
|
* gci a single .gci, as other apps send it
|
|
* card a whole memory card (.raw), as Dolphin keeps one: this console's
|
|
* saves for the game are put on a copy of RomM's card (kept in
|
|
* gccards/<rom id>.raw), so the card's other saves stay as they are. */
|
|
|
|
static const char *gc_form_of_name(const char *file_name) {
|
|
if (str_ends_with_ci(file_name, ".gci")) return "gci";
|
|
if (ext_in_list(file_name, ".raw,.gcp,.mcp")) return "card";
|
|
return "zip";
|
|
}
|
|
|
|
static void gc_card_cache(int rom_id, char *out, size_t n) {
|
|
char dir[PATH_MAX_LEN];
|
|
path_join(dir, sizeof dir, plat_data_dir(), "gccards");
|
|
snprintf(out, n, "%s/%d.raw", dir, rom_id);
|
|
}
|
|
|
|
static void gc_form_get(const local_rom *r, char *form, size_t n, char *name, size_t nn) {
|
|
state_lock();
|
|
cJSON *e = state_entry("roms", r->path);
|
|
str_copy(form, n, e ? jget_str(e, "gc_form", "zip") : "zip");
|
|
if (name) str_copy(name, nn, e ? jget_str(e, "gc_name", "") : "");
|
|
state_unlock();
|
|
}
|
|
|
|
static void gc_form_set(const local_rom *r, const char *form, const char *name) {
|
|
state_lock();
|
|
cJSON *e = state_entry("roms", r->path);
|
|
if (e) {
|
|
jset_str(e, "gc_form", form);
|
|
if (name && name[0]) jset_str(e, "gc_name", name);
|
|
}
|
|
state_unlock();
|
|
}
|
|
|
|
/* Fetches a save's content without telling RomM this console has it. */
|
|
static int gc_fetch(int save_id, const char *dest) {
|
|
char api[64];
|
|
snprintf(api, sizeof api, "/api/saves/%d/content", save_id);
|
|
http_resp resp;
|
|
mkdir_parent(dest);
|
|
if (romm_download(api, dest, NULL, NULL, &resp) != 0) {
|
|
LOGW("save %d: %s", save_id, resp.error);
|
|
unlink(dest);
|
|
return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static int gc_is_card_file(const char *path) {
|
|
size_t len = 0;
|
|
char *data = read_file(path, &len);
|
|
gc_card card;
|
|
int ok = data && gc_card_open(&card, (const unsigned char *)data, len) == 0;
|
|
if (ok) gc_card_free(&card);
|
|
free(data);
|
|
return ok;
|
|
}
|
|
|
|
/* Which form RomM has the game's saves in: its newest one in this console's
|
|
* slot, or else its newest one anywhere. For a card, a copy of it is kept
|
|
* here so this console's saves can be put on it. */
|
|
static void gc_learn_form(const scan_ctx *c, const local_rom *r) {
|
|
char api[64];
|
|
snprintf(api, sizeof api, "/api/saves?rom_id=%d", r->rom_id);
|
|
long st = 0;
|
|
cJSON *all = romm_call("GET", api, NULL, &st);
|
|
const cJSON *s, *mine = NULL, *any = NULL;
|
|
cJSON_ArrayForEach(s, (st == 200 ? all : NULL)) {
|
|
if ((int)jget_num(s, "rom_id", 0) != r->rom_id) continue;
|
|
const char *up = jget_str(s, "updated_at", "");
|
|
if (!any || strcmp(up, jget_str(any, "updated_at", "")) > 0) any = s;
|
|
if (!strcmp(jget_str(s, "slot", ""), c->slot) && (!mine || strcmp(up, jget_str(mine, "updated_at", "")) > 0))
|
|
mine = s;
|
|
}
|
|
const cJSON *pick = mine ? mine : any;
|
|
if (!pick && st == 200) { /* nothing on RomM: this app's own form, no card kept */
|
|
char cache[PATH_MAX_LEN];
|
|
gc_card_cache(r->rom_id, cache, sizeof cache);
|
|
unlink(cache);
|
|
gc_form_set(r, "zip", NULL);
|
|
}
|
|
if (pick) {
|
|
/* RomM stamps the names it keeps: "MemoryCardA.USA [2026-10-14_16-39-01].raw". */
|
|
char name[256];
|
|
str_copy(name, sizeof name, jget_str(pick, "file_name", ""));
|
|
char *open = strrchr(name, '['), *close = open ? strchr(open, ']') : NULL;
|
|
if (open && close && open > name && open[-1] == ' ') memmove(open - 1, close + 1, strlen(close + 1) + 1);
|
|
const char *form = gc_form_of_name(name);
|
|
char was[8];
|
|
gc_form_get(r, was, sizeof was, NULL, 0);
|
|
if (strcmp(was, form) != 0) LOGI("%s: RomM keeps its saves as %s (%s)", path_basename(r->path), form, name);
|
|
gc_form_set(r, form, name);
|
|
char cache[PATH_MAX_LEN];
|
|
gc_card_cache(r->rom_id, cache, sizeof cache);
|
|
if (strcmp(form, "card") != 0) unlink(cache); /* RomM keeps no card for it now */
|
|
if (!strcmp(form, "card") && !file_exists(cache) && gc_fetch((int)jget_num(pick, "id", 0), cache) == 0 &&
|
|
!gc_is_card_file(cache)) {
|
|
LOGW("%s: RomM's save %s isn't a GameCube memory card", path_basename(r->path), name);
|
|
unlink(cache);
|
|
}
|
|
}
|
|
cJSON_Delete(all);
|
|
}
|
|
|
|
/* .gci files taken off a card. */
|
|
typedef struct {
|
|
unsigned char *g[32];
|
|
size_t n[32];
|
|
int count;
|
|
} gc_list;
|
|
|
|
static void gc_keep(const unsigned char *g, size_t n, void *ud) {
|
|
gc_list *l = ud;
|
|
if (l->count >= 32 || !(l->g[l->count] = malloc(n))) return;
|
|
memcpy(l->g[l->count], g, n);
|
|
l->n[l->count++] = n;
|
|
}
|
|
|
|
static void gc_list_free(gc_list *l) {
|
|
for (int k = 0; k < l->count; k++) free(l->g[k]);
|
|
l->count = 0;
|
|
}
|
|
|
|
/* RomM's card with this console's saves for the game on it, in place of the
|
|
* card's own. Unchanged saves leave the card exactly as RomM has it. */
|
|
static int gc_virtual_card(int rom_id, const bundle *b, unsigned char **out, size_t *out_n) {
|
|
char cache[PATH_MAX_LEN];
|
|
gc_card_cache(rom_id, cache, sizeof cache);
|
|
size_t len = 0;
|
|
char *raw = read_file(cache, &len);
|
|
gc_card card;
|
|
if (!raw || gc_card_open(&card, (const unsigned char *)raw, len) != 0) {
|
|
free(raw);
|
|
return -1;
|
|
}
|
|
free(raw);
|
|
/* Same saves, same bytes (where each sits on the card aside)? */
|
|
gc_list on_card = {0};
|
|
gc_card_saves(&card, b->game_id, gc_keep, &on_card);
|
|
int same = on_card.count == b->n;
|
|
for (int i = 0; same && i < b->n; i++) {
|
|
size_t gl = 0;
|
|
char *g = read_file(b->files[i].path, &gl);
|
|
int match = 0;
|
|
for (int k = 0; g && k < on_card.count && !match; k++)
|
|
match = on_card.n[k] == gl && !memcmp(on_card.g[k], g, 0x36) &&
|
|
!memcmp(on_card.g[k] + 0x38, g + 0x38, gl - 0x38);
|
|
same = match;
|
|
free(g);
|
|
}
|
|
gc_list_free(&on_card);
|
|
int rc = 0;
|
|
if (!same) {
|
|
gc_card_remove(&card, b->game_id);
|
|
for (int i = 0; i < b->n && rc == 0; i++) {
|
|
size_t gl = 0;
|
|
char *g = read_file(b->files[i].path, &gl);
|
|
if (!g || gc_card_put(&card, (const unsigned char *)g, gl) != 0) {
|
|
LOGW("%s doesn't fit on RomM's memory card", b->files[i].name);
|
|
rc = -1;
|
|
}
|
|
free(g);
|
|
}
|
|
}
|
|
if (rc == 0) {
|
|
*out = card.data;
|
|
*out_n = card.size;
|
|
card.data = NULL;
|
|
}
|
|
gc_card_free(&card);
|
|
return rc;
|
|
}
|
|
|
|
/* The one .gci that goes up when RomM keeps a single one: the same save as
|
|
* RomM's, or the newest. */
|
|
static int gc_pick_gci(const bundle *b, const char *server_name, char *out, size_t n) {
|
|
time_t best = 0;
|
|
out[0] = 0;
|
|
for (int i = 0; i < b->n; i++)
|
|
if (server_name && !strcmp(b->files[i].name, server_name)) {
|
|
str_copy(out, n, b->files[i].path);
|
|
return 0;
|
|
}
|
|
for (int i = 0; i < b->n; i++) {
|
|
time_t t = file_mtime(b->files[i].path);
|
|
if (!out[0] || t > best) {
|
|
best = t;
|
|
str_copy(out, n, b->files[i].path);
|
|
}
|
|
}
|
|
return out[0] ? 0 : -1;
|
|
}
|
|
|
|
static void fill_bundle_file(local_file *f, const scan_ctx *c, const local_rom *r, const bundle *b) {
|
|
memset(f, 0, sizeof *f);
|
|
snprintf(f->path, sizeof f->path, "%s#%s", b->dir, b->game_id);
|
|
f->rom_id = r->rom_id;
|
|
f->map = r->map;
|
|
f->rom = r;
|
|
f->is_bundle = 1;
|
|
str_copy(f->game_id, sizeof f->game_id, b->game_id);
|
|
str_copy(f->slot, sizeof f->slot, c->slot);
|
|
f->mtime = b->mtime;
|
|
f->size = b->size;
|
|
bundle_hash(b, f->hash);
|
|
if (!strcmp(r->map->save_layout, "psp")) {
|
|
snprintf(f->upload_name, sizeof f->upload_name, "%s.zip", b->game_id);
|
|
} else if (!strcmp(r->map->save_layout, "3ds") || !strcmp(r->map->save_layout, "wiiu")) {
|
|
/* As RomM's save units name them: the title id's low half. */
|
|
snprintf(f->upload_name, sizeof f->upload_name, "%s.zip", strlen(b->game_id) == 16 ? b->game_id + 8 : b->game_id);
|
|
for (char *p = f->upload_name; *p; p++) *p = (char)tolower((unsigned char)*p);
|
|
} else {
|
|
char stem[200];
|
|
path_stem(stem, sizeof stem, r->path);
|
|
snprintf(f->upload_name, sizeof f->upload_name, "%s.zip", stem);
|
|
}
|
|
if (strcmp(r->map->save_layout, "gci") != 0) return;
|
|
/* In the form RomM has it: the hash RomM would give what we'd send. */
|
|
char form[8], name[256];
|
|
gc_form_get(r, form, sizeof form, name, sizeof name);
|
|
if (!strcmp(form, "gci") && gc_pick_gci(b, name, f->gc_file, sizeof f->gc_file) == 0) {
|
|
str_copy(f->gc_form, sizeof f->gc_form, "gci");
|
|
md5_file_hex(f->gc_file, f->hash);
|
|
str_copy(f->upload_name, sizeof f->upload_name, name[0] ? name : path_basename(f->gc_file));
|
|
} else if (!strcmp(form, "card")) {
|
|
unsigned char *card = NULL;
|
|
size_t n = 0;
|
|
str_copy(f->gc_form, sizeof f->gc_form, "card");
|
|
if (gc_virtual_card(r->rom_id, b, &card, &n) == 0) {
|
|
md5_hex(card, n, f->hash);
|
|
free(card);
|
|
} else {
|
|
f->hash[0] = 0; /* no card to put them on: not sent (scan_bundles) */
|
|
}
|
|
str_copy(f->upload_name, sizeof f->upload_name, name[0] ? name : "MemoryCardA.raw");
|
|
}
|
|
}
|
|
|
|
static void scan_bundles(scan_ctx *c) {
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
const local_rom *r = &c->roms[i];
|
|
if (!r->rules.sync_saves || !bundle_supported(r->map->save_layout)) continue;
|
|
char id[24];
|
|
bundle b;
|
|
if (rom_game_id(r, id, sizeof id) != 0 || bundle_for_rom(r, id, &b) != 0) continue;
|
|
state_lock();
|
|
cJSON_AddItemToArray(g_bundle_roots, cJSON_CreateString(b.dir));
|
|
state_unlock();
|
|
if (!strcmp(r->map->save_layout, "gci")) gc_learn_form(c, r);
|
|
if (b.n > 0 && !find_save(c, r->rom_id, c->slot)) {
|
|
local_file f;
|
|
fill_bundle_file(&f, c, r, &b);
|
|
if (f.hash[0]) add_file(&c->saves, &c->nsaves, &c->capsaves, &f);
|
|
else LOGW("%s: its saves can't go on RomM's memory card yet", path_basename(r->path));
|
|
}
|
|
bundle_free(&b);
|
|
}
|
|
}
|
|
|
|
/* A GameCube save from RomM that's a memory card or a single .gci: the
|
|
* game's saves on it replace the game's .gci files here. 1 when placed, 0
|
|
* when it's neither (a zip), -1 when it can't be used. The bundle is listed
|
|
* again. */
|
|
static int gc_place(const local_rom *r, bundle *b, const char *file) {
|
|
size_t len = 0;
|
|
char *data = read_file(file, &len);
|
|
if (!data) return -1;
|
|
const unsigned char *u = (const unsigned char *)data;
|
|
gc_card card;
|
|
int rc = 0;
|
|
if (gc_card_open(&card, u, len) == 0) {
|
|
gc_list on_card = {0};
|
|
gc_card_saves(&card, b->game_id, gc_keep, &on_card);
|
|
if (on_card.count) {
|
|
for (int i = 0; i < b->n; i++) unlink(b->files[i].path);
|
|
mkdir_p(b->dir);
|
|
} else {
|
|
LOGI("RomM's memory card has no saves for %s: keeping this console's", b->game_id);
|
|
}
|
|
for (int k = 0; k < on_card.count; k++) {
|
|
char name[256], path[PATH_MAX_LEN];
|
|
gc_gci_name(on_card.g[k], name, sizeof name);
|
|
path_join(path, sizeof path, b->dir, name);
|
|
if (write_file_atomic(path, on_card.g[k], on_card.n[k]) != 0) rc = -1;
|
|
}
|
|
gc_list_free(&on_card);
|
|
gc_card_free(&card);
|
|
char cache[PATH_MAX_LEN];
|
|
gc_card_cache(r->rom_id, cache, sizeof cache);
|
|
mkdir_parent(cache);
|
|
if (rc == 0) rc = copy_file(file, cache) == 0 ? 1 : -1;
|
|
if (rc > 0) gc_form_set(r, "card", NULL);
|
|
} else if (gc_gci_valid(u, len)) {
|
|
/* Over the same save here, or beside the others. */
|
|
char path[PATH_MAX_LEN] = "";
|
|
for (int i = 0; i < b->n && !path[0]; i++) {
|
|
size_t gl = 0;
|
|
char *g = read_file(b->files[i].path, &gl);
|
|
if (g && gl >= GC_DENTRY && gc_gci_same_save((const unsigned char *)g, u)) str_copy(path, sizeof path, b->files[i].path);
|
|
free(g);
|
|
}
|
|
if (!path[0]) {
|
|
char name[256];
|
|
gc_gci_name(u, name, sizeof name);
|
|
mkdir_p(b->dir);
|
|
path_join(path, sizeof path, b->dir, name);
|
|
}
|
|
rc = write_file_atomic(path, u, len) == 0 ? 1 : -1;
|
|
if (rc > 0) gc_form_set(r, "gci", NULL);
|
|
}
|
|
free(data);
|
|
if (rc > 0) {
|
|
char id[24];
|
|
str_copy(id, sizeof id, b->game_id);
|
|
bundle_free(b);
|
|
if (bundle_for_rom(r, id, b) != 0) rc = -1;
|
|
}
|
|
return rc;
|
|
}
|
|
|
|
/* Downloads a save zip and unpacks it over the game's current files. With
|
|
* record set, the result counts as synced; a manual restore leaves it to upload. */
|
|
static int do_download_bundle(const scan_ctx *c, int save_id, const local_rom *r, const char *slot, int session_id,
|
|
const char *server_hash, int record) {
|
|
char id[24], api[256], zip[PATH_MAX_LEN], tmpdir[PATH_MAX_LEN];
|
|
bundle b;
|
|
if (rom_game_id(r, id, sizeof id) != 0 || bundle_for_rom(r, id, &b) != 0) {
|
|
LOGE("no game id for %s, cannot place its save", path_basename(r->path));
|
|
return -1;
|
|
}
|
|
char *dev = url_q(c->device_id);
|
|
if (record) {
|
|
/* optimistic=false: RomM counts the save as on this console once
|
|
* /downloaded confirms it's written, not as soon as it's served. */
|
|
int o = snprintf(api, sizeof api, "/api/saves/%d/content?device_id=%s&optimistic=false", save_id, dev);
|
|
if (session_id) snprintf(api + o, sizeof api - (size_t)o, "&session_id=%d", session_id);
|
|
} else {
|
|
snprintf(api, sizeof api, "/api/saves/%d/content", save_id);
|
|
}
|
|
free(dev);
|
|
snprintf(zip, sizeof zip, "%s/tmp/save-%d.zip", plat_data_dir(), save_id);
|
|
snprintf(tmpdir, sizeof tmpdir, "%s/tmp/unpack-%d", plat_data_dir(), save_id);
|
|
http_resp resp;
|
|
int rc = -1;
|
|
if (romm_download(api, zip, NULL, NULL, &resp) != 0) {
|
|
LOGE("download of save %d failed: %s", save_id, resp.error);
|
|
goto out;
|
|
}
|
|
if (b.n > 0) { /* keep a copy of what's there now */
|
|
char cur[PATH_MAX_LEN];
|
|
snprintf(cur, sizeof cur, "%s/tmp/%s.zip", plat_data_dir(), id);
|
|
if (bundle_zip(&b, cur) == 0) backup_file(c, cur, r->rom_id);
|
|
unlink(cur);
|
|
}
|
|
int placed = !strcmp(r->map->save_layout, "gci") ? gc_place(r, &b, zip) : 0;
|
|
if (placed < 0) goto out;
|
|
if (!placed && bundle_replace(&b, zip, tmpdir) != 0) {
|
|
LOGE("save %d is not a usable zip", save_id);
|
|
goto out;
|
|
}
|
|
rc = 0;
|
|
LOGI("downloaded save %d into %s", save_id, b.dir);
|
|
if (record) {
|
|
local_file f;
|
|
fill_bundle_file(&f, c, r, &b);
|
|
str_copy(f.slot, sizeof f.slot, slot);
|
|
record_save(&f, save_id, f.hash);
|
|
cJSON *body = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(body, "device_id", c->device_id);
|
|
cJSON_AddStringToObject(body, "content_hash", server_hash && server_hash[0] ? server_hash : f.hash);
|
|
snprintf(api, sizeof api, "/api/saves/%d/downloaded", save_id);
|
|
long st = 0;
|
|
cJSON_Delete(romm_call("POST", api, body, &st));
|
|
if (st != 200) LOGW("RomM didn't take the confirmation for save %d (HTTP %ld); it may come down again", save_id, st);
|
|
cJSON_Delete(body);
|
|
}
|
|
out:
|
|
unlink(zip);
|
|
bundle_free(&b);
|
|
return rc;
|
|
}
|
|
|
|
static int do_upload(const scan_ctx *c, const local_file *f, int session_id, int overwrite,
|
|
long *status_out) {
|
|
char path[1024];
|
|
char *slot = url_q(f->slot), *emu = url_q(f->map->emulator), *dev = url_q(c->device_id);
|
|
int o = snprintf(path, sizeof path, "/api/saves?rom_id=%d&slot=%s&emulator=%s&device_id=%s&overwrite=%s",
|
|
f->rom_id, slot, emu, dev, overwrite ? "true" : "false");
|
|
if (session_id) o += snprintf(path + o, sizeof path - (size_t)o, "&session_id=%d", session_id);
|
|
if (c->server_versions > 0)
|
|
snprintf(path + o, sizeof path - (size_t)o, "&autocleanup=true&autocleanup_limit=%d", c->server_versions);
|
|
free(slot);
|
|
free(emu);
|
|
free(dev);
|
|
long st = 0;
|
|
cJSON *j;
|
|
char card_up[PATH_MAX_LEN] = "";
|
|
if (!strcmp(f->gc_form, "gci")) {
|
|
j = romm_upload("POST", path, "saveFile", f->gc_file, f->upload_name, &st);
|
|
} else if (!strcmp(f->gc_form, "card")) {
|
|
/* RomM's card with this console's saves for the game on it. */
|
|
bundle b;
|
|
unsigned char *card = NULL;
|
|
size_t n = 0;
|
|
snprintf(card_up, sizeof card_up, "%s/tmp/upload-%d.raw", plat_data_dir(), f->rom_id);
|
|
if (bundle_reopen(f, &b) != 0 || gc_virtual_card(f->rom_id, &b, &card, &n) != 0 ||
|
|
write_file_atomic(card_up, card, n) != 0) {
|
|
bundle_free(&b);
|
|
free(card);
|
|
if (status_out) *status_out = 0;
|
|
return -1;
|
|
}
|
|
bundle_free(&b);
|
|
free(card);
|
|
j = romm_upload("POST", path, "saveFile", card_up, f->upload_name, &st);
|
|
} else if (f->is_bundle) {
|
|
char zip[PATH_MAX_LEN];
|
|
bundle b;
|
|
snprintf(zip, sizeof zip, "%s/tmp/upload-%d.zip", plat_data_dir(), f->rom_id);
|
|
if (bundle_reopen(f, &b) != 0 || bundle_zip(&b, zip) != 0) {
|
|
bundle_free(&b);
|
|
if (status_out) *status_out = 0;
|
|
return -1;
|
|
}
|
|
bundle_free(&b);
|
|
j = romm_upload("POST", path, "saveFile", zip, f->upload_name, &st);
|
|
unlink(zip);
|
|
} else {
|
|
j = romm_upload("POST", path, "saveFile", f->path, path_basename(f->path), &st);
|
|
}
|
|
if (status_out) *status_out = st;
|
|
int ok = (st == 200 || st == 201) && j;
|
|
if (card_up[0]) { /* the card RomM has now is the one kept here */
|
|
char cache[PATH_MAX_LEN];
|
|
gc_card_cache(f->rom_id, cache, sizeof cache);
|
|
if (ok) move_file(card_up, cache);
|
|
else unlink(card_up);
|
|
}
|
|
if (ok) {
|
|
record_save(f, (int)jget_num(j, "id", 0), jget_str(j, "content_hash", f->hash));
|
|
LOGI("uploaded %s (rom %d, slot %s)", f->path, f->rom_id, f->slot);
|
|
}
|
|
cJSON_Delete(j);
|
|
return ok ? 0 : -1;
|
|
}
|
|
|
|
/* Where a downloaded save goes when the game has no local save yet. */
|
|
static void active_core(const profile_map *m, char *out, size_t n);
|
|
|
|
/* The save name the emulator uses with the core it runs now. */
|
|
static void save_name_now(const profile_map *m, char *out, size_t n) {
|
|
str_copy(out, n, m->save_name);
|
|
if (!m->save_names_by_core[0]) return;
|
|
char core[64], list[256], *save = NULL;
|
|
active_core(m, core, sizeof core);
|
|
str_copy(list, sizeof list, m->save_names_by_core);
|
|
for (char *t = strtok_r(list, ";", &save); t; t = strtok_r(NULL, ";", &save)) {
|
|
char *eq = strchr(t, '=');
|
|
if (!eq) continue;
|
|
*eq = 0;
|
|
if (!strcmp(t, core)) str_copy(out, n, eq + 1);
|
|
}
|
|
}
|
|
|
|
static void download_target(const local_rom *r, const char *server_name, const char *slot,
|
|
const scan_ctx *c, char *out, size_t n) {
|
|
char ext[32];
|
|
/* "autosave-rtc" holds a .rtc file */
|
|
size_t sl = strlen(c->slot);
|
|
if (!strncmp(slot, c->slot, sl) && slot[sl] == '-')
|
|
snprintf(ext, sizeof ext, ".%s", slot + sl + 1);
|
|
else {
|
|
char first[32];
|
|
str_copy(first, sizeof first, r->map->save_exts);
|
|
char *comma = strchr(first, ',');
|
|
if (comma) *comma = 0;
|
|
str_copy(ext, sizeof ext, first[0] ? first : path_ext(server_name));
|
|
}
|
|
const char *vars[15];
|
|
name_vars(r, ext, vars);
|
|
char name[512], dir[PATH_MAX_LEN], tmpl[128];
|
|
save_name_now(r->map, tmpl, sizeof tmpl);
|
|
str_template(name, sizeof name, tmpl, vars);
|
|
/* A name from a value this game lacks (no serial read) falls back to its own. */
|
|
if (!name[0] || name[0] == '_' || name[0] == '.' || strchr(name, '{'))
|
|
str_template(name, sizeof name, "{rom_stem}{ext}", vars);
|
|
if (!expected_dir(r, 0, dir, sizeof dir)) str_copy(dir, sizeof dir, r->map->save_dir);
|
|
path_join(out, n, dir, name);
|
|
}
|
|
|
|
/* Returns 0, -1 when it failed, or -2 when it would put a blank PS1 card over
|
|
* one with saves on it (only with guard; the user choosing RomM's copy isn't). */
|
|
static int do_download(const scan_ctx *c, int save_id, const char *dest, const local_file *f,
|
|
int rom_id, const char *slot, int session_id, const char *server_hash,
|
|
const char *server_updated_at, int guard) {
|
|
char path[512], tmp[PATH_MAX_LEN];
|
|
char *dev = url_q(c->device_id);
|
|
int o = snprintf(path, sizeof path, "/api/saves/%d/content?device_id=%s&optimistic=false", save_id, dev);
|
|
if (session_id) snprintf(path + o, sizeof path - (size_t)o, "&session_id=%d", session_id);
|
|
free(dev);
|
|
snprintf(tmp, sizeof tmp, "%s/tmp/save-%d.bin", plat_data_dir(), save_id);
|
|
http_resp r;
|
|
if (romm_download(path, tmp, NULL, NULL, &r) != 0) {
|
|
LOGE("download of save %d failed: %s", save_id, r.error);
|
|
unlink(tmp);
|
|
return -1;
|
|
}
|
|
if (guard && f && psx_card_saves(f->map, tmp) == 0 && psx_card_saves(f->map, dest) > 0) {
|
|
LOGW("save %d is a blank memory card and %s has saves on it: not replacing it", save_id, dest);
|
|
unlink(tmp);
|
|
return -2;
|
|
}
|
|
backup_file(c, dest, rom_id);
|
|
if (move_file(tmp, dest) != 0) {
|
|
LOGE("cannot write %s", dest);
|
|
return -1;
|
|
}
|
|
time_t t = iso8601_parse(server_updated_at);
|
|
if (t > 0) set_file_mtime(dest, t);
|
|
|
|
local_file rec;
|
|
memset(&rec, 0, sizeof rec);
|
|
if (f) rec = *f;
|
|
str_copy(rec.path, sizeof rec.path, dest);
|
|
rec.rom_id = rom_id;
|
|
str_copy(rec.slot, sizeof rec.slot, slot);
|
|
char hash[33] = "";
|
|
md5_file_hex(dest, hash);
|
|
str_copy(rec.hash, sizeof rec.hash, hash);
|
|
record_save(&rec, save_id, hash);
|
|
|
|
cJSON *body = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(body, "device_id", c->device_id);
|
|
cJSON_AddStringToObject(body, "content_hash", server_hash && server_hash[0] ? server_hash : hash);
|
|
snprintf(path, sizeof path, "/api/saves/%d/downloaded", save_id);
|
|
long st = 0;
|
|
cJSON_Delete(romm_call("POST", path, body, &st));
|
|
if (st != 200) LOGW("RomM didn't take the confirmation for save %d (HTTP %ld); it may come down again", save_id, st);
|
|
cJSON_Delete(body);
|
|
LOGI("downloaded save %d -> %s", save_id, dest);
|
|
return 0;
|
|
}
|
|
|
|
static const local_file *find_save(const scan_ctx *c, int rom_id, const char *slot) {
|
|
for (int i = 0; i < c->nsaves; i++)
|
|
if (c->saves[i].rom_id == rom_id && !strcmp(c->saves[i].slot, slot ? slot : "")) return &c->saves[i];
|
|
return NULL;
|
|
}
|
|
|
|
static const local_rom *find_rom(const scan_ctx *c, int rom_id, const char *emulator) {
|
|
const local_rom *any = NULL;
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
const local_rom *r = &c->roms[i];
|
|
if (r->rom_id != rom_id || !r->rules.sync_saves) continue;
|
|
if (emulator && str_ieq(r->map->emulator, emulator)) return r;
|
|
if (!any) any = r;
|
|
}
|
|
return any;
|
|
}
|
|
|
|
/* The core the emulator's log says it started last (the last "log_line"
|
|
* in it), by "log_names"; 0 when it says none of them. */
|
|
static int logged_core(const profile_map *m, char *out, size_t n) {
|
|
if (!m->core_log[0] || !m->core_log_line[0]) return 0;
|
|
char *txt = read_file(m->core_log, NULL), name[64] = "";
|
|
for (char *l = txt ? strstr(txt, m->core_log_line) : NULL; l; l = strstr(l + 1, m->core_log_line)) {
|
|
size_t k = 0;
|
|
for (const char *p = l + strlen(m->core_log_line); *p && *p != '\n' && *p != '\r' && k + 1 < sizeof name; p++)
|
|
name[k++] = (char)tolower((unsigned char)*p);
|
|
name[k] = 0;
|
|
}
|
|
free(txt);
|
|
char list[256], *save = NULL;
|
|
str_copy(list, sizeof list, m->core_log_names);
|
|
for (char *t = strtok_r(list, ",", &save); t && name[0]; t = strtok_r(NULL, ",", &save)) {
|
|
char *eq = strchr(t, '=');
|
|
if (!eq) continue;
|
|
*eq = 0;
|
|
for (char *c = t; *c; c++) *c = (char)tolower((unsigned char)*c);
|
|
if (strstr(name, t)) {
|
|
str_copy(out, n, eq + 1);
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* The core an emulator runs now, from its own settings file
|
|
* ("state_core_setting"); the first listed value when the file says nothing.
|
|
* Left to the emulator ("auto", or nothing set), its log says which. */
|
|
static void active_core(const profile_map *m, char *out, size_t n) {
|
|
out[0] = 0;
|
|
char value[32] = "";
|
|
char *txt = m->core_key[0] ? read_file(m->core_file, NULL) : NULL;
|
|
for (char *l = txt; l && *l;) {
|
|
size_t kl = strlen(m->core_key);
|
|
if (!strncmp(l, m->core_key, kl) && l[kl] == '=') {
|
|
size_t v = 0;
|
|
for (l += kl + 1; *l && *l != '\n' && *l != '\r' && v + 1 < sizeof value; l++) value[v++] = *l;
|
|
value[v] = 0;
|
|
break;
|
|
}
|
|
l = strchr(l, '\n');
|
|
if (l) l++;
|
|
}
|
|
free(txt);
|
|
if (m->core_auto[0] && (!value[0] || !strcmp(value, m->core_auto)) && logged_core(m, out, n)) return;
|
|
char list[256];
|
|
str_copy(list, sizeof list, m->core_values);
|
|
char *save = NULL;
|
|
for (char *t = strtok_r(list, ",", &save); t; t = strtok_r(NULL, ",", &save)) {
|
|
char *eq = strchr(t, '=');
|
|
if (!eq) continue;
|
|
*eq = 0;
|
|
if (!out[0] || !strcmp(t, value)) str_copy(out, n, eq + 1);
|
|
if (!strcmp(t, value)) break;
|
|
}
|
|
}
|
|
|
|
/* Whether an emulator can load a state RomM says `emulator` made. */
|
|
static int map_takes_state(const profile_map *m, const char *emulator) {
|
|
char want[64], key[64];
|
|
emulator_key(emulator ? emulator : "", want, sizeof want);
|
|
if (!want[0]) return 0;
|
|
if (m->core_values[0]) {
|
|
char core[64];
|
|
active_core(m, core, sizeof core);
|
|
emulator_key(core, key, sizeof key);
|
|
if (strcmp(key, want) != 0) return 0;
|
|
}
|
|
char list[256];
|
|
str_copy(list, sizeof list, m->state_cores);
|
|
char *save = NULL;
|
|
for (char *t = strtok_r(list, ",", &save); t; t = strtok_r(NULL, ",", &save)) {
|
|
emulator_key(t, key, sizeof key);
|
|
if (!strcmp(key, want)) return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* The local copy of a game whose emulator can load a state from `emulator`. */
|
|
static const local_rom *find_rom_for_state(const scan_ctx *c, int rom_id, const char *emulator) {
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
const local_rom *r = &c->roms[i];
|
|
if (r->rom_id == rom_id && r->rules.sync_saves && r->map->sync_states && map_takes_state(r->map, emulator)) return r;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* The emulator as the log names it: "RetroArch (mGBA)", "PSXS5". */
|
|
static const char *emu_name(const profile_map *m) {
|
|
static __thread char out[160];
|
|
if (m->core_name[0]) snprintf(out, sizeof out, "%s (%s)", m->profile_name, m->core_name);
|
|
else str_copy(out, sizeof out, m->profile_name);
|
|
return out;
|
|
}
|
|
|
|
/* Saves from before RomM's sync slots (no slot), or kept in another device's
|
|
* slot: this console's sync never brings them.
|
|
* A game here with no save of its own gets the newest one, and from then on
|
|
* syncs it in this console's slot. Never over a file that's there. */
|
|
static int fetch_slotless_saves(scan_ctx *c, int only_rom) {
|
|
int want = 0;
|
|
for (int i = 0; i < c->nroms && !want; i++) {
|
|
const local_rom *r = &c->roms[i];
|
|
want = r->rules.sync_saves && !r->map->save_layout[0] && (!only_rom || r->rom_id == only_rom) &&
|
|
!find_save(c, r->rom_id, c->slot);
|
|
}
|
|
if (!want) return 0;
|
|
long st = 0;
|
|
char api[64] = "/api/saves";
|
|
if (only_rom) snprintf(api, sizeof api, "/api/saves?rom_id=%d", only_rom);
|
|
cJSON *all = romm_call("GET", api, NULL, &st);
|
|
int n = 0;
|
|
for (int i = 0; st == 200 && i < c->nroms; i++) {
|
|
const local_rom *r = &c->roms[i];
|
|
if (!r->rules.sync_saves || r->map->save_layout[0] || (only_rom && r->rom_id != only_rom) ||
|
|
find_save(c, r->rom_id, c->slot))
|
|
continue;
|
|
int done_before = 0; /* another emulator's copy of the game took it */
|
|
for (int k = 0; k < i; k++) done_before |= c->roms[k].rom_id == r->rom_id;
|
|
if (done_before) continue;
|
|
/* The newest of each kind: the save, and files beside it such as a
|
|
* real-time clock (.rtc), each in the slot it would sync in. */
|
|
struct { char slot[64]; const cJSON *s; } picks[4];
|
|
int np = 0, slotted = 0;
|
|
const cJSON *s;
|
|
cJSON_ArrayForEach(s, all) {
|
|
if ((int)jget_num(s, "rom_id", 0) != r->rom_id) continue;
|
|
if (our_slot(c, jget_str(s, "slot", ""))) { /* the sync's own */
|
|
slotted = 1;
|
|
continue;
|
|
}
|
|
const char *name = jget_str(s, "file_name", "");
|
|
if (!ext_in_list(name, r->map->save_exts)) continue;
|
|
char sl[64];
|
|
slot_for(c, r->map, name, sl, sizeof sl);
|
|
int at = -1;
|
|
for (int k = 0; k < np; k++)
|
|
if (!strcmp(picks[k].slot, sl)) at = k;
|
|
if (at < 0 && np < 4) {
|
|
at = np++;
|
|
str_copy(picks[at].slot, sizeof picks[at].slot, sl);
|
|
picks[at].s = s;
|
|
} else if (at >= 0 && strcmp(jget_str(s, "updated_at", ""), jget_str(picks[at].s, "updated_at", "")) > 0) {
|
|
picks[at].s = s;
|
|
}
|
|
}
|
|
if (slotted) continue; /* the sync's own job */
|
|
for (int k = 0; k < np; k++) {
|
|
if (find_save(c, r->rom_id, picks[k].slot)) continue;
|
|
char dest[PATH_MAX_LEN];
|
|
download_target(r, jget_str(picks[k].s, "file_name", ""), picks[k].slot, c, dest, sizeof dest);
|
|
if (file_exists(dest)) continue;
|
|
mkdir_parent(dest);
|
|
if (do_download(c, (int)jget_num(picks[k].s, "id", 0), dest, NULL, r->rom_id, picks[k].slot, 0, "",
|
|
jget_str(picks[k].s, "updated_at", ""), 0) == 0) {
|
|
n++;
|
|
events_add("save", "downloaded", r->rom_id, dest, emu_name(r->map),
|
|
jget_str(picks[k].s, "slot", "")[0] ? "From another device" : "Older save with no slot");
|
|
}
|
|
}
|
|
}
|
|
cJSON_Delete(all);
|
|
return n;
|
|
}
|
|
|
|
/* Saves synced before whose file is gone from this console (deleted, or lost
|
|
* with the emulator): RomM sees nothing new, so negotiate never offers them.
|
|
* The newest version of that slot comes back to where it was. To get rid of a
|
|
* save for good, delete it on RomM too. */
|
|
static int restore_missing_saves(scan_ctx *c, int only_rom) {
|
|
typedef struct { char path[PATH_MAX_LEN]; char slot[64]; int rom_id, save_id; } missing;
|
|
missing *list = NULL;
|
|
int nlist = 0, cap = 0;
|
|
state_lock();
|
|
const cJSON *e;
|
|
cJSON_ArrayForEach(e, state_section("saves")) {
|
|
const char *path = e->string;
|
|
int rom_id = (int)jget_num(e, "rom_id", 0);
|
|
if (!rom_id || (only_rom && rom_id != only_rom) || strchr(path, '#') || file_exists(path)) continue;
|
|
if (nlist == cap) {
|
|
cap = cap ? cap * 2 : 16;
|
|
missing *grown = realloc(list, (size_t)cap * sizeof *list);
|
|
if (!grown) break;
|
|
list = grown;
|
|
}
|
|
str_copy(list[nlist].path, sizeof list[nlist].path, path);
|
|
str_copy(list[nlist].slot, sizeof list[nlist].slot, jget_str(e, "slot", ""));
|
|
list[nlist].save_id = (int)jget_num(e, "save_id", 0);
|
|
list[nlist++].rom_id = rom_id;
|
|
}
|
|
state_unlock();
|
|
int n = 0;
|
|
for (int i = 0; i < nlist; i++) {
|
|
const missing *m = &list[i];
|
|
const local_rom *r = NULL;
|
|
for (int k = 0; k < c->nroms && !r; k++)
|
|
if (c->roms[k].rom_id == m->rom_id && c->roms[k].rules.sync_saves && !c->roms[k].map->save_layout[0])
|
|
r = &c->roms[k];
|
|
if (!r || find_save(c, m->rom_id, m->slot) || file_exists(m->path)) continue; /* the game's gone, or it has a save */
|
|
int done_before = 0; /* one file per slot: another name of the same save came back already */
|
|
for (int k = 0; k < i; k++) done_before |= list[k].rom_id == m->rom_id && !strcmp(list[k].slot, m->slot) && file_exists(list[k].path);
|
|
if (done_before) continue;
|
|
char api[64];
|
|
snprintf(api, sizeof api, "/api/saves?rom_id=%d", m->rom_id);
|
|
long st = 0;
|
|
cJSON *all = romm_call("GET", api, NULL, &st);
|
|
const cJSON *s, *best = NULL;
|
|
cJSON_ArrayForEach(s, (st == 200 ? all : NULL))
|
|
if ((int)jget_num(s, "rom_id", 0) == m->rom_id && !strcmp(jget_str(s, "slot", ""), m->slot) &&
|
|
(!best || strcmp(jget_str(s, "updated_at", ""), jget_str(best, "updated_at", "")) > 0))
|
|
best = s;
|
|
/* Two names for one slot (a PS1 card by serial and by file name): the
|
|
* one that had RomM's newest version gets it. */
|
|
int other = 0;
|
|
const int best_id = best ? (int)jget_num(best, "id", 0) : 0;
|
|
for (int k = i + 1; best && m->save_id != best_id && k < nlist; k++)
|
|
other |= list[k].rom_id == m->rom_id && !strcmp(list[k].slot, m->slot) && list[k].save_id == best_id;
|
|
if (best && !other) {
|
|
mkdir_parent(m->path);
|
|
if (do_download(c, (int)jget_num(best, "id", 0), m->path, NULL, m->rom_id, m->slot, 0,
|
|
jget_str(best, "content_hash", ""), jget_str(best, "updated_at", ""), 0) == 0) {
|
|
n++;
|
|
events_add("save", "downloaded", m->rom_id, m->path, emu_name(r->map), "Missing here");
|
|
}
|
|
}
|
|
cJSON_Delete(all);
|
|
}
|
|
free(list);
|
|
return n;
|
|
}
|
|
|
|
/* A game whose saves are a folder (GameCube, PSP, Wii) with none of them on
|
|
* this console while RomM has some: a new emulator, or files deleted. RomM's
|
|
* newest in this console's slot (or else anywhere) comes down. */
|
|
static int restore_missing_bundles(scan_ctx *c, int only_rom) {
|
|
int n = 0;
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
const local_rom *r = &c->roms[i];
|
|
if (!r->rules.sync_saves || !bundle_supported(r->map->save_layout) || (only_rom && r->rom_id != only_rom)) continue;
|
|
int done_before = 0; /* another emulator's copy of the game */
|
|
for (int k = 0; k < i; k++) done_before |= c->roms[k].rom_id == r->rom_id && c->roms[k].rules.sync_saves && bundle_supported(c->roms[k].map->save_layout);
|
|
char id[24];
|
|
bundle b = {0};
|
|
if (done_before || rom_game_id(r, id, sizeof id) != 0 || bundle_for_rom(r, id, &b) != 0) continue;
|
|
const int empty = b.n == 0;
|
|
bundle_free(&b);
|
|
if (!empty) continue;
|
|
char api[64];
|
|
snprintf(api, sizeof api, "/api/saves?rom_id=%d", r->rom_id);
|
|
long st = 0;
|
|
cJSON *all = romm_call("GET", api, NULL, &st);
|
|
const cJSON *s, *mine = NULL, *any = NULL;
|
|
cJSON_ArrayForEach(s, (st == 200 ? all : NULL)) {
|
|
if ((int)jget_num(s, "rom_id", 0) != r->rom_id) continue;
|
|
const char *up = jget_str(s, "updated_at", "");
|
|
if (!any || strcmp(up, jget_str(any, "updated_at", "")) > 0) any = s;
|
|
if (!strcmp(jget_str(s, "slot", ""), c->slot) && (!mine || strcmp(up, jget_str(mine, "updated_at", "")) > 0))
|
|
mine = s;
|
|
}
|
|
const cJSON *pick = mine ? mine : any;
|
|
if (pick && do_download_bundle(c, (int)jget_num(pick, "id", 0), r, c->slot, 0, jget_str(pick, "content_hash", ""), 1) == 0) {
|
|
n++;
|
|
events_add("save", "downloaded", r->rom_id, b.dir, emu_name(r->map), "Missing here");
|
|
}
|
|
cJSON_Delete(all);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
static void record_conflict(const local_file *f, const cJSON *op) {
|
|
state_lock();
|
|
int is_new = state_entry("conflicts", f->path) == NULL;
|
|
cJSON *e = state_entry_ensure("conflicts", f->path);
|
|
jset_num(e, "rom_id", f->rom_id);
|
|
jset_num(e, "save_id", jget_num(op, "save_id", 0));
|
|
jset_str(e, "slot", f->slot);
|
|
jset_str(e, "reason", jget_str(op, "reason", ""));
|
|
jset_str(e, "server_updated_at", jget_str(op, "server_updated_at", ""));
|
|
jset_str(e, "server_hash", jget_str(op, "server_content_hash", ""));
|
|
jset_num(e, "local_mtime", (double)f->mtime);
|
|
jset_num(e, "detected_at", (double)time(NULL));
|
|
state_unlock();
|
|
if (is_new) {
|
|
LOGW("conflict for %s: %s", f->path, jget_str(op, "reason", ""));
|
|
events_add("save", "conflict", f->rom_id, f->path, emu_name(f->map), "Changed in both places");
|
|
}
|
|
}
|
|
|
|
|
|
/* Server states already brought down or sent up, by id, with the updated_at
|
|
* we saw. Caller holds state_lock. */
|
|
static int same_time(const char *a, const char *b) {
|
|
time_t ta = iso8601_parse(a), tb = iso8601_parse(b);
|
|
return ta > 0 ? ta == tb : !strcmp(a, b);
|
|
}
|
|
|
|
static void mark_seen(int id, const char *updated_at) {
|
|
char key[16];
|
|
snprintf(key, sizeof key, "%d", id);
|
|
cJSON *seen = state_section("states_seen");
|
|
cJSON_DeleteItemFromObjectCaseSensitive(seen, key);
|
|
cJSON_AddStringToObject(seen, key, updated_at);
|
|
}
|
|
|
|
static int was_seen(int id, const char *updated_at) {
|
|
char key[16];
|
|
snprintf(key, sizeof key, "%d", id);
|
|
const char *at = jget_str(state_section("states_seen"), key, NULL);
|
|
return at && same_time(at, updated_at);
|
|
}
|
|
|
|
/* The local file holding server state id, if we have one. */
|
|
static cJSON *state_record_for(int id, char *path, size_t n) {
|
|
cJSON *e;
|
|
cJSON_ArrayForEach(e, state_section("states")) {
|
|
if ((int)jget_num(e, "state_id", 0) == id) {
|
|
str_copy(path, n, e->string);
|
|
return e;
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* Name a state goes up under: the local name with this console's name in it,
|
|
* so other devices' states (and RomM, which replaces a state of the same
|
|
* name) never meet it. "Game.state1" -> "Game [PlayStation 5].state1". */
|
|
static void state_upload_name(const char *path, char *out, size_t n) {
|
|
char device[64];
|
|
config_lock();
|
|
str_copy(device, sizeof device, g_cfg.device_name[0] ? g_cfg.device_name : "RommPS");
|
|
config_unlock();
|
|
const char *base = path_basename(path), *ext = path_ext(base);
|
|
snprintf(out, n, "%.*s [%s]%s", (int)(ext - base), base, device, ext);
|
|
}
|
|
|
|
/* A local state as RomM's web player loads it, in a temp file: RetroArch's
|
|
* container, uncompressed. *core becomes the core a bare state comes from
|
|
* when its signature says (PSXS5 runs several). */
|
|
static int state_for_romm(const char *path, char *tmp, size_t n, const char **core) {
|
|
static int seq;
|
|
snprintf(tmp, n, "%s/tmp/state-up-%d.bin", plat_data_dir(), __sync_add_and_fetch(&seq, 1));
|
|
/* File to file, never whole in memory: states run to tens of MB (a
|
|
* GameCube one), more than the PS4's payload can hold. */
|
|
unsigned char head[64] = {0};
|
|
FILE *f = fopen(path, "rb");
|
|
size_t got = f ? fread(head, 1, sizeof head, f) : 0;
|
|
if (f) fclose(f);
|
|
state_kind kind = state_kind_of(head, got);
|
|
if (kind == STATE_RAW) {
|
|
const char *sig = state_core_of(head, got); /* a core's signature opens its state */
|
|
if (sig && core) *core = sig;
|
|
}
|
|
int rc = kind == STATE_RZIP ? state_unrzip_file(path, tmp)
|
|
: kind == STATE_RASTATE ? copy_file(path, tmp)
|
|
: state_wrap_file(path, tmp);
|
|
if (rc) LOGW("can't read the state %s", path);
|
|
return rc ? -1 : 0;
|
|
}
|
|
|
|
static int sync_states_up(scan_ctx *c, int only_rom) {
|
|
int uploaded = 0;
|
|
for (int i = 0; i < c->nstates; i++) {
|
|
local_file *f = &c->states[i];
|
|
if (only_rom && f->rom_id != only_rom) continue;
|
|
if (file_mtime_ns(f->path) != f->mtime_ns) { /* replaced by the download pass */
|
|
f->mtime = file_mtime(f->path);
|
|
f->mtime_ns = file_mtime_ns(f->path);
|
|
f->size = file_size(f->path);
|
|
md5_file_hex(f->path, f->hash);
|
|
}
|
|
state_lock();
|
|
cJSON *e = state_entry("states", f->path);
|
|
int same = e && !strcmp(jget_str(e, "hash", ""), f->hash);
|
|
int state_id = e ? (int)jget_num(e, "state_id", 0) : 0;
|
|
int mine = e && jget_num(e, "mine", 0);
|
|
state_unlock();
|
|
if (same) continue;
|
|
|
|
char path[512];
|
|
long st = 0;
|
|
cJSON *j = NULL;
|
|
const char *core = f->map->emulator; /* or the core a bare state's signature names */
|
|
/* Only a state this console made is replaced on RomM. One that came
|
|
* from elsewhere and was then saved over here goes up as a new one. */
|
|
if (state_id && mine) {
|
|
char tmp[PATH_MAX_LEN];
|
|
if (state_for_romm(f->path, tmp, sizeof tmp, &core) == 0) {
|
|
snprintf(path, sizeof path, "/api/states/%d", state_id);
|
|
j = romm_upload("PUT", path, "stateFile", tmp, path_basename(f->path), &st);
|
|
unlink(tmp);
|
|
}
|
|
}
|
|
if (!state_id || !mine || st == 404) {
|
|
cJSON_Delete(j);
|
|
j = NULL;
|
|
char name[300], tmp[PATH_MAX_LEN];
|
|
state_upload_name(f->path, name, sizeof name);
|
|
if (state_for_romm(f->path, tmp, sizeof tmp, &core) == 0) {
|
|
char *emu = url_q(core);
|
|
snprintf(path, sizeof path, "/api/states?rom_id=%d&emulator=%s", f->rom_id, emu);
|
|
free(emu);
|
|
j = romm_upload("POST", path, "stateFile", tmp, name, &st);
|
|
unlink(tmp);
|
|
}
|
|
}
|
|
if ((st == 200 || st == 201) && j) {
|
|
int id = (int)jget_num(j, "id", state_id);
|
|
state_lock();
|
|
cJSON *ne = state_entry_ensure("states", f->path);
|
|
jset_num(ne, "rom_id", f->rom_id);
|
|
jset_str(ne, "hash", f->hash);
|
|
jset_num(ne, "mtime", (double)f->mtime);
|
|
char ns[24];
|
|
snprintf(ns, sizeof ns, "%lld", (long long)file_mtime_ns(f->path));
|
|
jset_str(ne, "mtime_ns", ns);
|
|
jset_num(ne, "size", (double)f->size);
|
|
jset_num(ne, "state_id", id);
|
|
jset_num(ne, "mine", 1);
|
|
jset_str(ne, "server_updated_at", jget_str(j, "updated_at", ""));
|
|
jset_num(ne, "synced_at", (double)time(NULL));
|
|
mark_seen(id, jget_str(j, "updated_at", ""));
|
|
state_unlock();
|
|
uploaded++;
|
|
LOGI("uploaded state %s", f->path);
|
|
events_add("state", "uploaded", f->rom_id, f->path, emu_name(f->map), NULL);
|
|
}
|
|
cJSON_Delete(j);
|
|
}
|
|
return uploaded;
|
|
}
|
|
|
|
|
|
/* Local file name for a state from the server. States made elsewhere may use
|
|
* another name, so anything that doesn't start with the game's name gets it. */
|
|
static void state_local_name(const local_rom *r, const char *server_name, char *out, size_t n) {
|
|
for (int i = 0; i < r->nstems; i++) {
|
|
size_t l = strlen(r->stems[i]);
|
|
if (!strncmp(server_name, r->stems[i], l) && server_name[l] == '.') {
|
|
str_copy(out, n, server_name);
|
|
return;
|
|
}
|
|
}
|
|
const char *ext = path_ext(server_name), *vars[15];
|
|
if (!strcmp(ext, ".state")) ext = r->map->state_slot0;
|
|
name_vars(r, ext, vars);
|
|
str_template(out, n, r->map->state_name, vars);
|
|
/* A name from a value this game lacks (no hash read): none at all. */
|
|
if (out[0] == '.' || out[0] == '_' || strchr(out, '{')) out[0] = 0;
|
|
}
|
|
|
|
/* A file there already, or one another server state is kept in. */
|
|
static int state_path_taken(const char *path, int id) {
|
|
if (file_exists(path)) return 1;
|
|
state_lock();
|
|
const cJSON *e = state_entry("states", path);
|
|
int taken = e && (int)jget_num(e, "state_id", 0) != id;
|
|
state_unlock();
|
|
return taken;
|
|
}
|
|
|
|
/* The next free numbered slot beside path ("Game.state" -> "Game.state1"),
|
|
* for an emulator whose states are numbered. 0 if there's none. */
|
|
static int free_state_slot(const local_rom *r, char *path, size_t n, int id) {
|
|
if (!strchr(r->map->state_exts, '*')) return 0;
|
|
char base[PATH_MAX_LEN];
|
|
str_copy(base, sizeof base, path);
|
|
char *dot = strrchr(base, '.');
|
|
if (!dot || strncmp(dot, ".state", 6) != 0) return 0;
|
|
dot[6] = 0; /* "Game.state" */
|
|
for (int slot = 1; slot < 100; slot++) {
|
|
char p[PATH_MAX_LEN + 8];
|
|
snprintf(p, sizeof p, "%s%d", base, slot);
|
|
if (!state_path_taken(p, id)) {
|
|
str_copy(path, n, p);
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* Downloads a state into place (backing up what was there) and records it. */
|
|
static int fetch_state(const scan_ctx *c, const cJSON *s, const char *path, const local_rom *r) {
|
|
int rom_id = r->rom_id;
|
|
char api[128], tmp[PATH_MAX_LEN];
|
|
int id = (int)jget_num(s, "id", 0);
|
|
snprintf(api, sizeof api, "/api/states/%d/content", id);
|
|
snprintf(tmp, sizeof tmp, "%s/tmp/state-%d.bin", plat_data_dir(), id);
|
|
http_resp resp;
|
|
if (romm_download(api, tmp, NULL, NULL, &resp) != 0) {
|
|
unlink(tmp);
|
|
return -1;
|
|
}
|
|
if (!strcmp(r->map->state_format, "raw")) {
|
|
/* File to file: a state can be tens of MB, more than the PS4 holds. */
|
|
char raw[PATH_MAX_LEN + 8];
|
|
snprintf(raw, sizeof raw, "%s.raw", tmp);
|
|
if (state_unwrap_file(tmp, raw) != 0 || rename(raw, tmp) != 0) {
|
|
LOGW("state %d: can't unpack it for %s", id, path);
|
|
unlink(raw);
|
|
unlink(tmp);
|
|
return -1;
|
|
}
|
|
}
|
|
backup_file(c, path, rom_id);
|
|
if (move_file(tmp, path) != 0) return -1;
|
|
time_t t = iso8601_parse(jget_str(s, "updated_at", ""));
|
|
if (t > 0) set_file_mtime(path, t);
|
|
char hash[33] = "";
|
|
md5_file_hex(path, hash);
|
|
state_lock();
|
|
cJSON *e = state_entry_ensure("states", path);
|
|
jset_num(e, "rom_id", rom_id);
|
|
jset_str(e, "hash", hash);
|
|
jset_num(e, "mtime", (double)file_mtime(path));
|
|
char ns[24];
|
|
snprintf(ns, sizeof ns, "%lld", (long long)file_mtime_ns(path));
|
|
jset_str(e, "mtime_ns", ns);
|
|
jset_num(e, "size", (double)file_size(path));
|
|
jset_num(e, "state_id", id);
|
|
jset_num(e, "mine", 0);
|
|
jset_str(e, "server_updated_at", jget_str(s, "updated_at", ""));
|
|
jset_num(e, "synced_at", (double)time(NULL));
|
|
mark_seen(id, jget_str(s, "updated_at", ""));
|
|
state_unlock();
|
|
LOGI("downloaded state %s", path);
|
|
return 0;
|
|
}
|
|
|
|
/* Logs, once per version, a state no emulator here can load: made by another
|
|
* core, or by an app whose states only it reads. */
|
|
static void note_skipped_state(const scan_ctx *c, const cJSON *s) {
|
|
int rom_id = (int)jget_num(s, "rom_id", 0), local = 0;
|
|
for (int i = 0; i < c->nroms && !local; i++)
|
|
local = c->roms[i].rom_id == rom_id && c->roms[i].rules.sync_saves && c->roms[i].map->sync_states;
|
|
if (!local) return; /* game isn't on this console, or its emulator doesn't sync states */
|
|
char key[16];
|
|
snprintf(key, sizeof key, "%d", (int)jget_num(s, "id", 0));
|
|
const char *up = jget_str(s, "updated_at", "");
|
|
state_lock();
|
|
cJSON *sk = state_section("states_skipped");
|
|
int known = !strcmp(jget_str(sk, key, ""), up);
|
|
if (!known) {
|
|
cJSON_DeleteItemFromObjectCaseSensitive(sk, key);
|
|
cJSON_AddStringToObject(sk, key, up);
|
|
}
|
|
state_unlock();
|
|
if (!known) {
|
|
const char *by = jget_str(s, "emulator", "")[0] ? jget_str(s, "emulator", "") : "an unknown emulator";
|
|
LOGI("state %s skipped: made by %s, which no emulator here for rom %d can load", jget_str(s, "file_name", "?"), by,
|
|
rom_id);
|
|
events_add("state", "skipped", rom_id, jget_str(s, "file_name", ""), "", "No suitable emulator on console");
|
|
}
|
|
}
|
|
|
|
/* Pulls states that are new or changed on the server. Each server state keeps
|
|
* one local file; a new one never replaces a file that's there (the player's
|
|
* own slot, or another server state) but takes the next free slot. A state
|
|
* changed here since it came down is kept; the upload pass sends it. */
|
|
static int sync_states_down(scan_ctx *c, int only_rom) {
|
|
long st = 0;
|
|
cJSON *list = romm_call("GET", "/api/states", NULL, &st);
|
|
int n = 0;
|
|
const cJSON *s;
|
|
cJSON_ArrayForEach(s, list) {
|
|
if (only_rom && (int)jget_num(s, "rom_id", 0) != only_rom) continue;
|
|
int id = (int)jget_num(s, "id", 0);
|
|
const char *server_up = jget_str(s, "updated_at", "");
|
|
char path[PATH_MAX_LEN], rec_hash[33] = "", rec_up[64] = "";
|
|
state_lock();
|
|
int seen = was_seen(id, server_up);
|
|
cJSON *e = state_record_for(id, path, sizeof path);
|
|
/* One we kept whose file is gone from here comes back. */
|
|
const int missing = e && !file_exists(path);
|
|
if (missing) seen = 0;
|
|
if (e && !seen && !missing) {
|
|
str_copy(rec_hash, sizeof rec_hash, jget_str(e, "hash", ""));
|
|
str_copy(rec_up, sizeof rec_up, jget_str(e, "server_updated_at", ""));
|
|
/* Recorded before states_seen existed. */
|
|
if (same_time(rec_up, server_up)) {
|
|
mark_seen(id, server_up);
|
|
seen = 1;
|
|
}
|
|
}
|
|
state_unlock();
|
|
if (seen) continue;
|
|
|
|
const char *emulator = jget_str(s, "emulator", "");
|
|
const local_rom *r = NULL;
|
|
if (e) { /* a state kept here: the emulator whose folder it's in (two can play the game) */
|
|
char folder[PATH_MAX_LEN], exp[PATH_MAX_LEN];
|
|
str_copy(folder, sizeof folder, path);
|
|
char *slash = strrchr(folder, '/');
|
|
if (slash) *slash = 0;
|
|
for (int k = 0; k < c->nroms && !r; k++)
|
|
if (c->roms[k].rom_id == (int)jget_num(s, "rom_id", 0) && c->roms[k].rules.sync_saves &&
|
|
c->roms[k].map->sync_states && map_takes_state(c->roms[k].map, emulator) &&
|
|
expected_dir(&c->roms[k], 1, exp, sizeof exp) && !strcmp(exp, folder))
|
|
r = &c->roms[k];
|
|
}
|
|
if (!r) r = find_rom_for_state(c, (int)jget_num(s, "rom_id", 0), emulator);
|
|
if (!r) {
|
|
note_skipped_state(c, s);
|
|
continue;
|
|
}
|
|
char dir[PATH_MAX_LEN], name[256];
|
|
if (!expected_dir(r, 1, dir, sizeof dir)) continue;
|
|
const char *server_name = jget_str(s, "file_name", "");
|
|
/* A folder per game renames what comes down (slot1.state): any state will do. */
|
|
const int per_game = strstr(r->rules.state_dir, "{game_id}") != NULL;
|
|
const char *sx = path_ext(server_name);
|
|
if (per_game ? strncmp(sx, ".state", 6) != 0 : !ext_in_list(server_name, r->map->state_exts)) continue;
|
|
|
|
if (e) { /* a newer version of a state we keep */
|
|
if (file_exists(path)) {
|
|
char hash[33];
|
|
md5_file_hex(path, hash);
|
|
if (strcmp(hash, rec_hash) != 0) continue;
|
|
}
|
|
} else if (strstr(r->rules.state_dir, "{game_id}")) {
|
|
/* A folder per game holding slot1.state, slot2.state...: the first free. */
|
|
int k = 1;
|
|
for (; k < 10; k++) {
|
|
snprintf(name, sizeof name, "slot%d.state", k);
|
|
path_join(path, sizeof path, dir, name);
|
|
if (!state_path_taken(path, id)) break;
|
|
}
|
|
if (k == 10) {
|
|
LOGI("state %d skipped: no free slot in %s", id, dir);
|
|
continue;
|
|
}
|
|
} else {
|
|
state_local_name(r, server_name, name, sizeof name);
|
|
if (!name[0]) continue;
|
|
path_join(path, sizeof path, dir, name);
|
|
if (state_path_taken(path, id) && !free_state_slot(r, path, sizeof path, id)) {
|
|
LOGI("state %d skipped: no free slot beside %s", id, path);
|
|
continue;
|
|
}
|
|
}
|
|
if (missing) mkdir_parent(path);
|
|
if (fetch_state(c, s, path, r) == 0) {
|
|
n++;
|
|
events_add("state", "downloaded", r->rom_id, path, emu_name(r->map), missing ? "Missing here" : NULL);
|
|
} else {
|
|
events_add("state", "failed", r->rom_id, path, emu_name(r->map), "Download failed");
|
|
}
|
|
}
|
|
/* Forget states that are gone from the server, and the records of states
|
|
* gone from both sides. */
|
|
if (st == 200 && cJSON_IsArray(list)) {
|
|
static const char *const sections[] = {"states_seen", "states_skipped"};
|
|
state_lock();
|
|
cJSON *recs = state_section("states"), *rec = recs->child;
|
|
while (rec) {
|
|
cJSON *next = rec->next;
|
|
int id = (int)jget_num(rec, "state_id", 0), there = 0;
|
|
cJSON_ArrayForEach(s, list) if ((int)jget_num(s, "id", 0) == id) there = 1;
|
|
if (!there && !file_exists(rec->string)) cJSON_Delete(cJSON_DetachItemViaPointer(recs, rec));
|
|
rec = next;
|
|
}
|
|
for (int i = 0; i < 2; i++) {
|
|
cJSON *sec = state_section(sections[i]), *k = sec->child;
|
|
while (k) {
|
|
cJSON *next = k->next;
|
|
int there = 0;
|
|
cJSON_ArrayForEach(s, list) if ((int)jget_num(s, "id", 0) == atoi(k->string)) there = 1;
|
|
if (!there) cJSON_Delete(cJSON_DetachItemViaPointer(sec, k));
|
|
k = next;
|
|
}
|
|
}
|
|
state_unlock();
|
|
}
|
|
cJSON_Delete(list);
|
|
return n;
|
|
}
|
|
|
|
typedef struct {
|
|
int uploaded, downloaded, conflicts, skipped, failed, states_up, states_down, noop, deferred, cards, cards_down;
|
|
int unreachable; /* failed because the server never answered */
|
|
char error[256];
|
|
} sync_result;
|
|
|
|
/* A game played by several emulators that keep its save in the same format
|
|
* (save_format) has one save: the newest of their copies goes to the others,
|
|
* the copies replaced backed up first. Runs before the saves are scanned (so
|
|
* one save goes up) and after the downloads (so a new one reaches them all). */
|
|
static int share_bundles(scan_ctx *c);
|
|
|
|
static int share_saves(scan_ctx *c) {
|
|
int copied = share_bundles(c);
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
const local_rom *a = &c->roms[i];
|
|
if (!a->map->save_format[0] || a->map->own_slot || !a->rules.sync_saves || a->map->save_layout[0]) continue;
|
|
/* The first member of each group does the work. */
|
|
int first = 1;
|
|
for (int k = 0; k < i && first; k++)
|
|
if (c->roms[k].rom_id == a->rom_id && !strcmp(c->roms[k].map->save_format, a->map->save_format) &&
|
|
!c->roms[k].map->own_slot && c->roms[k].rules.sync_saves)
|
|
first = 0;
|
|
if (!first) continue;
|
|
char paths[8][PATH_MAX_LEN];
|
|
int n = 0, newest = -1, newest_blank = 0;
|
|
time_t newest_t = 0;
|
|
for (int k = i; k < c->nroms && n < 8; k++) {
|
|
const local_rom *r = &c->roms[k];
|
|
if (r->rom_id != a->rom_id || strcmp(r->map->save_format, a->map->save_format) || r->map->own_slot ||
|
|
!r->rules.sync_saves)
|
|
continue;
|
|
download_target(r, "", c->slot, c, paths[n], sizeof paths[n]);
|
|
time_t t = file_mtime(paths[n]);
|
|
/* A blank card (an emulator made a fresh one) never beats one with saves. */
|
|
const int blank = psx_card_saves(r->map, paths[n]) == 0;
|
|
if (t > 0 && (newest < 0 || (newest_blank && !blank) || (blank == newest_blank && t > newest_t)))
|
|
newest_t = t, newest = n, newest_blank = blank;
|
|
n++;
|
|
}
|
|
if (n < 2 || newest < 0) continue;
|
|
char want[33];
|
|
if (md5_file_hex(paths[newest], want) != 0) continue;
|
|
for (int k = 0; k < n; k++) {
|
|
char have[33] = "";
|
|
if (k == newest || (md5_file_hex(paths[k], have) == 0 && !strcmp(have, want))) continue;
|
|
if (file_exists(paths[k])) backup_file(c, paths[k], a->rom_id);
|
|
mkdir_parent(paths[k]);
|
|
if (copy_file(paths[newest], paths[k]) == 0) {
|
|
set_file_mtime(paths[k], newest_t);
|
|
LOGI("shared save: %s -> %s", paths[newest], paths[k]);
|
|
copied++;
|
|
}
|
|
}
|
|
}
|
|
return copied;
|
|
}
|
|
|
|
/* The same for a GameCube game two Dolphins play (GCI folders): the game's
|
|
* .gci files from the one that saved last go to the other's folder. */
|
|
static int share_bundles(scan_ctx *c) {
|
|
int copied = 0;
|
|
for (int i = 0; i < c->nroms; i++) {
|
|
const local_rom *a = &c->roms[i];
|
|
if (strcmp(a->map->save_layout, "gci") != 0 || a->map->own_slot || !a->rules.sync_saves) continue;
|
|
int first = 1;
|
|
for (int k = 0; k < i && first; k++)
|
|
if (c->roms[k].rom_id == a->rom_id && !strcmp(c->roms[k].map->save_layout, "gci") &&
|
|
!c->roms[k].map->own_slot && c->roms[k].rules.sync_saves)
|
|
first = 0;
|
|
char id[24];
|
|
if (!first || rom_game_id(a, id, sizeof id) != 0) continue;
|
|
bundle bs[4];
|
|
int n = 0, newest = -1;
|
|
for (int k = i; k < c->nroms && n < 4; k++) {
|
|
const local_rom *r = &c->roms[k];
|
|
if (r->rom_id != a->rom_id || strcmp(r->map->save_layout, "gci") || r->map->own_slot || !r->rules.sync_saves)
|
|
continue;
|
|
if (bundle_for_rom(r, id, &bs[n]) != 0) continue;
|
|
if (bs[n].n && (newest < 0 || bs[n].mtime > bs[newest].mtime)) newest = n;
|
|
n++;
|
|
}
|
|
char want[33] = "";
|
|
if (n >= 2 && newest >= 0) bundle_hash(&bs[newest], want);
|
|
for (int k = 0; want[0] && k < n; k++) {
|
|
char have[33];
|
|
bundle_hash(&bs[k], have);
|
|
if (k == newest || !strcmp(have, want)) continue;
|
|
if (bs[k].n) { /* what was there, kept as a zip */
|
|
char cur[PATH_MAX_LEN];
|
|
snprintf(cur, sizeof cur, "%s/tmp/%s-shared.zip", plat_data_dir(), id);
|
|
if (bundle_zip(&bs[k], cur) == 0) backup_file(c, cur, a->rom_id);
|
|
unlink(cur);
|
|
}
|
|
for (int f = 0; f < bs[k].n; f++) unlink(bs[k].files[f].path);
|
|
mkdir_p(bs[k].dir);
|
|
for (int f = 0; f < bs[newest].n; f++) {
|
|
char to[PATH_MAX_LEN];
|
|
path_join(to, sizeof to, bs[k].dir, bs[newest].files[f].name);
|
|
if (copy_file(bs[newest].files[f].path, to) == 0) set_file_mtime(to, file_mtime(bs[newest].files[f].path));
|
|
}
|
|
LOGI("shared save: %s's GameCube saves -> %s", id, bs[k].dir);
|
|
copied++;
|
|
}
|
|
for (int k = 0; k < n; k++) bundle_free(&bs[k]);
|
|
}
|
|
return copied;
|
|
}
|
|
|
|
/* Game ids, save ids and what was synced belong to one RomM server (and one
|
|
* device on it). Paired with another, they mean nothing there: they're
|
|
* forgotten, and the games are matched again by name. */
|
|
static void server_id(char *out, size_t n) {
|
|
config_lock();
|
|
snprintf(out, n, "%s|%s", g_cfg.server_url, g_cfg.device_id);
|
|
config_unlock();
|
|
}
|
|
|
|
static void forget_server_records(void) {
|
|
static const char *const sections[] = {"roms", "saves", "states", "conflicts", "memcards"};
|
|
for (size_t i = 0; i < sizeof sections / sizeof sections[0]; i++) {
|
|
cJSON *sec = state_section(sections[i]);
|
|
while (sec->child) cJSON_Delete(cJSON_DetachItemViaPointer(sec, sec->child));
|
|
}
|
|
}
|
|
|
|
void sync_new_pairing(void) {
|
|
char id[700];
|
|
server_id(id, sizeof id);
|
|
state_lock();
|
|
forget_server_records();
|
|
jset_str(state_section("server"), "id", id);
|
|
state_save();
|
|
state_unlock();
|
|
LOGI("new pairing: the games are matched again");
|
|
}
|
|
|
|
/* The same check at every sync, for a config changed some other way. A state
|
|
* from before servers were recorded belongs to the server paired now. */
|
|
static void forget_other_server(void) {
|
|
char id[700];
|
|
server_id(id, sizeof id);
|
|
state_lock();
|
|
cJSON *meta = state_section("server");
|
|
const char *was = jget_str(meta, "id", NULL);
|
|
if (was && strcmp(was, id) != 0) {
|
|
forget_server_records();
|
|
LOGI("paired with another RomM server: matching the games again");
|
|
}
|
|
jset_str(meta, "id", id);
|
|
state_save();
|
|
state_unlock();
|
|
}
|
|
|
|
/* only_rom limits the transfers to one game; the rest wait for the next sync. */
|
|
static void run_sync(const char *reason, int only_rom, sync_result *res) {
|
|
memset(res, 0, sizeof *res);
|
|
forget_other_server();
|
|
scan_ctx c;
|
|
set_phase("scanning local files");
|
|
if (scan_prepare(&c) != 0) {
|
|
snprintf(res->error, sizeof res->error, "cannot reach RomM server");
|
|
res->unreachable = c.server_status == 0;
|
|
scan_free(&c);
|
|
return;
|
|
}
|
|
romm_update_device_name();
|
|
share_saves(&c);
|
|
scan_saves(&c);
|
|
LOGI("sync (%s): %d local roms, %d saves, %d states", reason, c.nroms, c.nsaves, c.nstates);
|
|
|
|
set_phase("negotiating with server");
|
|
cJSON *body = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(body, "device_id", c.device_id);
|
|
cJSON *arr = cJSON_AddArrayToObject(body, "saves");
|
|
for (int i = 0; i < c.nsaves; i++) {
|
|
local_file *f = &c.saves[i];
|
|
char ts[40];
|
|
iso8601_utc(f->mtime, ts, sizeof ts);
|
|
cJSON *s = cJSON_CreateObject();
|
|
cJSON_AddNumberToObject(s, "rom_id", f->rom_id);
|
|
cJSON_AddStringToObject(s, "file_name", f->is_bundle ? f->upload_name : path_basename(f->path));
|
|
cJSON_AddStringToObject(s, "slot", f->slot);
|
|
cJSON_AddStringToObject(s, "emulator", f->map->emulator);
|
|
cJSON_AddStringToObject(s, "content_hash", f->hash);
|
|
cJSON_AddStringToObject(s, "updated_at", ts);
|
|
cJSON_AddNumberToObject(s, "file_size_bytes", (double)f->size);
|
|
cJSON_AddItemToArray(arr, s);
|
|
}
|
|
long st = 0;
|
|
cJSON *neg = romm_call("POST", "/api/sync/negotiate", body, &st);
|
|
cJSON_Delete(body);
|
|
if (st != 200 || !neg) {
|
|
char d[200];
|
|
romm_error_detail(neg, d, sizeof d);
|
|
snprintf(res->error, sizeof res->error, "negotiate failed (HTTP %ld) %s", st, d);
|
|
res->unreachable = st == 0;
|
|
cJSON_Delete(neg);
|
|
scan_free(&c);
|
|
return;
|
|
}
|
|
int session_id = (int)jget_num(neg, "session_id", 0);
|
|
/* A running game would overwrite a downloaded save, so downloads wait until
|
|
* it closes. The sync right after launch still downloads. */
|
|
int upload_only = g_game_running && strcmp(reason, "game started") != 0 && !only_rom;
|
|
|
|
const cJSON *op;
|
|
int nops = cJSON_GetArraySize(cJSON_GetObjectItemCaseSensitive(neg, "operations")), k = 0;
|
|
int last_rom = 0, roms_touched = 0;
|
|
cJSON_ArrayForEach(op, cJSON_GetObjectItemCaseSensitive(neg, "operations")) {
|
|
char ph[64];
|
|
snprintf(ph, sizeof ph, "syncing saves (%d/%d)", ++k, nops);
|
|
set_phase(ph);
|
|
const char *action = jget_str(op, "action", "");
|
|
int rom_id = (int)jget_num(op, "rom_id", 0);
|
|
int save_id = (int)jget_num(op, "save_id", 0);
|
|
const char *slot = jget_str(op, "slot", "");
|
|
if (only_rom && rom_id != only_rom) continue;
|
|
if (!our_slot(&c, slot)) continue; /* another device's slot: its saves stay its own */
|
|
const local_file *f = find_save(&c, rom_id, slot);
|
|
|
|
const char *server_hash = jget_str(op, "server_content_hash", "");
|
|
if (!strcmp(action, "no_op") && f && server_hash[0] && strcmp(server_hash, f->hash) != 0) {
|
|
/* RomM thinks in whole seconds, so a change right after a sync can
|
|
* come back as "nothing to do" while the hashes disagree. Trust our
|
|
* own record of which side moved instead. */
|
|
state_lock();
|
|
cJSON *e = state_entry("saves", f->path);
|
|
int local_changed = !e || strcmp(jget_str(e, "hash", ""), f->hash) != 0;
|
|
state_unlock();
|
|
action = local_changed ? "upload" : "download";
|
|
}
|
|
if (!strcmp(action, "no_op")) {
|
|
if (f) {
|
|
state_lock();
|
|
cJSON *e = state_entry("saves", f->path);
|
|
int changed = !e || strcmp(jget_str(e, "hash", ""), f->hash);
|
|
state_unlock();
|
|
if (changed) record_save(f, save_id, NULL);
|
|
else {
|
|
state_lock();
|
|
state_entry_remove("conflicts", f->path);
|
|
state_unlock();
|
|
}
|
|
}
|
|
res->noop++;
|
|
continue;
|
|
}
|
|
|
|
int want_up = !strcmp(action, "upload"), want_down = !strcmp(action, "download");
|
|
int overwrite = 0, is_conflict = !strcmp(action, "conflict");
|
|
if (want_down && f) {
|
|
/* With no sync history RomM just goes by timestamps, which is how you
|
|
* lose a save. Unsynced local changes are never overwritten; call it
|
|
* a conflict and let a human decide. */
|
|
state_lock();
|
|
cJSON *e = state_entry("saves", f->path);
|
|
int local_dirty = !e || strcmp(jget_str(e, "hash", ""), f->hash) != 0;
|
|
state_unlock();
|
|
if (local_dirty) {
|
|
is_conflict = 1;
|
|
want_down = 0;
|
|
}
|
|
}
|
|
if (is_conflict && f) {
|
|
conflict_policy p = c.policy;
|
|
if (p == CONFLICT_NEWEST) {
|
|
time_t srv = iso8601_parse(jget_str(op, "server_updated_at", ""));
|
|
p = f->mtime >= srv ? CONFLICT_LOCAL : CONFLICT_SERVER;
|
|
}
|
|
if (p == CONFLICT_LOCAL) want_up = overwrite = 1;
|
|
else if (p == CONFLICT_SERVER && upload_only) {
|
|
res->deferred++;
|
|
continue;
|
|
} else if (p == CONFLICT_SERVER) want_down = 1;
|
|
else {
|
|
record_conflict(f, op);
|
|
res->conflicts++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (want_up && f) {
|
|
long ust = 0;
|
|
if (do_upload(&c, f, session_id, overwrite, &ust) == 0) {
|
|
res->uploaded++;
|
|
if (last_rom != rom_id) roms_touched++;
|
|
last_rom = rom_id;
|
|
events_add("save", "uploaded", rom_id, f->path, emu_name(f->map), overwrite ? "Kept this console's" : NULL);
|
|
} else if (ust == 409) {
|
|
/* The server copy changed since negotiate. */
|
|
record_conflict(f, op);
|
|
res->conflicts++;
|
|
} else {
|
|
res->failed++;
|
|
events_add("save", "failed", rom_id, f->path, emu_name(f->map), "Upload failed");
|
|
}
|
|
} else if (want_down && upload_only) {
|
|
res->deferred++;
|
|
} else if (want_down) {
|
|
const local_rom *r = f ? f->rom : find_rom(&c, rom_id, jget_str(op, "emulator", NULL));
|
|
if (!r) {
|
|
res->skipped++; /* game isn't on this console */
|
|
continue;
|
|
}
|
|
char dest[PATH_MAX_LEN];
|
|
int ok;
|
|
if (r->map->save_layout[0]) {
|
|
ok = do_download_bundle(&c, save_id, r, slot, session_id, jget_str(op, "server_content_hash", ""), 1) == 0;
|
|
} else {
|
|
if (f)
|
|
str_copy(dest, sizeof dest, f->path);
|
|
else
|
|
download_target(r, jget_str(op, "file_name", ""), slot, &c, dest, sizeof dest);
|
|
int rc = do_download(&c, save_id, dest, f, rom_id, slot, session_id, jget_str(op, "server_content_hash", ""),
|
|
jget_str(op, "server_updated_at", ""), 1);
|
|
if (rc == -2) {
|
|
record_conflict(f, op);
|
|
res->conflicts++;
|
|
events_add("save", "conflict", rom_id, dest, emu_name(r->map), "RomM's card is blank");
|
|
continue;
|
|
}
|
|
ok = rc == 0;
|
|
}
|
|
const char *where = r->map->save_layout[0] ? r->path : dest;
|
|
if (ok) {
|
|
res->downloaded++;
|
|
events_add("save", "downloaded", rom_id, where, emu_name(r->map), is_conflict ? "Kept RomM's" : NULL);
|
|
} else {
|
|
res->failed++;
|
|
events_add("save", "failed", rom_id, where, emu_name(r->map), "Download failed");
|
|
}
|
|
} else {
|
|
res->skipped++;
|
|
}
|
|
}
|
|
cJSON_Delete(neg);
|
|
if (!upload_only) res->downloaded += restore_missing_saves(&c, only_rom) + restore_missing_bundles(&c, only_rom);
|
|
if (!upload_only) res->downloaded += fetch_slotless_saves(&c, only_rom);
|
|
|
|
set_phase("finishing");
|
|
cJSON *done = cJSON_CreateObject();
|
|
cJSON_AddNumberToObject(done, "operations_completed", res->uploaded + res->downloaded + res->noop);
|
|
cJSON_AddNumberToObject(done, "operations_failed", res->failed);
|
|
pthread_mutex_lock(&g_req_lock);
|
|
cJSON *ps = only_rom ? NULL : g_play_sessions;
|
|
if (!only_rom) g_play_sessions = NULL;
|
|
pthread_mutex_unlock(&g_req_lock);
|
|
if (ps) {
|
|
/* If one game's save changed, that's the game that was played. */
|
|
cJSON *e;
|
|
cJSON_ArrayForEach(e, ps) {
|
|
cJSON_DeleteItemFromObjectCaseSensitive(e, "title_id");
|
|
if (roms_touched == 1) cJSON_AddNumberToObject(e, "rom_id", last_rom);
|
|
cJSON_AddStringToObject(e, "save_slot", c.slot);
|
|
}
|
|
cJSON_AddItemToObject(done, "play_sessions", ps);
|
|
}
|
|
char p[128];
|
|
snprintf(p, sizeof p, "/api/sync/sessions/%d/complete", session_id);
|
|
long cst = 0;
|
|
cJSON_Delete(romm_call("POST", p, done, &cst));
|
|
cJSON_Delete(done);
|
|
if (cst != 200) LOGW("session complete returned HTTP %ld", cst);
|
|
|
|
int do_states;
|
|
config_lock();
|
|
do_states = g_cfg.sync_states;
|
|
config_unlock();
|
|
if (do_states) {
|
|
set_phase("syncing save states");
|
|
if (do_states == 2 && !upload_only) res->states_down = sync_states_down(&c, only_rom);
|
|
res->states_up = sync_states_up(&c, only_rom);
|
|
}
|
|
|
|
if (!only_rom) res->cards = memcard_sync(upload_only, &res->cards_down);
|
|
/* A save that just came down reaches the other emulators that share it. */
|
|
share_saves(&c);
|
|
|
|
/* BIOS files last: a first sync can bring hundreds of MB of them (Switch
|
|
* firmware), and saves shouldn't wait on that. Not while a game runs: its
|
|
* emulator may be reading the folder. */
|
|
if (!only_rom && !upload_only) {
|
|
set_phase("BIOS files");
|
|
firmware_sync(c.maps, c.nmaps, c.platforms, 0, NULL);
|
|
}
|
|
|
|
state_lock();
|
|
state_save();
|
|
state_unlock();
|
|
scan_free(&c);
|
|
}
|
|
|
|
/* Runs a sync and adds it to the history. */
|
|
static void run_and_record(const char *reason, int only_rom, sync_result *r) {
|
|
pthread_mutex_lock(&g_run_lock);
|
|
events_set_sync(reason);
|
|
g_running = 1;
|
|
run_sync(reason, only_rom, r);
|
|
g_running = 0;
|
|
g_last_run = time(NULL);
|
|
set_phase("idle");
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
events_save();
|
|
|
|
cJSON *h = cJSON_CreateObject();
|
|
g_sync_count++;
|
|
cJSON_AddNumberToObject(h, "time", (double)g_last_run);
|
|
cJSON_AddStringToObject(h, "reason", reason);
|
|
cJSON_AddNumberToObject(h, "uploaded", r->uploaded);
|
|
cJSON_AddNumberToObject(h, "downloaded", r->downloaded);
|
|
cJSON_AddNumberToObject(h, "conflicts", r->conflicts);
|
|
cJSON_AddNumberToObject(h, "skipped", r->skipped);
|
|
cJSON_AddNumberToObject(h, "failed", r->failed);
|
|
cJSON_AddNumberToObject(h, "states_uploaded", r->states_up);
|
|
cJSON_AddNumberToObject(h, "states_downloaded", r->states_down);
|
|
cJSON_AddNumberToObject(h, "deferred", r->deferred);
|
|
cJSON_AddNumberToObject(h, "cards_uploaded", r->cards);
|
|
cJSON_AddNumberToObject(h, "cards_downloaded", r->cards_down);
|
|
if (r->error[0]) cJSON_AddStringToObject(h, "error", r->error);
|
|
state_lock();
|
|
state_history_add(h);
|
|
state_save();
|
|
state_unlock();
|
|
}
|
|
|
|
static int sync_summary(const sync_result *r, char *out, size_t n);
|
|
|
|
static int notify_on(void) {
|
|
config_lock();
|
|
int notify = g_cfg.notify;
|
|
config_unlock();
|
|
return notify;
|
|
}
|
|
|
|
/* The console can lose the server for a few minutes while the rest
|
|
* of the LAN reaches it fine, and the sync a game launch asked for was then
|
|
* gone until the next periodic one. A sync the server never answered is
|
|
* tried again after 1, 2 and 4 minutes, quietly; only giving up gets a toast.
|
|
* The retry only uploads while a game runs, like any sync but the launch one. */
|
|
#define RETRY_FIRST_SEC 60
|
|
#define MAX_RETRIES 3
|
|
|
|
static volatile time_t g_retry_at;
|
|
static int g_retries;
|
|
|
|
int sync_retry_due(void) {
|
|
time_t at = g_retry_at;
|
|
if (!at || time(NULL) < at) return 0;
|
|
g_retry_at = 0;
|
|
return 1;
|
|
}
|
|
|
|
static void *worker(void *arg) {
|
|
(void)arg;
|
|
for (;;) {
|
|
char reason[64];
|
|
pthread_mutex_lock(&g_req_lock);
|
|
while (!g_req_reason[0]) pthread_cond_wait(&g_req_cond, &g_req_lock);
|
|
str_copy(reason, sizeof reason, g_req_reason);
|
|
g_req_reason[0] = 0;
|
|
pthread_mutex_unlock(&g_req_lock);
|
|
|
|
if (!config_is_paired()) continue;
|
|
/* Only manual syncs run until setup is finished, and none at all
|
|
* with save sync off (RomM is then only where games come from). */
|
|
int ready, on;
|
|
config_lock();
|
|
ready = g_cfg.setup_complete;
|
|
on = g_cfg.save_sync;
|
|
config_unlock();
|
|
if (!on) {
|
|
LOGI("sync (%s) skipped: save sync is off", reason);
|
|
continue;
|
|
}
|
|
if (!ready && strcmp(reason, "manual") != 0 && strcmp(reason, "first sync") != 0) {
|
|
LOGI("sync (%s) skipped: setup not finished", reason);
|
|
continue;
|
|
}
|
|
sync_result r;
|
|
const unsigned mark = events_seq();
|
|
run_and_record(reason, 0, &r);
|
|
|
|
int retry_in = 0;
|
|
if (r.error[0] && r.unreachable && g_retries < MAX_RETRIES) {
|
|
retry_in = RETRY_FIRST_SEC << g_retries++;
|
|
g_retry_at = time(NULL) + retry_in;
|
|
} else {
|
|
g_retries = 0;
|
|
g_retry_at = 0;
|
|
}
|
|
|
|
int notify = notify_on();
|
|
if (r.error[0] && retry_in) {
|
|
LOGE("sync failed: %s; trying again in %ds", r.error, retry_in);
|
|
} else if (r.error[0]) {
|
|
LOGE("sync failed: %s", r.error);
|
|
if (notify) plat_notify("RommPS: couldn't sync. %s", r.error);
|
|
} else {
|
|
/* Issue #10 - Added notification and log file for memory card operations. */
|
|
LOGI("sync done: %d up, %d down, %d conflicts, %d failed, %d states, %d cards up, %d cards down; largest free block %d MB",
|
|
r.uploaded, r.downloaded, r.conflicts, r.failed, r.states_up, r.cards, r.cards_down, mem_largest_mb());
|
|
char text[200], games[400];
|
|
if (notify && sync_summary(&r, text, sizeof text)) {
|
|
if (events_games(mark, 3, games, sizeof games)) plat_notify("RommPS: %s\n%s", text, games);
|
|
else plat_notify("RommPS: %s", text);
|
|
}
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* What a sync did, for its notification: "2 saves up, 1 state down, 1
|
|
* conflict to resolve". 0 when it did nothing worth telling. */
|
|
static int sync_summary(const sync_result *r, char *out, size_t n) {
|
|
struct { int count; const char *one, *many; } parts[] = {
|
|
{r->uploaded, "save up", "saves up"}, {r->downloaded, "save down", "saves down"},
|
|
{r->states_up, "state up", "states up"}, {r->states_down, "state down", "states down"},
|
|
{r->cards, "memory card up", "memory cards up"}, {r->cards_down, "memory card down", "memory cards down"},
|
|
{r->conflicts, "conflict to resolve", "conflicts to resolve"}, {r->failed, "failed", "failed"}};
|
|
size_t o = 0;
|
|
out[0] = 0;
|
|
for (size_t i = 0; i < sizeof parts / sizeof parts[0] && o < n; i++)
|
|
if (parts[i].count > 0)
|
|
o += (size_t)snprintf(out + o, n - o, "%s%d %s", o ? ", " : "", parts[i].count,
|
|
parts[i].count == 1 ? parts[i].one : parts[i].many);
|
|
return o > 0;
|
|
}
|
|
|
|
void sync_init(void) {
|
|
thread_start(worker, NULL);
|
|
}
|
|
|
|
static void plural(char *out, size_t n, int count, const char *what) {
|
|
snprintf(out, n, "%d %s%s", count, what, count == 1 ? "" : "s");
|
|
}
|
|
|
|
void download_notice(char *out, size_t n, const char *name, const char *system) {
|
|
if (system && system[0]) snprintf(out, n, "%s was downloaded for %s", name, system);
|
|
else snprintf(out, n, "%s was downloaded", name);
|
|
}
|
|
|
|
int sync_game(int rom_id, const char *name, const char *system, sync_game_result *out) {
|
|
memset(out, 0, sizeof *out);
|
|
config_lock();
|
|
int ready = g_cfg.setup_complete && g_cfg.save_sync;
|
|
config_unlock();
|
|
if (!config_is_paired() || !ready) return -1;
|
|
|
|
sync_result r;
|
|
run_and_record("game downloaded", rom_id, &r);
|
|
out->saves = r.uploaded + r.downloaded;
|
|
out->states = r.states_up + r.states_down;
|
|
out->conflicts = r.conflicts;
|
|
out->failed = r.failed || r.error[0];
|
|
LOGI("saves for %s: %d saves, %d states, %d conflicts%s", name, out->saves, out->states, out->conflicts,
|
|
out->failed ? ", some failed" : "");
|
|
if (!notify_on()) return 0;
|
|
char head[400];
|
|
download_notice(head, sizeof head, name, system);
|
|
if (out->failed) {
|
|
plat_notify("RommPS: %s, but its saves couldn't sync", head);
|
|
} else if (out->conflicts) {
|
|
plat_notify("RommPS: %s. A save conflict needs you", head);
|
|
} else if (out->saves || out->states) {
|
|
char saves[32] = "", states[32] = "";
|
|
if (out->saves) plural(saves, sizeof saves, out->saves, "save");
|
|
if (out->states) plural(states, sizeof states, out->states, "state");
|
|
plat_notify("RommPS: %s, with %s%s%s", head, saves, out->saves && out->states ? " and " : "", states);
|
|
} else {
|
|
plat_notify("RommPS: %s", head);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void sync_request(const char *reason) {
|
|
pthread_mutex_lock(&g_req_lock);
|
|
if (!g_req_reason[0]) str_copy(g_req_reason, sizeof g_req_reason, reason);
|
|
pthread_cond_signal(&g_req_cond);
|
|
pthread_mutex_unlock(&g_req_lock);
|
|
}
|
|
|
|
int sync_is_running(void) { return g_running; }
|
|
|
|
void sync_pause(void) { pthread_mutex_lock(&g_run_lock); }
|
|
void sync_resume(void) { pthread_mutex_unlock(&g_run_lock); }
|
|
time_t sync_last_run(void) { return g_last_run; }
|
|
|
|
void sync_add_play_session(time_t start, time_t end, const char *title_id) {
|
|
if (end - start < 60) return;
|
|
char a[40], b[40];
|
|
iso8601_utc(start, a, sizeof a);
|
|
iso8601_utc(end, b, sizeof b);
|
|
cJSON *e = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(e, "start_time", a);
|
|
cJSON_AddStringToObject(e, "end_time", b);
|
|
cJSON_AddNumberToObject(e, "duration_ms", (double)(end - start) * 1000.0);
|
|
cJSON_AddStringToObject(e, "title_id", title_id ? title_id : "");
|
|
pthread_mutex_lock(&g_req_lock);
|
|
if (!g_play_sessions) g_play_sessions = cJSON_CreateArray();
|
|
cJSON_AddItemToArray(g_play_sessions, e);
|
|
pthread_mutex_unlock(&g_req_lock);
|
|
}
|
|
|
|
cJSON *sync_status_json(void) {
|
|
cJSON *j = cJSON_CreateObject();
|
|
cJSON_AddBoolToObject(j, "running", g_running);
|
|
cJSON_AddStringToObject(j, "phase", g_phase);
|
|
cJSON_AddNumberToObject(j, "last_run", (double)g_last_run);
|
|
cJSON_AddNumberToObject(j, "sync_count", g_sync_count);
|
|
state_lock();
|
|
cJSON_AddItemToObject(j, "history", cJSON_Duplicate(state_section("history"), 1));
|
|
cJSON *conf = cJSON_CreateArray();
|
|
cJSON *e;
|
|
cJSON_ArrayForEach(e, state_section("conflicts")) {
|
|
cJSON *c = cJSON_Duplicate(e, 1);
|
|
cJSON_AddStringToObject(c, "path", e->string);
|
|
cJSON *rom = NULL, *r;
|
|
cJSON_ArrayForEach(r, state_section("roms"))
|
|
if ((int)jget_num(r, "rom_id", -1) == (int)jget_num(e, "rom_id", 0)) rom = r;
|
|
cJSON_AddStringToObject(c, "rom_name", rom ? jget_str(rom, "name", "") : "");
|
|
cJSON_AddItemToArray(conf, c);
|
|
}
|
|
cJSON_AddItemToObject(j, "conflicts", conf);
|
|
cJSON_AddItemToObject(j, "unmatched", g_unmatched ? cJSON_Duplicate(g_unmatched, 1) : cJSON_CreateArray());
|
|
cJSON_AddItemToObject(j, "strays", g_strays ? cJSON_Duplicate(g_strays, 1) : cJSON_CreateArray());
|
|
int tracked = cJSON_GetArraySize(state_section("saves"));
|
|
state_unlock();
|
|
cJSON_AddNumberToObject(j, "tracked_saves", tracked);
|
|
return j;
|
|
}
|
|
|
|
int sync_resolve_conflict(const char *path, const char *keep, char *err, int en) {
|
|
int rc = -1;
|
|
int local = !strcmp(keep, "local"), server = !strcmp(keep, "server");
|
|
if (!local && !server) {
|
|
snprintf(err, (size_t)en, "keep must be local or server");
|
|
return -1;
|
|
}
|
|
pthread_mutex_lock(&g_run_lock);
|
|
state_lock();
|
|
cJSON *e = cJSON_Duplicate(state_entry("conflicts", path), 1);
|
|
state_unlock();
|
|
scan_ctx c;
|
|
memset(&c, 0, sizeof c);
|
|
if (!e) {
|
|
snprintf(err, (size_t)en, "no such conflict");
|
|
goto done;
|
|
}
|
|
if (scan_prepare(&c) != 0) {
|
|
snprintf(err, (size_t)en, "cannot reach RomM");
|
|
goto done;
|
|
}
|
|
scan_saves(&c);
|
|
const local_file *f = NULL;
|
|
for (int i = 0; i < c.nsaves && !f; i++)
|
|
if (!strcmp(c.saves[i].path, path)) f = &c.saves[i];
|
|
if (!f) {
|
|
snprintf(err, (size_t)en, "the local save is gone");
|
|
goto done;
|
|
}
|
|
if (local) {
|
|
long st = 0;
|
|
rc = do_upload(&c, f, 0, 1, &st);
|
|
if (rc) snprintf(err, (size_t)en, "upload failed (HTTP %ld)", st);
|
|
} else if (f->is_bundle) {
|
|
rc = do_download_bundle(&c, (int)jget_num(e, "save_id", 0), f->rom, f->slot, 0, jget_str(e, "server_hash", ""), 1);
|
|
if (rc) snprintf(err, (size_t)en, "download failed");
|
|
} else {
|
|
rc = do_download(&c, (int)jget_num(e, "save_id", 0), path, f, f->rom_id, f->slot, 0, jget_str(e, "server_hash", ""),
|
|
jget_str(e, "server_updated_at", ""), 0);
|
|
if (rc) snprintf(err, (size_t)en, "download failed");
|
|
}
|
|
if (rc == 0) {
|
|
state_lock();
|
|
state_entry_remove("conflicts", path);
|
|
state_save();
|
|
state_unlock();
|
|
events_set_sync("conflict resolved");
|
|
events_add("save", "resolved", f->rom_id, path, emu_name(f->map), local ? "Kept this console's" : "Kept RomM's");
|
|
events_save();
|
|
}
|
|
done:
|
|
cJSON_Delete(e);
|
|
scan_free(&c);
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
return rc;
|
|
}
|
|
|
|
int sync_move_stray(const char *path, char *err, int en) {
|
|
int rc = -1;
|
|
pthread_mutex_lock(&g_run_lock);
|
|
char dest[PATH_MAX_LEN] = "";
|
|
int rom_id = 0;
|
|
state_lock();
|
|
const cJSON *e;
|
|
cJSON_ArrayForEach(e, g_strays) {
|
|
if (!strcmp(jget_str(e, "path", ""), path)) {
|
|
str_copy(dest, sizeof dest, jget_str(e, "expected_path", ""));
|
|
rom_id = (int)jget_num(e, "rom_id", 0);
|
|
}
|
|
}
|
|
state_unlock();
|
|
int keep;
|
|
config_lock();
|
|
keep = g_cfg.keep_backups;
|
|
config_unlock();
|
|
if (!dest[0]) {
|
|
snprintf(err, (size_t)en, "not a stray save (run a sync first)");
|
|
} else if (file_exists(dest)) {
|
|
snprintf(err, (size_t)en, "the emulator already has a save at %s; not overwriting it", dest);
|
|
} else if (!file_exists(path)) {
|
|
snprintf(err, (size_t)en, "file no longer exists");
|
|
} else {
|
|
scan_ctx c;
|
|
memset(&c, 0, sizeof c);
|
|
c.keep_backups = keep > 0 ? keep : 1;
|
|
backup_file(&c, path, rom_id);
|
|
if (move_file(path, dest) == 0) {
|
|
LOGI("moved stray save %s -> %s", path, dest);
|
|
rc = 0;
|
|
} else {
|
|
snprintf(err, (size_t)en, "could not move to %s", dest);
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
if (rc == 0) sync_request("stray moved");
|
|
return rc;
|
|
}
|
|
|
|
void sync_set_game_running(int running) { g_game_running = running; }
|
|
int sync_game_running(void) { return g_game_running; }
|
|
|
|
|
|
static const char *rom_display_name(const local_rom *r, char *buf, size_t n) {
|
|
state_lock();
|
|
cJSON *e = state_entry("roms", r->path);
|
|
str_copy(buf, n, e ? jget_str(e, "name", "") : "");
|
|
state_unlock();
|
|
if (!buf[0]) path_stem(buf, n, r->path);
|
|
return buf;
|
|
}
|
|
|
|
static void preview_item(cJSON *arr, const char *game, const char *path, const char *note) {
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "game", game);
|
|
cJSON_AddStringToObject(o, "path", path);
|
|
if (note) cJSON_AddStringToObject(o, "note", note);
|
|
cJSON_AddItemToArray(arr, o);
|
|
}
|
|
|
|
cJSON *sync_preview(char *err, int en) {
|
|
pthread_mutex_lock(&g_run_lock);
|
|
scan_ctx c;
|
|
cJSON *out = NULL;
|
|
if (scan_prepare(&c) != 0) {
|
|
snprintf(err, (size_t)en, "cannot reach the RomM server");
|
|
scan_free(&c);
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
return NULL;
|
|
}
|
|
scan_saves(&c);
|
|
|
|
cJSON *body = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(body, "device_id", c.device_id);
|
|
cJSON *arr = cJSON_AddArrayToObject(body, "saves");
|
|
for (int i = 0; i < c.nsaves; i++) {
|
|
local_file *f = &c.saves[i];
|
|
char ts[40];
|
|
iso8601_utc(f->mtime, ts, sizeof ts);
|
|
cJSON *s = cJSON_CreateObject();
|
|
cJSON_AddNumberToObject(s, "rom_id", f->rom_id);
|
|
cJSON_AddStringToObject(s, "file_name", f->is_bundle ? f->upload_name : path_basename(f->path));
|
|
cJSON_AddStringToObject(s, "slot", f->slot);
|
|
cJSON_AddStringToObject(s, "emulator", f->map->emulator);
|
|
cJSON_AddStringToObject(s, "content_hash", f->hash);
|
|
cJSON_AddStringToObject(s, "updated_at", ts);
|
|
cJSON_AddNumberToObject(s, "file_size_bytes", (double)f->size);
|
|
cJSON_AddItemToArray(arr, s);
|
|
}
|
|
long st = 0;
|
|
cJSON *neg = romm_call("POST", "/api/sync/negotiate", body, &st);
|
|
cJSON_Delete(body);
|
|
if (st != 200 || !neg) {
|
|
char d[200];
|
|
romm_error_detail(neg, d, sizeof d);
|
|
snprintf(err, (size_t)en, "negotiate failed (HTTP %ld) %s", st, d);
|
|
cJSON_Delete(neg);
|
|
scan_free(&c);
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
return NULL;
|
|
}
|
|
|
|
out = cJSON_CreateObject();
|
|
cJSON *up = cJSON_AddArrayToObject(out, "uploads");
|
|
cJSON *down = cJSON_AddArrayToObject(out, "downloads");
|
|
cJSON *conf = cJSON_AddArrayToObject(out, "conflicts");
|
|
int same = 0, elsewhere = 0;
|
|
const cJSON *op;
|
|
cJSON_ArrayForEach(op, cJSON_GetObjectItemCaseSensitive(neg, "operations")) {
|
|
const char *action = jget_str(op, "action", "");
|
|
int rom_id = (int)jget_num(op, "rom_id", 0);
|
|
const char *slot = jget_str(op, "slot", "");
|
|
if (!our_slot(&c, slot)) continue;
|
|
const local_file *f = find_save(&c, rom_id, slot);
|
|
char game[256] = "";
|
|
if (f) rom_display_name(f->rom, game, sizeof game);
|
|
if (!strcmp(action, "no_op")) {
|
|
same++;
|
|
} else if (!strcmp(action, "upload") && f) {
|
|
preview_item(up, game, f->path, NULL);
|
|
} else if (!strcmp(action, "conflict") && f) {
|
|
preview_item(conf, game, f->path, jget_str(op, "reason", ""));
|
|
} else if (!strcmp(action, "download")) {
|
|
if (f) {
|
|
state_lock();
|
|
cJSON *e = state_entry("saves", f->path);
|
|
int dirty = !e || strcmp(jget_str(e, "hash", ""), f->hash) != 0;
|
|
state_unlock();
|
|
if (dirty)
|
|
preview_item(conf, game, f->path, "Different save on the server and on this console");
|
|
else
|
|
preview_item(down, game, f->path, "replaces the local save (a backup is kept)");
|
|
continue;
|
|
}
|
|
const local_rom *r = find_rom(&c, rom_id, jget_str(op, "emulator", NULL));
|
|
if (!r) {
|
|
elsewhere++;
|
|
continue;
|
|
}
|
|
char dest[PATH_MAX_LEN];
|
|
if (r->map->save_layout[0]) {
|
|
char bases[6][PATH_MAX_LEN];
|
|
int nb = bundle_bases(r, bases, 6);
|
|
str_copy(dest, sizeof dest, nb ? bases[0] : r->rules.save_dir);
|
|
} else {
|
|
download_target(r, jget_str(op, "file_name", ""), slot, &c, dest, sizeof dest);
|
|
}
|
|
rom_display_name(r, game, sizeof game);
|
|
preview_item(down, game, dest, NULL);
|
|
}
|
|
}
|
|
cJSON_AddNumberToObject(out, "in_sync", same);
|
|
cJSON_AddNumberToObject(out, "not_on_console", elsewhere);
|
|
cJSON_AddNumberToObject(out, "local_games", c.nroms);
|
|
cJSON_AddNumberToObject(out, "local_saves", c.nsaves);
|
|
state_lock();
|
|
cJSON_AddItemToObject(out, "strays", cJSON_Duplicate(g_strays, 1));
|
|
cJSON_AddItemToObject(out, "unmatched", cJSON_Duplicate(g_unmatched, 1));
|
|
state_save();
|
|
state_unlock();
|
|
|
|
/* Close the session without doing anything. */
|
|
char p[128];
|
|
snprintf(p, sizeof p, "/api/sync/sessions/%d/complete", (int)jget_num(neg, "session_id", 0));
|
|
cJSON *done = cJSON_CreateObject();
|
|
cJSON_AddNumberToObject(done, "operations_completed", 0);
|
|
cJSON_AddNumberToObject(done, "operations_failed", 0);
|
|
cJSON_Delete(romm_call("POST", p, done, NULL));
|
|
cJSON_Delete(done);
|
|
cJSON_Delete(neg);
|
|
scan_free(&c);
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
return out;
|
|
}
|
|
|
|
int sync_scanned_dirs(char dirs[][1024], int *any_file, int max) {
|
|
int n = 0;
|
|
state_lock();
|
|
const cJSON *lists[2] = {g_roots, g_bundle_roots};
|
|
for (int k = 0; k < 2; k++) {
|
|
const cJSON *d;
|
|
cJSON_ArrayForEach(d, lists[k]) {
|
|
if (n >= max) break;
|
|
if (!cJSON_IsString(d)) continue;
|
|
any_file[n] = k == 1;
|
|
str_copy(dirs[n++], 1024, d->valuestring);
|
|
}
|
|
}
|
|
state_unlock();
|
|
return n;
|
|
}
|
|
|
|
cJSON *sync_history(int rom_id, char *err, int en) {
|
|
char path[128];
|
|
long st1 = 0, st2 = 0;
|
|
snprintf(path, sizeof path, "/api/saves?rom_id=%d", rom_id);
|
|
cJSON *saves = romm_call("GET", path, NULL, &st1);
|
|
snprintf(path, sizeof path, "/api/states?rom_id=%d", rom_id);
|
|
cJSON *states = romm_call("GET", path, NULL, &st2);
|
|
if (st1 != 200 || st2 != 200) {
|
|
snprintf(err, (size_t)en, "could not load history from RomM");
|
|
cJSON_Delete(saves);
|
|
cJSON_Delete(states);
|
|
return NULL;
|
|
}
|
|
cJSON *out = cJSON_CreateObject();
|
|
const char *kinds[2] = {"saves", "states"};
|
|
cJSON *lists[2] = {saves, states};
|
|
for (int k = 0; k < 2; k++) {
|
|
cJSON *arr = cJSON_AddArrayToObject(out, kinds[k]);
|
|
const cJSON *s;
|
|
cJSON_ArrayForEach(s, lists[k]) {
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddNumberToObject(o, "id", jget_num(s, "id", 0));
|
|
cJSON_AddStringToObject(o, "file_name", jget_str(s, "file_name", ""));
|
|
cJSON_AddStringToObject(o, "slot", jget_str(s, "slot", ""));
|
|
cJSON_AddStringToObject(o, "emulator", jget_str(s, "emulator", ""));
|
|
cJSON_AddStringToObject(o, "updated_at", jget_str(s, "updated_at", ""));
|
|
cJSON_AddNumberToObject(o, "size", jget_num(s, "file_size_bytes", 0));
|
|
const char *device = "";
|
|
const cJSON *d;
|
|
cJSON_ArrayForEach(d, cJSON_GetObjectItemCaseSensitive(s, "device_syncs"))
|
|
if (!strcmp(jget_str(d, "device_id", ""), jget_str(s, "origin_device_id", "-"))) device = jget_str(d, "device_name", "");
|
|
cJSON_AddStringToObject(o, "device", device);
|
|
cJSON_AddItemToArray(arr, o);
|
|
}
|
|
}
|
|
cJSON_Delete(saves);
|
|
cJSON_Delete(states);
|
|
return out;
|
|
}
|
|
|
|
int sync_restore(const char *kind, int id, char *err, int en) {
|
|
int is_state = !strcmp(kind, "state");
|
|
if (!is_state && strcmp(kind, "save") != 0) {
|
|
snprintf(err, (size_t)en, "kind must be save or state");
|
|
return -1;
|
|
}
|
|
if (!is_state && sync_game_running()) {
|
|
snprintf(err, (size_t)en, "close the game before restoring a save");
|
|
return -1;
|
|
}
|
|
char api[128];
|
|
snprintf(api, sizeof api, is_state ? "/api/states/%d" : "/api/saves/%d", id);
|
|
long st = 0;
|
|
cJSON *meta = romm_call("GET", api, NULL, &st);
|
|
if (st != 200 || !meta) {
|
|
snprintf(err, (size_t)en, "not found on RomM");
|
|
cJSON_Delete(meta);
|
|
return -1;
|
|
}
|
|
|
|
pthread_mutex_lock(&g_run_lock);
|
|
scan_ctx c;
|
|
int rc = -1;
|
|
if (scan_prepare(&c) != 0) {
|
|
snprintf(err, (size_t)en, "cannot reach RomM");
|
|
goto done;
|
|
}
|
|
scan_saves(&c);
|
|
int rom_id = (int)jget_num(meta, "rom_id", 0);
|
|
const local_rom *r = is_state ? find_rom_for_state(&c, rom_id, jget_str(meta, "emulator", ""))
|
|
: find_rom(&c, rom_id, jget_str(meta, "emulator", NULL));
|
|
if (!r && is_state && find_rom(&c, rom_id, NULL)) {
|
|
snprintf(err, (size_t)en, "made by %s, which this console's emulator can't load",
|
|
jget_str(meta, "emulator", "")[0] ? jget_str(meta, "emulator", "") : "an unknown emulator");
|
|
goto done;
|
|
}
|
|
if (!r) {
|
|
snprintf(err, (size_t)en, "the game isn't on this console");
|
|
goto done;
|
|
}
|
|
char dest[PATH_MAX_LEN], dir[PATH_MAX_LEN], name[256];
|
|
if (is_state) {
|
|
if (!expected_dir(r, 1, dir, sizeof dir)) {
|
|
snprintf(err, (size_t)en, "unknown state folder");
|
|
goto done;
|
|
}
|
|
state_local_name(r, jget_str(meta, "file_name", ""), name, sizeof name);
|
|
path_join(dest, sizeof dest, dir, name);
|
|
rc = fetch_state(&c, meta, dest, r);
|
|
if (rc) snprintf(err, (size_t)en, "download failed");
|
|
} else if (r->map->save_layout[0]) {
|
|
const char *slot = jget_str(meta, "slot", c.slot);
|
|
rc = do_download_bundle(&c, id, r, slot && slot[0] ? slot : c.slot, 0, "", 0);
|
|
if (rc) snprintf(err, (size_t)en, "download failed");
|
|
str_copy(dest, sizeof dest, r->path);
|
|
} else {
|
|
const char *slot = jget_str(meta, "slot", c.slot);
|
|
const local_file *f = find_save(&c, rom_id, slot && slot[0] ? slot : c.slot);
|
|
if (f)
|
|
str_copy(dest, sizeof dest, f->path);
|
|
else
|
|
download_target(r, jget_str(meta, "file_name", ""), slot ? slot : c.slot, &c, dest, sizeof dest);
|
|
char tmp[PATH_MAX_LEN];
|
|
snprintf(api, sizeof api, "/api/saves/%d/content", id);
|
|
snprintf(tmp, sizeof tmp, "%s/tmp/restore-save-%d.bin", plat_data_dir(), id);
|
|
http_resp resp;
|
|
if (romm_download(api, tmp, NULL, NULL, &resp) != 0) {
|
|
snprintf(err, (size_t)en, "download failed");
|
|
unlink(tmp);
|
|
goto done;
|
|
}
|
|
backup_file(&c, dest, rom_id);
|
|
rc = move_file(tmp, dest);
|
|
if (rc) snprintf(err, (size_t)en, "could not write %s", dest);
|
|
/* Not recorded as synced: the next sync uploads it as the newest version. */
|
|
}
|
|
if (rc == 0) {
|
|
LOGI("restored %s %d to %s", kind, id, dest);
|
|
events_add(is_state ? "state" : "save", "restored", rom_id, dest, r ? emu_name(r->map) : "", "Older version");
|
|
}
|
|
done:
|
|
scan_free(&c);
|
|
pthread_mutex_unlock(&g_run_lock);
|
|
cJSON_Delete(meta);
|
|
if (rc == 0) sync_request("restore");
|
|
return rc;
|
|
}
|
|
|
|
/* One tracked file for sync_tracked. */
|
|
static void tracked_file(cJSON *games, const cJSON *roms, const profile_map *maps, int nmaps, const char *kind,
|
|
const cJSON *e, const cJSON *conflicts) {
|
|
int rom_id = (int)jget_num(e, "rom_id", 0);
|
|
/* The game's record, one whose file is here first. */
|
|
const cJSON *game = NULL, *g;
|
|
int here = 0;
|
|
cJSON_ArrayForEach(g, roms) if ((int)jget_num(g, "rom_id", -1) == rom_id && rom_id) {
|
|
const int exists = file_exists(g->string) || dir_exists(g->string);
|
|
if (!game || (exists && !here)) game = g;
|
|
here |= exists;
|
|
}
|
|
const char *platform = game ? jget_str(game, "platform_dir", "") : "";
|
|
const char *profile = game ? jget_str(game, "profile", "") : "";
|
|
const char *emulator = "";
|
|
for (int i = 0; i < nmaps; i++)
|
|
if (!strcmp(maps[i].profile_id, profile) && (!platform[0] || !strcmp(maps[i].platform_dir, platform)))
|
|
emulator = maps[i].profile_name;
|
|
/* A record from an older version names its profile differently: by platform. */
|
|
for (int i = 0; i < nmaps && !emulator[0] && platform[0]; i++)
|
|
if (!strcmp(maps[i].platform_dir, platform)) emulator = maps[i].profile_name;
|
|
|
|
char key[16];
|
|
snprintf(key, sizeof key, "%d", rom_id);
|
|
cJSON *entry = cJSON_GetObjectItemCaseSensitive(games, key);
|
|
if (!entry) {
|
|
entry = cJSON_AddObjectToObject(games, key);
|
|
cJSON_AddNumberToObject(entry, "rom_id", rom_id);
|
|
cJSON_AddStringToObject(entry, "name", game ? jget_str(game, "name", "") : "");
|
|
cJSON_AddStringToObject(entry, "platform", platform);
|
|
cJSON_AddStringToObject(entry, "emulator", emulator);
|
|
cJSON_AddArrayToObject(entry, "files");
|
|
}
|
|
const char *path = e->string;
|
|
const char *status = "synced";
|
|
if (cJSON_GetObjectItemCaseSensitive(conflicts, path)) {
|
|
status = "conflict";
|
|
} else if (!here) { /* the game isn't on this console any more */
|
|
status = "no_game";
|
|
} else if (strchr(path, '#')) { /* a save folder synced as a zip */
|
|
status = "synced";
|
|
} else if (!file_exists(path)) {
|
|
status = "missing";
|
|
} else {
|
|
char ns[24];
|
|
snprintf(ns, sizeof ns, "%lld", (long long)file_mtime_ns(path));
|
|
/* A newer time with the same content (emulators rewrite saves) is still in sync. */
|
|
char hash[33] = "";
|
|
if ((strcmp(ns, jget_str(e, "mtime_ns", "")) != 0 || file_size(path) != (int64_t)jget_num(e, "size", -1)) &&
|
|
(md5_file_hex(path, hash) != 0 || strcmp(hash, jget_str(e, "hash", "")) != 0))
|
|
status = "changed";
|
|
}
|
|
cJSON *f = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(f, "kind", kind);
|
|
cJSON_AddStringToObject(f, "path", path);
|
|
cJSON_AddStringToObject(f, "status", status);
|
|
cJSON_AddNumberToObject(f, "synced_at", jget_num(e, "synced_at", 0));
|
|
cJSON_AddNumberToObject(f, "size", jget_num(e, "size", 0));
|
|
if (!strcmp(kind, "save")) {
|
|
cJSON_AddStringToObject(f, "slot", jget_str(e, "slot", ""));
|
|
cJSON_AddNumberToObject(f, "server_id", jget_num(e, "save_id", 0));
|
|
} else {
|
|
cJSON_AddNumberToObject(f, "server_id", jget_num(e, "state_id", 0));
|
|
cJSON_AddBoolToObject(f, "from_here", jget_num(e, "mine", 0) != 0);
|
|
}
|
|
cJSON_AddItemToArray(cJSON_GetObjectItemCaseSensitive(entry, "files"), f);
|
|
}
|
|
|
|
cJSON *sync_tracked(int only_rom) {
|
|
profile_map *maps = NULL;
|
|
config_lock();
|
|
int nmaps = profiles_expand(g_cfg.profiles, &maps);
|
|
config_unlock();
|
|
cJSON *games = cJSON_CreateObject();
|
|
state_lock();
|
|
const cJSON *roms = state_section("roms"), *conflicts = state_section("conflicts"), *e;
|
|
static const char *const kinds[][2] = {{"saves", "save"}, {"states", "state"}};
|
|
for (int k = 0; k < 2; k++)
|
|
cJSON_ArrayForEach(e, state_section(kinds[k][0])) {
|
|
if (only_rom && (int)jget_num(e, "rom_id", 0) != only_rom) continue;
|
|
tracked_file(games, roms, maps, nmaps, kinds[k][1], e, conflicts);
|
|
}
|
|
state_unlock();
|
|
free(maps);
|
|
|
|
/* {"platforms": [{"platform": "gba", "games": [...]}], "saves": n, "states": n, "conflicts": n} */
|
|
cJSON *out = cJSON_CreateObject(), *plats = cJSON_AddArrayToObject(out, "platforms"), *gm;
|
|
int n[3] = {0, 0, 0};
|
|
while ((gm = games->child)) {
|
|
cJSON_DetachItemViaPointer(games, gm);
|
|
const char *pl = jget_str(gm, "platform", "");
|
|
cJSON *p = NULL, *x;
|
|
cJSON_ArrayForEach(x, plats) if (!strcmp(jget_str(x, "platform", ""), pl)) p = x;
|
|
if (!p) {
|
|
p = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(p, "platform", pl);
|
|
cJSON_AddArrayToObject(p, "games");
|
|
cJSON_AddItemToArray(plats, p);
|
|
}
|
|
cJSON_ArrayForEach(x, cJSON_GetObjectItemCaseSensitive(gm, "files")) {
|
|
n[!strcmp(jget_str(x, "kind", ""), "state")]++;
|
|
if (!strcmp(jget_str(x, "status", ""), "conflict")) n[2]++;
|
|
}
|
|
cJSON_AddItemToArray(cJSON_GetObjectItemCaseSensitive(p, "games"), gm);
|
|
}
|
|
cJSON_Delete(games);
|
|
cJSON_AddNumberToObject(out, "saves", n[0]);
|
|
cJSON_AddNumberToObject(out, "states", n[1]);
|
|
cJSON_AddNumberToObject(out, "conflicts", n[2]);
|
|
return out;
|
|
}
|