Files

1792 lines
82 KiB
C

// Adapted from MacNativeGames games/totk/runtime/fs_host.c @ b75be8cf (MIT, Copyright (c) 2026 MacNativeGames
// contributors); the probe follows MacNativeGames' romfs.c runtime_fs_probe and fs_host.c runtime_save_probe.
// nn::fs host implementation of the functions main imports. RomFS paths and
// handles go to the read-only backend (romfs.c); save, temporary and cache storage are host directories under the
// profile directory (rt_config()->profile, default build/profile):
//
// <profile>/save/<uid hi><uid lo>/ committed application save data, created on first use as the system launcher
// does for titles that declare save data
// <profile>/save-tx/ the save mount's working copy and Commit's staging (section "save
// transactions")
// <profile>/save-backup/<time>/ the three committed saves before the current one
// <profile>/temp/ emptied at start
// <profile>/cache/<index>/ cache storage
//
// The save mount is journaled as on Horizon: changes go to a working copy and become the committed save atomically at
// Commit; a crash or an Unmount before Commit leaves the previous committed save. Commit returns at once and the copy
// runs on a background thread (section "save transactions"); fs_save_flush lets the exit paths wait for it. Paths are
// UTF-8 (platform.h plat_fs_*).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "fs.h"
#include "host.h"
#include "platform/platform.h"
#include "romfs_path.h"
#define TOTK_APPLICATION_ID 0x0100f2c0115b6000ull
#define FILE_BASE 0x200000000ull
#define DIR_BASE 0x300000000ull
#define NFILES 1024
#define NDIRS 256
#define NMOUNTS 16
#define PATH_CAP 1100
enum { M_FREE, M_SAVE, M_TEMP, M_CACHE, M_SAVE_RO }; // M_SAVE: the working copy; M_SAVE_RO: the committed save
typedef struct { int kind; char name[32]; char root[1024]; } Mount;
typedef struct { PlatFile *h; int mode, save; } File; // h NULL = free; save: on a save-data mount
typedef struct { char *name; int kind; uint64_t size; } Listed;
typedef struct { Listed *e; int n, next, mode, used; } Dir; // the directory's entries at open; used 0 = free
static Mount mounts[NMOUNTS];
static File files[NFILES];
static Dir dirs[NDIRS];
static PlatMutex lock = PLAT_MUTEX_INIT;
static char profile[1024], save_root[1100], work_root[1100];
static volatile int32_t storage_ready;
static void need(int c, const char *m) {
if (!c) rt_fatal("fs: %s", m);
}
// ---- directory listings: every entry but "." and "..", taken whole before the caller changes the directory.
typedef struct { Listed *e; int n, cap; } Listing;
static int listing_add(const char *name, int kind, uint64_t size, void *ctx) {
Listing *l = ctx;
if (l->n == l->cap) {
l->cap = l->cap ? l->cap * 2 : 16;
l->e = realloc(l->e, (size_t)l->cap * sizeof *l->e);
need(l->e != NULL, "directory listing");
}
l->e[l->n].name = strdup(name);
need(l->e[l->n].name != NULL, "directory listing");
l->e[l->n].kind = kind, l->e[l->n].size = size;
l->n++;
return 1;
}
static int list_dir(const char *dir, Listing *l) {
*l = (Listing){0};
return plat_fs_list(dir, listing_add, l);
}
static void listing_free(Listed *e, int n) {
for (int i = 0; i < n; i++) free(e[i].name);
free(e);
}
// dir/name in a malloc'd string.
static char *join(const char *dir, const char *name) {
size_t n = strlen(dir), k = strlen(name);
char *p = malloc(n + k + 2);
need(p != NULL, "path allocation");
memcpy(p, dir, n);
p[n] = '/';
memcpy(p + n + 1, name, k + 1);
return p;
}
// Save in progress: a save-data file open for writing, or save data changed in the last 3 s and not committed yet
// (TotK writes a slot's files back to back and then commits; between the files nothing is open for a moment).
static volatile int32_t save_writers, save_dirty;
static volatile int64_t save_changed_ns;
static void save_wait_commit(void);
static void save_touched(void) {
save_wait_commit(); // every change to save data comes here first: none lands in a commit's snapshot
plat_xchg64(&save_changed_ns, (int64_t)plat_now_ns());
plat_xchg32(&save_dirty, 1);
}
int fs_save_busy(void) {
if (plat_cas32(&save_writers, 0, 0) > 0) return 1;
return plat_cas32(&save_dirty, 0, 0) &&
plat_now_ns() - (uint64_t)plat_cas64(&save_changed_ns, 0, 0) < 3000000000ull;
}
// ---- host directories
static int make_dir(const char *p) {
int e = plat_fs_mkdir(p);
return !e || (e == PLAT_FS_EXISTS && plat_fs_kind(p) == PLAT_FS_DIR);
}
// p and every missing parent.
static void make_dirs(const char *p) {
char buf[1100];
need(snprintf(buf, sizeof buf, "%s", p) < (int)sizeof buf, "profile path");
for (char *q = buf + 1; *q; q++)
if ((*q == '/' || *q == '\\') && q[-1] != ':') {
char c = *q;
*q = 0;
make_dir(buf);
*q = c;
}
if (!make_dir(buf)) rt_fatal("fs: cannot create directory %s", buf);
}
// The first path remove_tree could not delete and the OS error code, for the message of a fatal.
static char remove_failed[1100];
static int remove_error;
static void remove_note(const char *path) {
if (*remove_failed) return;
snprintf(remove_failed, sizeof remove_failed, "%s", path);
remove_error = plat_fs_os_error();
}
// Remove a directory tree; a link (symbolic link, junction) is removed as a link, never followed. 0 also when
// nothing is there.
static int remove_tree(const char *path) {
int kind = plat_fs_kind(path);
if (kind == PLAT_FS_MISSING) return 0;
if (kind == PLAT_FS_FILE) {
if (!plat_fs_unlink(path)) return 0;
remove_note(path);
return -1;
}
if (kind == PLAT_FS_LINK) { // a link to a file goes by unlink, a link to a directory by rmdir
if (!plat_fs_unlink(path) || !plat_fs_rmdir(path)) return 0;
remove_note(path);
return -1;
}
int bad = 0;
if (kind == PLAT_FS_DIR) {
Listing l;
list_dir(path, &l);
for (int i = 0; i < l.n; i++) {
char *child = join(path, l.e[i].name);
bad |= remove_tree(child);
free(child);
}
listing_free(l.e, l.n);
}
if (plat_fs_rmdir(path)) {
remove_note(path);
bad |= -1;
}
return bad;
}
// remove_tree, tried again every 50 ms for up to 2 s while another process (the previous run still exiting, a virus
// scanner, a sync client) holds a file in the tree. The failing path and its OS error stay in remove_failed.
#define MOVE_TRIES 40 // 2 s, also move_dir's
static int remove_tree_retry(const char *path) {
for (int i = 0;; i++) {
*remove_failed = 0;
if (!remove_tree(path)) return 0;
if (i == MOVE_TRIES) return -1;
plat_sleep_ms(50);
}
}
// ---- save transactions
// The save mount's root is a working copy, made from the newest save when the first save mount appears. Commit copies
// it to a staging directory, which a rename makes a finished commit, and swaps that in for the committed save:
//
// <profile>/save/<uid>/ committed save: what the next start loads when no finished commit waits
// <profile>/save-tx/work/ working copy, the save mount's root; deleted at the last Unmount and at start
// <profile>/save-tx/new/ Commit's staging copy of the working copy
// <profile>/save-tx/ready-<n>/ a finished commit not swapped in yet: the newest save
// <profile>/save-tx/old/ the committed save a Commit replaced, on its way to save-backup
//
// Commit: (1) copy work to new and flush every file (FlushFileBuffers) and directory, a file equal to the newest
// save's as a hard link to it; (2) rename new to ready-<n>, the commit point, and delete the ready-<n> it supersedes;
// (3) rename save/<uid> to old; (4) rename ready-<n> to save/<uid>; (5) move old to save-backup/<UTC time> and keep
// the newest three. Every rename is durable (plat_fs_rename). Windows refuses (3) while any file under save/<uid> is
// open, in this or another process (a virus scanner, Explorer's preview, a sync tool), and a folder an elevated run
// made can refuse it too. ready-<n> then stays the newest save: later working copies are made from it, and the next
// Commit and the next start try (3) to (5) again. The game's Commit returns 0 once the steps are queued; a thread runs
// them (95-135 ms on the game thread before, in the 1.4.3 fight save) while every change to the working copy waits in
// save_touched, and Unmount and the next Commit wait too. A crash in that time loses this Commit only, and a failed
// one is logged instead of returned. The recovery rule at start (save_recover):
// new present an unfinished copy: deleted (rollback). An older build renamed save/<uid> away before its
// finished copy: with save/<uid> and ready-<n> absent, new is that copy and becomes save/<uid>;
// ready-<n> present the highest n is the newest save and (3) to (5) run (roll forward); the others are deleted;
// save/<uid> absent with no ready-<n> either, old goes back to save/<uid>.
// Then old, if present, goes to save-backup, and work (changes no Commit took) is deleted.
#define SAVE_BACKUPS 3
#define COPY_CHUNK (1u << 20)
static char tx_root[1100], backup_root[1100], new_root[1100], old_root[1100];
static char ready_root[1100]; // the newest finished commit not swapped in, empty when none
static unsigned ready_seq; // the highest ready-<n> this run made or found
static PlatMutex tx_lock = PLAT_MUTEX_INIT; // taken before lock, never inside it
static int save_mounts; // M_SAVE mounts: the working copy exists while this is above 0
// Probe hook: Commit stops at its tx_crash_at-th step (from 1) and leaves the disk as a process killed there would.
static int tx_crash_at, tx_step, tx_dead;
static int crashed(void) {
if (tx_crash_at && ++tx_step == tx_crash_at) tx_dead = 1;
return tx_dead;
}
static int exists(const char *p) { return plat_fs_kind(p) != PLAT_FS_MISSING; }
// Rename a directory, durably; retried every 50 ms, tries times in all, while another process (a virus scanner, the
// indexer) holds a file in it. 0 on success.
static int move_dir(const char *from, const char *to, int tries) {
for (int i = 0; i < tries; i++) {
int e = plat_fs_rename(from, to, 1);
if (!e) return 0;
if (e != PLAT_FS_BUSY) return -1;
plat_sleep_ms(50);
}
return -1;
}
// Copy one file; the source may be open for writing (a save file the game has not closed). 0 on success.
static int copy_file(const char *src, const char *dst, int durable, uint64_t *bytes) {
PlatFile *in = plat_fs_open(src, 0, NULL);
if (!in) return -1;
PlatFile *out = plat_fs_create(dst, NULL);
if (!out) {
plat_file_close(in);
return -1;
}
uint8_t *buf = malloc(COPY_CHUNK);
need(buf != NULL, "copy buffer");
int ok = 1;
for (uint64_t at = 0;;) {
int64_t got = plat_file_pread(in, buf, COPY_CHUNK, at);
if (got < 0) ok = 0;
if (!ok || !got) break;
if (plat_file_pwrite(out, buf, (uint32_t)got, at) != got) ok = 0;
if (!ok) break;
at += (uint64_t)got;
*bytes += (uint64_t)got;
}
if (ok && durable) ok = plat_file_fsync(out);
free(buf);
plat_file_close(in);
plat_file_close(out);
return ok ? 0 : -1;
}
// Whether files a and b hold the same bytes; 0 when either cannot be read.
static int same_file(const char *a, const char *b) {
PlatFile *h[2] = {plat_fs_open(a, 0, NULL), plat_fs_open(b, 0, NULL)};
PlatFileId ia, ib;
int same = h[0] && h[1] && plat_file_id(h[0], &ia) && plat_file_id(h[1], &ib) && ia.size == ib.size;
uint8_t *buf = same ? malloc(2 * (size_t)COPY_CHUNK) : NULL;
if (same) need(buf != NULL, "compare buffer");
for (uint64_t at = 0; same;) {
int64_t ga = plat_file_pread(h[0], buf, COPY_CHUNK, at), gb = plat_file_pread(h[1], buf + COPY_CHUNK, COPY_CHUNK, at);
if (ga < 0 || ga != gb || memcmp(buf, buf + COPY_CHUNK, (size_t)ga)) same = 0;
else if (!ga) break;
at += (uint64_t)ga;
}
free(buf);
for (int i = 0; i < 2; i++)
if (h[i]) plat_file_close(h[i]);
return same;
}
// Copy the tree src to dst, which must not exist; links are skipped. durable: every file is flushed, and every
// directory as far as the file system allows. base (or NULL): the committed save; a file whose bytes equal base's file
// at the same path becomes a hard link to it instead of a copy (*nlinked), as committed files are never written in
// place (the game writes the working copy). A save commit then writes the 3-4 files of the slot the game saved instead
// of all 82 (300-380 ms of flushes on the game thread). 0 on success.
static int copy_tree(const char *src, const char *dst, const char *base, int durable, int *nfiles, int *nlinked,
uint64_t *bytes) {
if (plat_fs_mkdir(dst)) return -1;
Listing l;
if (list_dir(src, &l)) return -1;
int bad = 0;
for (int i = 0; i < l.n && !bad; i++) {
if (l.e[i].kind == PLAT_FS_LINK) continue;
char *s = join(src, l.e[i].name), *d = join(dst, l.e[i].name);
char *b = base ? join(base, l.e[i].name) : NULL;
if (b && !exists(b)) free(b), b = NULL;
if (l.e[i].kind == PLAT_FS_DIR) bad = copy_tree(s, d, b, durable, nfiles, nlinked, bytes);
else {
if (b && same_file(s, b) && !plat_fs_link(b, d)) bad = 0, ++*nlinked;
else bad = copy_file(s, d, durable, bytes);
*nfiles += !bad;
if (!bad && durable && crashed()) bad = -1;
}
free(s);
free(d);
free(b);
}
listing_free(l.e, l.n);
if (!bad && durable) plat_fs_sync_dir(dst);
return bad;
}
static int listed_cmp(const void *a, const void *b) {
return strcmp(((const Listed *)a)->name, ((const Listed *)b)->name);
}
// Whether paths a and b are one file (hard links of it).
static int same_inode(const char *a, const char *b) {
PlatFile *h[2] = {plat_fs_open(a, 0, NULL), plat_fs_open(b, 0, NULL)};
PlatFileId ia, ib;
int same = h[0] && h[1] && plat_file_id(h[0], &ia) && plat_file_id(h[1], &ib) && ia.volume == ib.volume &&
ia.file_id == ib.file_id;
for (int i = 0; i < 2; i++)
if (h[i]) plat_file_close(h[i]);
return same;
}
// Whether tree a holds the names and kinds of tree b and each file of it as a hard link: what copy_tree makes from a
// working copy whose every file equals base's.
static int same_tree(const char *a, const char *b) {
Listing la, lb;
int ea = list_dir(a, &la), eb = list_dir(b, &lb);
int same = !ea && !eb && la.n == lb.n;
if (same) {
qsort(la.e, (size_t)la.n, sizeof *la.e, listed_cmp);
qsort(lb.e, (size_t)lb.n, sizeof *lb.e, listed_cmp);
}
for (int i = 0; same && i < la.n; i++) {
same = !strcmp(la.e[i].name, lb.e[i].name) && la.e[i].kind == lb.e[i].kind && la.e[i].kind != PLAT_FS_LINK;
if (!same) break;
char *pa = join(a, la.e[i].name), *pb = join(b, lb.e[i].name);
same = la.e[i].kind == PLAT_FS_DIR ? same_tree(pa, pb) : same_inode(pa, pb);
free(pa);
free(pb);
}
listing_free(la.e, la.n);
listing_free(lb.e, lb.n);
return same;
}
static int name_cmp(const void *a, const void *b) { return strcmp(a, b); }
// The backups' folder names (digits first), sorted: oldest first, as rotate_backup names them in creation order.
static int backup_names(char (*names)[48]) {
int n = 0;
Listing l;
list_dir(backup_root, &l);
for (int i = 0; i < l.n; i++)
if (l.e[i].kind == PLAT_FS_DIR && l.e[i].name[0] >= '0' && l.e[i].name[0] <= '9' && strlen(l.e[i].name) < 48 &&
n < 64)
strcpy(names[n++], l.e[i].name);
listing_free(l.e, l.n);
qsort(names, (size_t)n, sizeof *names, name_cmp);
return n;
}
// Move old to save-backup/<UTC time> and delete all backups but the newest SAVE_BACKUPS. The name always sorts after
// every existing one: a commit in the same millisecond as the newest backup (or after the clock went back) takes its
// time and the next -NNN suffix. A name that sorted lower would be the first one pruned, losing the backup just made.
static void rotate_backup(void) {
if (!exists(old_root)) return;
static char names[64][48];
int n = backup_names(names);
uint64_t ns = plat_unix_time_ns();
struct tm t;
plat_gmtime((int64_t)(ns / 1000000000u), &t);
char name[48];
int len = snprintf(name, sizeof name, "%04d%02d%02d-%02d%02d%02d-%03u", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday,
t.tm_hour, t.tm_min, t.tm_sec, (unsigned)(ns / 1000000u % 1000u));
const char *newest = n ? names[n - 1] : "";
if (strcmp(name, newest) <= 0 && strlen(newest) >= (size_t)len) {
unsigned k = 0;
sscanf(newest + len, "-%u", &k); // no suffix: k stays 0
do // until it sorts after newest, even after an unpadded "-1" of older builds
snprintf(name, sizeof name, "%.*s-%03u", len, newest, ++k);
while (strcmp(name, newest) <= 0 && k < 100000);
}
char *dst = join(backup_root, name);
if (move_dir(old_root, dst, MOVE_TRIES)) {
rt_log("fs: cannot move the replaced save to save-backup; deleted");
remove_tree(old_root);
}
free(dst);
n = backup_names(names);
for (int i = 0; i + SAVE_BACKUPS < n; i++) {
char *p = join(backup_root, names[i]);
remove_tree(p);
free(p);
}
}
// The newest save: the finished commit waiting to be swapped in, else the committed save.
static const char *newest_save(void) { return *ready_root ? ready_root : save_root; }
// Steps (3) to (5): swap the finished commit ready_root in for the committed save. 0 on success; on failure
// ready_root stays the newest save. (3) is tried for 200 ms only: the commit is safe in ready_root, the next Commit
// or start tries again, and the game's next change to its save data waits for this one.
#define SWAP_TRIES 4
static int swap_in(void) {
if (!*ready_root) return 0;
rotate_backup(); // an old left by a failed (5): (3) needs its name
if (exists(save_root) && move_dir(save_root, old_root, SWAP_TRIES)) {
char why[512];
plat_fs_busy_reason(save_root, why, sizeof why);
rt_log("fs: save commit: the committed save cannot be replaced (%s); the commit waits in %s and the next "
"Commit or start retries", *why ? why : "a file in it is in use", ready_root);
return -1;
}
if (crashed()) return -1;
if (move_dir(ready_root, save_root, MOVE_TRIES)) {
rt_log("fs: save commit: cannot move the finished commit to the committed save; it waits in %s", ready_root);
move_dir(old_root, save_root, MOVE_TRIES); // else save/<uid> stays absent and the next start rolls forward
return -1;
}
*ready_root = 0;
if (crashed()) return -1;
rotate_backup();
return 0;
}
// The number n of a "ready-<n>" name, 0 for any other name.
static unsigned ready_number(const char *name) {
char *end;
if (strncmp(name, "ready-", 6) || name[6] < '0' || name[6] > '9') return 0;
unsigned long n = strtoul(name + 6, &end, 10);
return *end || n > 0xffffffffu ? 0 : (unsigned)n;
}
// Startup: the newest finished commit into ready_root; the older ones, which it supersedes, are deleted.
static void ready_scan(void) {
Listing l;
list_dir(tx_root, &l);
*ready_root = 0, ready_seq = 0;
for (int i = 0; i < l.n; i++)
if (l.e[i].kind == PLAT_FS_DIR && ready_number(l.e[i].name) > ready_seq) ready_seq = ready_number(l.e[i].name);
for (int i = 0; i < l.n; i++) {
unsigned n = l.e[i].kind == PLAT_FS_DIR ? ready_number(l.e[i].name) : 0;
char *p = n ? join(tx_root, l.e[i].name) : NULL;
if (n == ready_seq && p) snprintf(ready_root, sizeof ready_root, "%s", p);
else if (p) remove_tree(p);
free(p);
}
listing_free(l.e, l.n);
}
// Startup: finish or roll back an interrupted Commit and drop uncommitted changes (the rule above).
static void save_recover(void) {
const char *did = "nothing to recover";
ready_scan();
int has_save = exists(save_root);
if (exists(new_root) && !has_save && !*ready_root) {
if (move_dir(new_root, save_root, MOVE_TRIES)) rt_fatal("fs: cannot complete an interrupted save commit");
did = "completed an interrupted commit";
has_save = 1;
} else if (exists(new_root)) {
if (remove_tree(new_root)) rt_fatal("fs: cannot delete an unfinished save commit");
did = "rolled back an unfinished commit";
}
if (!has_save && !*ready_root && exists(old_root)) {
if (move_dir(old_root, save_root, MOVE_TRIES)) rt_fatal("fs: cannot roll back an interrupted save commit");
did = "rolled back an interrupted commit";
} else if (exists(old_root))
did = "moved the save an interrupted commit replaced to save-backup";
rotate_backup();
if (*ready_root)
did = swap_in() ? "a finished commit still waits in save-tx; the game loads it from there"
: "completed a finished commit";
if (exists(work_root)) {
if (remove_tree_retry(work_root))
rt_fatal("fs: cannot delete the old save working copy: %s (OS error %d)", remove_failed, remove_error);
if (!strcmp(did, "nothing to recover"))
did = "deleted the last run's working copy (changes after its last Commit)";
}
rt_log("host save recovery: %s", did);
}
// The first save mount: a fresh working copy of the newest save.
static void work_create(void) {
int n = 0, linked = 0;
uint64_t bytes = 0;
if (remove_tree(work_root) || copy_tree(newest_save(), work_root, NULL, 0, &n, &linked, &bytes))
rt_fatal("fs: cannot make the save working copy");
rt_log("host save working copy: %d files, %.1f MiB", n, bytes / 1048576.0);
}
// The last save unmount: changes no Commit took are dropped.
static void work_discard(void) {
if (plat_xchg32(&save_dirty, 0)) rt_log("fs: save unmounted without Commit; changes discarded");
if (remove_tree(work_root)) rt_log("fs: save working copy not fully deleted (a file still open?)");
}
// Commit of the save mount, tx_lock held. Steps (1) to (5) above; a failure before (2) leaves the newest save as it
// was, one after it leaves the commit waiting in ready_root.
static uint32_t save_commit_locked(void) {
uint64_t t0 = plat_now_ns(), bytes = 0;
int nfiles = 0, linked = 0;
tx_step = tx_dead = 0;
const char *base = exists(newest_save()) ? newest_save() : NULL;
if (remove_tree(new_root) || copy_tree(work_root, new_root, base, 1, &nfiles, &linked, &bytes)) {
if (tx_dead) return FS_R_INVALID_ARGUMENT;
rt_log("fs: save commit: copying the working copy failed; the newest save is unchanged");
remove_tree(new_root);
return FS_R_INVALID_ARGUMENT;
}
if (crashed()) return FS_R_INVALID_ARGUMENT;
if (base && linked == nfiles && same_tree(new_root, base)) { // the game rewrote equal bytes: no save, no backup
remove_tree(new_root);
plat_xchg32(&save_dirty, 0);
int waits = swap_in();
if (tx_dead) return FS_R_INVALID_ARGUMENT;
rt_log("fs commit save: %d files, none changed, in %.0f ms%s", nfiles, (plat_now_ns() - t0) / 1e6,
waits ? "; the newest save waits in save-tx" : "");
return 0;
}
char ready[1100];
snprintf(ready, sizeof ready, "%s/ready-%010u", tx_root, ready_seq + 1);
if (move_dir(new_root, ready, MOVE_TRIES)) {
rt_log("fs: save commit: cannot finish the staging copy; the newest save is unchanged");
remove_tree(new_root);
return FS_R_INVALID_ARGUMENT;
}
ready_seq++;
if (*ready_root) remove_tree(ready_root); // superseded: the working copy it was made from held all of it
snprintf(ready_root, sizeof ready_root, "%s", ready);
plat_xchg32(&save_dirty, 0);
if (crashed()) return FS_R_INVALID_ARGUMENT;
int waits = swap_in();
if (tx_dead) return FS_R_INVALID_ARGUMENT;
rt_log("fs commit save: %d files, %d changed (%.1f MiB written), %d unchanged linked, in %.0f ms%s", nfiles,
nfiles - linked, bytes / 1048576.0, linked, (plat_now_ns() - t0) / 1e6, waits ? "; waits in save-tx" : "");
return 0;
}
// The background commit: commit_pending from the game's Commit until the thread has run save_commit_locked.
static PlatMutex pend_lock = PLAT_MUTEX_INIT;
static PlatCond pend_cv = PLAT_COND_INIT;
static volatile int32_t commit_pending;
static void save_wait_commit(void) {
if (!plat_cas32(&commit_pending, 0, 0)) return;
plat_mutex_lock(&pend_lock);
while (commit_pending) plat_cond_wait(&pend_cv, &pend_lock);
plat_mutex_unlock(&pend_lock);
}
void fs_save_flush(void) { save_wait_commit(); }
static uint32_t commit_run(void) {
plat_mutex_lock(&tx_lock);
uint32_t r = save_commit_locked();
plat_dec32(&save_writers);
plat_mutex_unlock(&tx_lock);
plat_mutex_lock(&pend_lock);
commit_pending = 0;
plat_mutex_unlock(&pend_lock);
plat_cond_broadcast(&pend_cv);
return r;
}
static void commit_main(void *arg) {
(void)arg;
commit_run();
return;
}
static uint32_t save_commit(void) {
save_wait_commit(); // the previous Commit
static int quiet;
if (!plat_cas32(&save_dirty, 0, 0)) {
if (!quiet++) rt_log("fs commit save: nothing changed (logged once)");
return 0;
}
plat_inc32(&save_writers); // fs_save_busy until the thread is done
plat_xchg32(&commit_pending, 1);
if (plat_thread_spawn(commit_main, NULL, "save commit", 0)) {
return 0;
}
rt_log("fs: cannot start the save commit thread; committing on the game thread");
return commit_run();
}
// ---- storage roots
static void storage_init(void *param) {
(void)param;
if (!*profile)
need(snprintf(profile, sizeof profile, "%s", rt_config()->profile) < (int)sizeof profile, "profile path");
uint64_t uid[2];
char p[PATH_CAP];
make_dirs(profile);
account_uid(uid);
snprintf(p, sizeof p, "%s/save", profile);
make_dirs(p);
need(snprintf(save_root, sizeof save_root, "%s/save/%016llx%016llx", profile, (unsigned long long)uid[1],
(unsigned long long)uid[0]) < (int)sizeof save_root, "save path");
need(snprintf(tx_root, sizeof tx_root, "%s/save-tx", profile) < (int)sizeof tx_root, "save path");
snprintf(work_root, sizeof work_root, "%s/work", tx_root);
snprintf(backup_root, sizeof backup_root, "%s/save-backup", profile);
make_dirs(tx_root);
make_dirs(backup_root);
snprintf(new_root, sizeof new_root, "%s/new", tx_root);
snprintf(old_root, sizeof old_root, "%s/old", tx_root);
save_recover(); // before save_root is created: an absent save_root means a commit to complete
make_dirs(save_root);
snprintf(p, sizeof p, "%s/temp", profile);
remove_tree(p);
make_dirs(p);
snprintf(p, sizeof p, "%s/cache", profile);
make_dirs(p);
plat_xchg32(&storage_ready, 1);
rt_log("host save storage: %s", save_root);
return;
}
static void ready(void) {
static PlatOnce once = PLAT_ONCE_INIT;
plat_once(&once, storage_init, NULL);
}
void fs_host_set_profile(const char *dir) {
if (plat_cas32(&storage_ready, 0, 0)) rt_fatal("fs_host_set_profile after the first storage use");
need(dir && snprintf(profile, sizeof profile, "%s", dir) < (int)sizeof profile, "profile path");
}
// ---- mounts and paths
static uint32_t mount_add(const char *name, int kind, const char *root) {
if (!name || !*name || strlen(name) >= 32 || strchr(name, ':') || strchr(name, '/')) return FS_R_INVALID_ARGUMENT;
char as_path[40];
snprintf(as_path, sizeof as_path, "%s:", name);
if (romfs_owns(as_path)) return FS_R_ALREADY_EXISTS; // the RomFS mount's name
plat_mutex_lock(&lock);
int slot = -1;
for (int i = 0; i < NMOUNTS; i++) {
if (mounts[i].kind && !strcmp(mounts[i].name, name)) {
plat_mutex_unlock(&lock);
return FS_R_ALREADY_EXISTS;
}
if (!mounts[i].kind && slot < 0) slot = i;
}
if (slot < 0) rt_fatal("fs mount table full");
mounts[slot].kind = kind;
strcpy(mounts[slot].name, name);
snprintf(mounts[slot].root, sizeof mounts[slot].root, "%s", root);
plat_mutex_unlock(&lock);
rt_log("fs mount %s: -> %s", name, root);
return 0;
}
// A path component every host stores under exactly this name: not empty, ".", "..", no reserved character, no trailing
// dot or space (Windows strips those, which would alias two names).
static int component_ok(const char *c, size_t n) {
if (!n || (n == 1 && c[0] == '.') || (n == 2 && c[0] == '.' && c[1] == '.')) return 0;
if (c[n - 1] == '.' || c[n - 1] == ' ') return 0;
for (size_t i = 0; i < n; i++)
if ((unsigned char)c[i] < 32 || strchr("\\:*?\"<>|", c[i])) return 0;
return 1;
}
// "name:/a/b" -> host path; 0 on success; *kind = the mount's kind (M_SAVE ...) when kind is given.
static uint32_t host_path_kind(const char *path, char *out, size_t cap, int *kind) {
if (!path) return FS_R_INVALID_PATH;
const char *colon = strchr(path, ':');
if (!colon || colon[1] != '/') return FS_R_INVALID_PATH;
size_t n = (size_t)(colon - path);
char root[1024];
int k = M_FREE;
plat_mutex_lock_shared(&lock);
for (int i = 0; i < NMOUNTS; i++)
if (mounts[i].kind && strlen(mounts[i].name) == n && !strncmp(mounts[i].name, path, n)) {
strcpy(root, mounts[i].root);
k = mounts[i].kind;
}
plat_mutex_unlock_shared(&lock);
if (k == M_FREE) return FS_R_PATH_NOT_FOUND;
if (kind) *kind = k;
const char *rest = colon + 1;
for (const char *q = rest + 1; *q;) {
const char *e = strchr(q, '/');
size_t len = e ? (size_t)(e - q) : strlen(q);
if (!e && !len) break;
if (!component_ok(q, len)) return FS_R_INVALID_PATH;
q = e ? e + 1 : q + len;
}
if (snprintf(out, cap, "%s%s", root, rest) >= (int)cap) return FS_R_INVALID_PATH;
size_t L = strlen(out);
while (L > strlen(root) && out[L - 1] == '/') out[--L] = 0;
return 0;
}
static uint32_t host_path(const char *path, char *out, size_t cap) { return host_path_kind(path, out, cap, NULL); }
// A change to save data through a path (create, delete, rename, directories) marks the save as in progress.
static uint32_t save_change_path(const char *path, char *out, size_t cap) {
int kind = M_FREE;
uint32_t r = host_path_kind(path, out, cap, &kind);
if (!r && kind == M_SAVE_RO) return FS_R_UNSUPPORTED_OPEN;
if (!r && kind == M_SAVE) save_touched();
return r;
}
static uint32_t fs_result(int e) {
switch (e) {
case PLAT_FS_OK: return 0;
case PLAT_FS_NOT_FOUND: return FS_R_PATH_NOT_FOUND;
case PLAT_FS_EXISTS: return FS_R_ALREADY_EXISTS;
case PLAT_FS_NOT_EMPTY: return FS_R_NOT_EMPTY;
default: return FS_R_INVALID_ARGUMENT;
}
}
// ---- storage mounts
static int my_uid(const uint64_t *uid) {
uint64_t me[2];
account_uid(me);
return uid && uid[0] == me[0] && uid[1] == me[1];
}
static uint32_t mount_save(const char *name, const uint64_t *uid) {
ready();
if (!my_uid(uid)) return FS_R_TARGET_NOT_FOUND;
plat_mutex_lock(&tx_lock);
if (!save_mounts) work_create();
uint32_t r = mount_add(name, M_SAVE, work_root);
if (!r) save_mounts++;
else if (!save_mounts) remove_tree(work_root);
plat_mutex_unlock(&tx_lock);
return r;
}
static uint8_t save_exists(uint64_t application, const uint64_t *uid) {
ready();
return application == TOTK_APPLICATION_ID && my_uid(uid);
}
// Other titles' save data (data-link bonuses) is not present on this host.
static uint32_t mount_save_ro(const char *name, uint64_t application, const uint64_t *uid) {
if (!save_exists(application, uid)) return FS_R_TARGET_NOT_FOUND;
return mount_add(name, M_SAVE_RO, save_root);
}
static uint32_t mount_temp(const char *name) {
ready();
char p[PATH_CAP];
snprintf(p, sizeof p, "%s/temp", profile);
return mount_add(name, M_TEMP, p);
}
static void cache_dir(int index, char *out, size_t cap) { snprintf(out, cap, "%s/cache/%d", profile, index); }
static uint32_t create_cache(int index, int64_t size, int64_t journal) {
ready();
if (index < 0 || size < 0 || journal < 0) return FS_R_INVALID_ARGUMENT;
char p[PATH_CAP];
cache_dir(index, p, sizeof p);
return fs_result(plat_fs_mkdir(p));
}
static uint32_t delete_cache(int index) {
ready();
char p[PATH_CAP];
cache_dir(index, p, sizeof p);
return remove_tree(p) ? FS_R_TARGET_NOT_FOUND : 0;
}
static uint32_t mount_cache(const char *name, int index) {
ready();
char p[PATH_CAP];
cache_dir(index, p, sizeof p);
if (plat_fs_kind(p) != PLAT_FS_DIR) return FS_R_TARGET_NOT_FOUND;
return mount_add(name, M_CACHE, p);
}
typedef struct { int32_t index; uint8_t reserved[0x1c]; } CacheStorageInfo;
_Static_assert(sizeof(CacheStorageInfo) == 0x20, "nn::fs::CacheStorageInfo");
typedef struct { int list[64]; int count, next; } CacheList;
static uint32_t open_cache_list(uint64_t *out) {
ready();
if (!out) return FS_R_INVALID_ARGUMENT;
CacheList *l = calloc(1, sizeof *l);
need(l != NULL, "cache list allocation");
char p[PATH_CAP];
snprintf(p, sizeof p, "%s/cache", profile);
Listing ls;
list_dir(p, &ls);
for (int i = 0; i < ls.n; i++) {
char *end;
long v = strtol(ls.e[i].name, &end, 10);
if (ls.e[i].kind == PLAT_FS_DIR && *ls.e[i].name && !*end && v >= 0 && l->count < 64) l->list[l->count++] = (int)v;
}
listing_free(ls.e, ls.n);
*out = (uint64_t)(uintptr_t)l;
return 0;
}
static uint32_t read_cache_list(int *count, CacheStorageInfo *out, uint64_t h, int max) {
CacheList *l = (CacheList *)(uintptr_t)h;
if (!l || !count || max < 0 || (!out && max)) return FS_R_INVALID_ARGUMENT;
int n = 0;
while (n < max && l->next < l->count) {
memset(&out[n], 0, sizeof *out);
out[n++].index = l->list[l->next++];
}
*count = n;
return 0;
}
static void close_cache_list(uint64_t h) { free((void *)(uintptr_t)h); }
static void unmount(const char *name) {
save_wait_commit(); // else the working copy could go before a Commit queued just now has copied it
plat_mutex_lock(&tx_lock);
plat_mutex_lock(&lock);
for (int i = 0; i < NMOUNTS; i++)
if (name && mounts[i].kind && !strcmp(mounts[i].name, name)) {
int save = mounts[i].kind == M_SAVE;
mounts[i].kind = M_FREE;
plat_mutex_unlock(&lock);
if (save && !--save_mounts) work_discard();
plat_mutex_unlock(&tx_lock);
rt_log("fs unmount %s", name);
return;
}
plat_mutex_unlock(&lock);
plat_mutex_unlock(&tx_lock);
size_t n = name ? strlen(name) : 0;
char probe[40];
if (!n || n >= 32 || (snprintf(probe, sizeof probe, "%s:", name), !romfs_owns(probe)))
rt_fatal("fs: unmount of unknown mount %s", name ? name : "(null)");
}
static uint32_t commit(const char *name) {
int found = 0, save = 0;
plat_mutex_lock_shared(&lock);
for (int i = 0; i < NMOUNTS; i++)
if (name && mounts[i].kind && !strcmp(mounts[i].name, name)) found = 1, save = mounts[i].kind == M_SAVE;
plat_mutex_unlock_shared(&lock);
if (!found) return FS_R_PATH_NOT_FOUND;
if (save) return save_commit(); // durability comes from the staging copy, not the working copy's handles
plat_mutex_lock_shared(&lock);
for (int i = 0; i < NFILES; i++)
if (files[i].h && (files[i].mode & FS_OPEN_WRITE)) plat_file_fsync(files[i].h);
plat_mutex_unlock_shared(&lock);
return 0;
}
// ---- files
static File *file_of(uint64_t h, PlatFile **fh, int *mode) {
if (h < FILE_BASE || h >= FILE_BASE + NFILES) return NULL;
plat_mutex_lock_shared(&lock);
File *f = &files[h - FILE_BASE];
PlatFile *v = f->h;
if (fh) *fh = f->h;
if (mode) *mode = f->mode;
plat_mutex_unlock_shared(&lock);
return v ? f : NULL;
}
// Positional reads: bytes read at off, 0 at the end, -1 on an error.
static int64_t host_read_at(PlatFile *f, void *buf, uint32_t n, uint64_t off) {
mem_thaw((uintptr_t)buf, n); // the kernel's write would fail on a frozen page instead of faulting
int64_t got = plat_file_pread(f, buf, n, off);
if (got == -2) // a read-only page in the buffer: a frozen region missed its thaw
rt_log("fs: read of %u bytes into %p refused: the buffer is not writable", n, buf);
return got < 0 ? -1 : got;
}
static uint32_t open_file(uint64_t *out, const char *path, int mode) {
if (romfs_owns(path)) return romfs_open(out, path, mode);
fs_trace(mode & FS_OPEN_WRITE ? "write" : "read", path);
if (!out || !(mode & (FS_OPEN_READ | FS_OPEN_WRITE)) || (mode & ~7)) return FS_R_INVALID_ARGUMENT;
char p[PATH_CAP];
int kind = M_FREE;
uint32_t r = host_path_kind(path, p, sizeof p, &kind);
if (r) return r;
if (plat_fs_kind(p) != PLAT_FS_FILE) return FS_R_PATH_NOT_FOUND;
if (kind == M_SAVE_RO && (mode & FS_OPEN_WRITE)) return FS_R_UNSUPPORTED_OPEN;
int save_writer = kind == M_SAVE && (mode & FS_OPEN_WRITE);
int err = 0;
PlatFile *f = plat_fs_open(p, (mode & FS_OPEN_WRITE) != 0, &err);
if (!f) return fs_result(err);
plat_mutex_lock(&lock);
for (int i = 0; i < NFILES; i++)
if (!files[i].h) {
files[i] = (File){f, mode, save_writer};
*out = FILE_BASE + (uint64_t)i;
plat_mutex_unlock(&lock);
if (save_writer) {
plat_inc32(&save_writers);
save_touched();
}
return 0;
}
plat_mutex_unlock(&lock);
rt_fatal("fs: host file handle table full (%d)", NFILES);
}
static uint32_t read_file(uint64_t *out, uint64_t h, int64_t offset, void *buffer, uint64_t size) {
if (romfs_handle(h)) return romfs_read(out, h, offset, buffer, size);
PlatFile *f;
int mode;
if (!out || offset < 0 || (!buffer && size) || !file_of(h, &f, &mode)) return FS_R_INVALID_ARGUMENT;
if (!(mode & FS_OPEN_READ)) return FS_R_OPEN_MODE;
uint64_t done = 0;
while (done < size) {
uint32_t chunk = size - done > 0x40000000 ? 0x40000000 : (uint32_t)(size - done);
int64_t n = host_read_at(f, (uint8_t *)buffer + done, chunk, (uint64_t)offset + done);
if (n < 0) rt_fatal("fs: host save read failure");
if (!n) break;
done += (uint64_t)n;
}
*out = done;
return 0;
}
static uint32_t read_exact(uint64_t h, int64_t off, void *buf, uint64_t size) {
uint64_t n = 0;
uint32_t r = read_file(&n, h, off, buf, size);
return r ? r : n == size ? 0 : FS_R_INVALID_ARGUMENT;
}
static uint32_t write_file(uint64_t h, int64_t offset, const void *buffer, uint64_t size, const int32_t *option) {
PlatFile *f;
int mode;
File *file = offset < 0 || (!buffer && size) ? NULL : file_of(h, &f, &mode);
if (!file) return FS_R_INVALID_ARGUMENT;
if (!(mode & FS_OPEN_WRITE)) return FS_R_OPEN_MODE;
PlatFileId id;
if (!plat_file_id(f, &id)) rt_fatal("fs: host save size");
if ((uint64_t)offset + size > id.size && !(mode & FS_OPEN_APPEND)) return FS_R_APPEND_REQUIRED;
if (file->save) save_touched();
for (uint64_t done = 0; done < size;) {
uint32_t chunk = size - done > 0x40000000 ? 0x40000000 : (uint32_t)(size - done);
int64_t put = plat_file_pwrite(f, (const uint8_t *)buffer + done, chunk, (uint64_t)offset + done);
if (put <= 0) rt_fatal("fs: host save write failure");
done += (uint64_t)put;
}
if (option && (*option & 1)) plat_file_fsync(f); // WriteOptionFlag_Flush
return 0;
}
static uint32_t file_size(int64_t *out, uint64_t h) {
if (romfs_handle(h)) return romfs_size(out, h);
PlatFile *f;
PlatFileId id;
if (!out || !file_of(h, &f, NULL) || !plat_file_id(f, &id)) return FS_R_INVALID_ARGUMENT;
*out = (int64_t)id.size;
return 0;
}
static uint32_t set_file_size(uint64_t h, int64_t size) {
PlatFile *f;
int mode;
File *file = size < 0 ? NULL : file_of(h, &f, &mode);
if (!file) return FS_R_INVALID_ARGUMENT;
if (!(mode & FS_OPEN_WRITE)) return FS_R_OPEN_MODE;
if (file->save) save_touched();
return fs_result(plat_file_set_size(f, (uint64_t)size));
}
static uint32_t flush_file(uint64_t h) {
PlatFile *f;
int mode;
if (!file_of(h, &f, &mode)) return FS_R_INVALID_ARGUMENT;
if (mode & FS_OPEN_WRITE) plat_file_fsync(f);
return 0;
}
static void close_file(uint64_t h) {
if (romfs_handle(h)) {
romfs_close(h);
return;
}
if (!file_of(h, NULL, NULL)) rt_fatal("fs: close of invalid file handle %#llx", (unsigned long long)h);
plat_mutex_lock(&lock);
PlatFile *f = files[h - FILE_BASE].h;
int save = files[h - FILE_BASE].save;
files[h - FILE_BASE] = (File){0};
plat_mutex_unlock(&lock);
plat_file_close(f);
if (save) {
save_touched();
plat_dec32(&save_writers);
}
}
static uint32_t create_file(const char *path, int64_t size) {
char p[PATH_CAP];
uint32_t r = save_change_path(path, p, sizeof p);
if (r) return r;
if (size < 0) return FS_R_INVALID_ARGUMENT;
int err = 0;
PlatFile *f = plat_fs_create(p, &err);
if (!f) return fs_result(err);
err = size ? plat_file_set_size(f, (uint64_t)size) : 0;
plat_file_close(f);
return fs_result(err);
}
static uint32_t delete_file(const char *path) {
char p[PATH_CAP];
uint32_t r = save_change_path(path, p, sizeof p);
if (r) return r;
if (plat_fs_kind(p) == PLAT_FS_DIR) return FS_R_INVALID_ARGUMENT; // DeleteFile on a directory
return fs_result(plat_fs_unlink(p));
}
static uint32_t rename_file(const char *from, const char *to) {
char a[PATH_CAP], b[PATH_CAP];
uint32_t r = save_change_path(from, a, sizeof a);
if (!r) r = host_path(to, b, sizeof b);
if (r) return r;
size_t n = strcspn(from, ":");
if (strcspn(to, ":") != n || strncmp(from, to, n)) return FS_R_INVALID_PATH; // across mounts
if (exists(b)) return FS_R_ALREADY_EXISTS;
return fs_result(plat_fs_rename(a, b, 0));
}
static uint32_t entry_type(int *out, const char *path) {
if (romfs_owns(path)) return romfs_entry_type(out, path);
char p[PATH_CAP];
uint32_t r = host_path(path, p, sizeof p);
if (r) return r;
if (!out) return FS_R_INVALID_ARGUMENT;
int kind = plat_fs_kind(p);
if (kind == PLAT_FS_MISSING) return FS_R_PATH_NOT_FOUND;
*out = kind == PLAT_FS_DIR ? FS_ENTRY_DIRECTORY : FS_ENTRY_FILE;
return 0;
}
// ---- directories
typedef struct { char name[769]; uint8_t pad0[3]; int8_t type; uint8_t pad1[3]; int64_t size; } DirectoryEntry;
_Static_assert(sizeof(DirectoryEntry) == 0x310, "nn::fs::DirectoryEntry");
static uint32_t create_directory(const char *path) {
char p[PATH_CAP];
uint32_t r = save_change_path(path, p, sizeof p);
if (r) return r;
return fs_result(plat_fs_mkdir(p));
}
static uint32_t delete_recursive(const char *path) {
char p[PATH_CAP];
uint32_t r = save_change_path(path, p, sizeof p);
if (r) return r;
if (plat_fs_kind(p) != PLAT_FS_DIR) return FS_R_PATH_NOT_FOUND;
return remove_tree(p) ? FS_R_INVALID_ARGUMENT : 0;
}
static uint32_t open_directory(uint64_t *out, const char *path, int mode) {
if (romfs_owns(path)) rt_fatal("fs: OpenDirectory on the RomFS (%s) is not implemented", path);
char p[PATH_CAP];
uint32_t r = host_path(path, p, sizeof p);
if (r) return r;
if (!out || mode < 1 || mode > 3) return FS_R_INVALID_ARGUMENT;
if (plat_fs_kind(p) != PLAT_FS_DIR) return FS_R_PATH_NOT_FOUND;
Listing l;
int e = list_dir(p, &l);
if (e) return fs_result(e);
plat_mutex_lock(&lock);
for (int i = 0; i < NDIRS; i++)
if (!dirs[i].used) {
dirs[i] = (Dir){l.e, l.n, 0, mode, 1};
*out = DIR_BASE + (uint64_t)i;
plat_mutex_unlock(&lock);
return 0;
}
plat_mutex_unlock(&lock);
rt_fatal("fs: host directory handle table full (%d)", NDIRS);
}
static uint32_t read_directory(int64_t *count, DirectoryEntry *out, uint64_t h, int64_t max) {
if (h < DIR_BASE || h >= DIR_BASE + NDIRS || !count || max < 0 || (!out && max)) return FS_R_INVALID_ARGUMENT;
Dir *d = &dirs[h - DIR_BASE];
if (!d->used) return FS_R_INVALID_ARGUMENT;
int64_t n = 0;
while (n < max && d->next < d->n) {
const Listed *e = &d->e[d->next++];
size_t k = strlen(e->name) + 1;
if (k > 769) continue; // over 768 bytes: nn::fs cannot name it
int is_dir = e->kind == PLAT_FS_DIR;
if (!(d->mode & (is_dir ? 1 : 2))) continue;
memset(&out[n], 0, sizeof *out);
memcpy(out[n].name, e->name, k);
out[n].type = is_dir ? FS_ENTRY_DIRECTORY : FS_ENTRY_FILE;
out[n].size = is_dir ? 0 : (int64_t)e->size;
n++;
}
*count = n;
return 0;
}
static void close_directory(uint64_t h) {
if (h < DIR_BASE || h >= DIR_BASE + NDIRS || !dirs[h - DIR_BASE].used)
rt_fatal("fs: close of invalid directory handle %#llx", (unsigned long long)h);
plat_mutex_lock(&lock);
Dir d = dirs[h - DIR_BASE];
dirs[h - DIR_BASE] = (Dir){0};
plat_mutex_unlock(&lock);
listing_free(d.e, d.n);
}
// ---- overrides: AAPCS64 thunks. FileHandle, DirectoryHandle, CacheStorageListHandle
// and ApplicationId are 8-byte structs passed in one register; Uid, ReadOption and WriteOption by reference.
#define I32(i) ((int32_t)arg_x(c, i))
#define I64(i) ((int64_t)arg_x(c, i))
static void ov_open_file(Cpu *c) { ret_x(c, open_file(arg_p(c, 0), arg_p(c, 1), I32(2))); }
static void ov_file_size(Cpu *c) { ret_x(c, file_size(arg_p(c, 0), arg_x(c, 1))); }
static void ov_close_file(Cpu *c) { close_file(arg_x(c, 0)); }
static void ov_entry_type(Cpu *c) { ret_x(c, entry_type(arg_p(c, 0), arg_p(c, 1))); }
static void ov_read_file(Cpu *c) { ret_x(c, read_file(arg_p(c, 0), arg_x(c, 1), I64(2), arg_p(c, 3), arg_x(c, 4))); }
static void ov_read_options(Cpu *c) { ret_x(c, read_file(arg_p(c, 0), arg_x(c, 1), I64(2), arg_p(c, 3), arg_x(c, 4))); }
static void ov_read_exact(Cpu *c) { ret_x(c, read_exact(arg_x(c, 0), I64(1), arg_p(c, 2), arg_x(c, 3))); }
static void ov_write_file(Cpu *c) { ret_x(c, write_file(arg_x(c, 0), I64(1), arg_p(c, 2), arg_x(c, 3), arg_p(c, 4))); }
static void ov_set_file_size(Cpu *c) { ret_x(c, set_file_size(arg_x(c, 0), I64(1))); }
static void ov_flush_file(Cpu *c) { ret_x(c, flush_file(arg_x(c, 0))); }
static void ov_create_file(Cpu *c) { ret_x(c, create_file(arg_p(c, 0), I64(1))); }
static void ov_delete_file(Cpu *c) { ret_x(c, delete_file(arg_p(c, 0))); }
static void ov_rename_file(Cpu *c) { ret_x(c, rename_file(arg_p(c, 0), arg_p(c, 1))); }
static void ov_create_directory(Cpu *c) { ret_x(c, create_directory(arg_p(c, 0))); }
static void ov_delete_recursive(Cpu *c) { ret_x(c, delete_recursive(arg_p(c, 0))); }
static void ov_open_directory(Cpu *c) { ret_x(c, open_directory(arg_p(c, 0), arg_p(c, 1), I32(2))); }
static void ov_read_directory(Cpu *c) { ret_x(c, read_directory(arg_p(c, 0), arg_p(c, 1), arg_x(c, 2), I64(3))); }
static void ov_close_directory(Cpu *c) { close_directory(arg_x(c, 0)); }
static void ov_mount_save(Cpu *c) { ret_x(c, mount_save(arg_p(c, 0), arg_p(c, 1))); }
static void ov_mount_save_ro(Cpu *c) { ret_x(c, mount_save_ro(arg_p(c, 0), arg_x(c, 1), arg_p(c, 2))); }
static void ov_save_exists(Cpu *c) { ret_x(c, save_exists(arg_x(c, 0), arg_p(c, 1))); }
static void ov_mount_temp(Cpu *c) { ret_x(c, mount_temp(arg_p(c, 0))); }
static void ov_unmount(Cpu *c) { unmount(arg_p(c, 0)); }
static void ov_commit(Cpu *c) { ret_x(c, commit(arg_p(c, 0))); }
static void ov_create_cache(Cpu *c) { ret_x(c, create_cache(I32(0), I64(1), I64(2))); }
static void ov_delete_cache(Cpu *c) { ret_x(c, delete_cache(I32(0))); }
static void ov_mount_cache(Cpu *c) { ret_x(c, mount_cache(arg_p(c, 0), I32(1))); }
static void ov_open_cache_list(Cpu *c) { ret_x(c, open_cache_list(arg_p(c, 0))); }
static void ov_read_cache_list(Cpu *c) { ret_x(c, read_cache_list(arg_p(c, 0), arg_p(c, 1), arg_x(c, 2), I32(3))); }
static void ov_close_cache_list(Cpu *c) { close_cache_list(arg_x(c, 0)); }
// The fs priority only orders I/O inside the system fs process; host I/O is unordered.
static void ov_set_priority(Cpu *c) { (void)c; }
#undef I32
#undef I64
#define S_OPEN_FILE "_ZN2nn2fs8OpenFileEPNS0_10FileHandleEPKci"
#define S_FILE_SIZE "_ZN2nn2fs11GetFileSizeEPlNS0_10FileHandleE"
#define S_CLOSE_FILE "_ZN2nn2fs9CloseFileENS0_10FileHandleE"
#define S_ENTRY_TYPE "_ZN2nn2fs12GetEntryTypeEPNS0_18DirectoryEntryTypeEPKc"
#define S_READ_FILE "_ZN2nn2fs8ReadFileEPmNS0_10FileHandleElPvm"
#define S_READ_OPTIONS "_ZN2nn2fs8ReadFileEPmNS0_10FileHandleElPvmRKNS0_10ReadOptionE"
#define S_READ_EXACT "_ZN2nn2fs8ReadFileENS0_10FileHandleElPvm"
#define S_WRITE_FILE "_ZN2nn2fs9WriteFileENS0_10FileHandleElPKvmRKNS0_11WriteOptionE"
#define S_SET_FILE_SIZE "_ZN2nn2fs11SetFileSizeENS0_10FileHandleEl"
#define S_FLUSH_FILE "_ZN2nn2fs9FlushFileENS0_10FileHandleE"
#define S_CREATE_FILE "_ZN2nn2fs10CreateFileEPKcl"
#define S_DELETE_FILE "_ZN2nn2fs10DeleteFileEPKc"
#define S_RENAME_FILE "_ZN2nn2fs10RenameFileEPKcS2_"
#define S_CREATE_DIRECTORY "_ZN2nn2fs15CreateDirectoryEPKc"
#define S_DELETE_RECURSIVE "_ZN2nn2fs26DeleteDirectoryRecursivelyEPKc"
#define S_OPEN_DIRECTORY "_ZN2nn2fs13OpenDirectoryEPNS0_15DirectoryHandleEPKci"
#define S_READ_DIRECTORY "_ZN2nn2fs13ReadDirectoryEPlPNS0_14DirectoryEntryENS0_15DirectoryHandleEl"
#define S_CLOSE_DIRECTORY "_ZN2nn2fs14CloseDirectoryENS0_15DirectoryHandleE"
#define S_MOUNT_SAVE "_ZN2nn2fs13MountSaveDataEPKcRKNS_7account3UidE"
#define S_MOUNT_SAVE_RO "_ZN2nn2fs21MountSaveDataReadOnlyEPKcNS_13ApplicationIdERKNS_7account3UidE"
#define S_SAVE_EXISTS "_ZN2nn2fs18IsSaveDataExistingENS_13ApplicationIdERKNS_7account3UidE"
#define S_MOUNT_TEMP "_ZN2nn2fs21MountTemporaryStorageEPKc"
#define S_UNMOUNT "_ZN2nn2fs7UnmountEPKc"
#define S_COMMIT "_ZN2nn2fs6CommitEPKc"
#define S_CREATE_CACHE "_ZN2nn2fs18CreateCacheStorageEill"
#define S_DELETE_CACHE "_ZN2nn2fs18DeleteCacheStorageEi"
#define S_MOUNT_CACHE "_ZN2nn2fs17MountCacheStorageEPKci"
#define S_OPEN_CACHE_LIST "_ZN2nn2fs20OpenCacheStorageListEPNS0_22CacheStorageListHandleE"
#define S_READ_CACHE_LIST "_ZN2nn2fs20ReadCacheStorageListEPiPNS0_16CacheStorageInfoENS0_22CacheStorageListHandleEi"
#define S_CLOSE_CACHE_LIST "_ZN2nn2fs21CloseCacheStorageListENS0_22CacheStorageListHandleE"
#define S_SET_PRIORITY "_ZN2nn2fs26SetPriorityOnCurrentThreadENS0_8PriorityE"
// romfs.c
#define S_MOUNT_ROM "_ZN2nn2fs8MountRomEPKcPvm"
#define S_QUERY_CACHE "_ZN2nn2fs22QueryMountRomCacheSizeEPm"
#define S_INITIALIZE "_ZN2nn2fs24InitializeForApplicationEv"
HOST_OVERRIDE(S_OPEN_FILE, ov_open_file);
HOST_OVERRIDE(S_FILE_SIZE, ov_file_size);
HOST_OVERRIDE(S_CLOSE_FILE, ov_close_file);
HOST_OVERRIDE(S_ENTRY_TYPE, ov_entry_type);
HOST_OVERRIDE(S_READ_FILE, ov_read_file);
HOST_OVERRIDE(S_READ_OPTIONS, ov_read_options);
HOST_OVERRIDE(S_READ_EXACT, ov_read_exact);
HOST_OVERRIDE(S_WRITE_FILE, ov_write_file);
HOST_OVERRIDE(S_SET_FILE_SIZE, ov_set_file_size);
HOST_OVERRIDE(S_FLUSH_FILE, ov_flush_file);
HOST_OVERRIDE(S_CREATE_FILE, ov_create_file);
HOST_OVERRIDE(S_DELETE_FILE, ov_delete_file);
HOST_OVERRIDE(S_RENAME_FILE, ov_rename_file);
HOST_OVERRIDE(S_CREATE_DIRECTORY, ov_create_directory);
HOST_OVERRIDE(S_DELETE_RECURSIVE, ov_delete_recursive);
HOST_OVERRIDE(S_OPEN_DIRECTORY, ov_open_directory);
HOST_OVERRIDE(S_READ_DIRECTORY, ov_read_directory);
HOST_OVERRIDE(S_CLOSE_DIRECTORY, ov_close_directory);
HOST_OVERRIDE(S_MOUNT_SAVE, ov_mount_save);
HOST_OVERRIDE(S_MOUNT_SAVE_RO, ov_mount_save_ro);
HOST_OVERRIDE(S_SAVE_EXISTS, ov_save_exists);
HOST_OVERRIDE(S_MOUNT_TEMP, ov_mount_temp);
HOST_OVERRIDE(S_UNMOUNT, ov_unmount);
HOST_OVERRIDE(S_COMMIT, ov_commit);
HOST_OVERRIDE(S_CREATE_CACHE, ov_create_cache);
HOST_OVERRIDE(S_DELETE_CACHE, ov_delete_cache);
HOST_OVERRIDE(S_MOUNT_CACHE, ov_mount_cache);
HOST_OVERRIDE(S_OPEN_CACHE_LIST, ov_open_cache_list);
HOST_OVERRIDE(S_READ_CACHE_LIST, ov_read_cache_list);
HOST_OVERRIDE(S_CLOSE_CACHE_LIST, ov_close_cache_list);
HOST_OVERRIDE(S_SET_PRIORITY, ov_set_priority);
// ---- --probe-fs
// Every check calls the overrides the way guest code does: by synthetic address through rt_call, with AAPCS64
// arguments in a Cpu, on this (probe) thread. Prints counts only, never RomFS paths.
static const char *const probe_symbols[] = {
S_OPEN_FILE, S_FILE_SIZE, S_CLOSE_FILE, S_ENTRY_TYPE, S_READ_FILE, S_READ_OPTIONS, S_READ_EXACT, S_WRITE_FILE,
S_SET_FILE_SIZE, S_FLUSH_FILE, S_CREATE_FILE, S_DELETE_FILE, S_RENAME_FILE, S_CREATE_DIRECTORY, S_DELETE_RECURSIVE,
S_OPEN_DIRECTORY, S_READ_DIRECTORY, S_CLOSE_DIRECTORY, S_MOUNT_SAVE, S_MOUNT_SAVE_RO, S_SAVE_EXISTS, S_MOUNT_TEMP,
S_UNMOUNT, S_COMMIT, S_CREATE_CACHE, S_DELETE_CACHE, S_MOUNT_CACHE, S_OPEN_CACHE_LIST, S_READ_CACHE_LIST,
S_CLOSE_CACHE_LIST, S_SET_PRIORITY, S_MOUNT_ROM, S_QUERY_CACHE, S_INITIALIZE};
#define NPROBE ((int)(sizeof probe_symbols / sizeof *probe_symbols))
#define SAMPLES 64
#define HEAD_BYTES (8u << 20) // a sampled file is compared whole up to this size, else head and tail
#define TAIL_BYTES (64u << 10)
static uint64_t probe_addr[NPROBE];
static Cpu *pcpu;
static int failures, checks;
#define CHECK(cond, ...) \
do { checks++; if (!(cond)) { failures++; printf(" FAIL: " __VA_ARGS__); putchar('\n'); } } while (0)
static uint64_t U(const void *p) { return (uint64_t)(uintptr_t)p; }
// The override registered for sym, called through its synthetic address with up to 6 arguments. Returns x0.
static uint64_t fs_call(const char *sym, const uint64_t a[6]) {
for (int i = 0; i < NPROBE; i++)
if (!strcmp(probe_symbols[i], sym)) {
for (int r = 0; r < 19; r++) pcpu->x[r] = 0xdead0000 + r; // caller-saved garbage
return rt_call(pcpu, probe_addr[i], 6, a[0], a[1], a[2], a[3], a[4], a[5]);
}
rt_fatal("probe-fs: %s is not a probed symbol", sym);
}
#define CALL(sym, ...) fs_call(sym, (const uint64_t[6]){__VA_ARGS__})
#define RES(sym, ...) ((uint32_t)CALL(sym, __VA_ARGS__))
static int64_t independent_read(PlatFile *f, uint8_t *buf, uint64_t n, uint64_t off) {
uint64_t done = 0;
while (done < n) {
int64_t r = host_read_at(f, buf + done, (uint32_t)(n - done), off + done);
if (r <= 0) return -1;
done += (uint64_t)r;
}
return (int64_t)done;
}
// Read [pos, pos+n) of the open RomFS handle h through one of the three ReadFile overloads, and compare it with an
// independent read of romfs.bin at the index offset.
static int compare_range(PlatFile *image, uint64_t h, uint64_t offset, uint64_t pos, uint64_t n, int variant,
uint8_t *a, uint8_t *b) {
uint64_t got = ~0ull;
uint32_t r;
int32_t option = 0;
if (variant == 0) r = RES(S_READ_FILE, U(&got), h, pos, U(a), n);
else if (variant == 1) r = RES(S_READ_OPTIONS, U(&got), h, pos, U(a), n, U(&option));
else r = RES(S_READ_EXACT, h, pos, U(a), n), got = r ? ~0ull : n;
if (r || got != n) return 0;
if (independent_read(image, b, n, offset + pos) != (int64_t)n) return 0;
return !memcmp(a, b, n);
}
static void probe_paths(void) {
static const struct { const char *path, *want; } cases[] = {
{"rom:/a/b.bin", "a/b.bin"}, {"rom://a/b.bin", "a/b.bin"}, {"rom:///a/b.bin", "a/b.bin"},
{"rom:/a", "a"}, {"rom:/../main", NULL}, {"rom://../a", NULL},
{"rom:/a/../b", NULL}, {"rom:/a/./b", NULL}, {"rom:/a//b", NULL},
{"rom:/a\\b", NULL}, {"rom:/", NULL}, {"rom://", NULL},
{"rom:a", NULL}, {"ro:/a", NULL}, {"romx:/a", NULL},
{"rom:/.", NULL}, {"rom:/..", NULL}, {"ROM:/a", NULL},
};
int bad = 0;
for (unsigned i = 0; i < sizeof cases / sizeof *cases; i++) {
const char *got = romfs_path_relative("rom", cases[i].path);
if (cases[i].want ? !got || strcmp(got, cases[i].want) : got != NULL) {
bad++;
printf(" FAIL: romfs_path_relative(\"rom\", \"%s\") = %s%s%s\n", cases[i].path, got ? "\"" : "",
got ? got : "NULL", got ? "\"" : "");
}
}
checks++;
failures += bad > 0;
CHECK(!romfs_path_relative(NULL, "rom:/a") && !romfs_path_relative("", ":/a") && !romfs_path_relative("rom", NULL),
"romfs_path_relative accepts an empty mount or path");
printf("paths: %u romfs_path.h normalisation cases: %s\n", (unsigned)(sizeof cases / sizeof *cases),
bad ? "FAIL" : "ok");
}
static uint64_t probe_romfs(void) {
uint64_t count = romfs_file_count(), cache = ~0ull;
CHECK(RES(S_QUERY_CACHE, U(&cache)) == 0 && cache == 0, "QueryMountRomCacheSize");
CHECK(RES(S_MOUNT_ROM, U("rom"), 0, 0) == 0, "MountRom(\"rom\")");
CHECK(RES(S_MOUNT_ROM, U("rom"), 0, 0) == 0, "MountRom(\"rom\") again");
CHECK(RES(S_MOUNT_ROM, U("../x"), 0, 0) == FS_R_INVALID, "MountRom with a bad name");
probe_paths();
PlatFile *image = plat_file_open(romfs_image_path());
if (!image) rt_fatal("probe-fs: cannot open the RomFS image");
uint8_t *a = malloc(HEAD_BYTES), *b = malloc(HEAD_BYTES);
need(a && b, "probe buffers");
uint64_t step = count / SAMPLES, bytes = 0, sampled = 0, deep = 0, high = 0, largest = 0, lsize = 0;
char path[4200], sample[4200] = "";
for (uint64_t i = 0, size; i < count; i++)
if (romfs_entry(i, NULL, &size) && size > lsize) largest = i, lsize = size;
for (uint64_t k = 0; k <= SAMPLES && step; k++) { // k == SAMPLES: the largest file, beyond the 64
uint64_t i = k < SAMPLES ? k * step : largest, off, size, h = 0;
int64_t fsize = -1;
int type = -1;
const char *name = romfs_entry(i, &off, &size);
snprintf(path, sizeof path, "rom:/%s", name);
if (!*sample && strchr(name, '/') && size >= 256) snprintf(sample, sizeof sample, "%s", name);
int ok = RES(S_ENTRY_TYPE, U(&type), U(path)) == 0 && type == FS_ENTRY_FILE;
ok = ok && RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_READ) == 0 && romfs_handle(h);
ok = ok && RES(S_FILE_SIZE, U(&fsize), h) == 0 && (uint64_t)fsize == size;
uint64_t head = size < HEAD_BYTES ? size : HEAD_BYTES;
ok = ok && compare_range(image, h, off, 0, head, (int)(k % 3), a, b);
bytes += head;
if (size > HEAD_BYTES) {
uint64_t tail = size - TAIL_BYTES;
ok = ok && compare_range(image, h, off, tail, TAIL_BYTES, (int)((k + 1) % 3), a, b);
bytes += TAIL_BYTES;
deep += k < SAMPLES;
}
high += k < SAMPLES && off >= (1ull << 32);
uint64_t got = ~0ull;
ok = ok && RES(S_READ_FILE, U(&got), h, size, U(a), 1) == 0 && got == 0; // at EOF: 0 bytes
ok = ok && RES(S_READ_FILE, U(&got), h, size + 1, U(a), 1) == FS_R_INVALID; // past EOF
if (h) CALL(S_CLOSE_FILE, h);
CHECK(ok, "sampled RomFS file %llu (index row %llu)", (unsigned long long)k, (unsigned long long)i);
sampled += ok && k < SAMPLES;
}
printf("romfs: %llu of %d sampled files equal romfs.bin at their index offset (%llu at offsets past 4 GiB, %llu "
"over 8 MiB compared by head and tail, %.1f MiB compared), EOF reads ok\n", (unsigned long long)sampled,
SAMPLES, (unsigned long long)high, (unsigned long long)deep, bytes / 1048576.0);
printf("romfs: largest file (%.1f MiB): head and tail equal romfs.bin\n", lsize / 1048576.0);
// The same file under "rom:/", "rom://" and "rom:///"; its folder is a directory; traversal and writes refused.
uint64_t h = 0, off, size, got = 0;
int type = -1;
need(*sample, "no sampled file in a folder");
uint64_t si = 0;
while (strcmp(romfs_entry(si, &off, &size), sample)) si++;
int same = 1;
for (int slashes = 1; slashes <= 3; slashes++) {
snprintf(path, sizeof path, "rom:%.*s%s", slashes, "///", sample);
same &= RES(S_ENTRY_TYPE, U(&type), U(path)) == 0 && type == FS_ENTRY_FILE;
if (RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_READ)) {
same = 0;
continue;
}
same &= RES(S_READ_FILE, U(&got), h, 0, U(a), 256) == 0 && got == 256;
same &= independent_read(image, b, 256, off) == 256 && !memcmp(a, b, 256);
CALL(S_CLOSE_FILE, h);
}
CHECK(same, "rom:/, rom:// and rom:/// do not open the same file");
snprintf(path, sizeof path, "rom:/%.*s", (int)(strrchr(sample, '/') - sample), sample);
CHECK(RES(S_ENTRY_TYPE, U(&type), U(path)) == 0 && type == FS_ENTRY_DIRECTORY, "a sampled file's folder");
snprintf(path, sizeof path, "rom://../%s", sample);
CHECK(RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_READ) == FS_R_PATH_NOT_FOUND, "rom://../<file> opens");
snprintf(path, sizeof path, "rom:/%s.absent", sample);
CHECK(RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_READ) == FS_R_PATH_NOT_FOUND, "an absent file opens");
uint32_t traversal = RES(S_OPEN_FILE, U(&h), U("rom:/../main"), FS_OPEN_READ);
uint32_t write_open = RES(S_OPEN_FILE, U(&h), U("rom:/absent"), FS_OPEN_WRITE);
snprintf(path, sizeof path, "rom:/%s", sample);
uint32_t write_existing = RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_READ | FS_OPEN_WRITE);
printf("romfs: rom:/, rom://, rom:/// same file: %s; rom:/../main -> %#x; write open -> %#x, %#x\n",
same ? "ok" : "FAIL", traversal, write_open, write_existing);
CHECK(traversal == FS_R_PATH_NOT_FOUND, "rom:/../main -> %#x, expected 0x202", traversal);
CHECK(write_open == FS_R_UNSUPPORTED_OPEN && write_existing == FS_R_UNSUPPORTED_OPEN,
"write open -> %#x/%#x, expected 0x6402", write_open, write_existing);
// Overlays (TOTK_ROMFS_PATCHES, TOTK_ROMFS_MODS): served in place of the original, added files and their folders
// exist, bytes equal the overlay file.
size_t nov = romfs_overlay_count(), ovok = 0;
for (size_t k = 0; k < nov; k++) {
const char *file;
uint64_t osize;
int is_added;
const char *name = romfs_overlay(k, &file, &osize, &is_added);
snprintf(path, sizeof path, "rom:/%s", name);
int ok = RES(S_ENTRY_TYPE, U(&type), U(path)) == 0 && type == FS_ENTRY_FILE;
char *slash = strrchr(path, '/');
if (is_added && slash > path + 5) {
*slash = 0;
ok = ok && RES(S_ENTRY_TYPE, U(&type), U(path)) == 0 && type == FS_ENTRY_DIRECTORY;
*slash = '/';
}
int64_t fsize = -1;
uint64_t n = osize < HEAD_BYTES ? osize : HEAD_BYTES;
ok = ok && RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_READ) == 0;
if (ok) {
ok = RES(S_FILE_SIZE, U(&fsize), h) == 0 && (uint64_t)fsize == osize &&
RES(S_READ_FILE, U(&got), h, 0, U(a), n) == 0 && got == n;
CALL(S_CLOSE_FILE, h);
}
PlatFile *of = plat_file_open(file);
ok = ok && of && independent_read(of, b, n, 0) == (int64_t)n && !memcmp(a, b, n);
if (of) plat_file_close(of);
CHECK(ok, "overlay %zu (%s)", k, is_added ? "added" : "replacement");
ovok += ok;
}
if (nov) printf("romfs: %zu of %zu overlay files served from their overlay\n", ovok, nov);
plat_file_close(image);
free(a);
free(b);
return count;
}
// ---- save transactions: restarts, uncommitted and committed changes, a crash at every step of a Commit
// The process dies here: the save mount goes without Unmount, and the next start runs save_recover and mounts again.
static void probe_restart(const uint64_t *uid) {
plat_mutex_lock(&lock);
for (int i = 0; i < NMOUNTS; i++)
if (mounts[i].kind == M_SAVE) mounts[i].kind = M_FREE;
plat_mutex_unlock(&lock);
save_mounts = tx_crash_at = tx_dead = 0;
plat_xchg32(&save_dirty, 0);
save_recover();
CHECK(RES(S_MOUNT_SAVE, U("save"), U(uid)) == 0, "MountSaveData after a restart");
}
// Commit through the override, then wait for its background thread, as the checks read the disk next.
static uint32_t tx_commit(void) {
uint32_t r = RES(S_COMMIT, U("save"));
fs_save_flush();
return r;
}
#define TX_SIZE 4096
static uint8_t tx_buf[TX_SIZE];
// Fill a save file, created when absent, with byte g through the overrides.
static int tx_write(const char *path, int g) {
uint64_t h = 0;
int32_t no_flush = 0;
memset(tx_buf, g, sizeof tx_buf);
uint32_t r = RES(S_CREATE_FILE, U(path), TX_SIZE);
if ((r && r != FS_R_ALREADY_EXISTS) || RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_WRITE)) return 0;
int ok = RES(S_WRITE_FILE, h, 0, U(tx_buf), TX_SIZE, U(&no_flush)) == 0;
CALL(S_CLOSE_FILE, h);
return ok;
}
static int tx_byte(int ok) {
for (int i = 1; ok && i < TX_SIZE; i++) ok = tx_buf[i] == tx_buf[0];
return ok ? tx_buf[0] : -2;
}
// The byte a save file is filled with, read through the overrides; -1 absent, -2 short or mixed.
static int tx_read(const char *path) {
uint64_t h = 0, got = 0;
if (RES(S_OPEN_FILE, U(&h), U(path), FS_OPEN_READ)) return -1;
int ok = RES(S_READ_FILE, U(&got), h, 0, U(tx_buf), TX_SIZE) == 0 && got == TX_SIZE;
CALL(S_CLOSE_FILE, h);
return tx_byte(ok);
}
// The same, read from the committed save on disk.
static int tx_read_committed(const char *name) {
char *p = join(save_root, name);
PlatFile *f = plat_file_open(p);
free(p);
if (!f) return -1;
int ok = independent_read(f, tx_buf, TX_SIZE, 0) == TX_SIZE;
plat_file_close(f);
return tx_byte(ok);
}
// Hard links of a file of the committed save on disk; 0 when absent.
static int tx_links_committed(const char *name) {
char *p = join(save_root, name);
int links = plat_fs_links(p);
free(p);
return links;
}
static int count_dirs(const char *dir) {
Listing l;
list_dir(dir, &l);
int n = 0;
for (int i = 0; i < l.n; i++) n += l.e[i].kind == PLAT_FS_DIR && l.e[i].name[0] != '.';
listing_free(l.e, l.n);
return n;
}
static void probe_save_tx(const uint64_t *uid) {
const char *a = "save:/tx_a.sav", *b = "save:/tx_b.sav";
uint64_t h = 0;
CHECK(tx_write(a, 1) && tx_write(b, 1) && tx_commit() == 0 && tx_read_committed("tx_a.sav") == 1,
"Commit of generation 1");
// uncommitted changes: the game reads them; the committed save and the next start do not see them
CHECK(tx_write(a, 2) && tx_write("save:/tx_c.sav", 2), "uncommitted writes");
CHECK(tx_read(a) == 2 && tx_read("save:/tx_c.sav") == 2, "reads do not see the game's uncommitted writes");
CHECK(tx_read_committed("tx_a.sav") == 1 && tx_read_committed("tx_c.sav") == -1,
"an uncommitted write reached the committed save");
probe_restart(uid);
CHECK(tx_read(a) == 1 && tx_read("save:/tx_c.sav") == -1, "an uncommitted write survived a restart");
// committed changes survive a restart; an Unmount without Commit drops changes
CHECK(tx_write(a, 3) && tx_write(b, 3) && tx_commit() == 0, "Commit of generation 3");
probe_restart(uid);
CHECK(tx_read(a) == 3 && tx_read(b) == 3, "a commit lost in a restart");
CHECK(tx_write(a, 4), "write before Unmount");
CALL(S_UNMOUNT, U("save"));
CHECK(RES(S_MOUNT_SAVE, U("save"), U(uid)) == 0 && tx_read(a) == 3, "Unmount without Commit kept the change");
// a crash at step k of a Commit, k = 1, 2, ... until a Commit completes: the next start sees all of the old
// generation or all of the new one
int committed = 3, gen = 4, steps = 0, olds = 0, news = 0, mixed = 0;
for (int k = 1; k < 64; k++) {
int next = ++gen, ok = tx_write(a, next) && tx_write(b, next);
tx_crash_at = k;
uint32_t r = tx_commit();
int died = tx_dead;
tx_crash_at = 0;
if (!died) {
steps = k - 1;
CHECK(ok && r == 0 && tx_read_committed("tx_a.sav") == next && tx_read_committed("tx_b.sav") == next,
"Commit after the crash points");
committed = next;
break;
}
probe_restart(uid);
int va = tx_read(a), vb = tx_read(b);
if (va == committed && vb == committed) olds++;
else if (va == next && vb == next) news++, committed = next;
else mixed++;
CHECK(ok && (va == vb) && (va == committed), "crash at commit step %d: tx_a %d, tx_b %d (old %d, new %d)", k,
va, vb, committed, next);
CHECK(!exists(new_root) && !exists(old_root) && !*ready_root, "crash at commit step %d: staging left after "
"recovery", k);
}
CHECK(steps >= 4 && olds && news && !mixed, "crash points: %d (%d old, %d new, %d mixed)", steps, olds, news,
mixed);
int backups = count_dirs(backup_root);
CHECK(backups == SAVE_BACKUPS, "save-backup holds %d saves", backups);
// a Commit writes the changed file and links the unchanged one to the committed save it replaces
int kept = committed;
CHECK(tx_write(a, ++gen) && tx_commit() == 0 && tx_read_committed("tx_a.sav") == gen &&
tx_read_committed("tx_b.sav") == kept && tx_links_committed("tx_b.sav") >= 2 &&
tx_links_committed("tx_a.sav") == 1, "Commit of one changed file: tx_a %d links, tx_b %d links",
tx_links_committed("tx_a.sav"), tx_links_committed("tx_b.sav"));
committed = gen;
// a Commit that changed no byte (the game rewrites its files at start) makes no new save and no backup
static char backups_before[64][48], backups_after[64][48];
int nbefore = backup_names(backups_before);
CHECK(tx_write(a, committed) && tx_commit() == 0 && tx_read_committed("tx_a.sav") == committed &&
backup_names(backups_after) == nbefore && !memcmp(backups_before, backups_after, sizeof backups_before),
"a Commit of unchanged bytes made a backup");
// the background commit: a write right after Commit waits for it and stays out of it; an Unmount right after
// Commit waits for it and keeps it
int queued = ++gen;
CHECK(tx_write(a, queued) && RES(S_COMMIT, U("save")) == 0 && tx_write(a, ++gen) && tx_read(a) == gen &&
tx_read_committed("tx_a.sav") == queued, "a write right after Commit: committed tx_a %d, expected %d",
tx_read_committed("tx_a.sav"), queued);
CHECK(RES(S_COMMIT, U("save")) == 0, "Commit before Unmount");
CALL(S_UNMOUNT, U("save"));
CHECK(tx_read_committed("tx_a.sav") == gen && RES(S_MOUNT_SAVE, U("save"), U(uid)) == 0 && tx_read(a) == gen,
"an Unmount right after Commit lost it");
committed = gen;
// a file of the committed save held open by another program (a virus scanner, Explorer's preview, a sync tool):
// Windows refuses to rename a directory with an open file under it, so the committed save cannot be swapped out.
// The game and a restart still see every Commit, and the first start after the file is closed swaps them in.
char *held_path = join(save_root, "tx_b.sav");
PlatFile *held = plat_fs_open(held_path, 0, NULL);
free(held_path);
int pending = ++gen;
CHECK(held && tx_write(a, pending) && tx_commit() == 0, "Commit with a committed file held open");
probe_restart(uid);
CHECK(tx_read(a) == pending, "a Commit made while a committed file was held open, after a restart: tx_a %d, "
"expected %d", tx_read(a), pending);
pending = ++gen;
CHECK(tx_write(a, pending) && tx_commit() == 0 && tx_read(a) == pending, "a second Commit while held open");
if (held) plat_file_close(held);
probe_restart(uid);
CHECK(tx_read(a) == pending && tx_read_committed("tx_a.sav") == pending && tx_read_committed("tx_b.sav") == kept,
"the held-open Commits after the file was closed and a restart: tx_a %d, on disk %d, expected %d",
tx_read(a), tx_read_committed("tx_a.sav"), pending);
committed = pending;
// the read-only mount shows the committed save and refuses changes
CHECK(tx_write(a, 99) && RES(S_MOUNT_SAVE_RO, U("ro"), TOTK_APPLICATION_ID, U(uid)) == 0 &&
tx_read("ro:/tx_a.sav") == committed && RES(S_OPEN_FILE, U(&h), U("ro:/tx_a.sav"), FS_OPEN_WRITE) ==
FS_R_UNSUPPORTED_OPEN && RES(S_CREATE_FILE, U("ro:/x"), 0) == FS_R_UNSUPPORTED_OPEN &&
RES(S_RENAME_FILE, U("save:/tx_a.sav"), U("ro:/y")) == FS_R_INVALID_PATH, "MountSaveDataReadOnly");
CALL(S_UNMOUNT, U("ro"));
CHECK(RES(S_DELETE_FILE, U(a)) == 0 && RES(S_DELETE_FILE, U(b)) == 0 && tx_commit() == 0 &&
tx_read_committed("tx_a.sav") == -1, "Commit of deletions");
// a run that left read-only folders and files in the working copy (attributes another emulator's save carried):
// the next start deletes them and names the failing path if it cannot
char *ro_dir = join(work_root, "ro_dir"), *ro_file = join(ro_dir, "ro.sav");
CHECK(!plat_fs_mkdir(ro_dir) && plat_file_write(ro_file, "x", 1) && plat_file_set_readonly(ro_file, 1) &&
plat_file_set_readonly(ro_dir, 1), "read-only leftovers in the working copy");
probe_restart(uid);
CHECK(!exists(ro_dir), "the next start deleted a working copy with read-only folders");
free(ro_dir);
free(ro_file);
printf("save tx: uncommitted writes visible to the game and dropped by a restart and by Unmount, commits kept; a "
"crash at each of %d commit steps recovers to the old save %d times, the new one %d times, mixed %d "
"times; %d backups\n", steps, olds, news, mixed, backups);
}
static void probe_storage(void) {
char tmp[PLAT_PATH_MAX * 3], dir[PLAT_PATH_MAX * 3 + 64];
need(plat_temp_dir(tmp, sizeof tmp), "temporary directory");
snprintf(dir, sizeof dir, "%s/totk-probe-fs-%u", tmp, plat_process_id());
remove_tree(dir);
fs_host_set_profile(dir);
uint64_t uid[2], other[2], h = 0, h2 = 0, d = 0, l = 0;
account_uid(uid);
other[0] = uid[0] ^ 1, other[1] = uid[1];
static uint8_t data[65536], back[65536];
for (unsigned i = 0; i < sizeof data; i++) data[i] = (uint8_t)(i * 7 + (i >> 8));
int32_t no_flush = 0, flush = 1;
int64_t size = -1, count = -1;
uint64_t got = 0;
int type = -1;
CHECK(RES(S_MOUNT_SAVE, U("save"), U(other)) == FS_R_TARGET_NOT_FOUND, "MountSaveData for another user");
CHECK(RES(S_MOUNT_SAVE, U("save"), U(uid)) == 0, "MountSaveData");
CHECK(RES(S_MOUNT_SAVE, U("save"), U(uid)) == FS_R_ALREADY_EXISTS, "MountSaveData twice");
CHECK((CALL(S_SAVE_EXISTS, TOTK_APPLICATION_ID, U(uid)) & 0xff) == 1 &&
(CALL(S_SAVE_EXISTS, TOTK_APPLICATION_ID + 0x1000, U(uid)) & 0xff) == 0, "IsSaveDataExisting");
CHECK(RES(S_MOUNT_SAVE_RO, U("other"), TOTK_APPLICATION_ID + 0x1000, U(uid)) == FS_R_TARGET_NOT_FOUND,
"MountSaveDataReadOnly of another title");
CHECK(!fs_save_busy(), "idle at start");
// create, write, commit
CHECK(RES(S_CREATE_FILE, U("save:/probe.sav"), 0) == 0 && fs_save_busy(), "CreateFile");
CHECK(RES(S_CREATE_FILE, U("save:/probe.sav"), 0) == FS_R_ALREADY_EXISTS, "CreateFile twice");
CHECK(RES(S_OPEN_FILE, U(&h), U("save:/probe.sav"), FS_OPEN_WRITE) == 0, "OpenFile for writing");
CHECK(RES(S_WRITE_FILE, h, 0, U(data), 16, U(&no_flush)) == FS_R_APPEND_REQUIRED, "a growing write without append");
CALL(S_CLOSE_FILE, h);
CHECK(RES(S_OPEN_FILE, U(&h), U("save:/probe.sav"), FS_OPEN_WRITE | FS_OPEN_APPEND) == 0, "OpenFile for append");
CHECK(RES(S_WRITE_FILE, h, 0, U(data), sizeof data / 2, U(&no_flush)) == 0 &&
RES(S_WRITE_FILE, h, sizeof data / 2, U(data + sizeof data / 2), sizeof data / 2, U(&flush)) == 0,
"WriteFile");
CHECK(RES(S_FILE_SIZE, U(&size), h) == 0 && size == sizeof data, "GetFileSize after writing");
CHECK(RES(S_READ_FILE, U(&got), h, 0, U(back), 16) == FS_R_OPEN_MODE, "ReadFile on a write-only handle");
CHECK(RES(S_FLUSH_FILE, h) == 0, "FlushFile");
CHECK(fs_save_busy(), "a save file open for writing does not count as a save in progress");
CALL(S_CLOSE_FILE, h);
CHECK(fs_save_busy(), "closed but uncommitted save data does not count as a save in progress");
CHECK(tx_commit() == 0 && !fs_save_busy(), "Commit");
CHECK(RES(S_COMMIT, U("nomount")) == FS_R_PATH_NOT_FOUND, "Commit of an unknown mount");
// paths
CHECK(RES(S_CREATE_FILE, U("save:/../x"), 0) == FS_R_INVALID_PATH, "save:/../x");
CHECK(RES(S_CREATE_FILE, U("save:/a\\b"), 0) == FS_R_INVALID_PATH, "save:/a\\b");
CHECK(RES(S_CREATE_FILE, U("save:/a:b"), 0) == FS_R_INVALID_PATH, "save:/a:b");
CHECK(RES(S_CREATE_FILE, U("save:/a."), 0) == FS_R_INVALID_PATH, "save:/a.");
CHECK(RES(S_CREATE_FILE, U("nomount:/x"), 0) == FS_R_PATH_NOT_FOUND, "a path on no mount");
// directories
CHECK(RES(S_CREATE_DIRECTORY, U("save:/dir")) == 0 && RES(S_CREATE_FILE, U("save:/dir/inner.bin"), 8) == 0,
"CreateDirectory");
CHECK(RES(S_ENTRY_TYPE, U(&type), U("save:/dir")) == 0 && type == FS_ENTRY_DIRECTORY, "GetEntryType (directory)");
static DirectoryEntry ents[8];
CHECK(RES(S_OPEN_DIRECTORY, U(&d), U("save:/"), 3) == 0, "OpenDirectory");
CHECK(RES(S_READ_DIRECTORY, U(&count), U(ents), d, 8) == 0 && count == 2, "ReadDirectory count %lld",
(long long)count);
int seen = 0;
for (int i = 0; i < count && i < 8; i++) {
if (!strcmp(ents[i].name, "dir") && ents[i].type == FS_ENTRY_DIRECTORY) seen |= 1;
if (!strcmp(ents[i].name, "probe.sav") && ents[i].type == FS_ENTRY_FILE && ents[i].size == sizeof data)
seen |= 2;
}
CHECK(seen == 3, "ReadDirectory entries");
CHECK(RES(S_READ_DIRECTORY, U(&count), U(ents), d, 8) == 0 && count == 0, "ReadDirectory at the end");
CALL(S_CLOSE_DIRECTORY, d);
CHECK(RES(S_OPEN_DIRECTORY, U(&d), U("save:/"), 2) == 0 && RES(S_READ_DIRECTORY, U(&count), U(ents), d, 8) == 0 &&
count == 1 && ents[0].type == FS_ENTRY_FILE, "ReadDirectory, files only");
CALL(S_CLOSE_DIRECTORY, d);
CHECK(tx_commit() == 0, "Commit of the directories");
// reopen after unmount: the bytes come back through the overrides and are on disk
CALL(S_UNMOUNT, U("save"));
CHECK(RES(S_OPEN_FILE, U(&h), U("save:/probe.sav"), FS_OPEN_READ) == FS_R_PATH_NOT_FOUND, "open after Unmount");
CHECK(RES(S_MOUNT_SAVE, U("save"), U(uid)) == 0, "MountSaveData again");
CHECK(RES(S_OPEN_FILE, U(&h), U("save:/probe.sav"), FS_OPEN_READ) == 0, "reopen");
memset(back, 0, sizeof back);
CHECK(RES(S_FILE_SIZE, U(&size), h) == 0 && size == sizeof data &&
RES(S_READ_EXACT, h, 0, U(back), sizeof back) == 0 && !memcmp(back, data, sizeof data), "reopened bytes");
CHECK(RES(S_READ_FILE, U(&got), h, sizeof data - 10, U(back), 100) == 0 && got == 10, "short read at the end");
CHECK(RES(S_WRITE_FILE, h, 0, U(data), 1, U(&no_flush)) == FS_R_OPEN_MODE, "WriteFile on a read-only handle");
CALL(S_CLOSE_FILE, h);
char host[1200];
snprintf(host, sizeof host, "%s/save/%016llx%016llx/probe.sav", dir, (unsigned long long)uid[1],
(unsigned long long)uid[0]);
PlatFile *f = plat_file_open(host);
memset(back, 0, sizeof back);
CHECK(f && independent_read(f, back, sizeof back, 0) == sizeof back && !memcmp(back, data, sizeof data),
"the save file on disk");
if (f) plat_file_close(f);
// resize, rename, delete
CHECK(RES(S_OPEN_FILE, U(&h), U("save:/probe.sav"), FS_OPEN_READ | FS_OPEN_WRITE) == 0 &&
RES(S_SET_FILE_SIZE, h, 100) == 0 && RES(S_FILE_SIZE, U(&size), h) == 0 && size == 100, "SetFileSize");
CHECK(RES(S_OPEN_FILE, U(&h2), U("save:/probe.sav"), FS_OPEN_READ) == 0, "a second handle on one file");
CALL(S_CLOSE_FILE, h2);
CALL(S_CLOSE_FILE, h);
CHECK(RES(S_RENAME_FILE, U("save:/probe.sav"), U("save:/dir/inner.bin")) == FS_R_ALREADY_EXISTS,
"RenameFile over an existing file");
CHECK(RES(S_RENAME_FILE, U("save:/probe.sav"), U("save:/renamed.sav")) == 0 &&
RES(S_ENTRY_TYPE, U(&type), U("save:/renamed.sav")) == 0 && type == FS_ENTRY_FILE &&
RES(S_ENTRY_TYPE, U(&type), U("save:/probe.sav")) == FS_R_PATH_NOT_FOUND, "RenameFile");
CHECK(RES(S_DELETE_FILE, U("save:/renamed.sav")) == 0 &&
RES(S_DELETE_FILE, U("save:/renamed.sav")) == FS_R_PATH_NOT_FOUND, "DeleteFile");
CHECK(RES(S_DELETE_RECURSIVE, U("save:/dir")) == 0 &&
RES(S_ENTRY_TYPE, U(&type), U("save:/dir")) == FS_R_PATH_NOT_FOUND, "DeleteDirectoryRecursively");
CHECK(tx_commit() == 0 && !fs_save_busy(), "Commit after the changes");
probe_save_tx(uid);
// temporary and cache storage
CHECK(RES(S_MOUNT_TEMP, U("temp")) == 0 && RES(S_CREATE_FILE, U("temp:/t.bin"), 4) == 0 && !fs_save_busy(),
"temporary storage");
CHECK(RES(S_CREATE_CACHE, 3, 1 << 20, 1 << 20) == 0 && RES(S_CREATE_CACHE, 3, 1 << 20, 1 << 20) ==
FS_R_ALREADY_EXISTS, "CreateCacheStorage");
CHECK(RES(S_MOUNT_CACHE, U("cache"), 3) == 0 && RES(S_MOUNT_CACHE, U("cache9"), 9) == FS_R_TARGET_NOT_FOUND,
"MountCacheStorage");
CacheStorageInfo infos[4];
int ncache = -1;
CHECK(RES(S_OPEN_CACHE_LIST, U(&l)) == 0 && RES(S_READ_CACHE_LIST, U(&ncache), U(infos), l, 4) == 0 &&
ncache == 1 && infos[0].index == 3, "cache storage list");
CALL(S_CLOSE_CACHE_LIST, l);
CALL(S_SET_PRIORITY, 1);
CALL(S_UNMOUNT, U("cache"));
CALL(S_UNMOUNT, U("temp"));
CHECK(RES(S_DELETE_CACHE, 3) == 0, "DeleteCacheStorage");
CALL(S_UNMOUNT, U("save"));
int removed = !remove_tree(dir) && !exists(dir);
CHECK(removed, "the temporary profile was not deleted");
printf("storage: save create/write/commit/reopen, directories, rename/delete, temporary and cache storage in a "
"temporary profile: %s; profile deleted: %s\n", failures ? "see FAIL lines" : "ok", removed ? "yes" : "no");
}
int probe_fs(void) {
fs_init();
for (int i = 0; i < NPROBE; i++) {
GuestFn fn = hostfn_override(probe_symbols[i]);
if (!fn) rt_fatal("probe-fs: no HOST_OVERRIDE for %s", probe_symbols[i]);
probe_addr[i] = hostfn_register(probe_symbols[i], fn);
}
printf("overrides: %d nn::fs overrides registered\n", NPROBE);
pcpu = plat_aligned_alloc(sizeof(Cpu), 64);
need(pcpu != NULL, "probe Cpu");
memset(pcpu, 0, sizeof *pcpu);
uint8_t *stack = mem_kernel_alloc(0x10000, 16);
pcpu->sp = (uint64_t)(uintptr_t)stack + 0x10000;
pcpu->excl_addr = ~0ull;
for (int r = 19; r < 30; r++) pcpu->x[r] = 0x1900 + r;
CALL(S_INITIALIZE, 0); // nn::fs::InitializeForApplication: fs_init again, idempotent
uint64_t files = probe_romfs();
probe_storage();
int kept = 1;
for (int r = 19; r < 30; r++) kept &= pcpu->x[r] == 0x1900u + r;
CHECK(kept && pcpu->sp == (uint64_t)(uintptr_t)stack + 0x10000, "an override lost a callee-saved register");
mem_kernel_free(stack, 0x10000);
plat_aligned_free(pcpu);
if (failures) {
printf("probe-fs: FAILED (%d of %d checks)\n", failures, checks);
return 1;
}
printf("probe-fs: passed (%llu files, %d sampled)\n", (unsigned long long)files, SAMPLES);
return 0;
}