feat(python): 在 app 进程里起 40250 脚本层(2P step 4,macOS)

移植 UserInterface/RunMainScript 到 platform/ScriptLib/PythonBoot:40250 把
CPythonLauncher 放在 Main() 的栈上,这里进程归 Godot,launcher 必须活过创建它的
那次调用,所以拆成 Start / RunMainScript / RunLine / Stop。37 个模块初始化函数里
只调已移植的 initpack(),system.py 因此仍停在第一个 import。

脚本报错能传回 GDScript,靠的是 platform/EterBase/Debug.cpp 实现 LogBox/LogBoxf:
ScriptLib 的 Traceback() 在 PyErr_Fetch 之后只剩这一条路,LastLogBoxMessage()
(platform/EterBase/LogBox.h)是 traceback 唯一幸存的地方。

GDScript 侧新增 Metin2PythonHost(python_host_node.{h,cpp})——唯一同时认识
godot-cpp 和 port 树的单元,PythonBoot 保持 godot-free。检查脚本
python_host_check.gd 分两段:解释器 + 标准库那段不需要 40250 的 pack,哪个平台都
能跑;system.py 那段需要 pack,没有就跳过。桌面入口 python_host_test.gd,真机入口
MT_TEST_MODE=python(导出模板不接受 --script)。

验证(macOS):标准库可 import、codec 注册表可用、sys.path 非空,system.py 停在和
ctest port.python_launcher 完全相同的 ImportError: No module named app;
ctest 27/27。Android/iOS 进程内未做,正式启动路径仍不起解释器(2V0)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-23 10:43:10 +09:00
co-authored by Claude Opus 5
parent e926759ff5
commit 0a1cdf3d3e
16 changed files with 638 additions and 76 deletions
+7
View File
@@ -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()
+44 -4
View File
@@ -6,10 +6,12 @@
#include "../PlatformStub.h"
#include "EterBase/Timer.h"
#include "LogBox.h"
#include <cstdarg>
#include <cstdio>
#include <ctime>
#include <string>
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
+14
View File
@@ -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 <string>
// The last LogBox/LogBoxf text, "" when none since the last ClearLogBoxMessage().
const std::string& LastLogBoxMessage();
void ClearLogBoxMessage();
@@ -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 <memory>
namespace
{
// 40250 has this on Main()'s stack. CPythonLauncher is a CSingleton, so there is still only ever one.
std::unique_ptr<CPythonLauncher> 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<CPythonLauncher>(); // 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();
}
}
@@ -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 <string>
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();
}
+94
View File
@@ -0,0 +1,94 @@
#include "python_host_node.h"
#include <godot_cpp/core/class_db.hpp>
#include "python_stdlib.h"
#ifdef MTGODOT_HAVE_PYTHON
// Standard types only — PythonBoot.h keeps <Python-2.7/*> and the Win32 shims out of this translation
// unit, which has to stay compilable next to godot-cpp.
#include "platform/ScriptLib/PythonBoot.h"
#include <string>
#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
+38
View File
@@ -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 <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/string.hpp>
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
+2
View File
@@ -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) {