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>
This commit is contained in:
shenlei
2026-09-22 17:17:49 +09:00
co-authored by Claude Opus 5
parent 6a175ee293
commit 55f733fda6
16 changed files with 621 additions and 468 deletions
@@ -30,7 +30,7 @@ The 40250 source is the specification. Do not design behavior; transcribe it. Co
| --- | --- | --- |
| Platform-independent C++ logic (packet structs/handlers, sequence table, formulas, state machines in `UserInterface/`, `GameLib/`; `EterPack`, `EterLocale`) | Transliterate C++ -> C++, keeping class/function structure and names so each function has one counterpart | `extension/src/` |
| 40250 C++ logic that currently lives in GDScript | Port function by function, same names in snake_case, same order of statements | existing `.gd` owner |
| Python UI (`assets/root/*.py`, `uiscript/`) | Translate per `.py` file into its GDScript window, keeping method structure | `project/ui/` |
| Python UI (40250 `Client/Eternexus/root/*.py` + `uiscript/`, **not** `assets/root`) | Translate per `.py` file into its GDScript window, keeping method structure | `project/ui/` |
| Direct3D, Granny, Miles, Win32 input/window, IME, DirectX math | Platform adapter; equivalence by observable output | `extension/`, `project/` |
Mark every ported function with a one-line tag at its definition so coverage can be scanned
@@ -39,9 +39,9 @@ mechanically: `// 40250: CInstanceBase::SetMoveSpeed` (C++) or `# 40250: CInstan
## Round workflow
1. **Pick a unit** from the queue in `audit/remediation-roadmap.md` (ordered by user-visible
gameplay; see Priority). Skip units whose port-map entries are all `PORTED`/`ADAPTED`/`N_A` and
whose reference hash is unchanged.
1. **Pick a unit** from the current batch in `audit/remediation-roadmap.md`, or from
`port_map.py queue` (ordered by user-visible gameplay; see Priority). Run
`port_map.py init <unit>` and `port_map.py show <unit>` to get the function list.
2. **Read the whole reference unit** under the reference root (see `references/project-map.md`;
use `grep -a`, many files contain CP949 bytes). Read every current counterpart, found by the
`40250:` tags, the port-map entry, or `references/project-map.md`.
@@ -110,8 +110,12 @@ behavior verified from code inspection alone.
## Tools
- `scripts/port_map.py status | queue | init <unit> | show <unit> | check` — function inventory
(active `.vcxproj` sources + the 40250 Python root), per-unit status, and tag/port-map consistency.
Run `check` before every commit that touches `audit/port-map/`.
- `scripts/audit_ledger.py refresh --write | report --write | validate` — run once per batch, not
per round. Missing reference files are an error, not a silent fingerprint.
per round. Missing reference, implementation or test files are an error; `evidence.tests` holds
paths only, command lines go in `evidence.commands`.
- `scripts/audit_packet_registry.py`, `audit_phase_dispatch.py`, `audit_sequence_table.py` — run
when a round touches `extension/src/net/`.
- `scripts/audit_source_coverage.py` — maps active 40250 sources (from the `.vcxproj` files) to
@@ -18,7 +18,8 @@ Required fields:
- `equivalence`: implementation-equivalence matrix described below.
- `platform_adaptations`: documented engine/platform substitutions; use an empty array when none exist.
- `evidence.contract`: detailed Markdown contract path relative to the repository root.
- `evidence.tests`: test paths relative to the repository root.
- `evidence.tests`: test file paths relative to the repository root (paths only; missing files fail `refresh`).
- `evidence.commands` (optional): command lines and live/manual evidence descriptions.
- `evidence.last_test_result`: `PASS`, `FAIL`, or `NOT_RUN`.
- `remaining`: explicit unverified branches; use an empty array only when none remain.
@@ -8,9 +8,12 @@
there are not product code.
- Many `UserInterface/` files contain CP949 bytes in comments: use `grep -a` / `grep -arn`, or plain
grep silently reports no match.
- Python UI and game scripts are **not** in the source tree: they are in this repo at
`assets/root/*.py` (e.g. `uitarget.py`, `uitaskbar.py`) and `assets/uiscript/`.
- Server source (for protocol questions only, not client behavior): `../40250/Server Client TMP4/Server`.
- Python UI and game scripts: `../../Client/Eternexus/root/*.py` relative to the reference root
(90 files; identical to the shipped `Client/pack/root.epk`), window layouts in
`../../Client/Eternexus/uiscript/uiscript/`, locale data in `../../Client/Eternexus/locale_*/`.
**`assets/root` in this repo is a different (m2dev) version** with material differences
(e.g. `uitarget.py` button table); never use it as the 40250 reference.
- Server source (for protocol questions only, not client behavior): `../40250/Server Client TMP4/Server/metin2`.
| Library | Lines | Content |
| --- | ---: | --- |
@@ -63,8 +66,10 @@ code path when that path exists.
```bash
godot --headless --path project --script <test>.gd # most project tests
cmake --build build-debug -j4 && ctest --test-dir build-debug --output-on-failure -j2
cmake --build build -j8 && ctest --test-dir build --output-on-failure -j4
```
If `build-debug/CMakeCache.txt` points at another checkout path, reconfigure first:
`cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug`. Always finish with `git diff --check`.
`build/` (Debug) is the configured tree; it also writes `project/bin/libmtgodot.*.dylib`, so rebuild
before running Godot tests after pulling native changes (a stale dylib shows up as "method not
found" parse errors). `build-debug/` is a stale tree from another checkout; do not use it.
Always finish with `git diff --check`.
@@ -11,6 +11,8 @@ from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import refroot # script directory is on sys.path when run directly
STATUSES = {
"UNMAPPED", "PARTIAL", "MAPPED", "STATIC_VERIFIED", "TEST_VERIFIED",
@@ -67,8 +69,7 @@ def resolve_files(root: Path, data: dict, section: str) -> list[Path]:
files = contract.get(section, {}).get("files", []) if section != "tests" else contract.get("evidence", {}).get("tests", [])
base = root
if section == "reference":
ref_root = Path(data.get("_reference_root", ""))
base = ref_root if ref_root.is_absolute() else root / ref_root
base = refroot.reference_root(root)
return [(base / item).resolve() for item in files]
@@ -77,8 +78,7 @@ def fingerprint(paths: list[Path]) -> str:
for path in sorted(paths, key=lambda p: str(p)):
digest.update(str(path).encode())
if not path.is_file():
digest.update(b"<missing>")
continue
raise SystemExit(f"fingerprint input missing: {path}")
digest.update(hashlib.sha256(path.read_bytes()).digest())
return digest.hexdigest()
@@ -218,9 +218,8 @@ def command_next(_root: Path, manifest: dict, args: argparse.Namespace) -> int:
def command_refresh(root: Path, manifest: dict, args: argparse.Namespace) -> int:
changed = 0
ref_root = manifest.get("reference_root", "")
for contract in manifest["contracts"]:
working = {**contract, "_reference_root": ref_root}
working = contract
current = {
"reference": fingerprint(resolve_files(root, working, "reference")),
"implementation": fingerprint(resolve_files(root, working, "implementation")),
@@ -14,6 +14,8 @@ 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",
@@ -196,7 +198,7 @@ def main() -> int:
args = parser.parse_args()
root = args.repo_root.resolve()
reference = root.parent / "40250/Server Client TMP4/ClientVS22/source/UserInterface/PythonNetworkStream.cpp"
reference = refroot.reference_root(root) / "UserInterface/PythonNetworkStream.cpp"
wire = root / "extension/src/net/classic/wire_classic.h"
try:
result = audit(reference, wire)
@@ -14,6 +14,8 @@ 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}
@@ -88,7 +90,7 @@ def reference_phase_values(root: Path, filename: str) -> set[int]:
def audit(repo_root: Path) -> dict:
reference_root = repo_root.parent / "40250/Server Client TMP4/ClientVS22/source"
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 = {
@@ -14,6 +14,8 @@ import json
import re
from pathlib import Path
import refroot # script directory is on sys.path when run directly
def read_array(path: Path, symbol: str) -> bytes:
text = path.read_text(errors="replace")
@@ -41,7 +43,7 @@ def main() -> int:
args = parser.parse_args()
root = args.repo_root.resolve()
reference = root.parent / "40250/Server Client TMP4/ClientVS22/source/EterLib/NetStream.cpp"
reference = refroot.reference_root(root) / "EterLib/NetStream.cpp"
current = root / "extension/src/net/classic/sequence_table.h"
try:
ref = read_array(reference, "s_bSequenceTable")
@@ -15,6 +15,8 @@ import re
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict
import refroot # script directory is on sys.path when run directly
REFERENCE_DIRS = (
"UserInterface",
@@ -259,7 +261,7 @@ def infer_owners(path: str) -> list[str]:
def build(repo: pathlib.Path) -> dict:
reference_root = (repo / "../40250/Server Client TMP4/ClientVS22/source").resolve()
reference_root = refroot.reference_root(repo)
listed, exact_owners = manifest_data(repo)
active = active_project_sources(reference_root)
rows = []
@@ -283,7 +285,7 @@ def build(repo: pathlib.Path) -> dict:
for owner in row["owners"]:
by_contract[owner][row["kind"]] += 1
return {
"reference_root": "../40250/Server Client TMP4/ClientVS22/source",
"reference_root": json.loads((repo / "audit/manifest.json").read_text(encoding="utf-8"))["reference_root"],
"active_source_count": len(rows),
"manifest_exact_count": counts["manifest_exact"],
"inferred_review_queue_count": counts["inferred_review_queue"],
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Per-function 40250 port-map: inventory, skeleton creation, coverage and queue.
Units are the active 40250 C++ sources (from the .vcxproj files) plus the 40250 Python
root (`../../Client/Eternexus/root/*.py` relative to the reference root). Port-map entries
live in `audit/port-map/<unit path>.json`; functions without an entry count as TODO.
port_map.py status [--lib UserInterface] coverage per library (and per unit with --units)
port_map.py queue [--limit 20] next units by priority, then by TODO count
port_map.py init <unit> [<unit>...] create or extend entries (new functions as TODO)
port_map.py show <unit> per-function status of one unit
port_map.py check stale hashes, unknown functions, tag mismatches
"""
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import json
import re
import sys
from collections import Counter
from pathlib import Path
import refroot # script directory is on sys.path when run directly
from audit_source_coverage import active_project_sources
STATUSES = ("TODO", "PORTED", "ADAPTED", "N_A", "DIVERGENT", "NEEDS_LIVE")
DONE = {"PORTED", "ADAPTED", "N_A"}
PY_ROOT = "../../Client/Eternexus/root"
# Order follows SKILL.md "Priority"; the first matching pattern wins.
PRIORITY = [
("P0", ["UserInterface/InstanceBase*.cpp", "GameLib/ActorInstance.cpp", "GameLib/ActorInstanceMotion*.cpp",
"GameLib/ActorInstanceRotation.cpp", "GameLib/ActorInstancePosition.cpp", "GameLib/ActorInstanceEvent.cpp",
"GameLib/ActorInstanceCollisionDetection.cpp", "GameLib/ActorInstanceSync.cpp",
"UserInterface/PythonPlayer.cpp", "UserInterface/PythonPlayerInput*.cpp",
"UserInterface/PythonPlayerEventHandler.cpp", "UserInterface/PythonCharacterManager.cpp"]),
("P1", ["GameLib/ActorInstanceBattle.cpp", "GameLib/ActorInstanceAttach*.cpp", "GameLib/ActorInstanceFly.cpp",
"GameLib/FlyingObject*.cpp", "GameLib/FlyTarget.cpp", "GameLib/FlyingInstance.cpp", "GameLib/FlyTrace.cpp",
"UserInterface/PythonPlayerSkill.cpp", "UserInterface/PythonSkill.cpp", "GameLib/RaceData*.cpp",
"GameLib/RaceMotionData*.cpp", "GameLib/ActorInstanceData.cpp", "GameLib/WeaponTrace.cpp"]),
("P2", ["UserInterface/PythonNetworkStream*.cpp", "UserInterface/AccountConnector.cpp",
"UserInterface/ServerStateChecker.cpp", "EterLib/NetStream.cpp", "EterLib/NetPacketHeaderMap.cpp"]),
("P3", ["UserInterface/PythonItem*.cpp", "UserInterface/PythonExchange*.cpp", "UserInterface/PythonSafeBox.cpp",
"UserInterface/PythonShop.cpp", "UserInterface/PythonQuest.cpp", "UserInterface/PythonGuild.cpp",
"UserInterface/PythonMessenger.cpp", "UserInterface/PythonChat*.cpp", "UserInterface/PythonTextTail.cpp",
"UserInterface/PythonMiniMap.cpp", "GameLib/ItemData.cpp", "GameLib/ItemManager.cpp", f"{PY_ROOT}/*.py"]),
]
DEFAULT_PRIORITY = "P4"
# Column-0 definition: `<ret> [Class::]name(` where the body `{` follows on this or the next line.
_DEF = re.compile(r"^(?!return\b|else\b|if\b|while\b|for\b|switch\b|case\b|#|//|/\*|\s)"
r"[^;(){}=]*?\b((?:[A-Za-z_]\w*::)*~?[A-Za-z_]\w*)\s*\(")
_PY_CLASS = re.compile(r"^class\s+(\w+)")
_PY_DEF = re.compile(r"^(\s*)def\s+(\w+)\s*\(")
_TAG = re.compile(r"(?://|#)\s*40250:\s*([A-Za-z_][\w:.~]*)")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def cpp_functions(path: Path) -> list[str]:
lines = path.read_bytes().decode("latin-1").splitlines()
names: list[str] = []
for i, line in enumerate(lines):
m = _DEF.match(line)
if not m or line.rstrip().endswith(";"):
continue
name = m.group(1)
if name.isupper() or name.split("::")[-1].isupper(): # macros
continue
rest = "\n".join(lines[i:i + 8])
body = rest.find("{")
semi = rest.find(";")
if body < 0 or (0 <= semi < body):
continue
if name not in names:
names.append(name)
return names
def py_functions(path: Path) -> list[str]:
names: list[str] = []
cls = ""
for line in path.read_bytes().decode("latin-1").splitlines():
if m := _PY_CLASS.match(line):
cls = m.group(1)
continue
if m := _PY_DEF.match(line):
indent, fn = m.groups()
if not indent:
cls = ""
name = f"{cls}.{fn}" if cls and indent else fn
if name not in names:
names.append(name)
return names
def inventory(ref: Path) -> dict[str, Path]:
units = {p: ref / p for p in sorted(active_project_sources(ref)) if p.endswith(".cpp")}
py_dir = (ref / PY_ROOT).resolve()
if py_dir.is_dir():
for p in sorted(py_dir.glob("*.py")):
units[f"{PY_ROOT}/{p.name}"] = p
return units
def functions(path: Path) -> list[str]:
return py_functions(path) if path.suffix == ".py" else cpp_functions(path)
def priority(unit: str) -> str:
for level, patterns in PRIORITY:
if any(fnmatch.fnmatch(unit, pat) for pat in patterns):
return level
return DEFAULT_PRIORITY
def entry_path(repo: Path, unit: str) -> Path:
rel = unit.replace("../../Client/Eternexus/", "Client/")
return repo / "audit" / "port-map" / (rel + ".json")
def load_entry(repo: Path, unit: str) -> dict | None:
path = entry_path(repo, unit)
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def lib_of(unit: str) -> str:
return "Client/root" if unit.startswith(PY_ROOT) else unit.split("/")[0]
def unit_counts(repo: Path, unit: str, path: Path) -> Counter:
entry = load_entry(repo, unit) or {"functions": {}}
recorded = entry["functions"]
counts = Counter()
for fn in functions(path):
counts[recorded.get(fn, {}).get("status", "TODO")] += 1
return counts
def resolve_unit(units: dict[str, Path], name: str) -> str:
if name in units:
return name
hits = [u for u in units if u.endswith("/" + name) or Path(u).name == name]
if len(hits) != 1:
raise SystemExit(f"unit {name!r}: {'ambiguous: ' + ', '.join(hits) if hits else 'not an active 40250 unit'}")
return hits[0]
def cmd_status(repo: Path, units: dict[str, Path], args) -> int:
per_lib: dict[str, Counter] = {}
rows = []
for unit, path in units.items():
lib = lib_of(unit)
if args.lib and lib != args.lib:
continue
counts = unit_counts(repo, unit, path)
per_lib.setdefault(lib, Counter()).update(counts)
rows.append((unit, counts))
total = Counter()
print(f"{'library':<16}{'funcs':>7}{'done':>7} " + " ".join(f"{s:>10}" for s in STATUSES))
for lib, c in sorted(per_lib.items()):
total.update(c)
n = sum(c.values())
done = sum(c[s] for s in DONE)
print(f"{lib:<16}{n:>7}{done:>7} " + " ".join(f"{c[s]:>10}" for s in STATUSES))
n = sum(total.values())
done = sum(total[s] for s in DONE)
print(f"{'TOTAL':<16}{n:>7}{done:>7} " + " ".join(f"{total[s]:>10}" for s in STATUSES)
+ f" ({100.0 * done / max(n, 1):.1f}% done)")
if args.units:
for unit, c in rows:
if sum(c.values()) != c["TODO"]:
print(f" {unit}: " + ", ".join(f"{s}={c[s]}" for s in STATUSES if c[s]))
return 0
def cmd_queue(repo: Path, units: dict[str, Path], args) -> int:
rows = []
for unit, path in units.items():
c = unit_counts(repo, unit, path)
open_ = c["TODO"] + c["DIVERGENT"]
if open_:
rows.append((priority(unit), -open_, unit, c))
for level, neg, unit, c in sorted(rows)[: args.limit]:
print(f"{level}\t{-neg:>4} open\t{unit}\t(TODO={c['TODO']} DIVERGENT={c['DIVERGENT']})")
return 0
def cmd_init(repo: Path, units: dict[str, Path], args) -> int:
for name in args.units:
unit = resolve_unit(units, name)
path = units[unit]
entry = load_entry(repo, unit) or {
"reference": unit, "reference_sha256": sha256(path), "priority": priority(unit),
"contracts": [], "functions": {},
}
added = 0
for fn in functions(path):
if fn not in entry["functions"]:
entry["functions"][fn] = {"status": "TODO"}
added += 1
out = entry_path(repo, unit)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(entry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"{out.relative_to(repo)}: {added} function(s) added, {len(entry['functions'])} total")
return 0
def cmd_show(repo: Path, units: dict[str, Path], args) -> int:
unit = resolve_unit(units, args.unit)
recorded = (load_entry(repo, unit) or {"functions": {}})["functions"]
for fn in functions(units[unit]):
info = recorded.get(fn, {"status": "TODO"})
impl = ", ".join(info.get("impl", []))
print(f"{info['status']:<11}{fn}" + (f" -> {impl}" if impl else "") + (f" # {info['note']}" if info.get("note") else ""))
return 0
def scan_tags(repo: Path) -> dict[str, list[str]]:
tags: dict[str, list[str]] = {}
for base, pattern in (("project", "*.gd"), ("extension/src", "*.[ch]*")):
for path in (repo / base).rglob(pattern):
if "/addons/" in path.as_posix():
continue
for i, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
for m in _TAG.finditer(line):
tags.setdefault(m.group(1), []).append(f"{path.relative_to(repo)}:{i}")
return tags
def cmd_check(repo: Path, units: dict[str, Path], args) -> int:
problems = 0
recorded_ported: set[str] = set()
for entry_file in sorted((repo / "audit" / "port-map").rglob("*.json")):
entry = json.loads(entry_file.read_text(encoding="utf-8"))
unit = entry.get("reference", "")
label = entry_file.relative_to(repo)
if unit not in units:
print(f"ERROR {label}: reference {unit!r} is not an active 40250 unit")
problems += 1
continue
if entry.get("reference_sha256") != sha256(units[unit]):
print(f"STALE {label}: reference changed; recheck non-TODO functions, then update reference_sha256")
problems += 1
known = set(functions(units[unit]))
for fn, info in entry.get("functions", {}).items():
status = info.get("status")
if fn not in known:
print(f"ERROR {label}: {fn} is not defined in {unit}")
problems += 1
if status not in STATUSES:
print(f"ERROR {label}: {fn} has invalid status {status!r}")
problems += 1
if status in {"PORTED", "ADAPTED", "NEEDS_LIVE"} and not info.get("impl"):
print(f"ERROR {label}: {fn} is {status} without impl")
problems += 1
if status in {"ADAPTED", "N_A", "DIVERGENT", "NEEDS_LIVE"} and not info.get("note"):
print(f"ERROR {label}: {fn} is {status} without note")
problems += 1
if status == "ADAPTED" and not info.get("test"):
print(f"ERROR {label}: {fn} is ADAPTED without test")
problems += 1
if status in {"PORTED", "ADAPTED"}:
recorded_ported.add(fn.split("::")[-1] if "::" not in fn else fn)
recorded_ported.add(fn)
tags = scan_tags(repo)
untracked = sorted(t for t in tags if t not in recorded_ported)
for tag in untracked:
print(f"WARN tag `40250: {tag}` at {tags[tag][0]} has no PORTED/ADAPTED port-map entry")
print(f"check: {problems} error(s), {len(untracked)} untracked tag(s), {len(tags)} tag(s) scanned")
return 1 if problems else 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("status"); p.add_argument("--lib"); p.add_argument("--units", action="store_true")
p = sub.add_parser("queue"); p.add_argument("--limit", type=int, default=20)
p = sub.add_parser("init"); p.add_argument("units", nargs="+")
p = sub.add_parser("show"); p.add_argument("unit")
sub.add_parser("check")
args = parser.parse_args()
repo = refroot.repo_root()
units = inventory(refroot.reference_root(repo))
return {"status": cmd_status, "queue": cmd_queue, "init": cmd_init, "show": cmd_show,
"check": cmd_check}[args.command](repo, units, args)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,30 @@
"""Locate the 40250 reference source tree for the audit scripts.
Order: `MT_40250_SOURCE` env var, then `reference_root` in `audit/manifest.json`
(relative to the repository root). A missing tree is an error, never a silent fallback.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
def repo_root() -> Path:
return Path(__file__).resolve().parents[4]
def reference_root(repo: Path | None = None) -> Path:
repo = (repo or repo_root()).resolve()
raw = os.environ.get("MT_40250_SOURCE")
if not raw:
manifest = json.loads((repo / "audit" / "manifest.json").read_text(encoding="utf-8"))
raw = manifest.get("reference_root", "")
if not raw:
raise SystemExit("40250 reference root not set: export MT_40250_SOURCE or set manifest reference_root")
path = Path(raw)
path = (path if path.is_absolute() else repo / path).resolve()
if not (path / "UserInterface").is_dir():
raise SystemExit(f"40250 reference root not found or incomplete: {path}")
return path