#!/usr/bin/env python3 """Generate the empty platform skeleton for mirrored platform-layer headers (docs/PORT-PLAN.md 2A). platform_stub.py list platform-layer headers in the mirror, with their stub state platform_stub.py gen [
...] [--force] write extension/src/platform//.cpp (no args: every platform header; never overwrites without --force, so hand-written implementations stay) platform_stub.py pending
... [--ctor] append stubs of just those members (the linker's undefined-symbol names, demangled as ld prints them) to the stand-in of a not-yet-ported logic unit, extension/src/platform/pending//.cpp; --ctor adds the class constructor/destructor so the singleton can exist (its members still default-construct) `
` is a mirror path such as `EterLib/GrpBase.h`. A header is platform-layer when its same-stem 40250 .cpp unit is (port_map.py LAYER_PATTERNS), or, without one, when its own path matches. A stub file defines everything the header declares and does not define: non-pure, non-template member functions, constructors/destructors, free functions, and static data members. Declarations come from clang's AST of the header's gate translation unit (the library StdAfx.h + the header, with the exact flags from the macOS gate build's compile_commands.json: `script/port_gate.sh macos` first), dumped per class/function name with -ast-dump-filter. Definitions use trailing return types, so types nested in the class resolve in class scope, and `decltype(C::x) C::x{}` for static data. Each body is MT_PLATFORM_STUB() plus a default return (platform/PlatformStub.h); `grep -r MT_PLATFORM_STUB` is the list of platform functions still to implement. Not covered: platform .cpp units that define members of logic headers (GameLib/MapOutdoorRender*.cpp for MapOutdoor.h, ...); those members get their stubs by hand when the logic side needs them. """ from __future__ import annotations import argparse import concurrent.futures import json import re import shlex import subprocess import sys from pathlib import Path import port_map import refroot # script directory is on sys.path when run directly REPO = refroot.repo_root() PORT = REPO / "extension" / "src" / "port" PLATFORM = REPO / "extension" / "src" / "platform" GATE_BUILD = REPO / "build-port-gate" / "macos" MARK = "platform_stub.py" COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S) LITERAL = re.compile(r'"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'') PREPROC = re.compile(r"^[ \t]*#(?:[^\n]*\\\n)*[^\n]*", re.M) RECORD = re.compile(r"\b(?:class|struct|union)\s+(?:__declspec\s*\([^)]*\)\s*)?([A-Za-z_]\w*)\s*(?:final\s*)?(?::[^;{}]*)?$") # --- header scan: names of top-level records and free functions -------------------------------------- def blank(text: str) -> str: """Comments, literals and preprocessor lines replaced by spaces (offsets and newlines kept).""" keep_nl = lambda m: re.sub(r"[^\n]", " ", m.group(0)) text = COMMENT.sub(keep_nl, text) text = LITERAL.sub(lambda m: '""', text) return PREPROC.sub(keep_nl, text) def scan(header: Path) -> tuple[list[str], list[str]]: """Record names defined, and function names declared, at namespace scope of the header.""" text = blank(header.read_text(encoding="utf-8")) records: list[str] = [] functions: list[str] = [] stack: list[str] = [] # "ns" (namespace / extern "C") or "body" stmt_start = 0 for i, ch in enumerate(text): if ch == "{": head = text[stmt_start:i].strip() if re.search(r"\bnamespace\b[^;]*$|\bextern\s*\"\"\s*$", head): stack.append("ns") else: if all(k == "ns" for k in stack) and (m := RECORD.search(head)) \ and not re.search(r"\btemplate\s*<", head): records.append(m.group(1)) stack.append("body") stmt_start = i + 1 elif ch == "}": if stack: stack.pop() stmt_start = i + 1 elif ch == ";": if all(k == "ns" for k in stack): head = " ".join(text[stmt_start:i].split()) if (head and not re.match(r"(?:typedef|using|template|friend|static_assert|enum|class|struct|union)\b", head) and not re.search(r"\binline\b", head)): if m := re.search(r"([A-Za-z_]\w*|operator\s*\S+?)\s*\((?!\s*\*)", head): if m.group(1) not in ("__declspec", "decltype", "sizeof", "alignas"): functions.append(m.group(1)) stmt_start = i + 1 return list(dict.fromkeys(records)), list(dict.fromkeys(functions)) # --- clang AST --------------------------------------------------------------------------------------- def gate_command(rel: str) -> tuple[list[str], str]: db = GATE_BUILD / "compile_commands.json" if not db.is_file(): raise SystemExit(f"{db}: missing; run `script/port_gate.sh macos` (it exports compile commands)") tu = re.sub(r"[^A-Za-z0-9_]", "_", rel) for entry in json.loads(db.read_text()): if Path(entry["file"]).name == f"{tu}.cpp": args = shlex.split(entry["command"]) out = args.index("-o") del args[out:out + 2] args.remove("-c") args.remove(entry["file"]) return args, entry["file"] raise SystemExit(f"{rel}: no gate translation unit in {db}; reconfigure the gate build") def dump(args: list[str], tu: str, name: str) -> list[dict]: cmd = args + ["-w", "-fsyntax-only", "-Xclang", "-ast-dump=json", "-Xclang", f"-ast-dump-filter={name}", tu] res = subprocess.run(cmd, capture_output=True, text=True, cwd=GATE_BUILD) if res.returncode: raise SystemExit(f"clang failed for {tu} ({name}):\n{res.stderr[-2000:]}") s, i, out = res.stdout, 0, [] dec = json.JSONDecoder() while True: while i < len(s) and s[i].isspace(): i += 1 if i >= len(s): return out if s[i] != "{": # "Dumping X:" banner i = s.find("\n", i) + 1 or len(s) continue node, i = dec.raw_decode(s, i) out.append(node) def annotate_files(nodes: list[dict]) -> None: """Give every node `_file`: clang writes a loc's file only when it changes, in document order.""" last = [""] def visit_loc(loc): if isinstance(loc, dict): for key in ("spellingLoc", "expansionLoc", "begin", "end"): if key in loc: visit_loc(loc[key]) if "file" in loc: last[0] = loc["file"] def visit(node): loc = node.get("loc", {}) if "expansionLoc" in loc: visit_loc(loc["spellingLoc"]) visit_loc(loc["expansionLoc"]) else: visit_loc(loc) node["_file"] = last[0] visit_loc(node.get("range")) for child in node.get("inner", []): visit(child) for n in nodes: visit(n) def has_body(node: dict) -> bool: return any(c.get("kind") == "CompoundStmt" for c in node.get("inner", [])) \ or "explicitlyDefaulted" in node or "explicitlyDeleted" in node def split_function_type(qual: str) -> tuple[str, str]: """`R (P...) quals` -> (R, quals).""" depth = 0 for i in range(len(qual) - 1, -1, -1): c = qual[i] if c == ")": depth += 1 elif c == "(": depth -= 1 if depth == 0: close = match_close(qual, i) rest = qual[close + 1:].strip() if re.fullmatch(r"(?:const|volatile|&&?|noexcept(?:\(.*\))?|throw\(.*\)|\s)*", rest): return qual[:i].strip(), rest raise ValueError(qual) def match_close(s: str, open_idx: int) -> int: depth = 0 for j in range(open_idx, len(s)): if s[j] == "(": depth += 1 elif s[j] == ")": depth -= 1 if depth == 0: return j raise ValueError(s) def params(node: dict) -> str: ps = [c["type"]["qualType"] for c in node.get("inner", []) if c.get("kind") == "ParmVarDecl"] if node.get("variadic"): ps.append("...") return ", ".join(ps) def function_def(qname: str, node: dict) -> str: ret, quals = split_function_type(node["type"]["qualType"]) quals = f" {quals}" if quals else "" kind = node["kind"] if kind == "CXXDestructorDecl": # clang spells the implicit noexcept of a destructor into its type quals = re.sub(r"\s*\bnoexcept\b", "", quals) if kind in ("CXXConstructorDecl", "CXXDestructorDecl"): return f"{qname}({params(node)}){quals}\n{{\n\tMT_PLATFORM_STUB();\n}}\n" if kind == "CXXConversionDecl": return (f"{qname}({params(node)}){quals}\n{{\n\tMT_PLATFORM_STUB();\n" f"\treturn mt_platform_stub_return<{ret}>();\n}}\n") body = "\tMT_PLATFORM_STUB();\n" + ("" if ret == "void" else f"\treturn mt_platform_stub_return<{ret}>();\n") return f"auto {qname}({params(node)}){quals} -> {ret}\n{{\n{body}}}\n" class Collector: def __init__(self, header_file: str): self.header_file = header_file self.defined: set[str] = set() # mangled names with a body anywhere in the dump self.decls: list[tuple[str, str, dict]] = [] # (kind, qualified name, node) self.seen: set[str] = set() def note_bodies(self, node: dict) -> None: if node.get("mangledName") and has_body(node): self.defined.add(node["mangledName"]) for c in node.get("inner", []): self.note_bodies(c) def record(self, node: dict, scope: str) -> None: if node.get("kind") != "CXXRecordDecl" or not node.get("completeDefinition") or not node.get("name"): return q = f"{scope}{node['name']}" for c in node.get("inner", []): kind = c.get("kind") if kind == "CXXRecordDecl" and c.get("isImplicit"): continue if kind == "CXXRecordDecl": self.record(c, f"{q}::") elif kind in ("CXXMethodDecl", "CXXConstructorDecl", "CXXDestructorDecl", "CXXConversionDecl"): if c.get("isImplicit") or c.get("pure") or has_body(c): continue self.add("fn", f"{q}::{c['name']}", c) elif kind == "VarDecl" and c.get("storageClass") == "static": if c.get("init") or c.get("constexpr") or c.get("inline"): continue self.add("var", f"{q}::{c['name']}", c) def add(self, kind: str, qname: str, node: dict) -> None: key = node.get("mangledName") or qname if key in self.seen: return self.seen.add(key) self.decls.append((kind, qname, node)) def root(self, node: dict, scope: str) -> None: if node["_file"] != self.header_file: return kind = node.get("kind") if kind == "CXXRecordDecl": self.record(node, scope) elif kind == "FunctionDecl" and not has_body(node) and node.get("storageClass") != "static" \ and not node.get("inline"): self.add("fn", f"{scope}{node['name']}", node) def render(self, only=None) -> list[str]: out = [] for kind, qname, node in self.decls: if node.get("mangledName") in self.defined: continue if only is not None and not only(qname, node): continue if kind == "var": out.append(f"decltype({qname}) {qname}{{}};\n") else: out.append(function_def(qname, node)) return out def namespaces(header: Path) -> dict[str, str]: """Name -> enclosing namespace prefix, for names declared inside `namespace X {`.""" text = blank(header.read_text(encoding="utf-8")) result: dict[str, str] = {} for m in re.finditer(r"\bnamespace\s+(\w+)\s*\{", text): depth, j = 1, m.end() while depth and j < len(text): depth += {"{": 1, "}": -1}.get(text[j], 0) j += 1 for n in re.findall(r"\b(?:class|struct)\s+(\w+)|(\w+)\s*\(", text[m.end():j]): result.setdefault(n[0] or n[1], f"{m.group(1)}::") return result def generate(rel: str, only=None) -> str: """The skeleton of `rel`; with `only(qname, node)`, just the declarations it accepts (the bodies alone).""" header = PORT / rel records, functions = scan(header) args, tu = gate_command(rel) ns = namespaces(header) col = Collector(str(header)) with concurrent.futures.ThreadPoolExecutor(8) as pool: dumps = list(pool.map(lambda n: (n, dump(args, tu, n)), records + functions)) for name, nodes in dumps: annotate_files(nodes) for n in nodes: col.note_bodies(n) for name, nodes in dumps: for n in nodes: if n.get("name") == name: col.root(n, ns.get(name, "")) body = col.render(only) if not body or only is not None: return "\n".join(body) lib = rel.split("/")[0] stdafx = next((p.name for p in (PORT / lib).glob("*") if p.name.lower() == "stdafx.h"), None) unit = unit_of(rel) lines = [f"// Platform skeleton for {rel}" + (f" (40250 {unit})" if unit else "") + f", generated by {MARK}.\n", "// Every MT_PLATFORM_STUB() body is unimplemented: replace it with the platform implementation.\n"] if stdafx and f"{lib}/{stdafx}" != rel: lines.append(f'#include "{lib}/{stdafx}"\n') lines += [f'#include "{rel}"\n', "\n", f'#include "{"../" * rel.count("/")}PlatformStub.h"\n', "\n"] return "".join(lines) + "\n".join(body) # --- selection --------------------------------------------------------------------------------------- _UNITS: dict[str, str] | None = None def unit_of(rel: str) -> str | None: global _UNITS if _UNITS is None: _UNITS = {u.lower(): u for u in port_map.inventory(refroot.reference_root())} return _UNITS.get((rel.rsplit(".", 1)[0] + ".cpp").lower()) def platform_headers() -> list[str]: out = [] for h in sorted(PORT.rglob("*.h")): rel = h.relative_to(PORT).as_posix() if rel.startswith("common/"): continue unit = unit_of(rel) if port_map.layer(unit or rel) == "platform": out.append(rel) return out def stub_path(rel: str) -> Path: return PLATFORM / (rel.rsplit(".", 1)[0] + ".cpp") def cmd_list(_args) -> int: for rel in platform_headers(): p = stub_path(rel) state = "-" if not p.exists() else ("generated" if MARK in p.read_text(encoding="utf-8")[:300] else "edited") pending = p.read_text(encoding="utf-8").count("MT_PLATFORM_STUB();") if p.exists() else 0 print(f"{rel:48} {state:9} {pending:4} stubs") return 0 def cmd_gen(args) -> int: headers = platform_headers() targets = headers if not args.headers else [] for h in args.headers: hit = [r for r in headers if r.lower() == h.lower()] if not hit: raise SystemExit(f"{h}: not a platform-layer header in the mirror") targets += hit written = 0 for rel in targets: path = stub_path(rel) if path.exists() and not args.force: print(f"skip {path.relative_to(REPO)} (exists; --force to regenerate)") continue text = generate(rel) if not text: print(f"none {rel} (declares nothing to define)") continue path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") written += 1 print(f"wrote {path.relative_to(REPO)} ({text.count('MT_PLATFORM_STUB();')} stubs)") print(f"{written} file(s) written") return 0 def cmd_pending(args) -> int: rel = next((h.relative_to(PORT).as_posix() for h in PORT.rglob("*.h") if h.relative_to(PORT).as_posix().lower() == args.header.lower()), None) if not rel: raise SystemExit(f"{args.header}: not in the mirror") wanted = {s.strip().strip('"') for s in args.symbols} demangled: dict[str, str] = {} def only(qname: str, node: dict) -> bool: mangled = node.get("mangledName", "") if mangled not in demangled: demangled[mangled] = subprocess.run(["c++filt", mangled], capture_output=True, text=True).stdout.strip() if demangled[mangled] in wanted: return True return args.ctor and node.get("kind") in ("CXXConstructorDecl", "CXXDestructorDecl") \ and qname.split("::")[-2:-1] == [node.get("name", "").lstrip("~")] and qname.count("::") == 1 body = generate(rel, only) if not body: print(f"none {rel} (no such undefined member)") return 0 path = PLATFORM / "pending" / (rel.rsplit(".", 1)[0] + ".cpp") if path.exists(): text = path.read_text(encoding="utf-8").rstrip("\n") + "\n\n" + body else: lib = rel.split("/")[0] stdafx = next((p.name for p in (PORT / lib).glob("*") if p.name.lower() == "stdafx.h"), None) unit = unit_of(rel) or rel text = (f"// Stand-in for the 40250 logic unit {unit}, not ported yet: the members the ported units\n" f"// reference, as {MARK} stubs. Delete this file in the commit that ports the unit into port/\n" f"// (docs/PORT-PLAN.md).\n" + (f'#include "{lib}/{stdafx}"\n' if stdafx else "") + f'#include "{rel}"\n\n#include "{"../" * (rel.count("/") + 1)}PlatformStub.h"\n\n' + body) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") print(f"wrote {path.relative_to(REPO)} (+{body.count('MT_PLATFORM_STUB();')} stubs)") return 0 def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("list").set_defaults(fn=cmd_list) g = sub.add_parser("gen") g.add_argument("headers", nargs="*") g.add_argument("--force", action="store_true") g.set_defaults(fn=cmd_gen) p = sub.add_parser("pending") p.add_argument("header") p.add_argument("symbols", nargs="*") p.add_argument("--ctor", action="store_true") p.set_defaults(fn=cmd_pending) args = ap.parse_args() return args.fn(args) if __name__ == "__main__": sys.exit(main())