#!/usr/bin/env python3 """Check that obfuscating the September client cannot break a by-name lookup. Obfuscar renames types and members, but it cannot see reflection. Anywhere this plugin hands a *name* to a reflection API for one of its OWN types, that name has to survive the rename - hence the SkipMethod rules in 2023Patch/RadicadePatch/obfuscar.xml. This script enumerates the names that must survive (from the C# sources) and then checks each one against Obfuscar's Mapping.txt to prove it was kept. Run after an obfuscation pass: Tools/check-obfuscation-safety.py Exit status is 0 only when every name was preserved. """ import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parent.parent CLIENT = ROOT / "2023Patch" / "RadicadePatch" SHARED = ROOT / "2023Patch" / "RadicadeEacBypass" MAPPING = CLIENT / "bin" / "obfuscated" / "Mapping.txt" SOURCES = [p for d in (CLIENT, SHARED) for p in d.rglob("*.cs") if "/obj/" not in p.as_posix() and "/bin/" not in p.as_posix()] def source_texts(): for path in SOURCES: yield path, path.read_text(encoding="utf-8", errors="replace") def our_type_names(): """Type names declared in the plugin and the shared folder (the ones Obfuscar can rename).""" names = set() pattern = re.compile(r"^\s*(?:public|internal|private|protected|static|sealed|abstract|partial|\s)*\b(?:class|struct|enum|interface|record)\s+([A-Za-z_]\w*)", re.M) for _, text in source_texts(): names.update(pattern.findall(text)) return names def names_resolved_at_runtime(): """Every member name handed to reflection for one of our own types.""" ours = our_type_names() found = {} def add(name, where, why): found.setdefault(name, (where, why)) for path, text in source_texts(): lines = text.splitlines() for index, line in enumerate(lines, 1): for match in re.finditer(r"nameof\(\s*([A-Za-z_][\w\.]*)\s*\)", line): qualified = match.group(1) owner = qualified.split(".")[0] if "." in qualified else None member = qualified.split(".")[-1] # nameof(SomeGameType.Member) is the game's own name and survives untouched. if owner is not None and owner not in ours: continue add(member, f"{path.name}:{index}", f"nameof({qualified})") # A literal handed to a reflection call on one of our own types. for match in re.finditer(r"(?:typeof\(\s*([A-Za-z_]\w*)\s*\)|GetType\(\s*\))[^;\n]*?[\"']([A-Za-z_]\w*)[\"']", line): owner, literal = match.group(1), match.group(2) if owner in ours: add(literal, f"{path.name}:{index}", f'"{literal}" on {owner}') return found def harmony_patch_methods(): """Methods carrying a Harmony attribute. Harmony resolves the special parameters __result/__instance/__state/__args/___field by NAME, and Obfuscar nulls a parameter's name whenever it renames the declaring method (see ShouldSkipParams). A renamed patch method therefore turns its own __result into P_0 and the patch throws at Apply time - which aborts the rest of Plugin.Load, so later patches never arm. Keeping the method name keeps the parameter names with it, so every one of these must be pinned. """ attribute = re.compile(r"^\s*\[\s*Harmony") declaration = re.compile(r"^\s*(?:[\w<>,\[\]\.\?]+\s+)+(?= 0 and len(above) < 6: stripped = lines[cursor].strip() if not stripped: cursor -= 1 continue above.append(stripped) if stripped.startswith("["): cursor -= 1 continue break if any(attribute.match(a) for a in above): found.setdefault(name, f"{path.name}:{index + 1}") return found def kept_by_obfuscar(): """Members Obfuscar reported as skipped (i.e. not renamed).""" kept = set() for line in MAPPING.read_text(encoding="utf-8", errors="replace").splitlines(): if "skipped:" not in line: continue match = re.search(r"::([A-Za-z_]\w*)(?:\[|\()", line) if match: kept.add(match.group(1)) return kept def renamed_by_obfuscar(): renamed = set() for line in MAPPING.read_text(encoding="utf-8", errors="replace").splitlines(): if " -> " not in line or "::" not in line: continue match = re.search(r"::([A-Za-z_]\w*)(?:\[|\()", line) if match: renamed.add(match.group(1)) return renamed def main(): if not MAPPING.exists(): print(f"no mapping at {MAPPING}; run obfuscar first", file=sys.stderr) return 2 required = names_resolved_at_runtime() patches = harmony_patch_methods() for name, where in patches.items(): required.setdefault(name, (where, "Harmony patch method (keeps __parameters)")) kept = kept_by_obfuscar() renamed = renamed_by_obfuscar() broken = sorted(n for n in required if n in renamed and n not in kept) print(f"by-name lookups that must survive : {len(required)}") print(f" of which Harmony patch methods : {len(patches)}") print(f"members Obfuscar kept : {len(kept)}") for name in sorted(required): where, why = required[name] status = "BROKEN" if name in broken else "kept" print(f" [{status:6}] {name:36} {why:46} {where}") if broken: print(f"\nFAIL: {len(broken)} name(s) renamed but still looked up at runtime: {', '.join(broken)}") return 1 print("\nOK: every by-name lookup points at a name Obfuscar preserved.") return 0 if __name__ == "__main__": sys.exit(main())