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>
This commit is contained in:
chrg6
2026-11-08 08:48:08 -05:00
parent 1f4839d4a5
commit 7f9ace35fb
3 changed files with 144 additions and 42 deletions

View File

@@ -33,8 +33,7 @@
static ModInfo *mods;
static int nmods;
static char dir[2048];
static char start_order[MAX_MODS][256]; // names in the scan's order and their switches, for mods_changed
static int start_enabled[MAX_MODS];
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);
@@ -170,6 +169,33 @@ static int same_build(const char *hex, size_t n) {
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;
@@ -238,15 +264,9 @@ static int exefs_file_cb(const char *leaf, int kind, uint64_t size, void *ctx) {
if (ips) {
for_main = same_build(leaf, strlen(leaf) - 4);
} else {
char *id = NULL; // after "@nsobid-" at the start of a line
for (char *p = data; !id && p && data + n - p > 8;) {
if (!memcmp(p, "@nsobid-", 8)) id = p + 8;
p = memchr(p, '\n', (size_t)(data + n - p));
if (p) p++;
}
const char *id = NULL;
size_t len = 0;
while (id && id + len < data + n && isxdigit((unsigned char)id[len])) len++;
for_main = id && same_build(id, len);
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)));
@@ -304,7 +324,7 @@ static int exefs_check(ModInfo *m, const char *path) {
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) return 1;
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;
@@ -317,6 +337,8 @@ static int scan_cb(const char *name, int kind, uint64_t size, void *ctx) {
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);
@@ -333,7 +355,7 @@ static int scan_cb(const char *name, int kind, uint64_t size, void *ctx) {
: m->nsets || m->kind & (MOD_ROMFS | MOD_CHEATS) ? MOD_OK
: m->kind & MOD_EXEFS ? MOD_EXEFS_OTHER
: MOD_EMPTY;
m->enabled = !listed(disabled, m->name);
m->enabled = m->start_enabled = !listed(disabled, m->name);
nmods++;
return 1;
}
@@ -342,17 +364,24 @@ 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) {
snprintf(dir, sizeof dir, "%s", d);
} else {
char exe[2048];
if (!plat_exe_path(exe, sizeof exe)) return;
char *cut = strrchr(exe, '/'), *back = strrchr(exe, '\\');
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 (!cut) return;
}
if (absolute) {
snprintf(dir, sizeof dir, "%s", d);
} else if (cut) {
*cut = 0;
snprintf(dir, sizeof dir, "%s/mods", exe);
snprintf(dir, sizeof dir, "%s/%s", exe, d);
} else {
return;
}
if (!plat_dir_exists(dir)) {
rt_log("mods: no folder %s", dir);
@@ -360,13 +389,13 @@ void mods_scan(void) {
}
mods = calloc(MAX_MODS, sizeof *mods);
if (!mods) return;
plat_fs_list(dir, scan_cb, (void *)cfg_get("NX2NAVI_MODS_DISABLED"));
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);
start_enabled[i] = mods[i].enabled;
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" : "",
@@ -380,7 +409,7 @@ void mods_exefs_switch(void) {
mods_scan();
for (int i = 0; i < nmods; i++) {
const ModInfo *m = &mods[i];
if (!start_enabled[i] || m->problem != MOD_OK || !m->nsets) continue;
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]);
@@ -402,7 +431,7 @@ void mods_exefs_switch(void) {
int mods_exefs_pending(uint32_t *key) {
int n = 0;
for (int i = 0; i < nmods; i++) {
if (!start_enabled[i] || mods[i].problem != MOD_EXEFS_LATER) continue;
if (!mods[i].start_enabled || mods[i].problem != MOD_EXEFS_LATER) continue;
n += (int)mods[i].pending;
*key = (*key ^ mods[i].pending_key) * 0x01000193u;
}
@@ -452,6 +481,6 @@ void mods_move(int i, int delta) {
int mods_changed(void) {
for (int i = 0; i < nmods; i++)
if (strcmp(mods[i].name, start_order[i]) || mods[i].enabled != start_enabled[i]) return 1;
if (strcmp(mods[i].name, start_order[i]) || mods[i].enabled != mods[i].start_enabled) return 1;
return 0;
}

View File

@@ -1,8 +1,8 @@
// Player mods (mods.c): one folder per mod under mods/ next to nx2navi.exe (NX2NAVI_MODS_DIR), unpacked as emulators take
// them: <mod>/romfs, <mod>/exefs and <mod>/cheats, the Atmosphere layout (atmosphere/contents/<title id>/romfs,
// atmosphere/exefs_patches/<name>/*.ips) or a mod manager's merged output (romfs or romfslite, exefs, cheats). ExeFS patch
// files (.ips, .pchtxt) for the game's main are patch sets the setup builds into the game (tools/exefs_patches.py),
// switched with their mod (mods_exefs_switch). The list is scanned once at start (romfs.c overlay_init);
// Player mods (mods.c): one folder per mod under mods/ next to nx2navi.exe (NX2NAVI_MODS_DIR; a relative one is under
// that folder too), unpacked as emulators take them: <mod>/romfs, <mod>/exefs and <mod>/cheats, the Atmosphere layout
// (atmosphere/contents/<title id>/romfs, atmosphere/exefs_patches/<name>/*.ips) or a mod manager's merged output
// (romfs or romfslite, exefs, cheats). ExeFS patch files (.ips, .pchtxt) for the game's main are patch sets the setup
// builds into the game (tools/exefs_patches.py), switched with their mod (mods_exefs_switch). The list is scanned once at start (romfs.c overlay_init);
// its order and switches live in nx2navi.ini [mods] (NX2NAVI_MODS_ORDER: names top first, separated by '|';
// NX2NAVI_MODS_DISABLED: names switched off). The top mod wins a file two mods replace.
#pragma once
@@ -27,6 +27,7 @@ typedef struct {
char exefs[2048]; // UTF-8 path of its ExeFS folder; "" none
char cheats[2048]; // UTF-8 path of its cheats folder; "" none
int kind, enabled;
int start_enabled; // enabled at the scan: what romfs.c, cheats.c and the ExeFS patch sets go by this run
ModProblem problem;
uint32_t replaced, added, overridden; // romfs.c: files served (replacing / added), files a higher mod serves
uint32_t other_version; // files for another game version (RomFS *.Product.<n>.*, ExeFS patches

View File

@@ -8,6 +8,10 @@
#include "mods.h"
#include "platform/platform.h"
#ifdef _WIN32
#include <windows.h>
#endif
static int fails;
#define CHECK(c, what) do { if (!(c)) { fails++; fprintf(stderr, "FAIL mods: %s\n", what); } } while (0)
#define EQ(a, b) ((a) && !strcmp((a), (b)))
@@ -23,6 +27,27 @@ static void put(const char *root, const char *rel) {
if (slash[1]) plat_file_write(p, "probe", 5);
}
// root/rel as a file holding text.
static void put_text(const char *root, const char *rel, const char *text) {
put(root, rel);
char p[2048];
snprintf(p, sizeof p, "%s/%s", root, rel);
plat_file_write(p, text, strlen(text));
}
// link (a path) as a symbolic link to the folder target; 0 when the system refuses (Windows without developer mode).
static int dir_link(const char *target, const char *link) {
#ifdef _WIN32
wchar_t t[1024], l[1024];
if (!MultiByteToWideChar(CP_UTF8, 0, target, -1, t, 1024) || !MultiByteToWideChar(CP_UTF8, 0, link, -1, l, 1024))
return 0;
return CreateSymbolicLinkW(l, t, SYMBOLIC_LINK_FLAG_DIRECTORY | SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE) != 0;
#else
(void)target, (void)link;
return 0;
#endif
}
static int remove_cb(const char *name, int kind, uint64_t size, void *ctx);
static void remove_tree(const char *dir) {
plat_fs_list(dir, remove_cb, (void *)dir);
@@ -33,7 +58,7 @@ static int remove_cb(const char *name, int kind, uint64_t size, void *ctx) {
char sub[2048];
snprintf(sub, sizeof sub, "%s/%s", (const char *)ctx, name);
if (kind == PLAT_FS_DIR) remove_tree(sub);
else plat_fs_unlink(sub);
else if (kind != PLAT_FS_LINK || plat_fs_rmdir(sub) != PLAT_FS_OK) plat_fs_unlink(sub); // a link: never followed
return 1;
}
@@ -71,23 +96,48 @@ int probe_mods(void) {
char ips[160];
snprintf(ips, sizeof ips, "M/atmosphere/exefs_patches/x/%s.ips", *mods_main_build_id() ? mods_main_build_id() : "0");
put(root, ips);
// pchtxt files for main as tools/exefs_patches.py reads them: a BOM and an indented build id line (N); a build id
// line with more than hex on it (O) and one after @stop (P) name no build, so another version's
const char *bid = *mods_main_build_id() ? mods_main_build_id() : "0";
char text[256];
snprintf(text, sizeof text, "\xEF\xBB\xBF// a title\r\n @nsobid-%s // main\r\n@enabled\r\n", bid);
put_text(root, "N/exefs/bom.pchtxt", text);
snprintf(text, sizeof text, "@nsobid-%s v2\n", bid);
put_text(root, "O/exefs/junk.pchtxt", text);
snprintf(text, sizeof text, "@stop\n@nsobid-%s\n", bid);
put_text(root, "P/exefs/stop.pchtxt", text);
// a link to a folder outside the mods folder is a mod (Q); left out when the system cannot make one
char outside[1100], link[1100];
snprintf(outside, sizeof outside, "%s-linked", root);
remove_tree(outside);
put(outside, "romfs/q.bin");
snprintf(link, sizeof link, "%s/Q", root);
int linked = dir_link(outside, link);
if (!linked) printf("mods: no symbolic link made (developer mode off?); the linked folder check is skipped\n");
cfg_set("NX2NAVI_MODS_DIR", root);
cfg_set("NX2NAVI_MODS_DIR", "mods-probe"); // relative: under the folder of nx2navi.exe
cfg_set("NX2NAVI_MODS_ORDER", "B|A|Gone");
cfg_set("NX2NAVI_MODS_DISABLED", "C");
mods_scan();
static const char *const order[] = {"B", "A", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"Ultra \xD0\x96"};
CHECK(ends_with(mods_dir(), "/mods-probe"), "a relative mods_dir is under the folder of nx2navi.exe");
static const char *const order[] = {"B", "A", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
"Q", "Ultra \xD0\x96"};
enum { N = sizeof order / sizeof *order };
CHECK(mods_count() == N, "fourteen mod folders");
int in_order = mods_count() == N;
for (int i = 0; in_order && i < N; i++) in_order = EQ(mods_at(i)->name, order[i]);
// without the link Q is missing: the names after it move up one
int n_want = linked ? N : N - 1, in_order = mods_count() == n_want;
CHECK(in_order, linked ? "eighteen mod folders" : "seventeen mod folders");
for (int i = 0, k = 0; in_order && i < N; i++) {
if (!linked && EQ(order[i], "Q")) continue;
in_order = EQ(mods_at(k)->name, order[i]);
k++;
}
CHECK(in_order, "order: mods_order first, the rest by name");
if (mods_count() == N && in_order) {
if (in_order) {
ModInfo *a = mods_at(1), *b = mods_at(0), *c = mods_at(2), *d = mods_at(3), *e = mods_at(4), *f = mods_at(5),
*g = mods_at(6), *hm = mods_at(7), *im = mods_at(8), *jm = mods_at(9), *km = mods_at(10),
*lm = mods_at(11), *mm = mods_at(12), *u = mods_at(13);
*lm = mods_at(11), *mm = mods_at(12), *nm = mods_at(13), *om = mods_at(14), *pm = mods_at(15),
*u = mods_at(n_want - 1);
CHECK(hm->kind == (MOD_ROMFS | MOD_SUBSDK) && hm->problem == MOD_PLUGIN, "RomFS + subsdk plugin: unusable");
CHECK(im->kind == (MOD_ROMFS | MOD_SUBSDK) && im->problem == MOD_PLUGIN, "RomFS + replaced exefs/main");
CHECK(ends_with(b->romfs, "B/atmosphere/contents/0400486024DEC000/romfs"), "Atmosphere RomFS root");
@@ -107,24 +157,46 @@ int probe_mods(void) {
CHECK(km->kind == MOD_ROMFS && km->problem == MOD_OK && ends_with(km->romfs, "K/romfslite"), "merged romfslite");
CHECK(lm->kind == (MOD_ROMFS | MOD_CHEATS) && lm->problem == MOD_OK, "RomFS + cheats, main.npdm is no code");
CHECK(u->kind == MOD_ROMFS && ends_with(u->romfs, "Ultra \xD0\x96/romfs"), "Unicode folder name");
CHECK(nm->kind == MOD_EXEFS && nm->problem == MOD_EXEFS_LATER && nm->pending == 1 && !nm->other_version,
"pchtxt with a BOM and an indented build id line is for main");
CHECK(om->problem == MOD_EXEFS_OTHER && om->other_version == 1 && !om->pending,
"pchtxt build id line with more than hex: another version's");
CHECK(pm->problem == MOD_EXEFS_OTHER && pm->other_version == 1, "pchtxt build id after @stop is not read");
if (linked) {
const ModInfo *q = mods_at(16);
CHECK(q->kind == MOD_ROMFS && q->problem == MOD_OK && ends_with(q->romfs, "Q/romfs"),
"a linked mod folder is a mod");
}
CHECK(!mods_changed(), "nothing changed after the scan");
mods_move(1, -1);
CHECK(EQ(mods_at(0)->name, "A") && EQ(mods_at(1)->name, "B"), "move up");
CHECK(EQ(cfg_get("NX2NAVI_MODS_ORDER"), "A|B|C|D|E|F|G|H|I|J|K|L|M|Ultra \xD0\x96"),
CHECK(EQ(cfg_get("NX2NAVI_MODS_ORDER"), linked ? "A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|Ultra \xD0\x96"
: "A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Ultra \xD0\x96"),
"order written without the gone name");
mods_move(0, -1);
CHECK(EQ(mods_at(0)->name, "A"), "move up at the top does nothing");
mods_move(N - 1, 1);
CHECK(EQ(mods_at(N - 1)->name, "Ultra \xD0\x96"), "move down at the bottom does nothing");
mods_move(n_want - 1, 1);
CHECK(EQ(mods_at(n_want - 1)->name, "Ultra \xD0\x96"), "move down at the bottom does nothing");
mods_set_enabled(2, 1);
const char *dis = cfg_get("NX2NAVI_MODS_DISABLED");
CHECK(mods_at(2)->enabled && (!dis || !*dis), "switching C on empties mods_disabled");
mods_set_enabled(0, 0);
CHECK(EQ(cfg_get("NX2NAVI_MODS_DISABLED"), "A"), "switching A off writes it");
CHECK(mods_changed(), "changes seen");
// the ExeFS patch files waiting for the rebuild follow the mods' start state, wherever the menu moved them:
// M and N wait (both on at the start); M moved onto C's place (off at the start) and switched off still waits
uint32_t key = 0;
CHECK(mods_exefs_pending(&key) == 2, "two ExeFS patch files wait for the rebuild");
for (int i = 12; i > 2; i--) mods_move(i, -1);
CHECK(EQ(mods_at(2)->name, "M"), "M moved up to the third place");
mods_set_enabled(2, 0);
key = 0;
CHECK(mods_exefs_pending(&key) == 2, "the start state goes with its mod when the menu moves it");
}
remove_tree(root);
remove_tree(outside);
printf("mods: %s\n", fails ? "FAIL" : "ok");
return fails ? 1 : 0;
}