Files
NX2navi/runtime/mods.c
chrg6 7f9ace35fb Read the mods folder the way the setup's tools do, and keep each mod's start state with it.
- A pchtxt file's build id is read as tools/exefs_patches.py reads it: after a UTF-8 BOM and blanks, the last
  @nsobid line before @stop, a // comment after it left out. A file saved with a BOM or an indented line was taken for
  another game version although the setup had built it into the game.
- A symbolic link or junction to a folder in mods/ is a mod, as the setup's tools see it; it was left out, so the
  patch sets built for it were never switched on.
- A relative mods_dir is under the game's folder, not the folder the game was started from.
- A mods folder that cannot be listed is logged instead of showing no mods without a word.
- The start state of a mod's switch moves with the mod when the list is reordered, so the ExeFS patches waiting for
  the rebuild are counted for the right mods.

--probe-mods checks each of these.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 15:51:48 -05:00

487 lines
20 KiB
C

// Player mods: the scan of the mods folder, its order and switches (mods.h).
//
// One subfolder per mod, as emulators take them. The RomFS root is the first of <mod>/romfs, <mod>/romfslite (a mod
// manager's name for it when it bypasses Atmosphere's RomFS build), <mod>/atmosphere/contents/<title>/romfs,
// <mod>/contents/<title>/romfs and <mod>/<title>/romfs that exists; the ExeFS and cheats folders likewise. A mod is
// listed whatever it holds; what the port cannot use is its problem, shown in the menu and logged, and never stops the
// start. romfs.c serves the RomFS files of the enabled mods, the top one winning a path.
//
// The order and the switches are nx2navi.ini keys (cfg.c): NX2NAVI_MODS_ORDER lists names top first, separated by '|' (a
// Windows file name cannot hold '|'; a folder whose name does on Linux is listed as unusable); a folder it does not name
// goes below the named ones, by name. NX2NAVI_MODS_DISABLED lists the switched-off names. The menu changes the list in
// place and writes both keys; the scan's state is kept to tell the menu a restart is due.
//
// ExeFS patch files: an .ips or .pchtxt file in the exefs folder or in atmosphere/exefs_patches/<name>/ is for the game
// when its build id is main's (an IPS file's name, a pchtxt file's @nsobid line); others are another version's and
// counted. The setup builds every mod's files into patch sets and writes build-cache/exefs.status next to nx2navi.exe
// (tools/exefs_patches.py has the format): a file whose line is missing or has another content hash waits for the
// rebuild at start (cheats.c), and its mod stays off until then.
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include "cfg.h"
#include "host.h"
#include "mods.h"
#include "platform/platform.h"
#define TITLE "0400486024DEC000" // the game's title id, which names a mod's Atmosphere folders
#define MAX_MODS 512
static ModInfo *mods;
static int nmods;
static char dir[2048];
static char start_order[MAX_MODS][256]; // names in the scan's order, for mods_changed
// 1 when a file lies anywhere under path.
static int has_file_cb(const char *name, int kind, uint64_t size, void *ctx);
static int has_file(const char *path) {
struct { const char *path; int found; } c = {path, 0};
plat_fs_list(path, has_file_cb, &c);
return c.found;
}
static int has_file_cb(const char *name, int kind, uint64_t size, void *ctx) {
(void)size;
struct { const char *path; int found; } *c = ctx;
if (kind == PLAT_FS_FILE) {
c->found = 1;
} else if (kind == PLAT_FS_DIR) {
char sub[2100];
if (snprintf(sub, sizeof sub, "%s/%s", c->path, name) < (int)sizeof sub) c->found = has_file(sub);
}
return !c->found;
}
// The first of the emulator and Atmosphere places of <mod>/<part> that is a folder, into out ("" none).
static void find_root(const char *mod, const char *part, char *out, size_t cap) {
static const char *const forms[] = {"%s/%s", "%s/atmosphere/contents/" TITLE "/%s", "%s/contents/" TITLE "/%s",
"%s/" TITLE "/%s"};
for (size_t i = 0; i < sizeof forms / sizeof *forms; i++) {
if (snprintf(out, cap, forms[i], mod, part) < (int)cap && plat_dir_exists(out)) return;
}
out[0] = 0;
}
static int ends_with_ci(const char *s, const char *tail) {
size_t n = strlen(s), t = strlen(tail);
return n >= t && !strcasecmp(s + n - t, tail);
}
// What the ExeFS folder holds: MOD_EXEFS for patches (.ips, .pchtxt), MOD_SUBSDK for code modules (subsdk* plugins, a
// replaced main). main.npdm alone is the process's metadata, not code: it counts as neither.
static int exefs_cb(const char *name, int kind, uint64_t size, void *ctx) {
(void)size;
int *k = ctx;
if (kind != PLAT_FS_FILE) return 1;
if (ends_with_ci(name, ".ips") || ends_with_ci(name, ".pchtxt")) *k |= MOD_EXEFS;
else if (!strncasecmp(name, "subsdk", 6) || !strcasecmp(name, "main")) *k |= MOD_SUBSDK;
return 1;
}
// MOD_CHEATS when the cheats folder holds an Atmosphere cheat file (<build id>.txt).
static int cheats_cb(const char *name, int kind, uint64_t size, void *ctx) {
(void)size;
if (kind == PLAT_FS_FILE && ends_with_ci(name, ".txt")) *(int *)ctx |= MOD_CHEATS;
return 1;
}
// 1 when name is one of the '|'-separated names of list.
static int listed(const char *list, const char *name) {
size_t n = strlen(name);
for (const char *p = list; p && *p;) {
const char *bar = strchr(p, '|');
size_t len = bar ? (size_t)(bar - p) : strlen(p);
if (len == n && !memcmp(p, name, n)) return 1;
p = bar ? bar + 1 : NULL;
}
return 0;
}
// The position of name in the '|'-separated list, or -1.
static int list_pos(const char *list, const char *name) {
size_t n = strlen(name);
int k = 0;
for (const char *p = list; p && *p; k++) {
const char *bar = strchr(p, '|');
size_t len = bar ? (size_t)(bar - p) : strlen(p);
if (len == n && !memcmp(p, name, n)) return k;
p = bar ? bar + 1 : NULL;
}
return -1;
}
static const char *order_list; // NX2NAVI_MODS_ORDER while sorting
static int by_order(const void *a, const void *b) {
const ModInfo *x = a, *y = b;
int px = list_pos(order_list, x->name), py = list_pos(order_list, y->name);
if (px >= 0 && py >= 0) return px - py;
if (px >= 0 || py >= 0) return px >= 0 ? -1 : 1;
return strcmp(x->name, y->name);
}
static const char *problem_name(ModProblem p) {
switch (p) {
case MOD_EMPTY: return "empty";
case MOD_PLUGIN: return "has a code module (subsdk or main), not supported; the whole mod stays off";
case MOD_EXEFS_LATER: return "ExeFS patches wait for a rebuild of the game code; the whole mod stays off";
case MOD_EXEFS_UNSUPPORTED: return "an ExeFS patch file cannot be applied; the whole mod stays off";
case MOD_EXEFS_OTHER: return "its ExeFS patches are for another game version";
case MOD_BAD_NAME: return "its folder name holds '|', which the order list uses; rename the folder";
default: return "ok";
}
}
// ---- ExeFS patch files
static uint32_t fnv(const void *p, size_t n) {
uint32_t h = 0x811C9DC5u;
for (size_t i = 0; i < n; i++) h = (h ^ ((const uint8_t *)p)[i]) * 0x01000193u;
return h;
}
const char *mods_main_build_id(void) {
static char bid[65];
static int read;
if (read) return bid;
read = 1;
char path[2100];
snprintf(path, sizeof path, "%s/main", rt_config()->exefs);
PlatFile *f = plat_file_open(path);
uint8_t head[0x60];
int ok = f && plat_file_pread(f, head, sizeof head, 0) == sizeof head && !memcmp(head, "NSO0", 4);
if (f) plat_file_close(f);
for (int i = 0; ok && i < 32; i++) snprintf(bid + 2 * i, 3, "%02x", head[0x40 + i]);
return bid;
}
// 1 when hex (any case; trailing zeros left out or not) names main's build id; also when main's is unknown.
static int same_build(const char *hex, size_t n) {
const char *bid = mods_main_build_id();
if (!*bid) return 1;
if (!n || n > 64) return 0;
for (size_t i = 0; i < 64; i++) {
char c = i < n ? hex[i] : '0';
if (c >= 'A' && c <= 'F') c = (char)(c - 'A' + 'a');
if (c != bid[i]) return 0;
}
return 1;
}
// The build id of a pchtxt file as tools/exefs_patches.py parse_pchtxt reads it: the last "@nsobid-<hex>" line before
// an "@stop" line, a UTF-8 BOM, blanks around it and a "//" comment after it left out. 1 when the file names one; *len
// is 0 when that line holds anything but hex digits (no build id, so for no main).
static int pchtxt_build(const char *data, size_t n, const char **id, size_t *len) {
int found = 0;
const char *p = data, *end = data + n;
if (n >= 3 && !memcmp(p, "\xEF\xBB\xBF", 3)) p += 3;
while (p < end) {
const char *eol = memchr(p, '\n', (size_t)(end - p));
if (!eol) eol = end;
while (p < eol && isspace((unsigned char)*p)) p++;
if (eol - p >= 5 && !memcmp(p, "@stop", 5)) break;
if (eol - p >= 8 && !memcmp(p, "@nsobid-", 8)) {
const char *h = p + 8, *t = h;
while (t < eol && isxdigit((unsigned char)*t)) t++;
const char *rest = t;
while (rest < eol && isspace((unsigned char)*rest)) rest++;
int clean = rest == eol || (eol - rest >= 2 && rest[0] == '/' && rest[1] == '/');
found = 1;
*id = h;
*len = clean ? (size_t)(t - h) : 0;
}
p = eol + 1;
}
return found;
}
// build-cache/exefs.status next to nx2navi.exe, read once; "" when there is none.
static const char *exefs_status(void) {
static char *text;
if (text) return text;
char path[2100];
uint64_t n = 0;
if (plat_exe_path(path, sizeof path)) {
char *cut = strrchr(path, '/'), *back = strrchr(path, '\\');
if (back > cut) cut = back;
if (cut) snprintf(cut + 1, sizeof path - (size_t)(cut + 1 - path), "build-cache/exefs.status");
text = (char *)plat_file_read(path, &n);
}
if (!text) text = calloc(1, 1);
return text;
}
// The status line of key: its kind, built-in sets and note, NUL-terminated copies; 0 when none.
static int status_line(const char *key, const char *content, char *kind, char *over, char *note) {
char want[20];
snprintf(want, sizeof want, "%s\t%s\t", key, content);
for (const char *line = exefs_status(); *line;) {
const char *end = strchr(line, '\n');
size_t len = end ? (size_t)(end - line) : strlen(line);
if (len > 18 && !memcmp(line, want, 18)) {
char copy[600];
snprintf(copy, sizeof copy, "%.*s", (int)(len < sizeof copy ? len : sizeof copy - 1), line + 18);
char *t1 = strchr(copy, '\t'), *t2 = t1 ? strchr(t1 + 1, '\t') : NULL;
if (!t1 || !t2) return 0;
*t1 = *t2 = 0;
char *r = strchr(t2 + 1, '\r');
if (r) *r = 0;
snprintf(kind, 16, "%s", copy);
snprintf(over, 256, "%s", strcmp(t1 + 1, "-") ? t1 + 1 : "");
snprintf(note, 256, "%s", t2 + 1);
return 1;
}
line = end ? end + 1 : line + len;
}
return 0;
}
typedef struct {
ModInfo *m;
const char *folder, *rel; // the folder listed and its path in the mod
int unsupported;
} ExefsWalk;
// One file of an exefs folder: its build, then its status line.
static int exefs_file_cb(const char *leaf, int kind, uint64_t size, void *ctx) {
(void)size;
ExefsWalk *w = ctx;
ModInfo *m = w->m;
int ips = ends_with_ci(leaf, ".ips");
if (kind != PLAT_FS_FILE || (!ips && !ends_with_ci(leaf, ".pchtxt"))) return 1;
m->kind |= MOD_EXEFS;
char path[4300], rel[600];
snprintf(path, sizeof path, "%s/%s", w->folder, leaf);
snprintf(rel, sizeof rel, "%s/%s/%s", m->name, w->rel, leaf);
uint64_t n = 0;
char *data = (char *)plat_file_read(path, &n);
if (!data) {
rt_log("mods: %s: cannot read %s", m->name, path);
return 1;
}
int for_main;
if (ips) {
for_main = same_build(leaf, strlen(leaf) - 4);
} else {
const char *id = NULL;
size_t len = 0;
for_main = pchtxt_build(data, (size_t)n, &id, &len) && len && same_build(id, len);
}
char key[9], content[9], kind_s[16], over[256], note[256];
snprintf(key, sizeof key, "%08X", fnv(rel, strlen(rel)));
snprintf(content, sizeof content, "%08X", fnv(data, (size_t)n));
free(data);
if (!for_main) {
m->other_version++;
return 1;
}
if (!status_line(key, content, kind_s, over, note)) {
m->pending++;
m->pending_key = (m->pending_key ^ fnv(key, 8)) * 0x01000193u;
m->pending_key = (m->pending_key ^ fnv(content, 8)) * 0x01000193u;
return 1;
}
if (!strcmp(kind_s, "unsupported")) {
if (!w->unsupported) snprintf(m->note, sizeof m->note, "%s: %s", leaf, note);
w->unsupported = 1;
} else if (!strcmp(kind_s, "code") && m->nsets < (int)(sizeof m->sets / sizeof *m->sets)) {
m->sets[m->nsets++] = (uint32_t)strtoul(key, NULL, 16);
size_t k = strlen(m->overrides);
if (*over) snprintf(m->overrides + k, sizeof m->overrides - k, "%s%s", k ? "," : "", over);
} else if (!strcmp(kind_s, "other")) {
m->other_version++;
}
return 1;
}
// atmosphere/exefs_patches/<name>/ of a mod: each <name> folder's files.
static int exefs_patches_cb(const char *leaf, int kind, uint64_t size, void *ctx) {
(void)size;
ExefsWalk *w = ctx;
if (kind != PLAT_FS_DIR) return 1;
char folder[4300], rel[600];
snprintf(folder, sizeof folder, "%s/%s", w->folder, leaf);
snprintf(rel, sizeof rel, "atmosphere/exefs_patches/%s", leaf);
ExefsWalk sub = {w->m, folder, rel, 0};
plat_fs_list(folder, exefs_file_cb, &sub);
w->unsupported |= sub.unsupported;
return 1;
}
// The ExeFS patch files of mod m (folder path): 1 when one cannot be applied.
static int exefs_check(ModInfo *m, const char *path) {
ExefsWalk w = {m, m->exefs, m->exefs + strlen(path) + 1, 0};
if (m->exefs[0]) plat_fs_list(m->exefs, exefs_file_cb, &w);
char patches[2200];
snprintf(patches, sizeof patches, "%s/atmosphere/exefs_patches", path);
ExefsWalk at = {m, patches, "", 0};
if (plat_dir_exists(patches)) plat_fs_list(patches, exefs_patches_cb, &at);
return w.unsupported || at.unsupported;
}
// One mod folder of the scan.
static int scan_cb(const char *name, int kind, uint64_t size, void *ctx) {
(void)size;
const char *disabled = ctx;
if (kind != PLAT_FS_DIR && kind != PLAT_FS_LINK) return 1;
if (nmods == MAX_MODS) {
rt_log("mods: more than %d folders in %s; the rest are left out", MAX_MODS, dir);
return 0;
}
ModInfo *m = &mods[nmods];
memset(m, 0, sizeof *m);
char path[2100];
if (snprintf(m->name, sizeof m->name, "%s", name) >= (int)sizeof m->name ||
snprintf(path, sizeof path, "%s/%s", dir, name) >= (int)sizeof path) {
rt_log("mods: %s: name too long, left out", name);
return 1;
}
// a symbolic link or junction to a folder is a mod, as the setup's tools see it (tools/cheats.py cheat_files)
if (kind == PLAT_FS_LINK && !plat_dir_exists(path)) return 1;
find_root(path, "romfs", m->romfs, sizeof m->romfs);
if (!m->romfs[0]) find_root(path, "romfslite", m->romfs, sizeof m->romfs);
find_root(path, "exefs", m->exefs, sizeof m->exefs);
find_root(path, "cheats", m->cheats, sizeof m->cheats);
if (m->romfs[0] && has_file(m->romfs)) m->kind |= MOD_ROMFS;
if (m->exefs[0]) plat_fs_list(m->exefs, exefs_cb, &m->kind);
if (m->cheats[0]) plat_fs_list(m->cheats, cheats_cb, &m->kind);
int unsupported = exefs_check(m, path);
// a mod whose code part the port cannot use is off as a whole: its RomFS files may expect that code
m->problem = strchr(m->name, '|') ? MOD_BAD_NAME
: m->kind & MOD_SUBSDK ? MOD_PLUGIN
: unsupported ? MOD_EXEFS_UNSUPPORTED
: m->pending ? MOD_EXEFS_LATER
: m->nsets || m->kind & (MOD_ROMFS | MOD_CHEATS) ? MOD_OK
: m->kind & MOD_EXEFS ? MOD_EXEFS_OTHER
: MOD_EMPTY;
m->enabled = m->start_enabled = !listed(disabled, m->name);
nmods++;
return 1;
}
void mods_scan(void) {
static int done;
if (done) return;
done = 1;
// a relative mods_dir is under the folder of nx2navi.exe, as the default is, whatever folder the game started in
const char *d = cfg_get("NX2NAVI_MODS_DIR");
if (!d || !*d) d = "mods";
int absolute = d[0] == '/' || d[0] == '\\' || (d[0] && d[1] == ':');
char exe[2048];
char *cut = NULL;
if (!absolute && plat_exe_path(exe, sizeof exe)) {
cut = strrchr(exe, '/');
char *back = strrchr(exe, '\\');
if (back > cut) cut = back;
}
if (absolute) {
snprintf(dir, sizeof dir, "%s", d);
} else if (cut) {
*cut = 0;
snprintf(dir, sizeof dir, "%s/%s", exe, d);
} else {
return;
}
if (!plat_dir_exists(dir)) {
rt_log("mods: no folder %s", dir);
return;
}
mods = calloc(MAX_MODS, sizeof *mods);
if (!mods) return;
if (plat_fs_list(dir, scan_cb, (void *)cfg_get("NX2NAVI_MODS_DISABLED")) != PLAT_FS_OK)
rt_log("mods: cannot list %s (error %d); no mods", dir, plat_fs_os_error());
order_list = cfg_get("NX2NAVI_MODS_ORDER");
qsort(mods, nmods, sizeof *mods, by_order);
order_list = NULL;
for (int i = 0; i < nmods; i++) {
snprintf(start_order[i], sizeof start_order[i], "%s", mods[i].name);
const ModInfo *m = &mods[i];
rt_log("mods: %s: %s,%s%s%s%s%s, %s%s%s", m->name, m->enabled ? "on" : "off", m->kind & MOD_ROMFS ? " romfs" : "",
m->kind & MOD_EXEFS ? " exefs" : "", m->kind & MOD_SUBSDK ? " subsdk" : "",
m->kind & MOD_CHEATS ? " cheats" : "", m->kind ? "" : " no files", problem_name(m->problem),
m->note[0] ? ": " : "", m->note);
}
rt_log("mods: %d folders in %s", nmods, dir);
}
void mods_exefs_switch(void) {
mods_scan();
for (int i = 0; i < nmods; i++) {
const ModInfo *m = &mods[i];
if (!m->start_enabled || m->problem != MOD_OK || !m->nsets) continue;
for (int k = 0; k < m->nsets; k++) {
char name[32];
snprintf(name, sizeof name, "mod_%08x", m->sets[k]);
int set = rt_patch_set(name);
if (set >= 0) rt_patch_switch(set, 1);
else rt_log("mods: %s: patch set %s is not in this build", m->name, name);
}
char over[256];
snprintf(over, sizeof over, "%s", m->overrides);
for (char *s = strtok(over, ","); s; s = strtok(NULL, ",")) {
int set = rt_patch_set(s);
if (set >= 0) rt_patch_switch(set, 0);
rt_log("mods: %s replaces the port's patch set %s; it stays off", m->name, s);
}
rt_log("mods: %s: %d ExeFS patch files on", m->name, m->nsets);
}
}
int mods_exefs_pending(uint32_t *key) {
int n = 0;
for (int i = 0; i < nmods; i++) {
if (!mods[i].start_enabled || mods[i].problem != MOD_EXEFS_LATER) continue;
n += (int)mods[i].pending;
*key = (*key ^ mods[i].pending_key) * 0x01000193u;
}
return n;
}
const char *mods_dir(void) { return dir; }
int mods_count(void) { return nmods; }
ModInfo *mods_at(int i) { return i >= 0 && i < nmods ? &mods[i] : NULL; }
// NX2NAVI_MODS_ORDER as every listed name top first; NX2NAVI_MODS_DISABLED as the switched-off names. A name holding '|'
// would split into two; it is left out of both (its mod is unusable anyway).
static void write_keys(void) {
size_t cap = (size_t)nmods * 257 + 1;
char *order = calloc(cap, 1), *off = calloc(cap, 1);
if (order && off) {
for (int i = 0; i < nmods; i++) {
if (mods[i].problem == MOD_BAD_NAME) continue;
if (*order) strcat(order, "|");
strcat(order, mods[i].name);
if (!mods[i].enabled) {
if (*off) strcat(off, "|");
strcat(off, mods[i].name);
}
}
cfg_set("NX2NAVI_MODS_ORDER", order);
cfg_set("NX2NAVI_MODS_DISABLED", off);
}
free(order);
free(off);
}
void mods_set_enabled(int i, int on) {
if (i < 0 || i >= nmods) return;
mods[i].enabled = on != 0;
write_keys();
}
void mods_move(int i, int delta) {
int j = i + delta;
if (i < 0 || i >= nmods || j < 0 || j >= nmods || i == j) return;
ModInfo t = mods[i];
mods[i] = mods[j];
mods[j] = t;
write_keys();
}
int mods_changed(void) {
for (int i = 0; i < nmods; i++)
if (strcmp(mods[i].name, start_order[i]) || mods[i].enabled != mods[i].start_enabled) return 1;
return 0;
}