- 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>
263 lines
10 KiB
C++
263 lines
10 KiB
C++
// 批次 2V0-f: the real 40250 system.py driven through the host lifecycle PythonBoot gives Godot.
|
|
//
|
|
// prototype.RunApp() runs app.Create, mainStream.SetLogoPhase() and then blocks in app.Loop() until
|
|
// the window closes (40250 PythonApplication.cpp:980). Here the script runs on PythonBoot's script
|
|
// thread and every PythonBoot::AppFrame() is one CPythonApplication::Process(); this test is what the
|
|
// Godot surface does each frame, minus Godot:
|
|
//
|
|
// 1. RunMainScript returns with the script parked in app.Loop(), the logo phase open;
|
|
// 2. frames advance introLogo.LogoWindow.OnUpdate through both logo videos into the login phase
|
|
// (40250's appLogoOpen/Update are themselves commented out: open succeeds, update reports done);
|
|
// 3. the popup that networkModule.MainStream created opens, draws, and closes from an ESC key
|
|
// (PopupDialog.OnPressEscapeKey) and from a click on its accept button;
|
|
// 4. Stop() ends Loop() and RunApp() runs mainStream.Destroy() before the thread is joined.
|
|
//
|
|
// port_app_loop_test <40250 Client dir> <python27.zip>
|
|
//
|
|
// Exit 77 (ctest SKIP) when the client is missing, unless MT_ASSETS_STRICT=1, which fails instead.
|
|
#include "ScriptLib/StdAfx.h"
|
|
#include "../src/platform/ScriptLib/PythonBoot.h"
|
|
#include "../src/platform/ScriptLib/PythonHost.h"
|
|
#include "../src/platform/UserInterface/UserInterface.h"
|
|
#include "../src/platform/EterLib/UIRenderCommands.h"
|
|
#include <dinput.h> // DIK_ESCAPE
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <string>
|
|
|
|
#include <unistd.h>
|
|
|
|
static int g_failures = 0;
|
|
#define CHECK(cond) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
std::fprintf(stderr, "%s:%d: CHECK(%s)\n", __FILE__, __LINE__, #cond); \
|
|
++g_failures; \
|
|
} \
|
|
} while (0)
|
|
|
|
static bool py(const char* source)
|
|
{
|
|
std::string error;
|
|
if (PythonBoot::RunLine(source, &error))
|
|
return true;
|
|
std::fprintf(stderr, "python: %s\n%s\n", source, error.c_str());
|
|
return false;
|
|
}
|
|
|
|
// Evaluates a Python expression on the host thread without raising (a raise would log every frame).
|
|
static bool py_true(const char* expression)
|
|
{
|
|
PyObject* globals = PyModule_GetDict(PyImport_AddModule((char*) "__main__"));
|
|
PyObject* result = PyRun_String(expression, Py_eval_input, globals, globals);
|
|
if (!result)
|
|
{
|
|
PyErr_Clear();
|
|
return false;
|
|
}
|
|
const bool truth = PyObject_IsTrue(result) == 1;
|
|
Py_DECREF(result);
|
|
return truth;
|
|
}
|
|
|
|
static bool has_text(const char* text)
|
|
{
|
|
for (const auto& command : UIRenderCommands())
|
|
if (command.kind == UIRenderCommand::Text && command.text == text)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
static bool near(float a, float b)
|
|
{
|
|
return std::fabs(a - b) < 0.01f;
|
|
}
|
|
|
|
static const UIRenderCommand* image_command(const char* suffix)
|
|
{
|
|
const std::string tail = suffix;
|
|
for (const auto& command : UIRenderCommands())
|
|
if (command.kind == UIRenderCommand::Image && command.quad && command.text.size() >= tail.size() &&
|
|
command.text.compare(command.text.size() - tail.size(), tail.size(), tail) == 0)
|
|
return &command;
|
|
return nullptr;
|
|
}
|
|
|
|
static void frames(int count)
|
|
{
|
|
for (int i = 0; i < count && PythonBoot::IsAppLooping(); ++i)
|
|
PythonBoot::UIUpdate();
|
|
}
|
|
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
const char* strict = std::getenv("MT_ASSETS_STRICT");
|
|
const bool is_strict = strict && std::string(strict) == "1";
|
|
const std::string client = argc > 1 ? argv[1] : "";
|
|
const std::string stdlib = argc > 2 ? argv[2] : PythonHost::DefaultStdLibPath();
|
|
if (client.empty() || chdir(client.c_str()) != 0 || access("pack/Index", 0) != 0)
|
|
{
|
|
std::fprintf(stderr, "port_app_loop_test: no 40250 Client/pack at '%s'\n", client.c_str());
|
|
return is_strict ? 1 : 77;
|
|
}
|
|
|
|
PackSingletons();
|
|
CHECK(PackInitialize("pack"));
|
|
|
|
std::string error;
|
|
CHECK(PythonBoot::Start(stdlib.c_str(), &error));
|
|
if (!error.empty())
|
|
std::fprintf(stderr, "Start: %s\n", error.c_str());
|
|
|
|
// app.Create has no device without a canvas, and RunApp reports it the 40250 way.
|
|
CHECK(!PythonBoot::RunMainScript("", &error));
|
|
CHECK(error.find("Sorry, Your system does not support 3D graphics") != std::string::npos);
|
|
CHECK(!PythonBoot::IsAppLooping());
|
|
PythonBoot::Stop();
|
|
|
|
CHECK(PythonBoot::Start(stdlib.c_str(), &error));
|
|
PythonBoot::SetUISize(800, 600);
|
|
error.clear();
|
|
const bool ran = PythonBoot::RunMainScript("", &error);
|
|
if (!ran)
|
|
std::fprintf(stderr, "RunMainScript: %s\n", error.c_str());
|
|
CHECK(ran);
|
|
CHECK(PythonBoot::IsAppLooping());
|
|
|
|
// The script thread is parked in app.Loop(); the host thread can run Python in between.
|
|
CHECK(py("import __main__, networkModule\n"
|
|
"__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]\n"
|
|
"import introLogo\n"
|
|
"assert isinstance(_stream.curPhaseWindow, introLogo.LogoWindow), _stream.curPhaseWindow"));
|
|
|
|
// The phase switch waits for the curtain fade; give it a bounded number of frames.
|
|
for (int i = 0; i < 600 && PythonBoot::IsAppLooping(); ++i)
|
|
{
|
|
PythonBoot::UIUpdate();
|
|
if (py_true("__import__('introLogin').LoginWindow.__instancecheck__(_stream.curPhaseWindow)"))
|
|
break;
|
|
}
|
|
CHECK(PythonBoot::IsAppLooping());
|
|
CHECK(py("import introLogin\n"
|
|
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow), _stream.curPhaseWindow"));
|
|
|
|
// ThinBoard.BOARD_COLOR is grp.GenerateColor(0, 0, 0, 0.51): the server list board's Bar draws it.
|
|
CHECK(py("import grp\nassert grp.GenerateColor(0.0, 0.0, 0.0, 0.51) & 0xffffffff == 0x82000000"));
|
|
frames(1);
|
|
bool has_board = false;
|
|
for (const auto& command : UIRenderCommands())
|
|
has_board |= command.kind == UIRenderCommand::Bar && command.argb == 0x82000000u;
|
|
CHECK(has_board);
|
|
|
|
// PopupDialog: open with a message, render, close with ESC through the window manager's key path.
|
|
CHECK(py("_stream.popupWindow.Open('POPUP_PROBE')\nassert _stream.popupWindow.IsShow()"));
|
|
frames(1);
|
|
CHECK(has_text("POPUP_PROBE"));
|
|
PythonBoot::UIKey(DIK_ESCAPE, true);
|
|
PythonBoot::UIKey(DIK_ESCAPE, false);
|
|
frames(1);
|
|
CHECK(py("assert not _stream.popupWindow.IsShow()"));
|
|
CHECK(!has_text("POPUP_PROBE"));
|
|
|
|
// ... and with a click on its accept button, which runs the close event. Off-centre: the button's
|
|
// caption is a 0x0 TextLine at its exact centre and CWindow::IsIn is inclusive, so in 40250 too
|
|
// that one pixel picks the caption instead of the button.
|
|
CHECK(py("_popup_closed = []\n"
|
|
"_stream.popupWindow.Open('POPUP_PROBE', lambda: _popup_closed.append(1))\n"
|
|
"_accept = _stream.popupWindow.GetChild('accept')\n"
|
|
"_ax, _ay = _accept.GetGlobalPosition()\n"
|
|
"__main__._accept_point = (_ax + 4, _ay + _accept.GetHeight() // 2)"));
|
|
frames(1);
|
|
PyObject* point = PyDict_GetItemString(PyModule_GetDict(PyImport_AddModule((char*) "__main__")), "_accept_point");
|
|
CHECK(point && PyTuple_Check(point));
|
|
if (point && PyTuple_Check(point))
|
|
{
|
|
const int x = int(PyInt_AsLong(PyTuple_GetItem(point, 0)));
|
|
const int y = int(PyInt_AsLong(PyTuple_GetItem(point, 1)));
|
|
PythonBoot::UIMouseMove(x, y);
|
|
PythonBoot::UIMouseButton(1, true, x, y);
|
|
PythonBoot::UIMouseButton(1, false, x, y);
|
|
}
|
|
frames(1);
|
|
CHECK(py("assert not _stream.popupWindow.IsShow() and _popup_closed == [1]"));
|
|
|
|
// ExpandedImageBox (CGraphicExpandedImageInstance::OnRender): scale + rendering rect, then rotation.
|
|
CHECK(py("import ui\n"
|
|
"_ex = ui.ExpandedImageBox()\n"
|
|
"_ex.LoadImage('d:/ymir work/ui/pattern/board_base.tga')\n"
|
|
"_ex.SetPosition(100, 50)\n"
|
|
"_ex.SetScale(2.0, 1.0)\n"
|
|
"_ex.SetRenderingRect(0.0, 0.0, -0.25, 0.0)\n"
|
|
"_ex.Show()"));
|
|
frames(1);
|
|
const UIRenderCommand* ex = image_command("board_base.tga");
|
|
CHECK(ex);
|
|
if (ex)
|
|
{
|
|
// Unscaled image size from the texture coordinates: eu = (w + right) / w, right = long(w * -0.25).
|
|
const float w = ex->qx[1] - ex->qx[0] + 0.0f; // 2w + right
|
|
const float h = ex->qy[2] - ex->qy[0];
|
|
const float image_w = std::round(w / (1.0f + ex->eu)); // 2w + w*(eu-1) = w*(1+eu)
|
|
CHECK(near(ex->qx[0], 100.0f) && near(ex->qy[0], 50.0f));
|
|
CHECK(near(ex->qx[2], 100.0f) && near(ex->qy[1], 50.0f));
|
|
CHECK(image_w > 0 && near(w, 2 * image_w + long(image_w * -0.25f)));
|
|
CHECK(near(ex->su, 0) && near(ex->sv, 0) && near(ex->ev, 1));
|
|
CHECK(near(ex->eu, (image_w + long(image_w * -0.25f)) / image_w));
|
|
CHECK(h > 0 && near(ex->qy[3], 50.0f + h));
|
|
}
|
|
CHECK(py("_ex.SetRenderingRect(0.0, 0.0, 0.0, 0.0)\n_ex.SetRotation(90.0)"));
|
|
frames(1);
|
|
ex = image_command("board_base.tga");
|
|
CHECK(ex);
|
|
if (ex)
|
|
{
|
|
// Rotated about the origin (the image centre, set on load): the top edge now points down.
|
|
const float cx = (ex->qx[0] + ex->qx[3]) / 2, cy = (ex->qy[0] + ex->qy[3]) / 2;
|
|
const float top_x = ex->qx[1] - ex->qx[0], top_y = ex->qy[1] - ex->qy[0];
|
|
CHECK(near(top_x, 0) && top_y > 0);
|
|
CHECK(near(ex->eu, 1) && near(ex->ev, 1));
|
|
CHECK(py("__main__._ex_origin = (100.0 + _ex.GetWidth() / 2.0 / 2.0, 50.0 + _ex.GetHeight() / 2.0)"));
|
|
PyObject* origin = PyDict_GetItemString(PyModule_GetDict(PyImport_AddModule((char*) "__main__")), "_ex_origin");
|
|
CHECK(origin && near(cx, float(PyFloat_AsDouble(PyTuple_GetItem(origin, 0)))) &&
|
|
near(cy, float(PyFloat_AsDouble(PyTuple_GetItem(origin, 1)))));
|
|
}
|
|
CHECK(py("_ex.Hide()\n_ex = None"));
|
|
|
|
// A .sub draws its parent texture through the sub rect (40250 CGraphicSubImage shares the D3D texture).
|
|
CHECK(py("_sub = ui.ImageBox()\n"
|
|
"_sub.LoadImage('d:/ymir work/ui/public/large_button_01.sub')\n"
|
|
"_sub.SetPosition(10, 10)\n"
|
|
"_sub.Show()"));
|
|
frames(1);
|
|
const UIRenderCommand* sub = nullptr;
|
|
for (const auto& command : UIRenderCommands())
|
|
if (command.kind == UIRenderCommand::Image && near(command.qx[0], 10) && near(command.qy[0], 10))
|
|
sub = &command;
|
|
CHECK(sub);
|
|
if (sub)
|
|
{
|
|
CHECK(sub->text.find(".sub") == std::string::npos && !sub->text.empty());
|
|
CHECK(sub->su >= 0 && sub->eu <= 1 && sub->sv >= 0 && sub->ev <= 1);
|
|
CHECK(sub->eu - sub->su < 1 || sub->ev - sub->sv < 1);
|
|
}
|
|
CHECK(py("_sub.Hide()\n_sub = None"));
|
|
|
|
// app.Exit() is 40250's PostQuitMessage: the next frame leaves Loop() and RunApp() finishes.
|
|
CHECK(py("import app\napp.Exit()"));
|
|
frames(1);
|
|
CHECK(!PythonBoot::IsAppLooping());
|
|
CHECK(py("assert _stream.curPhaseWindow == 0 and _stream.popupWindow == 0"));
|
|
|
|
PythonBoot::Stop();
|
|
CHECK(!PythonBoot::IsRunning());
|
|
|
|
if (g_failures)
|
|
std::fprintf(stderr, "%d check(s) failed\n", g_failures);
|
|
else
|
|
std::printf("port_app_loop_test: ok\n");
|
|
return g_failures ? 1 : 0;
|
|
}
|