mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 17:09:57 -04:00
A // on a metas[] line hides the rows after it on that line, and the game then ends at start with "FATAL: cfg:
NX2NAVI_<X> 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 <noreply@anthropic.com>
383 lines
17 KiB
Python
383 lines
17 KiB
Python
"""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_<X> 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_<KEY>" (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_<KEY>", rt_knob("<KEY>", ...), one level of functions whose const char * parameter reaches
|
|
cfg_get or a "NX2NAVI_%s" of it, and the `set <name> NX2NAVI_<KEY>` 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())
|