docs(parity): mirror 40250 layout for the logic layer; evaluate embedded Python UI
- SKILL.md: logic units port into extension/src/port/<Lib>/<File>, platform adapters under extension/src/platform; legacy GDScript owners are migration sources; python layer frozen until the embedding decision - port_map.py: logic/python/platform layers with per-layer progress, queue --layer, LEGACY layout check; inventory adds EterBase, EterPythonLib, ScriptLib, SpeedTreeLib, SphereLib, EterImageLib - py_embed_spike.py + docs/PYTHON-EMBED-EVAL.md: the 40250 system.py bootstrap loads 67/74 root modules unchanged under CPython 2.7 with stub natives Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,32 +24,50 @@ The 40250 source is the specification. Do not design behavior; transcribe it. Co
|
||||
runtime never loads (e.g. a `*_system.gd` referenced only by its own `test_*_parity.gd`) must be
|
||||
deleted or wired in by porting the real 40250 caller, never cited as parity.
|
||||
|
||||
## Porting target per layer
|
||||
## Code layout: mirror 40250
|
||||
|
||||
| 40250 layer | How to port | Where |
|
||||
| --- | --- | --- |
|
||||
| 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 (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/` |
|
||||
The logic layer has the 40250 structure, not a new architecture. `port_map.py` assigns every unit
|
||||
one of three layers:
|
||||
|
||||
Mark every ported function with a one-line tag at its definition so coverage can be scanned
|
||||
mechanically: `// 40250: CInstanceBase::SetMoveSpeed` (C++) or `# 40250: CInstanceBase::SetMoveSpeed`
|
||||
(GDScript). Tag only real counterparts.
|
||||
| Layer | 40250 units | How to port | Where |
|
||||
| --- | --- | --- | --- |
|
||||
| `logic` | `UserInterface/`, `GameLib/`, `EterLib` net/timer/text parsing, `EterPack`, `EterLocale` (everything not listed below) | Copy C++ -> C++. Same file name, class name, method names, member names and statement order; one reference file = one implementation file | `extension/src/port/<Lib>/<File>.{h,cpp}` |
|
||||
| `python` | `Client/Eternexus/root/*.py`, `uiscript/`, `UserInterface/*Module.cpp`, `EterPythonLib/`, `ScriptLib/` | Being evaluated as embedded CPython 2.7 running the scripts unchanged (`docs/PYTHON-EMBED-EVAL.md`). **Do not port or translate this layer until that evaluation is decided.** Reference is 40250 `Eternexus/root`, **not** `assets/root` | `extension/src/port/<Lib>/` (bindings), scripts from pack |
|
||||
| `platform` | Direct3D/`Grp*`, Granny (`EterGrnLib`), Miles, SpeedTree, `EffectLib`/terrain rendering, Win32 window/input/IME, threads, anti-cheat | Adapter behind the interface the 40250 caller uses; equivalence by observable output | `extension/src/platform/` + existing render code (`metin2_model`, `metin2_anim`, `gr2_bridge`, ...) |
|
||||
|
||||
Rules for the `logic` layer:
|
||||
|
||||
- Port a whole unit into its mirror file. Where 40250 calls a platform class (`CGraphicThingInstance`,
|
||||
`CStateManager`, `CSoundManager`, ...), call a same-named adapter interface declared under
|
||||
`extension/src/platform/`; never inline Godot calls into ported logic.
|
||||
- 40250 singletons (`CPythonPlayer::Instance()`, `CPythonCharacterManager`, `CPythonNetworkStream`)
|
||||
stay singletons owned by the extension. GDScript does not hold gameplay state.
|
||||
- GDScript (`net_play.gd`, `net_world.gd`, `game_scene.gd`, `player_controller.gd`) and
|
||||
`extension/src/net/entity_store.cpp` are migration sources. In the commit that ports a unit, delete
|
||||
the old logic it replaces there and leave only glue: node creation, forwarding Godot input to the
|
||||
ported input handlers, and reading ported state to place nodes.
|
||||
- Keep 40250 types and units (`TPixelPosition` in cm, `DWORD` ms from `ELTimer_GetMSec`, degrees).
|
||||
Convert to Godot space only in the adapter.
|
||||
- `port_map.py check` prints `LEGACY` for a `logic` function whose `impl` is outside its mirror file.
|
||||
|
||||
Mark each ported function with a one-line tag at its definition:
|
||||
`// 40250: CInstanceBase::SetMoveSpeed`. Tag real counterparts only. In mirror files the tag is
|
||||
optional, because the file and method name already map one to one.
|
||||
|
||||
## Round workflow
|
||||
|
||||
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 queue` (logic layer by default, 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`.
|
||||
3. **For each reference function**, compare statement by statement: preconditions and early
|
||||
returns, branches, formulas, constants and units, state writes and their order, timing/event
|
||||
source, data source (proto/msa/msm/txt, never hardcoded), packets sent, cleanup. Then:
|
||||
- transliterate it where missing or divergent;
|
||||
- delete current-only logic it replaces;
|
||||
3. **Copy the unit into its mirror file** and, for each reference function, compare it with the
|
||||
old counterpart statement by statement: preconditions and early returns, branches, formulas,
|
||||
constants and units, state writes and their order, timing/event source, data source
|
||||
(proto/msa/msm/txt, never hardcoded), packets sent, cleanup. Then:
|
||||
- keep the 40250 version; record each old behavior that differed as a divergence found;
|
||||
- delete the old logic it replaces and rewire callers to the mirror class;
|
||||
- mark `N_A` only for pure platform plumbing (D3D state, Python binding glue, Win32), with a reason.
|
||||
4. **Test what changed**: a focused test using the reference's own boundary values for each changed
|
||||
formula/branch (fails before, passes after). Do not write tests for unchanged or trivial code.
|
||||
@@ -110,9 +128,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/port_map.py status | queue [--layer logic|python|platform] | init <unit> | show <unit> | check`
|
||||
— function inventory (active `.vcxproj` sources + the 40250 Python root), progress per library and
|
||||
per layer, and tag/port-map/layout consistency. Run `check` before every commit that touches
|
||||
`audit/port-map/`.
|
||||
- `scripts/py_embed_spike.py` (Python 2.7) — runs the 40250 `system.py` bootstrap with stub native
|
||||
modules; used by the embedded-Python evaluation.
|
||||
- `scripts/audit_ledger.py refresh --write | report --write | validate` — run once per batch, not
|
||||
per round. Missing reference, implementation or test files are an error; `evidence.tests` holds
|
||||
paths only, command lines go in `evidence.commands`.
|
||||
|
||||
@@ -34,7 +34,16 @@
|
||||
- Native extension: `extension/src/` (network in `extension/src/net/`, classic 40250 protocol in `extension/src/net/classic/`).
|
||||
- Format readers: `formats/`; Granny: `libgr2/`; assets/tables: `assets/`; Granny oracle: `oracle/`; tools: `tools/`.
|
||||
|
||||
### Known counterparts
|
||||
### Target layout
|
||||
|
||||
- `extension/src/port/<Lib>/<File>.{h,cpp}` — mirror of each 40250 `logic` unit (see SKILL.md
|
||||
"Code layout"). Does not exist yet; the first ported unit creates it and adds it to
|
||||
`extension/CMakeLists.txt`.
|
||||
- `extension/src/platform/` — adapter interfaces named after the 40250 platform classes they replace.
|
||||
- The files in the table below are **migration sources**: when a unit is ported, its logic moves out
|
||||
of them into the mirror file.
|
||||
|
||||
### Legacy counterparts (migration sources)
|
||||
|
||||
| 40250 | Current |
|
||||
| --- | --- |
|
||||
@@ -47,14 +56,12 @@
|
||||
| `CPythonPlayer` skill use | `project/player_skill.gd` |
|
||||
| `CFlyingObject*` | `project/fly_object.gd` |
|
||||
| `EterLib/Camera.cpp` + `PythonApplicationCamera` | `project/game_camera.gd` |
|
||||
| `ui*.py` windows | `project/ui/*_ui.gd` |
|
||||
| `ui*.py` windows | `project/ui/*_ui.gd` (ported from the wrong `assets/root`; replaced by the python layer) |
|
||||
|
||||
### Code that is not a counterpart
|
||||
### Removed non-counterparts
|
||||
|
||||
About 100 of the 111 `project/*_system.gd` files are loaded only by their own
|
||||
`project/test_*_parity.gd` (never by the runtime). They are not implementations of 40250
|
||||
behavior and their tests are not evidence; delete them, or replace them by porting the real 40250
|
||||
code path when that path exists.
|
||||
The 106 `project/*_system.gd` files that only their own `test_*_parity.gd` loaded were deleted on
|
||||
2026-09-22 (commit `a989b8f1`). Do not recreate them; port the real 40250 code path instead.
|
||||
|
||||
## Existing evidence (historical)
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ def manifest_data(repo: pathlib.Path) -> tuple[set[str], dict[str, list[str]]]:
|
||||
return listed, owners
|
||||
|
||||
|
||||
def active_project_sources(reference_root: pathlib.Path) -> set[str]:
|
||||
def active_project_sources(reference_root: pathlib.Path, dirs: tuple[str, ...] = REFERENCE_DIRS) -> set[str]:
|
||||
active: set[str] = set()
|
||||
for project in sorted((reference_root.parent / "vs_files").glob("*/*.vcxproj")):
|
||||
try:
|
||||
@@ -140,7 +140,7 @@ def active_project_sources(reference_root: pathlib.Path) -> set[str]:
|
||||
relative = candidate.relative_to(reference_root.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if relative.parts and relative.parts[0] in REFERENCE_DIRS:
|
||||
if relative.parts and relative.parts[0] in dirs:
|
||||
if relative.suffix.lower() in SOURCE_SUFFIXES:
|
||||
active.add(relative.as_posix())
|
||||
return active
|
||||
|
||||
@@ -5,11 +5,16 @@ Units are the active 40250 C++ sources (from the .vcxproj files) plus the 40250
|
||||
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 status [--lib UserInterface] coverage per library and per layer (per unit with --units)
|
||||
port_map.py queue [--limit 20] [--layer L] next units by priority, then by TODO count (default layer: logic)
|
||||
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
|
||||
port_map.py check stale hashes, unknown functions, tag mismatches, layout
|
||||
|
||||
Layers: `logic` (ported into extension/src/port/<Lib>/<File>, same names as 40250), `python`
|
||||
(the embedded-Python track: Python binding modules, EterPythonLib window system, ScriptLib and the
|
||||
Python root) and `platform` (Direct3D/Granny/Miles/Win32 rendering, audio and OS plumbing, covered
|
||||
by adapters). Progress is reported per layer so platform code does not dilute the logic figure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,11 +29,34 @@ 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
|
||||
from audit_source_coverage import REFERENCE_DIRS, active_project_sources
|
||||
|
||||
STATUSES = ("TODO", "PORTED", "ADAPTED", "N_A", "DIVERGENT", "NEEDS_LIVE")
|
||||
DONE = {"PORTED", "ADAPTED", "N_A"}
|
||||
PY_ROOT = "../../Client/Eternexus/root"
|
||||
UNIT_DIRS = REFERENCE_DIRS + ("EterBase", "EterPythonLib", "ScriptLib", "SpeedTreeLib", "SphereLib", "EterImageLib")
|
||||
PORT_DIR = "extension/src/port"
|
||||
LAYERS = ("logic", "python", "platform")
|
||||
# First matching pattern wins; anything unmatched is `logic`.
|
||||
LAYER_PATTERNS = [
|
||||
("python", [f"{PY_ROOT}/*.py", "EterPythonLib/*", "ScriptLib/*", "UserInterface/*Module.cpp",
|
||||
"UserInterface/*Moudle.cpp", "UserInterface/PythonSkill.cpp", "UserInterface/PythonGuild.cpp",
|
||||
"UserInterface/PythonShop.cpp", "UserInterface/PythonSafeBox.cpp", "UserInterface/PythonQuest.cpp",
|
||||
"UserInterface/PythonMessenger.cpp"]),
|
||||
("platform", ["EterGrnLib/*", "MilesLib/*", "SpeedTreeLib/*", "SphereLib/*", "EterImageLib/*", "EffectLib/*",
|
||||
"PRTerrainLib/*", "EterLib/Grp*.cpp", "EterLib/StateManager.cpp", "EterLib/MSApplication.cpp",
|
||||
"EterLib/MSWindow.cpp", "EterLib/Input.cpp", "EterLib/IME.cpp", "EterLib/SkyBox.cpp",
|
||||
"EterLib/LensFlare.cpp", "EterLib/ScreenFilter.cpp", "EterLib/Decal.cpp", "EterLib/EnvironmentMap.cpp",
|
||||
"EterLib/JpegFile.cpp", "EterLib/TargaResource.cpp", "EterLib/DibBar.cpp", "EterLib/BlockTexture.cpp",
|
||||
"EterLib/CullingManager.cpp", "EterLib/Thread.cpp", "EterLib/Mutex.cpp", "EterLib/FileLoaderThread.cpp",
|
||||
"EterLib/TextBar.cpp", "EterBase/*", "GameLib/MapOutdoorRender*.cpp", "GameLib/MapOutdoorWater.cpp",
|
||||
"GameLib/SnowParticle.cpp", "GameLib/SnowEnvironment.cpp", "GameLib/TerrainDecal.cpp",
|
||||
"GameLib/TerrainPatch.cpp", "UserInterface/PythonApplicationLogo.cpp", "UserInterface/MovieMan.cpp",
|
||||
"UserInterface/HackShield.cpp", "UserInterface/NProtectGameGuard.cpp", "UserInterface/ProcessCRC.cpp",
|
||||
"UserInterface/ProcessScanner.cpp", "UserInterface/WiseLogicXTrap.cpp",
|
||||
"UserInterface/PythonApplicationWebPage.cpp", "UserInterface/PythonExceptionSender.cpp",
|
||||
"UserInterface/UserInterface.cpp", "UserInterface/CheckLatestFiles.cpp"]),
|
||||
]
|
||||
|
||||
# Order follows SKILL.md "Priority"; the first matching pattern wins.
|
||||
PRIORITY = [
|
||||
@@ -100,7 +128,8 @@ def py_functions(path: Path) -> list[str]:
|
||||
|
||||
|
||||
def inventory(ref: Path) -> dict[str, Path]:
|
||||
units = {p: ref / p for p in sorted(active_project_sources(ref)) if p.endswith(".cpp")}
|
||||
units = {p: ref / p for p in sorted(active_project_sources(ref, UNIT_DIRS))
|
||||
if p.endswith(".cpp") and not p.lower().endswith("stdafx.cpp")}
|
||||
py_dir = (ref / PY_ROOT).resolve()
|
||||
if py_dir.is_dir():
|
||||
for p in sorted(py_dir.glob("*.py")):
|
||||
@@ -119,6 +148,18 @@ def priority(unit: str) -> str:
|
||||
return DEFAULT_PRIORITY
|
||||
|
||||
|
||||
def layer(unit: str) -> str:
|
||||
for name, patterns in LAYER_PATTERNS:
|
||||
if any(fnmatch.fnmatch(unit, pat) for pat in patterns):
|
||||
return name
|
||||
return "logic"
|
||||
|
||||
|
||||
def port_file(unit: str) -> str:
|
||||
"""Expected implementation path stem for a `logic` unit (`extension/src/port/GameLib/ActorInstance`)."""
|
||||
return f"{PORT_DIR}/{unit.rsplit('.', 1)[0]}"
|
||||
|
||||
|
||||
def entry_path(repo: Path, unit: str) -> Path:
|
||||
rel = unit.replace("../../Client/Eternexus/", "Client/")
|
||||
return repo / "audit" / "port-map" / (rel + ".json")
|
||||
@@ -162,6 +203,9 @@ def cmd_status(repo: Path, units: dict[str, Path], args) -> int:
|
||||
per_lib.setdefault(lib, Counter()).update(counts)
|
||||
rows.append((unit, counts))
|
||||
total = Counter()
|
||||
per_layer: dict[str, Counter] = {}
|
||||
for unit, counts in rows:
|
||||
per_layer.setdefault(layer(unit), Counter()).update(counts)
|
||||
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)
|
||||
@@ -172,6 +216,13 @@ def cmd_status(repo: Path, units: dict[str, Path], args) -> int:
|
||||
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)")
|
||||
print()
|
||||
for name in LAYERS:
|
||||
c = per_layer.get(name, Counter())
|
||||
n = sum(c.values())
|
||||
done = sum(c[s] for s in DONE)
|
||||
print(f"layer {name:<10}{n:>7}{done:>7} " + " ".join(f"{c[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"]:
|
||||
@@ -182,6 +233,8 @@ def cmd_status(repo: Path, units: dict[str, Path], args) -> int:
|
||||
def cmd_queue(repo: Path, units: dict[str, Path], args) -> int:
|
||||
rows = []
|
||||
for unit, path in units.items():
|
||||
if layer(unit) != args.layer:
|
||||
continue
|
||||
c = unit_counts(repo, unit, path)
|
||||
open_ = c["TODO"] + c["DIVERGENT"]
|
||||
if open_:
|
||||
@@ -235,6 +288,7 @@ def scan_tags(repo: Path) -> dict[str, list[str]]:
|
||||
|
||||
def cmd_check(repo: Path, units: dict[str, Path], args) -> int:
|
||||
problems = 0
|
||||
legacy = 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"))
|
||||
@@ -265,6 +319,10 @@ def cmd_check(repo: Path, units: dict[str, Path], args) -> int:
|
||||
if status == "ADAPTED" and not info.get("test"):
|
||||
print(f"ERROR {label}: {fn} is ADAPTED without test")
|
||||
problems += 1
|
||||
if status in {"PORTED", "NEEDS_LIVE"} and layer(unit) == "logic" and not any(
|
||||
i.startswith(port_file(unit) + ".") for i in info.get("impl", [])):
|
||||
print(f"LEGACY {label}: {fn} impl is outside {port_file(unit)}.{{h,cpp}}; move it when the unit is ported")
|
||||
legacy += 1
|
||||
if status in {"PORTED", "ADAPTED"}:
|
||||
recorded_ported.add(fn.split("::")[-1] if "::" not in fn else fn)
|
||||
recorded_ported.add(fn)
|
||||
@@ -272,7 +330,8 @@ def cmd_check(repo: Path, units: dict[str, Path], args) -> int:
|
||||
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")
|
||||
print(f"check: {problems} error(s), {legacy} legacy location(s), {len(untracked)} untracked tag(s), "
|
||||
f"{len(tags)} tag(s) scanned")
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
@@ -281,6 +340,7 @@ def main() -> int:
|
||||
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.add_argument("--layer", choices=LAYERS, default="logic")
|
||||
p = sub.add_parser("init"); p.add_argument("units", nargs="+")
|
||||
p = sub.add_parser("show"); p.add_argument("unit")
|
||||
sub.add_parser("check")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python2.7
|
||||
"""Embedded-Python feasibility spike (Python 2.7 only; see docs/PYTHON-EMBED-EVAL.md).
|
||||
|
||||
Emulates CPythonLauncher::Create + system.py bootstrap: stub native modules (every call returns 0),
|
||||
a directory-backed `pack` module over the 40250 Eternexus tree, then imports every root module
|
||||
through system.py's own `__pack_import`. Reports loaded/failed modules to --out (default
|
||||
py_embed_spike.txt); system.py redirects stdout, so nothing useful is printed.
|
||||
|
||||
python2.7 py_embed_spike.py [--out FILE] (reference root: MT_40250_SOURCE or audit manifest)
|
||||
"""
|
||||
import sys, types, os, glob, traceback, json
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.abspath(os.path.join(HERE, "../../../.."))
|
||||
_ref = os.environ.get("MT_40250_SOURCE") or json.load(open(os.path.join(REPO, "audit/manifest.json")))["reference_root"]
|
||||
E = os.path.normpath(os.path.join(REPO, _ref, "../../Client/Eternexus"))
|
||||
OUT = sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else "py_embed_spike.txt"
|
||||
FILES = {}
|
||||
for p in glob.glob(E + "/root/*"): FILES[os.path.basename(p).lower()] = p
|
||||
for base, pre in ((E + "/locale_en/locale/en", "locale/en/"), (E + "/uiscript/uiscript", "uiscript/")):
|
||||
for d, _, fs in os.walk(base):
|
||||
for f in fs:
|
||||
full = os.path.join(d, f)
|
||||
FILES[(pre + os.path.relpath(full, base)).lower()] = full
|
||||
LOG = open(OUT, "w")
|
||||
MISSING = {}
|
||||
class Stub(types.ModuleType):
|
||||
def __getattr__(self, name):
|
||||
if name.startswith("__"): raise AttributeError(name)
|
||||
key = "%s.%s" % (self.__name__, name)
|
||||
def f(*a, **k):
|
||||
MISSING[key] = MISSING.get(key, 0) + 1
|
||||
return 0
|
||||
setattr(self, name, f); return f
|
||||
NATIVE = "app net player chr chrmgr wndMgr grp grpImage grpText grpThing item skill snd chat background miniMap ime systemSetting guild safebox shop exchange quest messenger textTail nonplayer effect event eventMgr fly dbg profiler ServerStateChecker udp".split()
|
||||
for n in NATIVE: sys.modules[n] = Stub(n)
|
||||
pack = types.ModuleType("pack")
|
||||
def norm(n): return n.replace("\\", "/").lower().lstrip("/")
|
||||
pack.Exist = lambda n: norm(n) in FILES
|
||||
pack.Get = lambda n: open(FILES[norm(n)], "rb").read()
|
||||
sys.modules["pack"] = pack
|
||||
sys.modules["dbg"].Trace = sys.modules["dbg"].TraceError = lambda *a: None
|
||||
sys.modules["dbg"].LogBox = lambda m: LOG.write("LOGBOX: %s\n" % m)
|
||||
sys.modules["app"].Loop = lambda: LOG.write("app.Loop reached\n")
|
||||
STR = {"app.GetLocalePath": "locale/en", "app.GetLocaleName": "en", "app.GetLocaleServiceName": "en",
|
||||
"app.GetLocalePathCommon": "locale/common"}
|
||||
for k, v in STR.items():
|
||||
m, f = k.split(".")
|
||||
setattr(sys.modules[m], f, (lambda v: lambda *a: v)(v))
|
||||
import __builtin__
|
||||
__builtin__.TRUE = 1; __builtin__.FALSE = 0 # CPythonLauncher::Create
|
||||
import __main__
|
||||
__main__.__DEBUG__ = 0
|
||||
__main__.__COMMAND_LINE__ = ""
|
||||
src = pack.Get("system.py")
|
||||
try:
|
||||
exec compile(src, "system.py", "exec") in __main__.__dict__
|
||||
except BaseException:
|
||||
LOG.write("system.py raised:\n" + traceback.format_exc())
|
||||
ok, bad = [], []
|
||||
_pimp = __main__.__dict__["__pack_import"]
|
||||
for name in sorted(FILES):
|
||||
if not name.endswith(".py") or "/" in name: continue
|
||||
mod = name[:-3]
|
||||
real = [m for m in sys.modules if m.lower() == mod]
|
||||
if real: ok.append(mod); continue
|
||||
try:
|
||||
_pimp(mod); ok.append(mod)
|
||||
except BaseException as e:
|
||||
bad.append((mod, "%s: %s" % (type(e).__name__, str(e)[:100])))
|
||||
LOG.write("modules loaded %d, failed %d\n" % (len(ok), len(bad)))
|
||||
for m, e in bad: LOG.write(" FAIL %s %s\n" % (m, e))
|
||||
LOG.write("native functions called during bootstrap: %d\n" % len(MISSING))
|
||||
LOG.write(" " + " ".join(sorted(MISSING)) + "\n")
|
||||
LOG.close()
|
||||
@@ -16,13 +16,17 @@ python3 .agents/skills/metin2-40250-parity-audit/scripts/port_map.py queue --lim
|
||||
| --- | --- | --- |
|
||||
| 0 | 工具:参考根解析、缺失文件报错、`port_map.py`、构建/测试入口 | 完成 2026-09-22 |
|
||||
| 1 | 删除只被自身测试引用的 `project/*_system.gd` 与其 `test_*_parity.gd`,清除 manifest 中对应证据 | 完成 2026-09-22(106+106 个文件) |
|
||||
| 2 | P0 角色/移动单元(并行 worktree) | 未开始 |
|
||||
| 3 | P1 战斗/技能 → P2 游戏阶段封包 → P3 物品与 Python UI | 未开始 |
|
||||
| 2 | P0 角色/移动单元,移植到 `extension/src/port/<Lib>/<File>`(并行 worktree) | 未开始 |
|
||||
| 2P | Python 层原型 2:CPython 2.7.18 静态编入 libmtgodot(macOS → Android → iOS),见 `docs/PYTHON-EMBED-EVAL.md` | 待确认 |
|
||||
| 3 | P1 战斗/技能 → P2 游戏阶段封包 → P3 物品;Python 层按 2P 的结论进行 | 未开始 |
|
||||
| 4 | `NEEDS_LIVE` 真服验证批 | 未开始 |
|
||||
|
||||
## 批次 2 单元(互不共享实现文件的可并行)
|
||||
|
||||
- [ ] `UserInterface/PythonPlayerEventHandler.cpp`(已做 OnMove/OnMoving/OnStop;OnWaiting DIVERGENT)— `net_play.gd`
|
||||
实现位置一律是镜像文件 `extension/src/port/<Lib>/<File>.{h,cpp}`;下面列出的是需要删减旧逻辑的迁移来源。
|
||||
第一个单元负责建立 `extension/src/port/` 与 `extension/src/platform/` 目录和 CMake 目标。
|
||||
|
||||
- [ ] `UserInterface/PythonPlayerEventHandler.cpp`(OnMove/OnMoving/OnStop 已在 `net_play.gd` 对齐,需迁到镜像文件;OnWaiting DIVERGENT)— `net_play.gd`
|
||||
- [ ] `UserInterface/InstanceBaseMovement.cpp` + `UserInterface/InstanceBase.cpp`(同一 owner,串行)— `player_controller.gd`、`entity_store.cpp`
|
||||
- [ ] `UserInterface/PythonPlayerInput.cpp` + `PythonPlayerInputKeyboard.cpp` + `PythonPlayerInputMouse.cpp` — `player_controller.gd`(与上一项串行)
|
||||
- [ ] `GameLib/ActorInstanceMotion.cpp` + `ActorInstanceEvent.cpp` — 动作/事件
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 内嵌 Python 运行 40250 UI 的评估
|
||||
|
||||
日期:2026-09-22。目的:判断能否不翻译 40250 的 Python UI,而是内嵌解释器原样运行
|
||||
`Client/Eternexus/root/*.py` 和 `uiscript/`,只移植它调用的 C++ 模块。
|
||||
|
||||
## 40250 的实际情况
|
||||
|
||||
| 项 | 数据 |
|
||||
| --- | --- |
|
||||
| 解释器 | CPython 2.7.6 内嵌(`extern/include/Python-2.7`、`python27.lib`) |
|
||||
| 启动 | `ScriptLib/PythonLauncher.cpp`:`CPythonLauncher::Create` 注入 `TRUE`/`FALSE` 到 `__builtin__`,然后把 `system.py` 作为 `__main__` 运行;`system.py` 替换 `__import__`,从 pack 读源码并 `compile` |
|
||||
| 脚本 | `root/*.py` 90 个(其中 74 个 `.py` 模块),`uiscript/uiscript/*.py` 94 个 |
|
||||
| 语法 | 154 个文件在 Python 2.7.18 下全部能编译;Python 3 下有 56 个编译失败;约 292 行只在 Python 2 下成立(`print` 语句、`except X, e`、`has_key`、`iteritems` 等) |
|
||||
| 语义风险 | 约 185 行在整数之间用 `/`(Python 2 是整除,Python 3 是浮点);字符串是 CP949 字节串 |
|
||||
| C++ 暴露的模块 | 35 个(`app` `net` `player` `chr` `chrmgr` `wndMgr` `grp` `item` `skill` 等),约 1160 个函数、约 994 个常量 |
|
||||
| 需要移植的 C++ | `UserInterface/*Module.cpp` + `EterPythonLib`(窗口系统)+ `ScriptLib`,共约 2.44 万行 |
|
||||
| 脚本实际引用 | 1627 个不同的 `模块.属性`(含常量) |
|
||||
|
||||
对比:如果逐个翻译成 GDScript,需要翻译 `root` 下的 3350 个函数,外加 uiscript 数据文件;而这些 C++
|
||||
模块在任何方案里都要移植(它们就是 UI 和游戏逻辑的接口)。现在仓库里 `project/ui/*.gd` 有 60 个文件、
|
||||
2.39 万行,当初依据的是错误版本的脚本(m2dev `assets/root`),无论选哪条路都要重做。
|
||||
|
||||
## 原型验证(已完成)
|
||||
|
||||
`.agents/skills/metin2-40250-parity-audit/scripts/py_embed_spike.py`,在本机 Python 2.7.18 下:
|
||||
|
||||
- 按 `CPythonLauncher::Create` 注入 `TRUE`/`FALSE`,所有 C++ 模块用返回 0 的桩代替,`pack` 用目录代替;
|
||||
- 原样运行 40250 的 `system.py`,再通过它自己的 `__pack_import` 导入全部 74 个 root 模块。
|
||||
|
||||
结果:**不改任何脚本,67/74 个模块加载成功**,引导过程一路执行到 `prototype.py` 的
|
||||
`app.Create` 等调用。剩下 7 个失败都是桩的问题,不是解释器或脚本的问题:
|
||||
|
||||
- `chr.MOTION_SKILL + 121` 这类常量需要真实整数(真实模块用 `PyModule_AddIntConstant` 提供);
|
||||
- locale 数据路径(`app.GetLocaleServiceName` 等返回值决定走哪个 locale 目录);
|
||||
- `rootlibcythonizer`、`test_affect` 是开发用脚本,40250 运行时也不加载。
|
||||
|
||||
结论:在 Python 2.7 上,40250 的脚本和导入机制可以原封不动运行;工作量集中在 C++ 模块本身。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 脚本改动 | 与 40250 一致性 | 平台风险 | 结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A. 内嵌 CPython 2.7.18(静态链接) | 无 | 最高:解释器语义、`/` 整除、字节串、C API(`Py_InitModule`、`PyTuple_GetInteger`)都和 40250 相同,`*Module.cpp` 可以几乎原样复制 | 需要自己为 Android/iOS 交叉编译;2.7 已停止维护 | **推荐** |
|
||||
| B. 内嵌 CPython 3.13+ | 56 个文件要转换,约 185 处 `/` 需逐一判断 | 中:字符串/整除/字典顺序语义不同,转换本身会引入偏差;C API 要改写 | 3.13 起官方支持 Android/iOS | A 失败时的备选 |
|
||||
| C. pocketpy 等轻量解释器 | 需要大量改动 | 低:只支持 Python 3 子集 | 小 | 不推荐 |
|
||||
| D. 翻译成 GDScript | 全部重写 3350 个函数 | 取决于翻译质量,难以机械核对 | 无 | 不推荐 |
|
||||
|
||||
### 方案 A 的平台问题
|
||||
|
||||
- macOS / Linux / Windows:直接编译,无风险。
|
||||
- Android(arm64,NDK 27):CPython 2.7 能用 NDK 编译(python-for-android、Kivy 曾长期支持),但需要补丁:
|
||||
关闭 `dlopen` 扩展、把用到的标准库 C 模块静态编进去、`pyconfig.h` 按 NDK 调整。
|
||||
- iOS:同样静态链接(Kivy-ios 曾支持 2.7)。App Store 允许包内自带的解释型代码(不下载代码即可)。
|
||||
- 标准库只需要极少一部分:脚本用到的是 `sys` `os` `marshal` `imp` `types` `copy` `math` `traceback`
|
||||
`_weakref` `__builtin__` `locale`,`psyco` 有 try 保护。可以冻结为内置模块,不需要带整个 `Lib/`。
|
||||
- 2.7 停止维护的风险:解释器只运行包内自带的脚本;服务器数据先经过 C++ 解析再以普通值交给 Python,
|
||||
不会执行远端代码。
|
||||
|
||||
## 采用方案 A 后的结构
|
||||
|
||||
```
|
||||
extension/src/port/ScriptLib/PythonLauncher.cpp # CPythonLauncher,几乎原样
|
||||
extension/src/port/EterPythonLib/PythonWindow*.cpp # 窗口树、焦点、命中、事件:逻辑照搬
|
||||
extension/src/port/UserInterface/*Module.cpp # net/player/chr/... 模块,调用已移植的 CPython* 单例
|
||||
extension/src/platform/ui/ # 适配层:CGraphicImageInstance/CGraphicTextInstance
|
||||
# 的绘制 -> Godot RenderingServer/CanvasItem
|
||||
extension/third_party/cpython-2.7.18/ # 静态库
|
||||
```
|
||||
|
||||
Godot 这边只剩一个宿主 Control:把输入转发给 `CPythonWindowManager`,每帧调用它的 Update/Render。
|
||||
`root/*.py` 和 `uiscript/` 直接从 pack 读取,和 40250 相同。port-map 中 `Client/root` 的 3350 个函数
|
||||
全部记为 `N_A`(原样运行,无需移植),python 层剩下约 1540 个 C++ 函数要移植。
|
||||
|
||||
## 下一步:原型 2(待确认后执行)
|
||||
|
||||
1. 把 CPython 2.7.18 静态编进 `libmtgodot`,先做 macOS arm64 和 Android arm64,再做 iOS 模拟器;
|
||||
2. 在 Godot 进程里运行 `py_embed_spike.py` 的同等流程(C++ 桩模块),确认 67/74 的结果在三个平台上一致;
|
||||
3. 验收门槛:Android 和 iOS 都能加载 `system.py` 并导入 `ui.py`。如果 Android/iOS 编译在合理时间内做不出来,
|
||||
就改用方案 B,并对每处 `/` 做单独核对。
|
||||
|
||||
原型 2 通过之前,python 层(`Client/root`、`EterPythonLib`、`ScriptLib`、`*Module.cpp`)不开工;
|
||||
逻辑层(阶段 2)不受影响,可以并行推进。
|
||||
Reference in New Issue
Block a user