Files
mtgodot-poc/.agents/skills/metin2-40250-parity-audit/scripts/audit_phase_dispatch.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

146 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Compare 40250 phase switch values with the classic session gates.
The Win32 source has three kinds of cases that must not be confused: phase
payload handlers, transport/control packets consumed by ClassicStream, and
observer-only cases that are not in the main packet registry. Compare numeric
wire values so enum aliases cannot hide a missing branch.
"""
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
CONTROL_VALUES = {44, 152, 153, 250, 251, 252, 253, 254, 255}
OBSERVER_VALUES = {96, 97, 98}
def active_lines(text: str, *, gaiden: bool = False) -> list[str]:
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 values(path: Path, prefix: str) -> dict[str, int]:
result: 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")):
match = pattern.search(line)
if match:
result.setdefault(match.group(1), int(match.group(2), 0))
return result
def function_body(text: str, name: str) -> str:
match = re.search(rf"\bbool\s+{re.escape(name)}\s*\([^)]*\)\s*\{{", text)
if not match:
raise ValueError(f"cannot find {name}")
start = match.end()
depth = 1
index = start
while index < len(text) and depth:
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
index += 1
if depth:
raise ValueError(f"unterminated {name}")
return text[start:index - 1]
def case_values(body: str, prefix: str, table: dict[str, int]) -> set[int]:
result: set[int] = set()
for name in re.findall(rf"\b({re.escape(prefix)}[A-Z0-9_]+)\s*:", body):
if name in table:
result.add(table[name])
return result
def reference_phase_values(root: Path, filename: str) -> set[int]:
text = (root / "UserInterface" / filename).read_text(errors="replace")
enum_values = values(root / "UserInterface" / "Packet.h", "HEADER_GC_")
return case_values(text, "HEADER_GC_", enum_values)
def audit(repo_root: Path) -> dict:
reference_root = refroot.reference_root(repo_root)
wire = repo_root / "extension/src/net/classic/wire_classic.h"
session_path = repo_root / "extension/src/net/classic/classic_session.cpp"
reference_phases = {
"login": reference_phase_values(reference_root, "PythonNetworkStreamPhaseLogin.cpp"),
"select": reference_phase_values(reference_root, "PythonNetworkStreamPhaseSelect.cpp"),
"loading": reference_phase_values(reference_root, "PythonNetworkStreamPhaseLoading.cpp"),
"game": reference_phase_values(reference_root, "PythonNetworkStreamPhaseGame.cpp"),
}
current_values = values(wire, "HDR_GC_")
session_text = session_path.read_text(errors="replace")
current_phases = {
"login": case_values(function_body(session_text, "login_phase_packet"), "HDR_GC_", current_values),
"select": case_values(function_body(session_text, "select_phase_packet"), "HDR_GC_", current_values),
"loading": case_values(function_body(session_text, "loading_phase_packet"), "HDR_GC_", current_values),
"game": case_values(function_body(session_text, "game_phase_packet"), "HDR_GC_", current_values),
}
differences: dict[str, dict[str, list[int]]] = {}
for phase, reference in reference_phases.items():
ignored = CONTROL_VALUES | (OBSERVER_VALUES if phase == "game" else set())
expected = reference - ignored
actual = current_phases[phase] - ignored
differences[phase] = {
"reference_payload_not_current": sorted(expected - actual),
"current_not_reference_payload": sorted(actual - expected),
}
status = "PASS" if all(not values for row in differences.values() for values in row.values()) else "PARTIAL"
return {
"status": status,
"ignored_transport_control_values": sorted(CONTROL_VALUES),
"ignored_observer_values": sorted(OBSERVER_VALUES),
"reference_case_counts": {phase: len(items) for phase, items in reference_phases.items()},
"current_case_counts": {phase: len(items) for phase, items in current_phases.items()},
"differences": differences,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[4])
parser.add_argument("--strict", action="store_true")
args = parser.parse_args()
try:
result = audit(args.repo_root.resolve())
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())