Files
mtgodot-poc/.agents/skills/metin2-40250-parity-audit/scripts/audit_packet_registry.py
T
shenleiandClaude Opus 5 55f733fda6 audit: phase 0 tooling for file-by-file 40250 porting
- refroot.py: one reference-root resolver (MT_40250_SOURCE, then manifest)
  used by every audit script; missing tree or fingerprint input is an error
- port_map.py: per-function inventory of active 40250 units plus the
  40250 Python root, status/queue/init/show/check, 40250: tag scan
- manifest: fix 18 wrong reference paths (Python UI now points at the
  40250 Client/Eternexus/root, not the m2dev assets/root), drop 2 deleted
  implementation files, move 64 prose test entries to evidence.commands
- first port-map entry: PythonPlayerEventHandler.cpp
- roadmap replaced by a batch/unit queue

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:17:49 +09:00

214 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Compare the 40250 GC header registry with the classic wire adapter.
This is intentionally a static audit, not a replacement for wire tests. It
keeps the reference-side registry count and dynamic/static classification
machine-checkable so a new packet alias or compatibility branch cannot be
silently treated as 40250 parity.
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
import refroot # script directory is on sys.path when run directly
REFERENCE_DYNAMIC = {
"HEADER_GC_QUEST_INFO",
"HEADER_GC_DUEL_START",
"HEADER_GC_CHAT",
"HEADER_GC_SYNC_POSITION",
"HEADER_GC_SHOP",
"HEADER_GC_SCRIPT",
"HEADER_GC_MESSENGER",
"HEADER_GC_GUILD",
"HEADER_GC_DUNGEON",
"HEADER_GC_NPC_POSITION",
"HEADER_GC_LAND_LIST",
"HEADER_GC_HYBRIDCRYPT_KEYS",
"HEADER_GC_HYBRIDCRYPT_SDB",
}
# These are documented current-client adaptations. They remain differences:
# allowing them here makes the report useful without pretending they are in the
# reference CMainPacketHeaderMap.
KNOWN_CURRENT_DYNAMIC_ADAPTATIONS = {
"HDR_GC_WHISPER",
"HDR_GC_SYMBOL_DATA",
}
def active_lines(text: str, *, gaiden: bool = False) -> list[str]:
"""Keep the 40250 branch used by the checked-in Win32 project.
The snapshot contains both GAIDEN and non-GAIDEN enum aliases in Packet.h.
The UserInterface project does not define GAIDEN, so evaluating that one
branch is necessary before comparing numeric header identities. Other
optional blocks are intentionally retained: the registry source itself
uses the improved-encryption symbols supplied by ServiceDefs.h.
"""
out: list[str] = []
stack: list[bool] = []
enabled = True
for line in text.splitlines():
stripped = line.strip()
if stripped in {"#if defined(GAIDEN)", "#ifdef GAIDEN"}:
stack.append(enabled)
enabled = enabled and gaiden
continue
if stripped == "#ifndef GAIDEN":
stack.append(enabled)
enabled = enabled and not gaiden
continue
if stripped == "#else" and stack:
parent = stack[-1]
enabled = parent and not enabled
continue
if stripped.startswith("#endif") and stack:
enabled = stack.pop()
continue
if enabled and not stripped.startswith("//"):
out.append(line)
return out
def reference_registry(path: Path) -> list[dict[str, str]]:
row = re.compile(
r"\bSet\((HEADER_GC_[A-Z0-9_]+),\s*"
r"CNetworkPacketHeaderMap::TPacketType\(sizeof\(([^)]+)\),\s*"
r"(STATIC_SIZE_PACKET|DYNAMIC_SIZE_PACKET)\)"
)
out: list[dict[str, str]] = []
for line in active_lines(path.read_text(errors="replace")):
match = row.search(line)
if match:
out.append({"header": match.group(1), "struct": match.group(2), "kind": match.group(3)})
return out
def function_body(text: str, name: str, return_type: str) -> str:
match = re.search(
rf"constexpr\s+{re.escape(return_type)}\s+{re.escape(name)}\s*\([^)]*\)\s*\{{(?P<body>.*?)\n\}}",
text,
re.S,
)
if not match:
raise ValueError(f"cannot find {name} in wire header")
return match.group("body")
def current_wire(path: Path) -> dict[str, list[str]]:
text = path.read_text(errors="replace")
size_body = function_body(text, "packet_size_gc", "int")
dynamic_body = function_body(text, "is_dynamic_gc", "bool")
case_re = re.compile(r"\bcase\s+(HDR_[A-Z0-9_]+)\s*:")
return {
"size_cases": case_re.findall(size_body),
"dynamic_cases": case_re.findall(dynamic_body),
}
def header_values(path: Path, prefix: str) -> dict[str, int]:
values: dict[str, int] = {}
pattern = re.compile(rf"\b({re.escape(prefix)}[A-Z0-9_]+)\s*=\s*(0x[0-9A-Fa-f]+|\d+)")
for line in active_lines(path.read_text(errors="replace"), gaiden=False):
match = pattern.search(line)
if match:
values.setdefault(match.group(1), int(match.group(2), 0))
return values
def audit(reference_path: Path, wire_path: Path) -> dict:
ref = reference_registry(reference_path)
wire = current_wire(wire_path)
reference_values = header_values(reference_path.parent / "Packet.h", "HEADER_GC_")
current_values = header_values(wire_path, "HDR_")
ref_dynamic = {row["header"].replace("HEADER_", "HDR_") for row in ref if row["kind"] == "DYNAMIC_SIZE_PACKET"}
current_dynamic = set(wire["dynamic_cases"])
extras = sorted(current_dynamic - ref_dynamic)
missing = sorted(ref_dynamic - current_dynamic)
unexpected_extras = sorted(set(extras) - KNOWN_CURRENT_DYNAMIC_ADAPTATIONS)
reference_by_value: dict[int, list[dict[str, str]]] = {}
for row in ref:
value = reference_values.get(row["header"])
if value is not None:
reference_by_value.setdefault(value, []).append(row)
current_registered_names = set(wire["size_cases"]) | current_dynamic
current_by_value: dict[int, list[str]] = {}
for name in current_registered_names:
value = current_values.get(name)
if value is not None:
current_by_value.setdefault(value, []).append(name)
reference_values_set = set(reference_by_value)
current_values_set = set(current_by_value)
numeric_missing = sorted(reference_values_set - current_values_set)
numeric_extras = sorted(current_values_set - reference_values_set)
kind_mismatches: list[dict[str, object]] = []
for value, rows in sorted(reference_by_value.items()):
expected = {row["kind"] for row in rows}
actual = {
"DYNAMIC_SIZE_PACKET" if name in current_dynamic else "STATIC_SIZE_PACKET"
for name in current_by_value.get(value, [])
}
if actual and not actual.intersection(expected):
kind_mismatches.append({"value": value, "reference": rows, "current": current_by_value[value]})
result = {
"reference": {
"registered": len(ref),
"static": sum(row["kind"] == "STATIC_SIZE_PACKET" for row in ref),
"dynamic": sum(row["kind"] == "DYNAMIC_SIZE_PACKET" for row in ref),
"dynamic_headers": sorted(ref_dynamic),
"numeric_values": len(reference_values_set),
},
"current": {
"packet_size_gc_cases": len(set(wire["size_cases"])),
"is_dynamic_gc_cases": len(current_dynamic),
"dynamic_headers": sorted(current_dynamic),
"numeric_values": len(current_values_set),
},
"difference": {
"missing_reference_dynamic": missing,
"current_dynamic_extras": extras,
"unapproved_current_dynamic_extras": unexpected_extras,
"known_adaptations": sorted(set(extras) & KNOWN_CURRENT_DYNAMIC_ADAPTATIONS),
"missing_reference_numeric_values": numeric_missing,
"current_extra_numeric_values": numeric_extras,
"kind_mismatches": kind_mismatches,
},
"status": "PASS"
if not missing and not extras and not numeric_missing and not numeric_extras and not kind_mismatches
else "PARTIAL",
}
return result
def main() -> int:
parser = argparse.ArgumentParser()
default_root = Path(__file__).resolve().parents[4]
parser.add_argument("--repo-root", type=Path, default=default_root)
parser.add_argument("--strict", action="store_true", help="return non-zero when the registry is not exact")
args = parser.parse_args()
root = args.repo_root.resolve()
reference = refroot.reference_root(root) / "UserInterface/PythonNetworkStream.cpp"
wire = root / "extension/src/net/classic/wire_classic.h"
try:
result = audit(reference, wire)
except (OSError, ValueError) as exc:
print(json.dumps({"status": "ERROR", "error": str(exc)}, indent=2, sort_keys=True))
return 2
print(json.dumps(result, indent=2, sort_keys=True))
return 1 if args.strict and result["status"] != "PASS" else 0
if __name__ == "__main__":
raise SystemExit(main())