2V0-f: run the real system.py app loop in Godot

- system.py runs app.Loop() on the script fiber; each UIUpdate is one
  CPythonApplication::Process(). Logo -> Login, popup ESC/click and
  app.Exit teardown (launcher cleared first, as in 40250 Main) work.
- Image commands are base-texture quads with UVs (a .sub shares its parent
  texture); ImageInstance/ExpandedImageInstance OnRender and SetRenderingRect
  ported verbatim, so scale/rotation/rendering rect are drawn.
- CGraphicBase::GetColor / grp.GenerateColor ported (ThinBoard background).
- port.app_loop test, python_host_test app-loop checks, MT_TEST_MODE=python_ui.
- TODO: .fnt/CGraphicText font pipeline, ExpandedImage blend modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-23 13:38:13 +09:00
co-authored by Claude Opus 5.5
parent 5590714b16
commit 141206cb69
20 changed files with 916 additions and 57 deletions
+189 -8
View File
@@ -5,12 +5,16 @@
#include "ScriptLib/Resource.h"
#include "UserInterface/StdAfx.h" // initpack and the rest of the module initializer list
#include "EterPythonLib/StdAfx.h"
#include "EterBase/Timer.h"
#include "PythonBoot.h"
#include "PythonHost.h"
#include "../EterLib/UIRenderCommands.h"
#include "../EterBase/LogBox.h"
#include <condition_variable>
#include <memory>
#include <mutex>
#include <pthread.h>
namespace
{
@@ -18,6 +22,8 @@ namespace
std::unique_ptr<CPythonLauncher> g_launcher;
std::unique_ptr<UI::CWindowManager> g_window_manager;
std::unique_ptr<CPythonResource> g_resource;
// 40250 CPythonApplication::m_timer; CSlotWindow's cool time and Process() read it via Instance().
std::unique_ptr<CTimer> g_timer;
bool fail(std::string* error, const std::string& text)
{
@@ -25,6 +31,78 @@ bool fail(std::string* error, const std::string& text)
*error = text;
return false;
}
// PORT: 40250 RunApp() blocks inside app.Loop() (CPythonApplication::Loop, PythonApplication.cpp:980)
// and only returns to run mainStream.Destroy() when the window closes. Godot owns the frame loop here,
// so system.py runs on its own thread and the two threads pass a baton: exactly one of them runs at
// any time. Loop() hands the baton back after every Process(), and the host hands it over once per
// Godot frame (AppFrame). The script keeps its original control flow — including the code after
// app.Loop() — without ever blocking the Godot main thread for more than one Process().
struct ScriptFiber
{
enum Turn { Host, Script };
std::mutex mutex;
std::condition_variable cv;
Turn turn = Host;
pthread_t thread{};
bool alive = false; // the thread exists and has not been joined
bool finished = false; // RunMainScript's body returned
bool looping = false; // parked inside CPythonApplication::Loop
bool quit = false; // app.Exit / app.Abort / Stop
bool ok = false;
std::string error;
std::string command_line;
};
ScriptFiber g_fiber;
thread_local bool t_on_fiber = false;
// Caller holds the GIL. Returns with the GIL held again after the script has given the baton back.
void hand_to_script()
{
PyThreadState* state = PyEval_SaveThread();
{
std::unique_lock<std::mutex> lock(g_fiber.mutex);
g_fiber.turn = ScriptFiber::Script;
g_fiber.cv.notify_all();
g_fiber.cv.wait(lock, [] { return g_fiber.turn == ScriptFiber::Host; });
}
PyEval_RestoreThread(state);
}
void join_fiber()
{
if (!g_fiber.alive)
return;
PyThreadState* state = PyEval_SaveThread();
pthread_join(g_fiber.thread, nullptr);
PyEval_RestoreThread(state);
g_fiber.alive = false;
}
bool run_main_script_body(const char* lpCmdLine, std::string* error);
void* fiber_main(void*)
{
{
std::unique_lock<std::mutex> lock(g_fiber.mutex);
g_fiber.cv.wait(lock, [] { return g_fiber.turn == ScriptFiber::Script; });
}
t_on_fiber = true;
PyGILState_STATE gil = PyGILState_Ensure();
std::string error;
const bool ok = run_main_script_body(g_fiber.command_line.c_str(), &error);
PyGILState_Release(gil);
std::lock_guard<std::mutex> lock(g_fiber.mutex);
g_fiber.ok = ok;
g_fiber.error = error;
g_fiber.finished = true;
g_fiber.looping = false;
g_fiber.turn = ScriptFiber::Host;
g_fiber.cv.notify_all();
return nullptr;
}
}
void init_2v0_gameplay_stubs();
@@ -63,20 +141,32 @@ void UIMouseButton(int button, bool pressed, int x, int y)
}
}
// 40250 CPythonApplication::OnKeyDown/OnKeyUp (PythonApplicationEvent.cpp:130-146): ESC first goes
// to RunPressEscapeKey (the OnPressEscapeKey chain every dialog closes on), then to RunKeyDown.
void UIKey(int key, bool pressed)
{
if (!g_window_manager) return;
if (pressed) g_window_manager->RunKeyDown(key);
else g_window_manager->RunKeyUp(key);
if (pressed)
{
if (DIK_ESCAPE == key)
g_window_manager->RunPressEscapeKey();
g_window_manager->RunKeyDown(key);
}
else
g_window_manager->RunKeyUp(key);
}
void UIUpdate()
{
// While system.py is inside app.Loop(), one host frame is one CPythonApplication::Process(),
// which updates and renders the window tree itself.
if (IsAppLooping()) { AppFrame(); return; }
if (g_window_manager) g_window_manager->Update();
}
void UIRender()
{
if (IsAppLooping()) return;
UIRenderBeginFrame();
if (g_window_manager) g_window_manager->Render();
}
@@ -94,6 +184,8 @@ bool Start(const char* stdlib_path, std::string* error)
PythonHost::Configure();
g_launcher = std::make_unique<CPythonLauncher>(); // Py_Initialize
// The script fiber (RunMainScript) is a second OS thread; 2.7 creates the GIL lazily.
PyEval_InitThreads();
if (!g_launcher->Create())
{
Stop();
@@ -106,17 +198,19 @@ bool Start(const char* stdlib_path, std::string* error)
Stop();
return fail(error, "InstallStdLib(" + stdlib + "): " + stdlib_error);
}
g_timer = std::make_unique<CTimer>();
g_resource = std::make_unique<CPythonResource>();
g_window_manager = std::make_unique<UI::CWindowManager>();
return true;
}
// 40250: RunMainScript
bool RunMainScript(const char* lpCmdLine, std::string* error)
{
if (!g_launcher)
return fail(error, "PythonBoot::Start has not run");
}
namespace
{
// Mirrors 40250 RunMainScript. Runs on the script fiber.
bool run_main_script_body(const char* lpCmdLine, std::string* error)
{
CPythonLauncher& pyLauncher = *g_launcher;
// The module initializers follow 40250's order; missing modules arrive in later 2V steps.
@@ -189,6 +283,81 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
NANOEND
return true;
}
}
namespace PythonBoot
{
bool RunMainScript(const char* lpCmdLine, std::string* error)
{
if (!g_launcher)
return fail(error, "PythonBoot::Start has not run");
if (g_fiber.alive)
return fail(error, "RunMainScript is already running");
g_fiber.turn = ScriptFiber::Host;
g_fiber.finished = g_fiber.looping = g_fiber.quit = g_fiber.ok = false;
g_fiber.error.clear();
g_fiber.command_line = lpCmdLine ? lpCmdLine : "";
// 40250's main thread has the default 1 MB Windows stack; Python frames are deep during
// uiScriptLocale/ui imports, so give the script thread more than a secondary thread's default.
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 16u * 1024u * 1024u);
const int rc = pthread_create(&g_fiber.thread, &attr, fiber_main, nullptr);
pthread_attr_destroy(&attr);
if (rc != 0)
return fail(error, "cannot start the script thread");
g_fiber.alive = true;
hand_to_script(); // until system.py parks in app.Loop() or returns
if (g_fiber.finished)
{
join_fiber();
if (!g_fiber.ok)
return fail(error, g_fiber.error);
}
return true;
}
bool IsAppLooping()
{
return g_fiber.alive && g_fiber.looping && !g_fiber.finished;
}
bool AppFrame()
{
if (!IsAppLooping())
return false;
hand_to_script();
if (g_fiber.finished)
join_fiber();
return IsAppLooping();
}
bool AppLoopWait()
{
if (!t_on_fiber)
return false;
g_fiber.looping = true;
if (g_fiber.quit)
return false;
PyThreadState* state = PyEval_SaveThread();
{
std::unique_lock<std::mutex> lock(g_fiber.mutex);
g_fiber.turn = ScriptFiber::Host;
g_fiber.cv.notify_all();
g_fiber.cv.wait(lock, [] { return g_fiber.turn == ScriptFiber::Script; });
}
PyEval_RestoreThread(state);
return !g_fiber.quit;
}
void AppRequestQuit()
{
g_fiber.quit = true;
}
bool RunLine(const char* source, std::string* error)
{
@@ -205,9 +374,21 @@ void Stop()
{
if (!g_launcher)
return;
// In 40250, closing the window ends Loop(); RunApp() then runs mainStream.Destroy() and returns.
if (g_fiber.alive)
{
AppRequestQuit();
while (g_fiber.alive && !g_fiber.finished)
hand_to_script();
join_fiber();
}
g_fiber.finished = false;
// 40250 Main(): pyLauncher.Clear() runs before app->Destroy()/delete app, so the window manager
// (a CPythonApplication member) is still alive while Py_Finalize runs the windows' __del__.
g_launcher->Clear();
g_window_manager.reset();
g_resource.reset();
g_launcher->Clear(); // Main() calls Clear() explicitly before the launcher leaves scope
g_timer.reset();
g_launcher.reset();
}