Files
mtgodot-poc/.agents/skills/metin2-40250-parity-audit/scripts/platform_stub.py
T
shenleiandClaude Opus 5.5 62b6e23bfc port 2V2-b: GamePhase dispatch consumes the Entergame burst
PythonNetworkStreamPhaseGame/PhaseGameActor/PhaseGameItem/Command,
NetworkActorManager and PythonChat/PythonChatModule are ported verbatim and
dispatched at runtime; the 2V0 chat stub module is removed and initChat()
runs before initnet() as in 40250. InstanceBase, PythonCharacterManager and
GameLib ActorInstance/RaceData/RaceManager compile but stay TODO until
2V2-c/d wire them.

Units not ported yet get their singletons from PythonBoot's GameSingletons
(CPythonApplication member order) and platform/pending stand-ins generated
by platform_stub.py pending; const char* stand-ins return "".

port.login_flow's fake server now sends the CInputLogin::Entergame order
(actor insert, NPC positions, PHASE_GAME, land list, time, channel, greet
notice, ping) and a TraceError observer asserts no unknown packet header.
port.login_live passes against the real server for 10 s of game phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 18:20:57 +09:00

451 lines
18 KiB
Python

#!/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 [<header>...] [--force] write extension/src/platform/<Lib>/<File>.cpp
(no args: every platform header; never overwrites
without --force, so hand-written implementations stay)
platform_stub.py pending <header> <symbol>... [--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/<Lib>/<File>.cpp;
--ctor adds the class constructor/destructor so the
singleton can exist (its members still default-construct)
`<header>` 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())