#!/usr/bin/env python3 """Write the module constants and return shapes of the 2V0 native stub modules from the 40250 sources. stub_constants.py [--check] The stub modules in extension/src/platform/ScriptLib/GameplayModules.cpp only export names. Their constants are data the root scripts compute with (uiSafebox divides by safebox.SAFEBOX_SLOT_Y_COUNT), so each stub module gets its 40250 init function's `PyModule_Add{Int,String}Constant` lines verbatim, with the preprocessor lines around them and the source file's includes, in extension/src/platform/ScriptLib/GameplayModuleConstants.cpp as `AddStubConstants_(poModule)`. A module that is ported for real drops out of the stub list and out of this file. The stub functions themselves (`stub__` in GameplayModules.cpp) return a zero value of the shape their 40250 function returns on its last `return`: the `Py_BuildValue` format with every number 0, every string "" and every object None, or None for `Py_BuildNone()`. uiTaskBar unpacks `player.GetLocalQuickSlot` as a pair, so an `int` 0 stops the script where the real module would not. Functions whose last return is not a build call (lists built by hand, ...) keep returning 0. """ from __future__ import annotations import argparse import re import sys import refroot REPO = refroot.repo_root() STUBS = REPO / "extension/src/platform/ScriptLib/GameplayModules.cpp" OUT = REPO / "extension/src/platform/ScriptLib/GameplayModuleConstants.cpp" INIT = re.compile(r'Py_InitModule\("([A-Za-z_]+)"') KEEP = re.compile(r'^\s*(PyModule_Add(Int|String)Constant\s*\(|#\s*(if|ifdef|ifndef|elif|else|endif)\b)') INCLUDE = re.compile(r'^\s*#\s*include\s*"([^"]+)"') def read(path) -> list[str]: return path.read_bytes().decode("cp949", "replace").replace("\r", "").split("\n") def init_body(lines: list[str], module: str) -> list[str]: start = next(i for i, l in enumerate(lines) if f'Py_InitModule("{module}"' in l and not l.strip().startswith("//")) body = [] for line in lines[start + 1:]: if line.startswith("}"): break if KEEP.match(line): body.append(line.strip() if line.lstrip().startswith("#") else "\t" + line.strip()) # Preprocessor pairs with nothing between them carry no constant. changed = True while changed: changed = False for i in range(len(body) - 1): if re.match(r"#\s*if", body[i]) and re.match(r"#\s*endif", body[i + 1]): del body[i:i + 2] changed = True break return body def enums(lines: list[str]) -> list[str]: """The file's constant definitions and enum blocks (file scope or inside the init function) that constants name.""" out, inside = [], False for line in lines: if re.match(r"extern const \w+\s+\w+\s*=", line): out.append(line) continue if re.match(r"\s*enum\b", line): inside = True if inside: out.append(line[1:] if line.startswith("\t") and out and out[0].startswith("\t") else line) if "};" in line: inside = False out.append("") return out ZERO = {"i": "0", "b": "0", "h": "0", "B": "0", "H": "0", "I": "0u", "c": "0", "l": "0L", "k": "0UL", "L": "0LL", "K": "0ULL", "n": "(Py_ssize_t) 0", "f": "0.0", "d": "0.0", "s": '""', "z": '""', "O": "Py_None", "S": "Py_None", "N": "Py_None"} def zero_value(fmt: str) -> str | None: """A C expression building `fmt` with zeros, or None when the format has a converter.""" out_fmt, args, i = "", [], 0 while i < len(fmt): ch = fmt[i] if ch in "()[]{}:, \t": out_fmt += ch elif ch in ZERO: out_fmt += "O" if ch in "SN" else ch args.append(ZERO[ch]) if i + 1 < len(fmt) and fmt[i + 1] == "#": out_fmt += "#" args.append("0") i += 1 elif ch == "O" and i + 1 < len(fmt) and fmt[i + 1] in "&!": return None else: return None i += 1 return "Py_BuildValue(" + ", ".join([f'"{out_fmt}"'] + args) + ")" def function_bodies(ref) -> dict[str, str]: bodies = {} for path in ref.glob("*.cpp"): text = "\n".join(read(path)) for m in re.finditer(r"PyObject\s*\*\s*(\w+)\s*\(\s*PyObject\s*\*\s*\w*\s*,\s*PyObject\s*\*\s*\w*\s*\)\s*\{", text): depth, j = 1, m.end() while depth and j < len(text): depth += {"{": 1, "}": -1}.get(text[j], 0) j += 1 bodies.setdefault(m.group(1), text[m.end():j - 1]) return bodies def return_shape(body: str) -> str | None: body = re.sub(r"//[^\n]*|/\*.*?\*/", "", body, flags=re.S) returns = [r.strip() for r in re.findall(r"\breturn\b([^;]*);", body)] returns = [r for r in returns if not r.startswith("Py_BuildException")] if not returns: return None last = returns[-1] if last.startswith("Py_BuildNone"): return "Py_BuildNone()" m = re.match(r'Py_BuildValue\s*\(\s*"([^"]*)"', last) return zero_value(m.group(1)) if m else None def shapes() -> tuple[str, int]: """GameplayModules.cpp with every stub returning its 40250 shape; also the count left at 0.""" ref = refroot.reference_root() / "UserInterface" bodies = function_bodies(ref) tables = {} for path in ref.glob("*.cpp"): text = "\n".join(read(path)) m = re.search(r'Py_InitModule\("(\w+)"', text) if not m: continue for name, func in re.findall(r'\{\s*"(\w+)"\s*,\s*(\w+)\s*,\s*METH_VARARGS', text): tables.setdefault(m.group(1), {}).setdefault(name, func) left = 0 def fix(m): nonlocal left module, name = m.group(2).split(".", 1) shape = return_shape(bodies.get(tables.get(module, {}).get(name, ""), "")) if not shape: left += 1 return f'{m.group(1)}"{m.group(2)}"); }}' return f'{m.group(1)}"{m.group(2)}", {shape}); }}' text = re.sub(r'(\{ return stub_call_named\()"([\w.]+)"(?:, .*)?\); \}$', fix, STUBS.read_text(), flags=re.M) return text, left def generate() -> str: ref = refroot.reference_root() / "UserInterface" modules = INIT.findall(STUBS.read_text()) sources = {} for path in sorted(ref.glob("*.cpp")): for line in read(path): m = INIT.search(line) if m and not line.strip().startswith("//"): sources[m.group(1)] = path out = [ "// Generated by .agents/skills/metin2-40250-parity-audit/scripts/stub_constants.py; do not edit.", "// The module constants of the 2V0 stub modules (GameplayModules.cpp), copied from each 40250", "// module's init function with the preprocessor lines around them.", '#include "UserInterface/StdAfx.h"', ] root = REPO / "extension/src/port" port = {p.relative_to(root).as_posix().lower(): p.relative_to(root).as_posix() for p in root.rglob("*.h")} includes, bodies = [], [] for module in modules: path = sources[module] body = init_body(read(path), module) for line in read(path): m = INCLUDE.match(line) if m and m.group(1).lower() != "stdafx.h": rel = m.group(1)[3:] if m.group(1).startswith("../") else "UserInterface/" + m.group(1) if rel.lower() not in port: continue # not mirrored (e.g. the UDP datagram unit); its constants need none of it inc = f'#include "{port[rel.lower()]}"' if inc not in includes: includes.append(inc) blocks = enums(read(path)) if blocks: bodies.append(f"\n// 40250 UserInterface/{path.name}") bodies.extend(l for l in blocks[:-1]) bodies.append(f"\n// 40250 UserInterface/{path.name}\nvoid AddStubConstants_{module}(PyObject* poModule)\n{{") bodies.append("\t(void) poModule;") bodies.extend(body) bodies.append("}") return "\n".join(out + includes + bodies) + "\n" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--check", action="store_true", help="fail if the file is out of date") args = ap.parse_args() text = generate() stubs, left = shapes() if args.check: stale = [p for p, t in ((OUT, text), (STUBS, stubs)) if not p.exists() or p.read_text() != t] for p in stale: print(f"{p.relative_to(REPO)} is out of date; run stub_constants.py", file=sys.stderr) return 1 if stale else 0 OUT.write_text(text) STUBS.write_text(stubs) print(f"wrote {OUT.relative_to(REPO)} and {STUBS.relative_to(REPO)} ({left} stubs keep returning 0)") return 0 if __name__ == "__main__": sys.exit(main())