"""Inventory CW's local protobuf graph and dispatcher coverage (no Client calls). This is a discovery artifact, not a gameplay or wire-compatibility test. Command identity comes from the revision-tagged upstream table. The message graph retains obfuscated names rather than assigning guessed meanings. """ import argparse import hashlib import json import pathlib import re def declarations(source): result = {} for match in re.finditer(r"^\s*(message|enum)\s+(\w+)\s*\{", source, re.M): depth, end = 1, match.end() while end < len(source) and depth: depth += (source[end] == "{") - (source[end] == "}") end += 1 if depth: raise ValueError("Unterminated declaration: " + match[2]) result[match[2]] = (match[1], source[match.end():end - 1]) return result def inventory(root): schema = root / "protocol/proto/StarRail.proto" upstream = (root / "protocol/proto/upstream_cmdids.go").read_text(encoding="utf-8") revision = re.search(r'CommandSourceRevision = "([^"]+)"', upstream)[1] commands = {name: int(number) for name, number in re.findall(r'^\s*"(\w+)":\s*(\d+),', upstream, re.M)} types = declarations(schema.read_text(encoding="utf-8")) family = {name: number for name, number in commands.items() if name.startswith("GridFight")} # BuyGoodRsp is the explicitly named response of GridFightBuyGoodsCsReq # in the current schema. Include the source declaration, without inventing # a GridFightBuyGoodsScRsp alias or choosing a numeric response here. seeds = set(family) | {"GridFightGameInfo", "BuyGoodRsp"} reachable, queue = {}, sorted(seeds) while queue: name = queue.pop() if name in reachable or name not in types: continue kind, body = types[name] deps = sorted(set(re.findall(r"\b\w+\b", body)) & types.keys() - {name}) reachable[name] = {"kind": kind, "references": deps, "body": body.strip()} queue.extend(deps) handlers = {} for path in sorted((root / "internal/gameserver").glob("*.go")): text = path.read_text(encoding="utf-8") for match in re.finditer(r'\b(handleNamed|handle|rawNamed|dummy)\("([^"]+)"', text): if match[2].startswith("GridFight"): handlers.setdefault(match[2], []).append({ "file": path.relative_to(root).as_posix(), "kind": match[1], "line": text.count("\n", 0, match.start()) + 1, }) return { "command_source_revision": revision, "schema_sha256": hashlib.sha256(schema.read_bytes()).hexdigest(), "limitations": [ "Local schema graph only; field semantics and current Client tags require separate evidence.", "Handler registration does not establish correct behavior or completed gameplay.", "Non-GridFight command names used by pending selections require separate mapping.", ], "commands": dict(sorted(family.items())), "registrations": handlers, "types": dict(sorted(reachable.items())), } def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[1]) parser.add_argument("--output", type=pathlib.Path, required=True) args = parser.parse_args() result = inventory(args.root.resolve()) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"CW: {len(result['commands'])} commands, {len(result['types'])} reachable declarations, " f"{len(result['registrations'])} handler registration names") if __name__ == "__main__": main()