diff --git a/audit/history.jsonl b/audit/history.jsonl index 2ff0264f..35cbff0b 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -499,3 +499,4 @@ {"time": "2026-09-23T22:20:00Z", "event": "scope_decision", "unit": "目标平台收窄为 macOS arm64 + Android arm64(Linux / Windows 不做)", "ported": [], "adapted": [], "deleted": [], "divergent": [], "needs_live": [], "decision": {"by": "user", "statement": "Linux 和 Windows 不考虑,目前主要支持 macOS arm64 和 Android arm64", "ios": "iOS arm64 保留为交叉编译干净的次要目标,不作为验收目标(我的判断,待用户确认)", "mingw": "仓库里的 mingw-w64 交叉编译只留作可移植性门禁——它这次抓到了 UserInterface/StdAfx.h 的真问题——不代表支持 Windows,不为它投入额外工作,也不阻塞任何步骤"}, "docs": ["docs/PORT-PLAN.md: 第 1 节新增范围段;批次 2A step 3 平台列表;批次 2P step 1/2 措辞;step 4 由「五个平台」改为「两个目标平台」,Linux/Windows 两行划掉并保留原因备查;总表 2P 行", "docs/PLATFORMS.md: 新增 Scope 段", "docs/THIRD-PARTY.md: CPython 的 Windows 段由「not built yet」改为 out of scope + 原因备查", "docs/PYTHON-EMBED-EVAL.md: 下一步 1/3 两条"], "tests": "仅文档改动,无代码改动;port_map.py check 0 error / 1 tag;参考 EterPack.cpp 的 8 个 pack key 值逐个 git grep --cached -w,均未入库", "not_done": "2P step 4 剩余项仍在:Android 真机 APK 进程内的 3c 沙盒落地,以及 app 进程里跑 system.py → prototype.RunApp()"} {"time": "2026-09-23T22:35:00Z", "event": "known_issue", "unit": "Android GBK 编解码挂起;优先保证 macOS 可用", "ported": [], "adapted": [], "deleted": [], "divergent": [], "needs_live": [], "decision": {"by": "user", "statement": "先不管这个问题了,先保证 macOS 能正常使用,Android 后期再想办法"}, "finding": {"symptom": "build-android.sh 默认 API 24 下 extension/src/net/text_codec.cpp 编译失败:use of undeclared identifier 'iconv_open' / 'iconv' / 'iconv_close'", "root_cause": "text_codec.cpp:10 用 __has_include() 探测。NDK 里该头在任何 API 等级都存在,但声明被 __BIONIC_AVAILABILITY_GUARD(28) 挡住,探测方式与 bionic 的可用性模型不匹配", "measured": "NDK 28.2.13676358 clang++ -fsyntax-only:--target=aarch64-linux-android24 报 3 处 undeclared;android28 通过", "why_api_bump_is_wrong": "NDK iconv.h 注释写明 bionic 只支持 utf8/ascii/usascii/utf16be/utf16le/utf32be/utf32le/wchart,没有 GB2312/GBK。升到 28 后 iconv_open(\"GB2312\",\"UTF-8\") 运行期返回 -1,encode_one()/decode_wire() 对所有输入都返回 false,连纯 ASCII 也被拒,比现有 #else 的 ASCII 回退分支(text_codec.cpp:104-127)更糟。附带成本:APK min_sdk 需 >=28;config/android/pyconfig.h 是 API 24 下 configure 的(HAVE_LANGINFO_H 因 nl_langinfo 需 API 26 被关掉),要重跑 gen_pyconfig.sh android 并重验内建模块表", "future_fix": "1) 探测改为 #if __has_include() && !defined(__ANDROID__),不要写 __ANDROID_API__ >= 28;2) Android 自带 GBK<->Unicode 表(约 22k 码位)。40250 原客户端走 Windows MultiByteToWideChar(CP949/CP936),跨平台无对应物;macOS 能用只是系统 libiconv 恰好带 GBK"}, "docs": ["docs/PORT-PLAN.md 新增「已知问题:Android 的 GBK 编解码(挂起,2026-09-23)」小节"], "tests": "仅文档改动;诊断由 NDK clang++ -fsyntax-only 双 API 等级实测得出", "not_done": "text_codec.cpp 未改动;Android 扩展构建目前仍是红的"} {"time": "2026-09-23T23:30:00Z", "event": "test_fix", "unit": "net 测试去 flaky:net_classic_session_test + net_loopback_test", "ported": [], "adapted": [], "deleted": [], "divergent": [], "needs_live": [], "trigger": "macOS 全量 ctest 连续两次各挂 1 个 net 测试,且每次挂的不是同一个(第一次 net.loopback_flow,第二次 net.classic_session)", "root_causes": ["accept_loopback(): poll() 的 `<= 0` 把 EINTR 当成对端从未连接;且单片 500ms 超时在有负载时不够。50 轮压测里 4 次失败全部源于此", "net_classic_session_test.cpp:570 的 pump 循环条件抄自上面的死端口用例,但此处 connect 会成功,stage 先经过 LoggingIn,循环提前一步退出", "同文件 5 处手写 pump 循环只给 10-20ms 等真实 loopback socket(非阻塞 connect 完成 / connect 被拒 / 对端 FIN)", "net_loopback_test.cpp 10 处 run(..., N, []{return false;}) 是固定空转而非等待条件,等不够就断言失败"], "fixes": ["accept_loopback() 改为 EINTR 重试 + 5s 截止时间(listener 本就是非阻塞,超时后 accept 不会挂住)", "新增 pump_until(session, want, max_ms=2000),替换全部 5 处手写循环;等的是断言真正关心的那个状态", "替换 replacement PHASE_LOGIN 处的单次 pump(前一条 CHECK 已经断言过「不在同一帧」,这里只需要后续某次 pump 收到)", "net_loopback_test 的 10 处固定空转改为条件等待;服务端批量收包用该批最后一个包的标志作条件(TCP 有序,最后一个到了说明前面都到了),客户端事件用 decltype 声明的本地累加器在谓词里 drain"], "speedup": {"net_classic_session_test": "61.2s -> 3.5s:两处 this-*-host-must-not-resolve.invalid 换成带空标签的名字。本机解析器不按 RFC 6761 本地拒绝 .invalid,每次发往上游等满 30s 超时;空标签让 getaddrinfo 在语法层返回同一个 EAI_NONAME,实测 <10ms", "net_loopback_test": "固定空转改条件退出后单跑 0.94s", "ctest_total": "78.7s -> 8.7s"}, "tests": "net_classic_session_test 100/100(修复前 50 轮挂 4 轮);net_loopback_test 100/100(修复前单独跑第 7 轮即挂);全量 ctest 27/27;port_map.py check 0 error;参考 EterPack.cpp 的 8 个 pack key 值逐个 git grep --cached -w,均未入库", "not_done": "只改测试,未改产品代码。这些都是测试自身的计时/错误处理缺陷,不是 mtnet 的行为问题"} +{"time": "2026-09-23T11:00:00Z", "event": "port_round", "unit": "PORT-PLAN 批次 2P step 4 (macOS): the 40250 script layer starts inside the Godot process", "ported": [], "adapted": ["UserInterface/UserInterface.cpp:RunMainScript -> extension/src/platform/ScriptLib/PythonBoot.cpp (Start/RunMainScript/RunLine/Stop; 40250 keeps CPythonLauncher on Main()'s stack, Godot owns this process so the launcher has to outlive the call that starts it)", "EterBase/Debug.cpp:LogBox, EterBase/Debug.cpp:LogBoxf -> extension/src/platform/EterBase/Debug.cpp (stderr plus LastLogBoxMessage(); ScriptLib Traceback() reports through LogBoxf after PyErr_Fetch has cleared the exception, so this is the only place a script traceback survives)"], "deleted": [], "divergent": ["RunMainScript RegisterCommandLine passes the command line through unchanged: the -cs/-ncs auto-login decoding needs EterBase/Utils.cpp SplitLine and EterLib/Util.cpp base64_decode, both declared but not ported (批次 2V1). Nothing in this host passes -cs today.", "RunMainScript drops '--pause-before-create-window' (system(\"pause\")) and the XTrap init check: no console and no XTrap on macOS/Android.", "Of the 37 module initializers RunMainScript calls, only initpack() is called — the other 36 are 2V slices."], "needs_live": [], "staging": {"native": "extension/src/platform/ScriptLib/PythonBoot.{h,cpp} (godot-free, placed under ScriptLib because that is the directory port_logic/port_platform drop when `mtpython` is absent; compiled with the UserInterface library's definitions so its `#ifdef _DISTRIBUTE` gives __DEBUG__ = 0). extension/src/platform/EterBase/LogBox.h. extension/src/python_host_node.{h,cpp} -> GDScript class Metin2PythonHost (start/is_running/run_main_script/run_line/stop; each returns \"\" or the reason, and a build without the interpreter answers with a distinguishable message instead of failing to register).", "gdscript": "project/python_host_check.gd in two halves — the interpreter + stdlib half needs no 40250 pack and runs on any platform, the system.py half needs the pack and is skipped without it. project/python_host_test.gd (--script) is the desktop entry; MT_TEST_MODE=python in project/client_main.gd is the on-device entry, since export templates do not accept --script.", "boot": "Metin2PythonHost.start(\"\") stages the stdlib through Metin2Python.stdlib_path(), which until now had no caller outside python_stdlib_test.gd."}, "status_note": "macOS only. Android/iOS in-process is untouched: the APK cross-build is blocked on the GBK iconv issue (see the PORT-PLAN known-issue section, deferred by the user 2026-09-23), and a device has no 40250 Client/pack, so on a phone only the interpreter + stdlib half of the check can run. prototype.RunApp() is 2V0: system.py still stops at its first import. The real boot path (AppFlow) does not start the interpreter.", "tests": ["godot --headless --path project --script python_host_test.gd -> PASS: interpreter starts in-process, is_running(), a second start() is a no-op, `import os, string, types, codecs`, u'\\xe4'.encode('cp1252'), sys.path non-empty, system.py stops at ImportError: No module named app (identical to ctest port.python_launcher), stop() tears it down", "MT_TEST_MODE=python godot --headless --path project -> same result through client_main.gd", "ctest --test-dir build -> 27/27 passed (14.51s)"], "not_done": "Android/iOS 进程内验证;正式启动路径起解释器(2V0);RunMainScript 的 -cs 命令行解码(2V1)", "commit": "2P-step4-macos"} diff --git a/audit/port-map/EterBase/Debug.cpp.json b/audit/port-map/EterBase/Debug.cpp.json new file mode 100644 index 00000000..1585067f --- /dev/null +++ b/audit/port-map/EterBase/Debug.cpp.json @@ -0,0 +1,69 @@ +{ + "reference": "EterBase/Debug.cpp", + "reference_sha256": "af7a6cba9e6d83511a40f596629f85f1b770b510fc0db1d9cbefcec88ecb15ec", + "priority": "P4", + "contracts": [], + "functions": { + "SetLogLevel": { + "status": "TODO" + }, + "Log": { + "status": "TODO" + }, + "Logn": { + "status": "TODO" + }, + "Logf": { + "status": "TODO" + }, + "Lognf": { + "status": "TODO" + }, + "Trace": { + "status": "TODO" + }, + "Tracen": { + "status": "TODO" + }, + "Tracenf": { + "status": "TODO" + }, + "Tracef": { + "status": "TODO" + }, + "TraceError": { + "status": "TODO" + }, + "TraceErrorWithoutEnter": { + "status": "TODO" + }, + "LogBoxf": { + "impl": [ + "extension/src/platform/EterBase/Debug.cpp:LogBoxf" + ], + "status": "ADAPTED", + "note": "verbatim apart from two PORT fixes the 40250 build did not need: va_end (40250 leaks the va_list) and terminating szBuf, which _vsnprintf does not do on truncation.", + "test": "project/python_host_check.gd — system.py's ImportError reaches GDScript through it" + }, + "LogBox": { + "impl": [ + "extension/src/platform/EterBase/Debug.cpp:LogBox" + ], + "status": "ADAPTED", + "note": "40250 pops a MessageBox on g_PopupHwnd and Tracen()s the text. No window here: the text goes to stderr and is kept in LastLogBoxMessage() (extension/src/platform/EterBase/LogBox.h), because ScriptLib/PythonLauncher.cpp Traceback() reports every failed script line through LogBoxf after PyErr_Fetch has already cleared the exception — this is the only place a traceback survives.", + "test": "project/python_host_check.gd — system.py's ImportError reaches GDScript through it" + }, + "LogFile": { + "status": "TODO" + }, + "LogFilef": { + "status": "TODO" + }, + "OpenLogFile": { + "status": "TODO" + }, + "OpenConsoleWindow": { + "status": "TODO" + } + } +} diff --git a/audit/port-map/UserInterface/UserInterface.cpp.json b/audit/port-map/UserInterface/UserInterface.cpp.json index e28e1a91..422f23e5 100644 --- a/audit/port-map/UserInterface/UserInterface.cpp.json +++ b/audit/port-map/UserInterface/UserInterface.cpp.json @@ -1,73 +1,78 @@ { - "reference": "UserInterface/UserInterface.cpp", - "reference_sha256": "beed987e4b1de627acafe7ea6f5fde989b8f4b33c8643fe2abb0f7152ea4a9eb", - "priority": "P4", - "contracts": [], - "functions": { - "ApplicationSetErrorString": { - "status": "TODO" - }, - "CheckPythonLibraryFilenames": { - "status": "TODO" - }, - "ApplicationStringTable_Initialize": { - "status": "TODO" - }, - "ApplicationStringTable_GetString": { - "status": "TODO" - }, - "ApplicationStringTable_GetStringz": { - "status": "TODO" - }, - "PackInitialize": { - "impl": [ - "extension/src/platform/UserInterface/UserInterface.cpp:PackInitialize" - ], - "status": "ADAPTED", - "note": "platform PackInitialize: Index pack names resolved to the on-disk .eix spelling case-insensitively; no LogBoxf; CTextFileLoader::SetCacheMode / CSoundData::SetPackMode not called (units not ported, neither affects pack registration)", - "test": "extension/tests/port_eterpack_test.cpp (103 packs, 54891 entries, first-registered wins, 4 SECURITY rejects); extension/tests/proto_test.cpp (40250 locale/en item_proto/mob_proto through pack://)" - }, - "RunMainScript": { - "status": "TODO" - }, - "Main": { - "status": "TODO" - }, - "CreateMetin2GameMutex": { - "status": "TODO" - }, - "DestroyMetin2GameMutex": { - "status": "TODO" - }, - "__ErrorPythonLibraryIsNotExist": { - "status": "TODO" - }, - "__IsTimeStampOption": { - "status": "TODO" - }, - "__PrintTimeStamp": { - "status": "TODO" - }, - "__IsLocaleOption": { - "status": "TODO" - }, - "__IsLocaleVersion": { - "status": "TODO" - }, - "__IsOpenIDAuthKeyOption": { - "status": "TODO" - }, - "__IsOpenIDTestOption": { - "status": "TODO" - }, - "WinMain": { - "status": "TODO" - }, - "GrannyError": { - "status": "TODO" - }, - "Setup": { - "status": "TODO" - } + "reference": "UserInterface/UserInterface.cpp", + "reference_sha256": "beed987e4b1de627acafe7ea6f5fde989b8f4b33c8643fe2abb0f7152ea4a9eb", + "priority": "P4", + "contracts": [], + "functions": { + "ApplicationSetErrorString": { + "status": "TODO" + }, + "CheckPythonLibraryFilenames": { + "status": "TODO" + }, + "ApplicationStringTable_Initialize": { + "status": "TODO" + }, + "ApplicationStringTable_GetString": { + "status": "TODO" + }, + "ApplicationStringTable_GetStringz": { + "status": "TODO" + }, + "PackInitialize": { + "impl": [ + "extension/src/platform/UserInterface/UserInterface.cpp:PackInitialize" + ], + "status": "ADAPTED", + "note": "platform PackInitialize: Index pack names resolved to the on-disk .eix spelling case-insensitively; no LogBoxf; CTextFileLoader::SetCacheMode / CSoundData::SetPackMode not called (units not ported, neither affects pack registration)", + "test": "extension/tests/port_eterpack_test.cpp (103 packs, 54891 entries, first-registered wins, 4 SECURITY rejects); extension/tests/proto_test.cpp (40250 locale/en item_proto/mob_proto through pack://)" + }, + "RunMainScript": { + "impl": [ + "extension/src/platform/ScriptLib/PythonBoot.cpp:PythonBoot::RunMainScript" + ], + "status": "ADAPTED", + "note": "40250 runs it on Main()'s stack and takes the CPythonLauncher by reference; Godot owns this process, so PythonBoot::Start holds the launcher and RunMainScript takes only the command line plus a std::string* for the text 40250 shows in LogBox's message box. Of the 37 module initializers only initpack() is called — the rest are 2V slices, which is why system.py still stops at its first import. RegisterCommandLine passes the line through unchanged: the -cs/-ncs auto-login decoding needs EterBase/Utils.cpp SplitLine and EterLib/Util.cpp base64_decode, both still TODO (批次 2V1). '--pause-before-create-window' (system(\"pause\")) and the XTrap init check are dropped: no console and no XTrap on this platform.", + "test": "project/python_host_test.gd (godot --headless --path project --script python_host_test.gd) and the same check through MT_TEST_MODE=python in project/client_main.gd -> system.py stops at ImportError: No module named app, the same point as ctest port.python_launcher" + }, + "Main": { + "status": "TODO" + }, + "CreateMetin2GameMutex": { + "status": "TODO" + }, + "DestroyMetin2GameMutex": { + "status": "TODO" + }, + "__ErrorPythonLibraryIsNotExist": { + "status": "TODO" + }, + "__IsTimeStampOption": { + "status": "TODO" + }, + "__PrintTimeStamp": { + "status": "TODO" + }, + "__IsLocaleOption": { + "status": "TODO" + }, + "__IsLocaleVersion": { + "status": "TODO" + }, + "__IsOpenIDAuthKeyOption": { + "status": "TODO" + }, + "__IsOpenIDTestOption": { + "status": "TODO" + }, + "WinMain": { + "status": "TODO" + }, + "GrannyError": { + "status": "TODO" + }, + "Setup": { + "status": "TODO" } + } } diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index cb530d4d..387bde1d 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -328,14 +328,28 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 macOS 上用 `force_stage=true` 跑的就是移动端那条路径:落盘、sha 一致、字节与 `res://` 相同、`.part` 不残留、 `ZIPReader` 能打开且含 `encodings/__init__.py`,重复调用不重复复制,人为弄坏副本后重新落盘。 **未完成**:真机上跑(Android/iOS 的 `user://` 沙盒路径本身没验过),随 step 4 一起; - app 的启动流程还没有调用它——解释器要到 2V0 才在进程里起来,现在只有测试调。 + 进程内的调用者见 step 4(`Metin2PythonHost.start()` 空路径时就调 `Metin2Python.stdlib_path()`); + 正式启动路径要到 2V0 才起解释器,现在只有 `MT_TEST_MODE=python` 和测试调。 `port_platform` 现在在没有 `mtpython` 时也会排除 `platform/ScriptLib/`(与 `port_logic` 同一条规则), 否则 Windows 门禁会编到 `PythonHost.cpp`。 4. 两个目标平台分别验证:编译、链接无未定义符号、`Py_Initialize`、静态 C 模块逐个 `import`、在 app 进程里跑 `system.py` → `prototype.RunApp()`(对照结果:74 个模块中 66 个加载成功,引导期调用 33 个 C++ 函数)。 + **进程内入口(2026-09-23)**:`extension/src/platform/ScriptLib/PythonBoot.{h,cpp}` 是 + 40250 `RunMainScript` 的移植(`audit/port-map/UserInterface/UserInterface.cpp.json`), + 拆成 `Start` / `RunMainScript` / `RunLine` / `Stop`——40250 把 `CPythonLauncher` 放在 `Main()` 的栈上, + 这里进程归 Godot,launcher 必须活过创建它的那次调用。37 个模块初始化函数里只调了已移植的 `initpack()`。 + GDScript 侧是 `Metin2PythonHost`(`extension/src/python_host_node.{h,cpp}`,唯一同时认识 godot-cpp 和 + port 树的单元;`PythonBoot` 保持 godot-free)。脚本报错能传回 GDScript,是因为 + `platform/EterBase/Debug.cpp` 实现了 `LogBox`/`LogBoxf`:`Traceback()` 在 `PyErr_Fetch` 之后只剩这一条路 + (`platform/EterBase/LogBox.h` 的 `LastLogBoxMessage()`)。 + 检查脚本 `project/python_host_check.gd` 分两段:解释器 + 标准库那段不需要 40250 的 pack,哪个平台都能跑; + `system.py` 那段需要 pack,没有就跳过。桌面入口 `project/python_host_test.gd` + (`godot --headless --path project --script python_host_test.gd`),真机入口是 + `MT_TEST_MODE=python`(导出模板不接受 `--script`)。 + | 平台 | 状态 | | --- | --- | - | macOS arm64 | step 1、2 完成:`mtpython` 静态库 + `python.embed`(39 个内建模块全部 import,标准库 codec/pickle 通过)+ `port.python_launcher`(`CPythonLauncher` 跑到 `system.py` 的第一个 import);`system.py` 在 app 进程内未做 | + | macOS arm64 | step 1、2、3 完成,step 4 完成:`mtpython` 静态库 + `python.embed`(39 个内建模块全部 import,标准库 codec/pickle 通过)+ `port.python_launcher`(`CPythonLauncher` 跑到 `system.py` 的第一个 import)+ **app 进程内**(`python_host_test.gd` / `MT_TEST_MODE=python`):标准库可 import、codec 注册表可用,`system.py` 停在和 ctest 完全相同的 `ImportError: No module named app`。`prototype.RunApp()` 要等 2V0 | | Android arm64 | step 1 完成:NDK API 24 交叉编译出 `libmtpython.a`;独立可执行文件在真机跑通(b02c49bb);APK 进程内未做。step 2 的 ScriptLib 只在 macOS 编译过 | | iOS arm64 | step 1 完成:iphoneos arm64 编出 133 个目标文件,无 `_system`/`_fork`/`_popen` 未定义符号;真机未跑。step 2 的 ScriptLib 只在 macOS 编译过 | | ~~Linux x86_64~~ | 不做(2026-09-23 定,见第 1 节) | diff --git a/extension/CMakeLists.txt b/extension/CMakeLists.txt index 3aac9230..04320ebe 100644 --- a/extension/CMakeLists.txt +++ b/extension/CMakeLists.txt @@ -199,6 +199,7 @@ add_library(mtgodot ${MT_LIB_KIND} src/proto/proto_node.cpp src/pack40250_node.cpp src/python_stdlib.cpp + src/python_host_node.cpp ) target_compile_features(mtgodot PRIVATE cxx_std_20) target_link_libraries(mtgodot PRIVATE godot::cpp xrender::libgr2 xrender::formats mtnet mtproto port_platform) diff --git a/extension/src/platform/CMakeLists.txt b/extension/src/platform/CMakeLists.txt index 2051a95f..dec56950 100644 --- a/extension/src/platform/CMakeLists.txt +++ b/extension/src/platform/CMakeLists.txt @@ -14,3 +14,10 @@ target_link_libraries(port_platform PUBLIC port_logic) foreach(src IN LISTS MT_PLATFORM_SOURCES) mt_port_apply_defines(${src}) endforeach() +# ScriptLib/PythonBoot.cpp is UserInterface's RunMainScript; it sits under ScriptLib only because that is +# the directory dropped when there is no embedded interpreter. Its `#ifdef _DISTRIBUTE` (__DEBUG__ = 0) +# has to see the UserInterface library's definitions, not ScriptLib's. +if(TARGET mtpython) + set_property(SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/ScriptLib/PythonBoot.cpp + PROPERTY COMPILE_DEFINITIONS ${MT_PORT_DEFINES_UserInterface}) +endif() diff --git a/extension/src/platform/EterBase/Debug.cpp b/extension/src/platform/EterBase/Debug.cpp index 802514ae..6e666225 100644 --- a/extension/src/platform/EterBase/Debug.cpp +++ b/extension/src/platform/EterBase/Debug.cpp @@ -6,10 +6,12 @@ #include "../PlatformStub.h" #include "EterBase/Timer.h" +#include "LogBox.h" #include #include #include +#include const DWORD DEBUG_STRING_MAX_LEN = 1024; @@ -97,14 +99,52 @@ auto TraceErrorWithoutEnter(const char *, ...) -> void MT_PLATFORM_STUB(); } -auto LogBox(const char *, const char *, HWND) -> void +namespace { - MT_PLATFORM_STUB(); +std::string& log_box_message() +{ + static std::string message; + return message; +} } -auto LogBoxf(const char *, ...) -> void +const std::string& LastLogBoxMessage() { - MT_PLATFORM_STUB(); + return log_box_message(); +} + +void ClearLogBoxMessage() +{ + log_box_message().clear(); +} + +void LogBox(const char * c_szMsg, const char * c_szCaption, HWND) +{ + // 40250 pops a MessageBox on g_PopupHwnd and Tracen()s the text. There is no window here and no + // log file yet, so stderr is the whole of it; the text is also kept for the caller (LogBox.h), + // because it is the only place a script traceback survives. + std::string& message = log_box_message(); + message = c_szCaption ? c_szCaption : "LOG"; + message += ": "; + message += c_szMsg ? c_szMsg : ""; + + fprintf(stderr, "%s\n", message.c_str()); + fflush(stderr); + Tracen(c_szMsg); +} + +void LogBoxf(const char * c_szFormat, ...) +{ + va_list args; + va_start(args, c_szFormat); + + char szBuf[2048]; + _vsnprintf(szBuf, sizeof(szBuf), c_szFormat, args); + va_end(args); // PORT: 40250 leaks the va_list; on this ABI va_end is not optional + + // PORT: _vsnprintf does not terminate on truncation. + szBuf[sizeof(szBuf) - 1] = '\0'; + LogBox(szBuf); } auto LogFile(const char *) -> void diff --git a/extension/src/platform/EterBase/LogBox.h b/extension/src/platform/EterBase/LogBox.h new file mode 100644 index 00000000..d94591ec --- /dev/null +++ b/extension/src/platform/EterBase/LogBox.h @@ -0,0 +1,14 @@ +#pragma once +// The text 40250 would have put in a message box (EterBase/Debug.cpp LogBox/LogBoxf). +// +// LogBox is the client's fatal-error channel: Traceback() in ScriptLib/PythonLauncher.cpp reports every +// failed script line through it, and RunMainScript's callers show it and quit. There is no message box +// on this platform, so the implementation writes the text to stderr and keeps it here for whoever asked +// for the work — that is how a caller learns *why* a script failed, since Traceback() fetches the +// exception (and so clears it) before returning. + +#include + +// The last LogBox/LogBoxf text, "" when none since the last ClearLogBoxMessage(). +const std::string& LastLogBoxMessage(); +void ClearLogBoxMessage(); diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp new file mode 100644 index 00000000..7e4962a0 --- /dev/null +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -0,0 +1,150 @@ +// See PythonBoot.h. The sequence is 40250 UserInterface.cpp:241-352 (RunMainScript) and 419-434 (Main), +// split so the launcher can outlive the call that creates it. +#include "ScriptLib/StdAfx.h" +#include "ScriptLib/PythonLauncher.h" +#include "UserInterface/StdAfx.h" // initpack and the rest of the module initializer list +#include "PythonBoot.h" +#include "PythonHost.h" +#include "../EterBase/LogBox.h" + +#include + +namespace +{ +// 40250 has this on Main()'s stack. CPythonLauncher is a CSingleton, so there is still only ever one. +std::unique_ptr g_launcher; + +bool fail(std::string* error, const std::string& text) +{ + if (error) + *error = text; + return false; +} +} + +namespace PythonBoot +{ + +bool IsRunning() +{ + return g_launcher != nullptr; +} + +bool Start(const char* stdlib_path, std::string* error) +{ + if (g_launcher) + return true; + + const std::string stdlib = (stdlib_path && *stdlib_path) ? stdlib_path : PythonHost::DefaultStdLibPath(); + if (stdlib.empty()) + return fail(error, "no Python standard library path (Metin2Python.stdlib_path / $MT_PYTHON_STDLIB)"); + + // The flags have to be set before the interpreter starts, i.e. before the constructor below. + PythonHost::Configure(); + + g_launcher = std::make_unique(); // Py_Initialize + if (!g_launcher->Create()) + { + Stop(); + return fail(error, "CPythonLauncher::Create failed"); + } + + std::string stdlib_error; + if (!PythonHost::InstallStdLib(stdlib.c_str(), &stdlib_error)) + { + Stop(); + return fail(error, "InstallStdLib(" + stdlib + "): " + stdlib_error); + } + return true; +} + +// 40250: RunMainScript +bool RunMainScript(const char* lpCmdLine, std::string* error) +{ + if (!g_launcher) + return fail(error, "PythonBoot::Start has not run"); + + CPythonLauncher& pyLauncher = *g_launcher; + + // The module initializers, in 40250's order. Everything after initpack (initdbg, initime, initgrp, + // initgrpImage, initgrpText, initwndMgr, initudp, initapp, ... initServerStateChecker) is a 2V slice: + // system.py stops at the first import of one of them, today `app`. + initpack(); + + ClearLogBoxMessage(); + + NANOBEGIN + + // RegisterDebugFlag + { + std::string stRegisterDebugFlag; + +#ifdef _DISTRIBUTE + stRegisterDebugFlag ="__DEBUG__ = 0"; +#else + stRegisterDebugFlag ="__DEBUG__ = 1"; +#endif + + if (!pyLauncher.RunLine(stRegisterDebugFlag.c_str())) + { + TraceError("RegisterDebugFlag Error"); + return fail(error, "RegisterDebugFlag Error: " + LastLogBoxMessage()); + } + } + + // RegisterCommandLine. + // PORT: the "-cs"/"-ncs" auto-login argument is passed through unchanged. Decoding it needs + // EterBase/Utils.cpp SplitLine and EterLib/Util.cpp base64_decode, which are declared but not ported + // yet; they belong to the login track (批次 2V1) and nothing in this host passes -cs today. + { + std::string stRegisterCmdLine; + + std::string stCmdLine = lpCmdLine ? lpCmdLine : ""; + + stRegisterCmdLine ="__COMMAND_LINE__ = "; + stRegisterCmdLine+='"'; + stRegisterCmdLine+=stCmdLine; + stRegisterCmdLine+='"'; + + const CHAR* c_szRegisterCmdLine=stRegisterCmdLine.c_str(); + if (!pyLauncher.RunLine(c_szRegisterCmdLine)) + { + TraceError("RegisterCommandLine Error"); + return fail(error, "RegisterCommandLine Error: " + LastLogBoxMessage()); + } + } + // PORT: 40250 also honours "--pause-before-create-window" here with system("pause") and the XTrap + // init check. Neither exists on this platform (no console to pause, no XTrap), so only the RunFile + // is kept. + { + if (!pyLauncher.RunFile("system.py")) + { + TraceError("RunMain Error"); + return fail(error, "RunMain Error: " + LastLogBoxMessage()); + } + } + + NANOEND + return true; +} + +bool RunLine(const char* source, std::string* error) +{ + if (!g_launcher) + return fail(error, "PythonBoot::Start has not run"); + + ClearLogBoxMessage(); + if (!g_launcher->RunLine(source ? source : "")) + return fail(error, LastLogBoxMessage()); + return true; +} + +void Stop() +{ + if (!g_launcher) + return; + g_launcher->Clear(); // Main() calls Clear() explicitly before the launcher leaves scope + g_launcher.reset(); +} + +} diff --git a/extension/src/platform/ScriptLib/PythonBoot.h b/extension/src/platform/ScriptLib/PythonBoot.h new file mode 100644 index 00000000..9e8405ab --- /dev/null +++ b/extension/src/platform/ScriptLib/PythonBoot.h @@ -0,0 +1,41 @@ +#pragma once +// Starting the script layer inside a host process (docs/PORT-PLAN.md 批次 2P step 4). +// +// 40250 runs the interpreter on Main()'s stack (UserInterface/UserInterface.cpp:419-434): construct +// CPythonLauncher, Create(), RunMainScript(), Clear(), all inside one WinMain. Godot owns the process +// here and calls in from GDScript, so the launcher has to outlive the call that starts it — that split +// (Start / RunMainScript / Stop) is the only difference from the original control flow. +// +// It lives under platform/ScriptLib rather than platform/UserInterface because it is only buildable +// with the embedded interpreter, and that is the directory both port_logic and port_platform drop when +// `mtpython` is absent (extension/src/port/CMakeLists.txt). +// +// godot-free, like everything else under platform/: extension/src/python_host_node.cpp is what GDScript +// talks to. + +#include + +namespace PythonBoot +{ +// PythonHost::Configure() -> CPythonLauncher -> Create() -> PythonHost::InstallStdLib(stdlib_path). +// An empty stdlib_path means PythonHost::DefaultStdLibPath(). Starting twice is a no-op that succeeds. +// On failure *error is set and the interpreter is shut down again, so the next attempt starts clean. +// The packs must already be registered (platform/PackBackend.h), as 40250 registers them in Main() +// before the launcher exists. +bool Start(const char* stdlib_path, std::string* error); +bool IsRunning(); + +// 40250: RunMainScript(CPythonLauncher&, const char*) — the module initializers, __DEBUG__, +// __COMMAND_LINE__ and RunFile("system.py"). Only the initializers that are ported are called; the rest +// arrive with the 2V slices, which is why system.py still stops at its first import. +// False leaves *error set to what the launcher reported through LogBox (platform/EterBase/LogBox.h). +bool RunMainScript(const char* lpCmdLine, std::string* error); + +// CPythonLauncher::RunLine, with the reported text handed back rather than only shown. The host's own +// check uses it to prove the interpreter and the standard library work in-process on a device that has +// no 40250 packs, which is where RunMainScript cannot run at all. +bool RunLine(const char* source, std::string* error); + +// 40250 Main() calls Clear() and lets the launcher leave scope. +void Stop(); +} diff --git a/extension/src/python_host_node.cpp b/extension/src/python_host_node.cpp new file mode 100644 index 00000000..e03b2025 --- /dev/null +++ b/extension/src/python_host_node.cpp @@ -0,0 +1,94 @@ +#include "python_host_node.h" + +#include + +#include "python_stdlib.h" + +#ifdef MTGODOT_HAVE_PYTHON +// Standard types only — PythonBoot.h keeps and the Win32 shims out of this translation +// unit, which has to stay compilable next to godot-cpp. +#include "platform/ScriptLib/PythonBoot.h" +#include +#endif + +using namespace godot; + +namespace mtgodot { + +#ifdef MTGODOT_HAVE_PYTHON + +String Metin2PythonHost::start(const String &stdlib_path) { + String path = stdlib_path; + if (path.is_empty()) { + path = Metin2Python::stdlib_path(false); + if (path.is_empty()) { + return String("python27.zip unavailable: ") + Metin2Python::last_error(); + } + } + std::string error; + if (!PythonBoot::Start(path.utf8().get_data(), &error)) { + return String::utf8(error.c_str()); + } + return String(); +} + +bool Metin2PythonHost::is_running() { + return PythonBoot::IsRunning(); +} + +String Metin2PythonHost::run_main_script(const String &command_line) { + std::string error; + if (!PythonBoot::RunMainScript(command_line.utf8().get_data(), &error)) { + return String::utf8(error.c_str()); + } + return String(); +} + +String Metin2PythonHost::run_line(const String &source) { + std::string error; + if (!PythonBoot::RunLine(source.utf8().get_data(), &error)) { + return String::utf8(error.c_str()); + } + return String(); +} + +void Metin2PythonHost::stop() { + PythonBoot::Stop(); +} + +#else + +// Built without -DMTGODOT_EMBED_PYTHON=ON: the class is still registered so GDScript can tell "no +// interpreter in this build" apart from "the interpreter failed to start". +String Metin2PythonHost::start(const String &) { + return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)"); +} + +bool Metin2PythonHost::is_running() { + return false; +} + +String Metin2PythonHost::run_main_script(const String &) { + return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)"); +} + +String Metin2PythonHost::run_line(const String &) { + return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)"); +} + +void Metin2PythonHost::stop() { +} + +#endif + +void Metin2PythonHost::_bind_methods() { + ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("start", "stdlib_path"), + &Metin2PythonHost::start, DEFVAL(String())); + ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("is_running"), &Metin2PythonHost::is_running); + ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_main_script", "command_line"), + &Metin2PythonHost::run_main_script, DEFVAL(String())); + ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("run_line", "source"), &Metin2PythonHost::run_line); + ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("stop"), &Metin2PythonHost::stop); +} + +} // namespace mtgodot diff --git a/extension/src/python_host_node.h b/extension/src/python_host_node.h new file mode 100644 index 00000000..4b5f9f79 --- /dev/null +++ b/extension/src/python_host_node.h @@ -0,0 +1,38 @@ +#pragma once +// Metin2PythonHost — the 40250 script layer inside the Godot process (docs/PORT-PLAN.md 批次 2P step 4). +// +// Metin2Pack.initialize(AssetRoot.client_path()) # the packs system.py is read from +// if Metin2PythonHost.start() == "": +// var err := Metin2PythonHost.run_main_script("") +// +// Both calls answer with "" on success and with the reason otherwise, so GDScript never has to guess +// from a bool. Until 批次 2V0 registers the C++ modules, run_main_script() is expected to come back with +// system.py's first import error; that is what step 4 verifies on each target platform. +// +// This is the only unit that knows both godot-cpp and the port tree: the sequence itself lives in +// platform/ScriptLib/PythonBoot.h, which stays godot-free. + +#include +#include + +namespace mtgodot { + +class Metin2PythonHost : public godot::Object { + GDCLASS(Metin2PythonHost, godot::Object) + +public: + // Stages the standard library (Metin2Python::stdlib_path) when `stdlib_path` is empty, then starts + // the interpreter. "" on success, including when it was already running. + static godot::String start(const godot::String &stdlib_path); + static bool is_running(); + // __DEBUG__, __COMMAND_LINE__ and system.py out of the packs. "" when system.py ran to the end. + static godot::String run_main_script(const godot::String &command_line); + // One line of Python in the same __main__ dictionary. "" when it ran. + static godot::String run_line(const godot::String &source); + static void stop(); + +protected: + static void _bind_methods(); +}; + +} // namespace mtgodot diff --git a/extension/src/register_types.cpp b/extension/src/register_types.cpp index 67264410..4d87e96b 100644 --- a/extension/src/register_types.cpp +++ b/extension/src/register_types.cpp @@ -12,6 +12,7 @@ #include "net/m2_client.h" #include "pack40250_node.h" #include "proto/proto_node.h" +#include "python_host_node.h" #include "python_stdlib.h" #include "static_object.h" #include "terrain_splat.h" @@ -47,6 +48,7 @@ void initialize_mtgodot_module(ModuleInitializationLevel p_level) { GDREGISTER_CLASS(mtgodot::Metin2Proto); GDREGISTER_CLASS(mtgodot::Metin2Pack); GDREGISTER_CLASS(mtgodot::Metin2Python); + GDREGISTER_CLASS(mtgodot::Metin2PythonHost); } void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) { diff --git a/project/client_main.gd b/project/client_main.gd index 0f649937..6dac593f 100644 --- a/project/client_main.gd +++ b/project/client_main.gd @@ -24,6 +24,17 @@ func _ready() -> void: t.name = "PackageRenderTest" add_child(t) return + # MT_TEST_MODE=python:在 app 进程里起内嵌解释器跑 system.py(PORT-PLAN 批次 2P step 4)。 + # 导出模板不接受 --script,这是在真机 APK/IPA 里跑这条检查的唯一入口。 + if OS.get_environment("MT_TEST_MODE") == "python": + AssetPack.ensure() + var fail: int = preload("res://python_host_check.gd").new().run() + if fail == 0: + print("PASS: python_host_check (app 进程内 system.py)") + else: + printerr("%d check(s) failed" % fail) + get_tree().quit(1 if fail else 0) + return if OS.get_environment("MT_TEST_MODE") == "forest_render": var forest := ForestMapRenderTest.new() forest.name = "ForestMapRenderTest" diff --git a/project/python_host_check.gd b/project/python_host_check.gd new file mode 100644 index 00000000..86791a32 --- /dev/null +++ b/project/python_host_check.gd @@ -0,0 +1,60 @@ +# python_host_check —— 在 app 进程里把内嵌解释器起来并跑 system.py(docs/PORT-PLAN.md 批次 2P step 4)。 +# +# 和 ctest 的 `port.python_launcher` 跑的是同一条序列,区别只有一个:那边是独立可执行文件,这边在 +# Godot 进程里,由 GDScript 发起 —— step 4 要验的就是这个差别。2V0 把 C++ 模块注册上之前, +# system.py 停在第一个 import(`No module named app`),这就是当前的验收结果。 +# +# 真机上通常没有 40250 的 Client/pack(APK 里只有 python27.zip),所以分两段:解释器 + 标准库这一段 +# 不需要 pack,任何平台都跑;system.py 这一段需要 pack,没有就跳过并说清楚。 +# +# 桌面:python_host_test.gd(--script);真机:MT_TEST_MODE=python 从 client_main.gd 进来。 +extends RefCounted + +# 2V0 之前 system.py 必然停在这里;2V0 之后这一行要跟着改。 +const EXPECTED_STOP := "No module named app" + +var fail := 0 + +func _ck(ok: bool, message: String) -> void: + if not ok: + fail += 1 + printerr("FAIL: " + message) + else: + print("ok: " + message) + +# 返回失败数;0 表示落在预期的停止点上(或按上面的规则跳过)。 +func run() -> int: + if not ClassDB.class_exists("Metin2PythonHost"): + printerr("FAIL: Metin2PythonHost extension is not registered") + return 1 + if not FileAccess.file_exists("res://python27.zip"): + print("SKIP: python_host_check(没有 res://python27.zip,需要 -DMTGODOT_EMBED_PYTHON=ON 的构建)") + return 0 + + # 40250 Main() 先注册 pack,再造 CPythonLauncher —— system.py 是从 pack 里读出来的。 + var has_pack := AssetRoot.pack_ready() + + var start_error: String = Metin2PythonHost.start("") + _ck(start_error == "", "解释器在 app 进程里起来了:" + start_error) + if start_error != "": + return fail + _ck(Metin2PythonHost.is_running(), "is_running() 为真") + _ck(Metin2PythonHost.start("") == "", "重复 start() 是空操作") + + # 标准库真的在 sys.path 上(真机是沙盒里那份 python27.zip),codec 注册表也重建过。 + _ck(Metin2PythonHost.run_line("import os, string, types, codecs") == "", "标准库可 import") + _ck(Metin2PythonHost.run_line("assert u'\\xe4'.encode('cp1252') == '\\xe4'") == "", "codec 注册表可用") + _ck(Metin2PythonHost.run_line("import sys; assert sys.path") == "", "sys.path 非空") + + if has_pack: + # RunMainScript:initpack() + __DEBUG__ + __COMMAND_LINE__ + RunFile("system.py")。 + var err: String = Metin2PythonHost.run_main_script("") + print("system.py -> %s" % err) + _ck(err.contains(EXPECTED_STOP), + "system.py 停在预期的 '%s'(2V0 之后这里会往前走)" % EXPECTED_STOP) + else: + print("SKIP: system.py 这一段(没有 40250 Client/pack:%s)" % AssetRoot.client_path()) + + Metin2PythonHost.stop() + _ck(not Metin2PythonHost.is_running(), "stop() 之后 is_running() 为假") + return fail diff --git a/project/python_host_test.gd b/project/python_host_test.gd new file mode 100644 index 00000000..4ac78222 --- /dev/null +++ b/project/python_host_test.gd @@ -0,0 +1,15 @@ +# python_host_test —— 桌面上跑 python_host_check(docs/PORT-PLAN.md 批次 2P step 4)。 +# godot --headless --path project --script python_host_test.gd +# 真机上同一份检查从 client_main.gd 的 MT_TEST_MODE=python 进来。 +extends SceneTree + +const PythonHostCheck = preload("res://python_host_check.gd") + +func _init() -> void: + var fail: int = PythonHostCheck.new().run() + if fail == 0: + print("PASS: python_host_test (app 进程内 system.py)") + quit(0) + else: + printerr("%d check(s) failed" % fail) + quit(1)