From 0fbd2b8f31d8787226abdb0249c8636264a6020e Mon Sep 17 00:00:00 2001 From: chrg6 Date: Fri, 13 Nov 2026 17:32:02 -0500 Subject: [PATCH] Add a static check of cfg.c's settings tables to the content guard, and list the 15 names it found missing from known[]. A // on a metas[] line hides the rows after it on that line, and the game then ends at start with "FATAL: cfg: NX2NAVI_ has no row in the settings table"; that shipped in two test builds and nothing caught it before a start. And 15 names the runtime reads were not in known[], so setting any of them in nx2navi.ini logged "unknown key" and put it in the menu's list of unknown keys although it took effect: AUDIO_DUMP, DEFER_STAMP, DEPTH_TWIN, DLAA_ZFIT, DUMP_SKIP_RUNS, DUMP_TEX, DUMP_UBO, EVENT_ORDER, GATHER_BIAS, PIN_SWEEP, REC_CHECK, REC_DELAY_US, REG_ORDER, SHADOW and ZERO_LOCALS (GATHER_BIAS and ZERO_LOCALS through vk_caps.c env_u32). tests/cfg_rows_check.py (standard library only) parses known[], metas[], sections[], DECK[] and LOWEND[] by brace matching that skips string and character literals and comments (an apostrophe in a comment inside a table would otherwise open a character literal and run the table on past its end), line by line with the first // outside a string split off, and the first #if branch standing for the table. It fails on a row or name after //, a /* in a table, a name twice in known[], two metas[] rows for a key, a metas[] row not in known[], a cfg_value/cfg_int/cfg_bool/cfg_double read (directly or through one level of wrapper) with no row and no cfg_get, cfg_is_set or cfg_meta guard earlier in the same function (kernel.c SPIN and SVC_WAITS, vk_headroom.c VRAM_HEADROOM pass that way), and a name read through cfg_get, cfg_is_set, cfg_meta, rt_knob, a cfg_get or "NX2NAVI_%s" wrapper, or a patches-v111.txt set line that is not in known[] or a cheat. --self-test puts each kind of fault into a copy and checks it is caught. The content guard workflow runs the check after tools/check_content.py. The 15 names go into known[] on a line of their own with no //. Reproduced before the change: the check reported the 15 names on stability-pass, as the audit's prototype did. Now it reports 0 problems and its self-test catches all 15 injected faults; the scratchpad cfg rows check ("missing []"), cfg_parse_check and the compile checks pass. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/content-guard.yml | 1 + runtime/cfg.c | 3 + tests/cfg_rows_check.py | 382 ++++++++++++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 tests/cfg_rows_check.py diff --git a/.github/workflows/content-guard.yml b/.github/workflows/content-guard.yml index f8ff2b68..53b7fe81 100644 --- a/.github/workflows/content-guard.yml +++ b/.github/workflows/content-guard.yml @@ -13,3 +13,4 @@ jobs: with: python-version: "3.12" - run: python tools/check_content.py + - run: python tests/cfg_rows_check.py diff --git a/runtime/cfg.c b/runtime/cfg.c index 9b702449..65b26c93 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -88,6 +88,9 @@ static const char *const known[] = { "MEM_BUDGET_TEST_VRAM_MB", "MEM_BUDGET_TEST_RAM_MB", "TEX_COMPACT", "TEX_COMPACT_MIN_MB", "TEX_COMPACT_FRAME_MB", // texture compaction (vk_texture.c); no menu row "STANDIN_FILTER", // the filter of the scene upscaler's stand-in (vk_tonemap.c); no menu row + // developer switches read with cfg_get, rt_knob or vk_caps.c env_u32, without a menu row (tests/cfg_rows_check.py) + "AUDIO_DUMP", "DEFER_STAMP", "DEPTH_TWIN", "DLAA_ZFIT", "DUMP_SKIP_RUNS", "DUMP_TEX", "DUMP_UBO", "EVENT_ORDER", + "GATHER_BIAS", "PIN_SWEEP", "REC_CHECK", "REC_DELAY_US", "REG_ORDER", "SHADOW", "ZERO_LOCALS", // retired: the settings of the co-op of versions up to 0.9.2; read by nothing, listed so that a file which still // sets them logs no unknown key "COOP", "COOP_BETA", "COOP_NAME", "COOP_SERVER", "COOP_PORT", "COOP_ROOM", "COOP_AUTO", "COOP_DROP", diff --git a/tests/cfg_rows_check.py b/tests/cfg_rows_check.py new file mode 100644 index 00000000..869032ed --- /dev/null +++ b/tests/cfg_rows_check.py @@ -0,0 +1,382 @@ +"""Static check of runtime/cfg.c's settings tables against what the runtime reads. Standard library only, so the +content guard workflow runs it beside tools/check_content.py. + +It fails on: +- a row or name after a // on a line of known[], metas[], sections[], DECK[] or LOWEND[]: it is not compiled, and a + hidden metas[] row ends every start with "FATAL: cfg: NX2NAVI_ has no row in the settings table" (this shipped + in two test builds); a /* inside those tables, which this check does not read; +- a name twice in known[], or two metas[] rows for one key in one #if branch (the first branch stands for the table, + later ones repeat its keys); +- a metas[] row whose key is not in known[]; +- a read with cfg_value, cfg_int, cfg_bool or cfg_double of "NX2NAVI_" (directly, or through one level of a + function that passes its const char * parameter on to one of them) whose key has no metas[] row, unless the same + function asked cfg_get, cfg_is_set or cfg_meta about that key before it; +- a name the runtime reads that is not in known[] and does not start with CHEAT_ (cfg.c is_known): cfg_get, cfg_is_set + and cfg_meta of "NX2NAVI_", rt_knob("", ...), one level of functions whose const char * parameter reaches + cfg_get or a "NX2NAVI_%s" of it, and the `set NX2NAVI_` lines of recomp/patches-v111.txt. Such a key set + in nx2navi.ini logs "unknown key" and is listed as unknown in the menu. + +Names read only with getenv, plat_setenv or cfg_setenv are environment-only and not checked. runtime/cfg_probe.c asks +for an unknown key on purpose and is left out, as are third_party/ and the generated *_spv.h headers. + +Usage: python tests/cfg_rows_check.py [repo] one line per problem with file:line; exit 1 if any + python tests/cfg_rows_check.py --self-test inject each kind of fault into copies and check each is caught +""" +import re +import sys +from pathlib import Path + +KEY = r'[A-Z][A-Z0-9_]*' +TABLES = { # declaration, what a row looks like on a line + 'known[]': ('static const char *const known[]', r'"(' + KEY + r')"'), + 'metas[]': ('static const CfgMeta metas[]', r'\{\s*"(' + KEY + r')"'), + 'sections[]': ('sections[] = {', r'\{\s*"(' + KEY + r')"'), + 'DECK[]': ('DECK[] = {', r'\{\s*"(' + KEY + r')"'), + 'LOWEND[]': ('LOWEND[] = {', r'\{\s*"(' + KEY + r')"'), +} +VALUE_FNS = ('cfg_value', 'cfg_int', 'cfg_bool', 'cfg_double') +GUARD_FNS = ('cfg_get', 'cfg_is_set', 'cfg_meta') +LIT = r'"NX2NAVI_(' + KEY + r')"' + + +def matching(src, open_at): + """The index of the bracket that closes the one at open_at (brackets in string literals and comments left out).""" + pair = {'{': '}', '(': ')'}[src[open_at]] + depth, k, n = 0, open_at, len(src) + while k < n: + c = src[k] + if src.startswith('//', k): + k = src.find('\n', k) + if k < 0: + return n + elif src.startswith('/*', k): + k = src.find('*/', k + 2) + if k < 0: + return n + k += 1 + elif c == '"' or c == "'": + k += 1 + while k < n and src[k] != c: + k += 2 if src[k] == '\\' else 1 + elif c == src[open_at]: + depth += 1 + elif c == pair: + depth -= 1 + if depth == 0: + return k + k += 1 + return n + + +def split_comment(line): + """(code, comment) of one line: the comment starts at the first // outside a string literal.""" + i, n, quote = 0, len(line), None + while i < n: + c = line[i] + if quote: + if c == '\\': + i += 2 + continue + if c == quote: + quote = None + elif c in '"\'': + quote = c + elif line.startswith('//', i): + return line[:i], line[i:] + i += 1 + return line, '' + + +def strip_comments(src): + """C source with its comments blanked out, line numbers and string literals kept.""" + out, i, n = [], 0, len(src) + while i < n: + c = src[i] + if c == '"' or c == "'": + j = i + 1 + while j < n and src[j] != c and src[j] != '\n': + j += 2 if src[j] == '\\' else 1 + out.append(src[i:j + 1]) + i = j + 1 + elif src.startswith('//', i): + j = src.find('\n', i) + i = n if j < 0 else j + elif src.startswith('/*', i): + j = src.find('*/', i + 2) + j = n if j < 0 else j + 2 + out.append('\n' * src.count('\n', i, j)) + i = j + else: + out.append(c) + i += 1 + return ''.join(out) + + +def line_of(src, pos): + return src.count('\n', 0, pos) + 1 + + +def tables(cfg, problems): + """The names of each table, the first #if branch of each line only, with the problems of hidden rows.""" + out = {} + for name, (decl, row) in TABLES.items(): + i = cfg.find(decl) + if i < 0: + problems.append(f'runtime/cfg.c: no {name} table ("{decl}")') + out[name] = [] + continue + j = cfg.index('{', i + len(decl) - 1) + body = cfg[j + 1:matching(cfg, j)] + line0 = line_of(cfg, j) + names, branch, seen_branch = [], [], {} + for n, line in enumerate(body.split('\n')): + st = line.strip() + if st.startswith('#if'): + branch.append(0) + continue + if st.startswith(('#else', '#elif')): + if branch: + branch[-1] += 1 + continue + if st.startswith('#endif'): + if branch: + branch.pop() + continue + code, comment = split_comment(line) + if '/*' in code: + problems.append(f'runtime/cfg.c:{line0 + n}: a /* comment in {name}, which this check does not read') + hidden = re.findall(row, comment) + if hidden: + problems.append(f'runtime/cfg.c:{line0 + n}: {name} rows after // are not compiled: {", ".join(hidden)}') + if branch and branch[-1] > 0: + continue # a later branch repeats the first one's keys + for k in re.findall(row, code): + names.append((k, line0 + n)) + out[name] = names + return out + + +def functions(src): + """(name, parameters, body start, body end) of every function definition in comment-stripped source.""" + out = [] + for m in re.finditer(r'\b([A-Za-z_]\w*)\s*\(([^;{}()]*)\)\s*(?:const\s*)?\{', src): + name = m.group(1) + if name in ('if', 'for', 'while', 'switch', 'return', 'sizeof', 'catch'): + continue + b = m.end() - 1 + out.append((name, m.group(2), b, matching(src, b))) + return out + + +def call_args(src, open_at): + """The arguments of the call whose ( is at open_at, as text.""" + end = matching(src, open_at) + args, depth, cur, k = [], 0, '', open_at + 1 + while k < end: + c = src[k] + if c == '"': + j = k + 1 + while j < end and src[j] != '"': + j += 2 if src[j] == '\\' else 1 + cur += src[k:j + 1] + k = j + 1 + continue + if c in '([{': + depth += 1 + elif c in ')]}': + depth -= 1 + elif c == ',' and depth == 0: + args.append(cur.strip()) + cur = '' + k += 1 + continue + cur += c + k += 1 + args.append(cur.strip()) + return args + + +def check(cfg_text, sources, patches): + """The problems, one line each. sources: {path as reported: text}; cfg_text is also among them as runtime/cfg.c.""" + problems = [] + tabs = tables(cfg_text, problems) + known = [k for k, _ in tabs['known[]']] + known_s = set(known) + metas_s = {k for k, _ in tabs['metas[]']} + for k in sorted({k for k in known if known.count(k) > 1}): + problems.append(f'runtime/cfg.c: known[] lists {k} twice') + seen = {} + for k, n in tabs['metas[]']: + if k in seen: + problems.append(f'runtime/cfg.c:{n}: metas[] has a second row for {k} (the first at line {seen[k]})') + seen.setdefault(k, n) + for k, n in tabs['metas[]']: + if k not in known_s: + problems.append(f'runtime/cfg.c:{n}: metas[] row {k} is not in known[]') + + stripped = {p: strip_comments(t) for p, t in sources.items()} + # one level of wrappers: a const char * parameter passed on to a value read, or to cfg_get / "NX2NAVI_%s" + wrappers = {} # name -> (argument index, 'value' | 'name', the literal carries the prefix) + for p, s in stripped.items(): + for fname, params, b, e in functions(s): + if fname in VALUE_FNS or fname in GUARD_FNS or fname == 'rt_knob': + continue + body = s[b:e] + for idx, prm in enumerate(x.strip() for x in params.split(',')): + pm = re.fullmatch(r'const\s+char\s*\*\s*(\w+)', prm) + if not pm: + continue + v = pm.group(1) + if re.search(r'\b(?:%s)\s*\(\s*%s\s*\)' % ('|'.join(VALUE_FNS), v), body): + wrappers[fname] = (idx, 'value', True) + elif re.search(r'\bcfg_get\s*\(\s*%s\s*\)' % v, body): + wrappers.setdefault(fname, (idx, 'name', True)) + elif re.search(r'"NX2NAVI_%%s"\s*,\s*%s\b' % v, body): + wrappers.setdefault(fname, (idx, 'name', False)) + + value_sites, name_sites = [], [] # (key, file, position, how) + for p, s in stripped.items(): + start = s.index('static int is_known') if p.endswith('runtime/cfg.c') and 'static int is_known' in s else 0 + for m in re.finditer(r'\b(%s)\s*\(\s*' % '|'.join(VALUE_FNS) + LIT, s): + if m.start() >= start: + value_sites.append((m.group(2), p, m.start(), m.group(1))) + for m in re.finditer(r'\b(%s)\s*\(\s*' % '|'.join(GUARD_FNS) + LIT, s): + if m.start() >= start: + name_sites.append((m.group(2), p, m.start(), m.group(1))) + for m in re.finditer(r'\brt_knob\s*\(\s*"(' + KEY + r')"', s): + name_sites.append((m.group(1), p, m.start(), 'rt_knob')) + for w, (idx, kind, prefixed) in wrappers.items(): + for m in re.finditer(r'\b' + re.escape(w) + r'\s*\(', s): + args = call_args(s, m.end() - 1) + if idx >= len(args): + continue + lm = re.fullmatch(r'"(NX2NAVI_)?(' + KEY + r')"', args[idx]) + if not lm or bool(lm.group(1)) != prefixed: + continue + (value_sites if kind == 'value' else name_sites).append((lm.group(2), p, m.start(), w)) + for n, line in enumerate(patches.split('\n'), 1): + m = re.match(r'\s*set\s+\S+\s+NX2NAVI_(' + KEY + r')\b', line) + if m: + name_sites.append((m.group(1), 'recomp/patches-v111.txt', -n, 'a patch set')) + + def where(p, pos): + return f'{p}:{-pos if pos < 0 else line_of(stripped[p], pos)}' + + bodies = {p: functions(s) for p, s in stripped.items()} + for k, p, pos, how in value_sites: + if k in metas_s: + continue + body = next(((b, e) for _, _, b, e in bodies[p] if b < pos < e), None) + text = stripped[p][body[0]:pos] if body else '' + if re.search(r'\b(?:%s)\s*\(\s*"NX2NAVI_%s"' % ('|'.join(GUARD_FNS), k), text): + continue # the function asked whether the key is set first (kernel.c SPIN, vk_headroom.c) + problems.append(f'{where(p, pos)}: {how}("NX2NAVI_{k}") has no metas[] row and no cfg_get guard before it: ' + f'the game would end with "has no row in the settings table"') + missing = {} + for k, p, pos, how in name_sites + value_sites: + if k not in known_s and not k.startswith('CHEAT_'): + missing.setdefault(k, []).append(f'{where(p, pos)} {how}') + for k, ws in sorted(missing.items()): + problems.append(f'{ws[0].split()[0]}: {k} is read ({"; ".join(ws[:3])}{"; ..." if len(ws) > 3 else ""}) but ' + f'is not in cfg.c known[]: set in nx2navi.ini it logs "unknown key"') + return problems, len(known), len(tabs['metas[]']), len(value_sites), len(name_sites), sorted(wrappers) + + +def read_repo(repo): + rt = repo / 'runtime' + files = sorted(p for p in rt.rglob('*') if p.suffix in ('.c', '.cpp', '.h', '.inc') and 'third_party' not in p.parts + and not p.name.endswith('_spv.h') and p.name != 'cfg_probe.c') + sources = {p.relative_to(repo).as_posix(): p.read_text(encoding='utf-8-sig', errors='replace') for p in files} + patches = repo / 'recomp' / 'patches-v111.txt' + return sources['runtime/cfg.c'], sources, patches.read_text(encoding='utf-8') if patches.exists() else '' + + +def self_test(repo): + """Each kind of fault, put into a copy of the tables or a made-up source file, must be reported.""" + cfg, sources, patches = read_repo(repo) + base, *_ = check(cfg, sources, patches) + bad = 0 + if base: + print(f'self-test: the repository itself has {len(base)} problems; the cases below are judged on new ones') + + def case(what, want, cfg_text=None, extra=None, patch_text=None): + nonlocal bad + src = dict(sources) + src['runtime/cfg.c'] = cfg_text if cfg_text is not None else cfg + src.update(extra or {}) + got, *_ = check(src['runtime/cfg.c'], src, patch_text if patch_text is not None else patches) + new = [p for p in got if p not in base] + ok = any(re.search(want, p) for p in new) + print(f'self-test: {what}: {"caught" if ok else "MISSED"}') + if not ok: + bad += 1 + for p in new: + print(' ', p) + + def add_line(table_decl, line): + """cfg.c with line put in as the table's first line.""" + i = cfg.index(table_decl) + j = cfg.index('\n', cfg.index('{', i + len(table_decl) - 1)) + 1 + return cfg[:j] + ' ' + line + '\n' + cfg[j:] + + case('a metas[] row after //', r'metas\[\] rows after // are not compiled: HIDDEN_ROW', + add_line(TABLES['metas[]'][0], '{"FULLSCREEN_X", "0", "0|1", LIVE}, // {"HIDDEN_ROW", "0", "0|1", LIVE},')) + case('a known[] name after //', r'known\[\] rows after // are not compiled: HIDDEN_NAME', + add_line(TABLES['known[]'][0], '"AB_X", // "HIDDEN_NAME",')) + case('a sections[] entry after //', r'sections\[\] rows after // are not compiled: HIDDEN_SECTION', + add_line(TABLES['sections[]'][0], '// {"HIDDEN_SECTION", "display"},')) + case('a /* in a table', r'a /\* comment in known\[\]', add_line(TABLES['known[]'][0], '/* "X", */')) + case('a name twice in known[]', r'known\[\] lists AA twice', add_line(TABLES['known[]'][0], '"AA",')) + case('two metas[] rows for one key', r'second row for FULLSCREEN', + add_line(TABLES['metas[]'][0], '{"FULLSCREEN", "0", "0|1", LIVE},')) + case('a metas[] row not in known[]', r'metas\[\] row ROW_NOT_KNOWN is not in known\[\]', + add_line(TABLES['metas[]'][0], '{"ROW_NOT_KNOWN", "0", "0|1", LIVE},')) + # a row the game reads, hidden behind a // put before it on its line: its readers are left without a row + at = cfg.index('{"FULLSCREEN"', cfg.index(TABLES['metas[]'][0])) + case('a value read on a row hidden by // (FULLSCREEN)', r'FULLSCREEN"\) has no metas\[\] row', + cfg[:at] + '// ' + cfg[at:]) + fake = ''' +static int my_opt(const char *key) { return cfg_int(key); } +static unsigned my_env(const char *name) { return cfg_get(name) ? 1 : 0; } +void fake(void) { + int a = cfg_int("NX2NAVI_NO_ROW_INT"); + if (cfg_get("NX2NAVI_GUARDED_BOOL") && cfg_bool("NX2NAVI_GUARDED_BOOL")) a++; + a += my_opt("NX2NAVI_WRAPPED_NO_ROW"); + a += rt_knob("KNOB_NOT_KNOWN", 0); + a += (int)my_env("NX2NAVI_ENV_NOT_KNOWN"); +} +''' + extra = {'runtime/fake_check.c': fake} + case('an unguarded cfg_int without a row', r'cfg_int\("NX2NAVI_NO_ROW_INT"\) has no metas', extra=extra) + case('a value read through a wrapper without a row', r'my_opt\("NX2NAVI_WRAPPED_NO_ROW"\) has no metas', extra=extra) + case('a guarded read without a row is not reported as such, its name is', + r'GUARDED_BOOL is read .* not in cfg.c known', extra=extra) + got, *_ = check(cfg, {**sources, **extra}, patches) + guarded_flagged = any('GUARDED_BOOL") has no metas' in p for p in got) + print(f'self-test: a read after a cfg_get guard in the same function: {"REPORTED" if guarded_flagged else "passes"}') + bad += guarded_flagged + case('an rt_knob name not in known[]', r'KNOB_NOT_KNOWN is read', extra=extra) + case('a name read through a cfg_get wrapper', r'ENV_NOT_KNOWN is read', extra=extra) + case('a patch set\'s name not in known[]', r'PATCH_SET_NOT_KNOWN is read', + patch_text=patches + '\nset some_patch NX2NAVI_PATCH_SET_NOT_KNOWN 1\n') + print(f'cfg_rows_check self-test: {"FAILED" if bad else "ok"}') + return 1 if bad else 0 + + +def main(): + args = sys.argv[1:] + repo = Path(__file__).resolve().parents[1] + if args and args[0] != '--self-test': + repo = Path(args[0]) + if '--self-test' in args: + return self_test(repo) + problems, nk, nm, nv, nn, wr = check(*read_repo(repo)) + for p in problems: + print(p) + print(f'cfg_rows_check: known[] {nk} names, metas[] {nm} rows, {nv} value reads and {nn} name reads (wrappers: ' + f'{", ".join(wr)}): {len(problems)} problem{"" if len(problems) == 1 else "s"}') + return 1 if problems else 0 + + +if __name__ == '__main__': + sys.exit(main())