diff --git a/audit/history.jsonl b/audit/history.jsonl index 5bc8d311..8ab5c64c 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -505,3 +505,4 @@ {"time": "2026-09-23T02:57:22Z", "event": "port_round", "unit": "PORT-PLAN 2V0-c/d and beginning of 2V0-e", "ported": ["EterPythonLib/PythonWindowManagerModule.cpp", "EterPythonLib/PythonGraphicModule.cpp", "EterPythonLib/PythonGraphicImageModule.cpp", "EterPythonLib/PythonGraphicTextModule.cpp", "EterPythonLib/PythonGraphicThingModule.cpp", "UserInterface/PythonApplicationModule.cpp", "ScriptLib/PythonDebugModule.cpp", "42 UserInterface dependency headers"], "adapted": ["Python pointer handles via PyLong_FromVoidPtr/PyLong_AsVoidPtr (native pointer width)", "Win32 FindFirstFile/FindNextFile shim for app file enumeration", "Godot PythonBoot registers dbg, grp, grpImage, grpText, wndMgr and app and propagates dbg.LogBox failures"], "divergent": ["CPythonApplication platform lifecycle and graphics methods remain MT_PLATFORM_STUB; no visible Logo/Popup yet", "DevIL image and DirectShow logo backends remain MT_PLATFORM_STUB"], "tests": ["cmake --build build -> pass", "ctest --test-dir build --output-on-failure -j6 -> 29/29 passed, 1 skipped", "godot --headless --path project --script python_host_test.gd -> system.py reaches constInfo.py import net and reports ImportError through RunMainScript", "port_map.py check -> 0 errors"], "not_done": "2V0-e native gameplay module stubs and prototype.RunApp; 2V0-f Godot UI renderer/input/Logo/Popup runtime acceptance", "status_note": "The app binding imports, but its CPythonApplication adapter is only a link surface. The 2V0 acceptance gate is not yet met."} {"time": "2026-09-23T03:04:18Z", "event": "port_round", "unit": "PORT-PLAN 2V0-e: native gameplay import stubs through prototype.RunApp", "ported": [], "adapted": ["27 native module import surfaces generated from 40250 UserInterface export names; functions log module.method and return a placeholder zero", "LocaleService default en/1252 from supplied Client/locale.cfg; ForceSetLocale stores requested path", "CPythonApplication::Create platform stub raises CREATE_DEVICE so prototype.RunApp handles failure as original Python code expects"], "divergent": ["Gameplay module methods and constants are placeholders, not ports; their calls do not implement gameplay", "The app platform adapter does not create a device, so RunApp exits before Logo/Popup"], "tests": ["godot --headless --path project --script python_host_test.gd -> PASS: system.py reaches prototype.RunApp, catches CREATE_DEVICE, and net/player/item are marked __port_stub__", "ctest --test-dir build --output-on-failure -j6 -> 29/29 passed, one skipped", "port_map.py check -> 0 errors"], "not_done": "2V0-f: real Godot UI adapter, visible interactive Logo/Popup, input/focus/clipping/text/image tests"} {"time": "2026-09-23T03:27:45Z", "event": "port_round", "unit": "PORT-PLAN 2V0-f: Godot window input and Canvas UI command adapter (partial)", "ported": [], "adapted": ["40250 CWindowManager mouse/key/focus/update bridge into Godot", "CGraphicScreen Bar/Line viewport clip and CGraphicTextInstance text render commands", "CGraphicImage and CGraphicImageInstance image command generation; CPythonResource registration; TGA/DDS/PNG dimensions", "Godot Canvas Control renders native commands and decodes TGA images from 40250 pack", "CGraphicSubImage .sub registration, referenced image dimensions and crop bounds", "CGraphicExpandedImageInstance allocation, type identity and lifetime for script bindings"], "divergent": ["CPythonApplication::Create and Loop are still platform stubs: real system.py cannot show Logo/Popup", "Text metrics, fonts, vertical clipping and non-TGA packed image variants are not verified at parity", "ExpandedImage Canvas rendering still omits original rotation, scale, rendering rectangle and blend mode", "Exploratory monkeypatch past app.Create reaches logo setup but stopping that second RunApp crashes during Python finalization; lifecycle ownership remains unresolved"], "needs_live": [], "tests": ["cmake --build build --target mtgodot -j6: pass", "godot --headless --path project --script python_host_test.gd: pass input/focus/update, bar, clip, text, packed TGA texture, .sub and ExpandedImage create/delete", "ctest --test-dir build --output-on-failure -j6: 29/29 pass, one skipped", "git diff --check: pass"], "not_done": "2V0-f application lifecycle adapter and real visible interactive Logo/Popup; normal app route remains legacy and 2V0 acceptance gate is open", "status_note": "No audit/port-map entry changed. The skill mentions scripts/port_map.py, but that file is absent from this checkout; no port-map check was run."} +{"time": "2026-09-23T04:35:58Z", "event": "port_round", "unit": "PORT-PLAN 2V0-f: real system.py app loop in Godot (complete)", "ported": ["CGraphicExpandedImageInstance::OnRender and SetRenderingRect (40250 verbatim)", "CGraphicImageInstance::OnRender (40250 verbatim)", "CGraphicBase::GetColor / CPythonGraphic::GenerateColor"], "adapted": ["script fiber runs system.py app.Loop; UIUpdate = one CPythonApplication::Process", "image render commands carry base texture name + quad + UV (D3D -0.5 offset compensated)", "PythonBoot Stop clears the launcher before window manager teardown like 40250 Main", "MT_TEST_MODE=python_ui visible mode"], "tests": ["port.app_loop", "python_host_test.gd"], "todo": ["fnt/CGraphicText font pipeline and text alignment", "ExpandedImage blend modes"]} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index ece5de7c..160dceb3 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -400,7 +400,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 | 2V0-c | `wndMgr` / `grp` / `grpImage` / `grpText` / `grpThing` 模块 | 绑定已移植并通过导入/窗口句柄测试;图形绘制仍属 2V0-f | | 2V0-d | 42 个 `UserInterface` 头(都在 `PythonApplication.h` 后面)+ `PythonApplicationModule.cpp`(`app`) | 头与绑定已编译、链接并导入;应用生命周期 adapter 仍属 2V0-f | | 2V0-e | `dbg` 等桩模块补齐,`system.py` 跑到 `prototype.RunApp()` | 完成:27 个玩法 native 模块导出名桩记录每次调用;真实 `system.py` 已进入 `RunApp()`,目前由待实现的 `app.Create()` 报 `CREATE_DEVICE` | - | 2V0-f | Godot 渲染器上的 platform UI adapter;Logo/Popup 可显示可操作 | 进行中:40250 窗口树输入/焦点/更新和 Bar、Line、裁剪、TextLine、ImageBox 命令已接 Godot Canvas;pack TGA、`.sub` 和 ExpandedImage 创建路径在 app 进程内测试。ExpandedImage 的变换绘制仍待实现;`CPythonApplication::Create`/`Loop` 仍是平台桩,真实 `system.py` 尚未显示 Logo/Popup,验收未通过。 | + | 2V0-f | Godot 渲染器上的 platform UI adapter;Logo/Popup 可显示可操作 | 完成:真实 `system.py` 在 app 进程内的脚本纤程上跑 `app.Loop()`(每个 `UIUpdate` = 一次 `CPythonApplication::Process()`),Logo→Login 切换、Popup ESC/点击、`app.Exit` 收尾(按 40250 Main 先 `pyLauncher.Clear()`);图像统一为基贴图 + UV 的四边形命令(`.sub` 共用父贴图名),ExpandedImage 缩放/旋转/RenderingRect 原样移植;`grp.GenerateColor` 已移植(ThinBoard 半透明底)。`port.app_loop` + `python_host_test.gd` 覆盖输入/焦点/裁剪/文字/图像;`MT_TEST_MODE=python_ui` 可视运行。TODO:`.fnt`/`CGraphicText` 字体管线(现用 Godot 回退字体、无对齐)、ExpandedImage 混合模式(SCREEN/COLOR_DODGE/MODULATE)未应用。 | 2. **2V1 — 登录/选角**:加入 `PythonNetworkStream{,PhaseHandShake,PhaseLogin,PhaseSelect,PhaseLoading}.cpp`、 `PythonNetworkStreamModule.cpp`、AccountConnector 及登录/选角脚本真实调用到的模块。验收为 FakeServer 离线完成 phase 顺序, 再以真服 smoke 证明登录、角色列表和选择进入 Loading;未验证的服务器分支记 `NEEDS_LIVE`。 diff --git a/extension/src/platform/EterLib/GrpBase.cpp b/extension/src/platform/EterLib/GrpBase.cpp index 61783f84..39f48275 100644 --- a/extension/src/platform/EterLib/GrpBase.cpp +++ b/extension/src/platform/EterLib/GrpBase.cpp @@ -4,6 +4,7 @@ #include "EterLib/GrpBase.h" #include "../PlatformStub.h" +#include #include "UIRenderCommands.h" auto CGraphicBase::GetAvailableTextureMemory() -> DWORD @@ -171,10 +172,20 @@ auto CGraphicBase::SetScreenEffectFlashing(float, const D3DXCOLOR &) -> void MT_PLATFORM_STUB(); } -auto CGraphicBase::GetColor(float, float, float, float) -> DWORD +// 40250 GrpBase.cpp:458: the bytes B, G, R, A of a little-endian D3DCOLOR. +auto CGraphicBase::GetColor(float r, float g, float b, float a) -> DWORD { - MT_PLATFORM_STUB(); - return mt_platform_stub_return(); + BYTE argb[4] = + { + (BYTE) (255.0f * b), + (BYTE) (255.0f * g), + (BYTE) (255.0f * r), + (BYTE) (255.0f * a) + }; + + DWORD color; + std::memcpy(&color, argb, sizeof(color)); + return color; } auto CGraphicBase::GetFaceCount() -> DWORD diff --git a/extension/src/platform/EterLib/GrpExpandedImageInstance.cpp b/extension/src/platform/EterLib/GrpExpandedImageInstance.cpp index 131babc9..3c432de6 100644 --- a/extension/src/platform/EterLib/GrpExpandedImageInstance.cpp +++ b/extension/src/platform/EterLib/GrpExpandedImageInstance.cpp @@ -5,6 +5,9 @@ #include "../PlatformStub.h" #include "EterBase/CRC32.h" +#include "UIRenderCommands.h" + +#include auto CGraphicExpandedImageInstance::Type() -> DWORD { @@ -19,7 +22,7 @@ CGraphicExpandedImageInstance::CGraphicExpandedImageInstance() CGraphicExpandedImageInstance::~CGraphicExpandedImageInstance() { - MT_PLATFORM_STUB(); + Destroy(); } auto CGraphicExpandedImageInstance::Destroy() -> void @@ -53,10 +56,19 @@ auto CGraphicExpandedImageInstance::SetScale(float x, float y) -> void m_v2Scale = D3DXVECTOR2(x, y); } -auto CGraphicExpandedImageInstance::SetRenderingRect(float left, float top, float right, float bottom) -> void +// In 40250, the arguments are fractions of the image size (uiToolTip gauges, SetPercentage). +auto CGraphicExpandedImageInstance::SetRenderingRect(float fLeft, float fTop, float fRight, float fBottom) -> void { - m_RenderingRect = {static_cast(left), static_cast(top), - static_cast(right), static_cast(bottom)}; + if (IsEmpty()) + return; + + float fWidth = float(GetWidth()); + float fHeight = float(GetHeight()); + + m_RenderingRect.left = fWidth * fLeft; + m_RenderingRect.top = fHeight * fTop; + m_RenderingRect.right = fWidth * fRight; + m_RenderingRect.bottom = fHeight * fBottom; } auto CGraphicExpandedImageInstance::SetRenderingMode(int mode) -> void @@ -74,9 +86,68 @@ auto CGraphicExpandedImageInstance::Initialize() -> void m_iRenderingMode = RENDERING_MODE_NORMAL; } +// 40250 GrpExpandedImageInstance.cpp:29-142. The vertex and texture-coordinate arithmetic is +// verbatim; the D3D stream/blend-state calls become one UIRenderCommand with the four vertices. auto CGraphicExpandedImageInstance::OnRender() -> void { - CGraphicImageInstance::OnRender(); + CGraphicImage * pImage = m_roImage.GetPointer(); + CGraphicTexture * pTexture = pImage->GetTexturePointer(); + if (pTexture->GetWidth() <= 0 || pTexture->GetHeight() <= 0) + return; + + const RECT& c_rRect = pImage->GetRectReference(); + float texReverseWidth = 1.0f / float(pTexture->GetWidth()); + float texReverseHeight = 1.0f / float(pTexture->GetHeight()); + float su = (c_rRect.left - m_RenderingRect.left) * texReverseWidth; + float sv = (c_rRect.top - m_RenderingRect.top) * texReverseHeight; + float eu = (c_rRect.left + m_RenderingRect.right + (c_rRect.right-c_rRect.left)) * texReverseWidth; + float ev = (c_rRect.top + m_RenderingRect.bottom + (c_rRect.bottom-c_rRect.top)) * texReverseHeight; + + float x[4], y[4]; + for (int i = 0; i < 4; ++i) + { + x[i] = m_v2Position.x-0.5f; + y[i] = m_v2Position.y-0.5f; + } + + if (0.0f == m_fRotation) + { + float fimgWidth = float(pImage->GetWidth()) * m_v2Scale.x; + float fimgHeight = float(pImage->GetHeight()) * m_v2Scale.y; + + x[0] -= m_RenderingRect.left; + y[0] -= m_RenderingRect.top; + x[1] += fimgWidth + m_RenderingRect.right; + y[1] -= m_RenderingRect.top; + x[2] -= m_RenderingRect.left; + y[2] += fimgHeight + m_RenderingRect.bottom; + x[3] += fimgWidth + m_RenderingRect.right; + y[3] += fimgHeight + m_RenderingRect.bottom; + // PORT: 40250 flips D3DRS_CULLMODE for a mirrored scale; the Godot canvas has no culling. + } + else + { + float fimgHalfWidth = float(pImage->GetWidth())/2.0f * m_v2Scale.x; + float fimgHalfHeight = float(pImage->GetHeight())/2.0f * m_v2Scale.y; + + for (int i = 0; i < 4; ++i) + { + x[i] += m_v2Origin.x; + y[i] += m_v2Origin.y; + } + + float fRadian = D3DXToRadian(m_fRotation); + x[0] += (-fimgHalfWidth*cosf(fRadian)) - (-fimgHalfHeight*sinf(fRadian)); + y[0] += (-fimgHalfWidth*sinf(fRadian)) + (-fimgHalfHeight*cosf(fRadian)); + x[1] += (+fimgHalfWidth*cosf(fRadian)) - (-fimgHalfHeight*sinf(fRadian)); + y[1] += (+fimgHalfWidth*sinf(fRadian)) + (-fimgHalfHeight*cosf(fRadian)); + x[2] += (-fimgHalfWidth*cosf(fRadian)) - (+fimgHalfHeight*sinf(fRadian)); + y[2] += (-fimgHalfWidth*sinf(fRadian)) + (+fimgHalfHeight*cosf(fRadian)); + x[3] += (+fimgHalfWidth*cosf(fRadian)) - (+fimgHalfHeight*sinf(fRadian)); + y[3] += (+fimgHalfWidth*sinf(fRadian)) + (+fimgHalfHeight*cosf(fRadian)); + } + + UIRenderAddImage(pTexture, x, y, su, sv, eu, ev, UIRenderColor(m_DiffuseColor), m_iRenderingMode); } auto CGraphicExpandedImageInstance::OnSetImagePointer() -> void diff --git a/extension/src/platform/EterLib/GrpImageInstance.cpp b/extension/src/platform/EterLib/GrpImageInstance.cpp index 10513b64..b6c7a14c 100644 --- a/extension/src/platform/EterLib/GrpImageInstance.cpp +++ b/extension/src/platform/EterLib/GrpImageInstance.cpp @@ -6,7 +6,6 @@ #include "../PlatformStub.h" #include "UIRenderCommands.h" #include "EterBase/CRC32.h" -#include auto CGraphicImageInstance::Type() -> DWORD { @@ -27,12 +26,15 @@ CGraphicImageInstance::CGraphicImageInstance() CGraphicImageInstance::~CGraphicImageInstance() { - MT_PLATFORM_STUB(); + Destroy(); } +// 40250 Destroy(): drop the image reference, then Initialize() (white, at the origin). auto CGraphicImageInstance::Destroy() -> void { m_roImage.Clear(); + m_DiffuseColor = D3DXCOLOR(1, 1, 1, 1); + m_v2Position = D3DXVECTOR2(0, 0); } auto CGraphicImageInstance::Render() -> void @@ -78,14 +80,13 @@ auto CGraphicImageInstance::GetHeight() -> int auto CGraphicImageInstance::GetTexturePointer() -> CGraphicTexture * { - MT_PLATFORM_STUB(); - return mt_platform_stub_return(); + CGraphicImage* pkImage = m_roImage.GetPointer(); + return pkImage ? pkImage->GetTexturePointer() : NULL; } auto CGraphicImageInstance::GetTextureReference() const -> const CGraphicTexture & { - MT_PLATFORM_STUB(); - return mt_platform_stub_return(); + return m_roImage->GetTextureReference(); } auto CGraphicImageInstance::GetGraphicImagePointer() -> CGraphicImage * @@ -104,20 +105,31 @@ auto CGraphicImageInstance::Initialize() -> void MT_PLATFORM_STUB(); } +// 40250 GrpImageInstance.cpp:44-96: the image's rect inside its texture (a .sub shares its parent's) +// becomes the texture coordinates of one quad. auto CGraphicImageInstance::OnRender() -> void { - CGraphicImage* image = m_roImage.GetPointer(); - if (!image) return; - auto channel = [](float value) -> std::uint32_t { - return static_cast(std::clamp(value, 0.0f, 1.0f) * 255.0f + 0.5f); - }; - const std::uint32_t color = (channel(m_DiffuseColor.a) << 24) | - (channel(m_DiffuseColor.r) << 16) | (channel(m_DiffuseColor.g) << 8) | - channel(m_DiffuseColor.b); - UIRenderCommand command{UIRenderCommand::Image, m_v2Position.x, m_v2Position.y, - m_v2Position.x + image->GetWidth(), m_v2Position.y + image->GetHeight(), color}; - command.text = image->GetFileName(); - UIRenderAdd(std::move(command)); + CGraphicImage * pImage = m_roImage.GetPointer(); + CGraphicTexture * pTexture = pImage->GetTexturePointer(); + if (pTexture->GetWidth() <= 0 || pTexture->GetHeight() <= 0) + return; + + float fimgWidth = pImage->GetWidth(); + float fimgHeight = pImage->GetHeight(); + + const RECT& c_rRect = pImage->GetRectReference(); + float texReverseWidth = 1.0f / float(pTexture->GetWidth()); + float texReverseHeight = 1.0f / float(pTexture->GetHeight()); + float su = c_rRect.left * texReverseWidth; + float sv = c_rRect.top * texReverseHeight; + float eu = (c_rRect.left + (c_rRect.right-c_rRect.left)) * texReverseWidth; + float ev = (c_rRect.top + (c_rRect.bottom-c_rRect.top)) * texReverseHeight; + + const float x[4] = {m_v2Position.x-0.5f, m_v2Position.x + fimgWidth-0.5f, + m_v2Position.x-0.5f, m_v2Position.x + fimgWidth-0.5f}; + const float y[4] = {m_v2Position.y-0.5f, m_v2Position.y-0.5f, + m_v2Position.y + fimgHeight-0.5f, m_v2Position.y + fimgHeight-0.5f}; + UIRenderAddImage(pTexture, x, y, su, sv, eu, ev, UIRenderColor(m_DiffuseColor)); } auto CGraphicImageInstance::OnSetImagePointer() -> void diff --git a/extension/src/platform/EterLib/GrpImageTexture.cpp b/extension/src/platform/EterLib/GrpImageTexture.cpp index 65d21297..e9b4a918 100644 --- a/extension/src/platform/EterLib/GrpImageTexture.cpp +++ b/extension/src/platform/EterLib/GrpImageTexture.cpp @@ -9,7 +9,33 @@ namespace { unsigned little16(const unsigned char* data) { return data[0] | (unsigned(data[1]) << 8); } unsigned little32(const unsigned char* data) { return little16(data) | (little16(data + 2) << 16); } +unsigned big16(const unsigned char* data) { return (unsigned(data[0]) << 8) | data[1]; } unsigned big32(const unsigned char* data) { return (unsigned(data[0]) << 24) | (unsigned(data[1]) << 16) | (unsigned(data[2]) << 8) | data[3]; } + +// Walks the JPEG markers to the first SOFn (the frame header holds the size), as D3DX does when it +// sniffs a .jpg; login/loading backgrounds (serverlist.jpg, loading/*.jpg) are JPEGs. +bool jpeg_size(const unsigned char* data, unsigned size, unsigned* width, unsigned* height) +{ + if (size < 4 || data[0] != 0xFF || data[1] != 0xD8) return false; + unsigned pos = 2; + while (pos + 4 <= size) { + if (data[pos] != 0xFF) return false; + const unsigned char marker = data[pos + 1]; + if (marker == 0xFF) { ++pos; continue; } + if (marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { pos += 2; continue; } + const unsigned length = big16(data + pos + 2); + if (length < 2) return false; + const bool sof = marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC; + if (sof) { + if (pos + 9 > size) return false; + *height = big16(data + pos + 5); + *width = big16(data + pos + 7); + return true; + } + pos += 2 + length; + } + return false; +} } CGraphicImageTexture::CGraphicImageTexture() @@ -45,6 +71,9 @@ auto CGraphicImageTexture::CreateFromTexturePointer(const CGraphicTexture *sourc m_width = source->GetWidth(); m_height = source->GetHeight(); m_bEmpty = source->IsEmpty(); + // 40250 shares the source's D3D texture; the canvas names textures by pack path instead. + if (const auto* image = dynamic_cast(source)) + m_stFileName = image->m_stFileName; } auto CGraphicImageTexture::CreateFromDiskFile(const char *, D3DFORMAT, DWORD) -> bool @@ -62,6 +91,11 @@ auto CGraphicImageTexture::CreateFromMemoryFile(UINT size, const void *bytes, D3 height = little32(data + 12); width = little32(data + 16); } else if (size >= 24 && std::memcmp(data, "\x89PNG\r\n\x1a\n", 8) == 0) { width = big32(data + 16); height = big32(data + 20); + } else if (jpeg_size(data, size, &width, &height)) { + } else if (size >= 26 && data[0] == 'B' && data[1] == 'M') { + width = little32(data + 18); + const int bmp_height = static_cast(little32(data + 22)); // negative: top-down rows + height = static_cast(bmp_height < 0 ? -bmp_height : bmp_height); } else if (size >= 18 && (data[2] == 2 || data[2] == 10 || data[2] == 3)) { width = little16(data + 12); height = little16(data + 14); } @@ -82,6 +116,17 @@ auto CGraphicImageTexture::SetFileName(const char *name) -> void m_stFileName = name ? name : ""; } +// PORT: where 40250 binds pTexture->GetD3DTexture(), the canvas gets the pack path of the file the +// texture was decoded from (m_stFileName is protected, hence the derived accessor). +std::string UIRenderTextureName(const CGraphicTexture* texture) +{ + struct Access : CGraphicImageTexture { + static const std::string& name(const CGraphicImageTexture* t) { return t->*(&Access::m_stFileName); } + }; + const auto* image = dynamic_cast(texture); + return image ? Access::name(image) : std::string(); +} + auto CGraphicImageTexture::Lock(int *, void **, int) -> bool { MT_PLATFORM_STUB(); diff --git a/extension/src/platform/EterLib/UIRenderCommands.cpp b/extension/src/platform/EterLib/UIRenderCommands.cpp index 8b827b5d..50da08c6 100644 --- a/extension/src/platform/EterLib/UIRenderCommands.cpp +++ b/extension/src/platform/EterLib/UIRenderCommands.cpp @@ -1,5 +1,7 @@ #include "UIRenderCommands.h" +#include + namespace { std::vector commands; int canvas_width = 0; @@ -34,3 +36,21 @@ void UIRenderRestoreClip() { clip_x1 = saved_x1; clip_y1 = saved_y1; clip_x2 = saved_x2; clip_y2 = saved_y2; } + +void UIRenderAddImage(const CGraphicTexture* texture, const float x[4], const float y[4], + float su, float sv, float eu, float ev, std::uint32_t argb, int blend) { + UIRenderCommand command{UIRenderCommand::Image, 0, 0, 0, 0, argb}; + for (int i = 0; i < 4; ++i) { + command.qx[i] = x[i] + 0.5f; + command.qy[i] = y[i] + 0.5f; + } + command.x1 = std::min({command.qx[0], command.qx[1], command.qx[2], command.qx[3]}); + command.y1 = std::min({command.qy[0], command.qy[1], command.qy[2], command.qy[3]}); + command.x2 = std::max({command.qx[0], command.qx[1], command.qx[2], command.qx[3]}); + command.y2 = std::max({command.qy[0], command.qy[1], command.qy[2], command.qy[3]}); + command.text = UIRenderTextureName(texture); + command.quad = true; + command.su = su; command.sv = sv; command.eu = eu; command.ev = ev; + command.blend = blend; + UIRenderAdd(std::move(command)); +} diff --git a/extension/src/platform/EterLib/UIRenderCommands.h b/extension/src/platform/EterLib/UIRenderCommands.h index eaee5586..457dc72c 100644 --- a/extension/src/platform/EterLib/UIRenderCommands.h +++ b/extension/src/platform/EterLib/UIRenderCommands.h @@ -12,8 +12,32 @@ struct UIRenderCommand { std::uint32_t argb; float clip_x1 = 0, clip_y1 = 0, clip_x2 = 0, clip_y2 = 0; std::string text; + // Image only: text is the texture's file, drawn as the quad (vertices 0..3 = TL, TR, BL, BR) + // with texture coordinates su,sv..eu,ev; x1..y2 is the quad's bounding box. + bool quad = false; + float qx[4] = {}, qy[4] = {}; + float su = 0, sv = 0, eu = 1, ev = 1; + int blend = 0; // CGraphicExpandedImageInstance::RENDERING_MODE_* }; +// D3DXCOLOR (0..1 floats) -> the 0xAARRGGBB the commands carry. +template +std::uint32_t UIRenderColor(const Color& color) { + auto channel = [](float value) -> std::uint32_t { + return static_cast((value < 0 ? 0 : value > 1 ? 1 : value) * 255.0f + 0.5f); + }; + return (channel(color.a) << 24) | (channel(color.r) << 16) | (channel(color.g) << 8) | channel(color.b); +} + +class CGraphicTexture; +std::string UIRenderTextureName(const CGraphicTexture* texture); + +// A textured quad from 40250's TPDTVertex[4] (TL, TR, BL, BR) positions and texture coordinates. +// PORT: D3D8 puts pixel centres on integers, which is why 40250 subtracts 0.5 from every vertex; the +// Godot canvas puts them on .5, so the quad is shifted back by +0.5 here. +void UIRenderAddImage(const CGraphicTexture* texture, const float x[4], const float y[4], + float su, float sv, float eu, float ev, std::uint32_t argb, int blend = 0); + void UIRenderSetSize(int width, int height); void UIRenderGetSize(unsigned* width, unsigned* height); void UIRenderBeginFrame(); diff --git a/extension/src/platform/EterPythonLib/PythonGraphic.cpp b/extension/src/platform/EterPythonLib/PythonGraphic.cpp index 7778f1c0..ff849dd9 100644 --- a/extension/src/platform/EterPythonLib/PythonGraphic.cpp +++ b/extension/src/platform/EterPythonLib/PythonGraphic.cpp @@ -15,7 +15,7 @@ void CPythonGraphic::SetViewport(float x, float y, float width, float height) { UIRenderSetClip(x, y, width, height); } void CPythonGraphic::RestoreViewport() { UIRenderRestoreClip(); } void CPythonGraphic::SetOmniLight() { MT_PLATFORM_STUB(); } -long CPythonGraphic::GenerateColor(float, float, float, float) { MT_PLATFORM_STUB(); return 0; } +long CPythonGraphic::GenerateColor(float r, float g, float b, float a) { return GetColor(r, g, b, a); } void CPythonGraphic::RenderUpButton(float, float, float, float) { MT_PLATFORM_STUB(); } void CPythonGraphic::RenderDownButton(float, float, float, float) { MT_PLATFORM_STUB(); } bool CPythonGraphic::SaveScreenShot(const char*) { MT_PLATFORM_STUB(); return false; } diff --git a/extension/src/platform/ScriptLib/GameplayModules.cpp b/extension/src/platform/ScriptLib/GameplayModules.cpp index bb809b91..e5663f31 100644 --- a/extension/src/platform/ScriptLib/GameplayModules.cpp +++ b/extension/src/platform/ScriptLib/GameplayModules.cpp @@ -2,6 +2,7 @@ // These modules expose names for script loading; calls record that they still need the real 2V1-2V3 implementation. #include "ScriptLib/StdAfx.h" #include +#include "../EterLib/UIRenderCommands.h" namespace { PyObject* stub_call_named(const char* name) { @@ -1629,8 +1630,19 @@ PyMethodDef methods_snd[] = { const char* ints_snd[] = {NULL}; const char* strings_snd[] = {NULL}; // 40250 UserInterface/PythonSystemModule.cpp: systemSetting export names. -PyObject* stub_systemSetting_GetWidth(PyObject*, PyObject*) { return stub_call_named("systemSetting.GetWidth"); } -PyObject* stub_systemSetting_GetHeight(PyObject*, PyObject*) { return stub_call_named("systemSetting.GetHeight"); } +// 40250 CPythonSystem::GetWidth/GetHeight return metin2.cfg's WIDTH/HEIGHT (default 800x600), which +// RunApp passes to wndMgr.SetScreenSize and app.Create. PORT (2V0-f): the Godot canvas is the window, +// so report its size; the rest of systemSetting stays a stub until CPythonSystem is ported. +PyObject* stub_systemSetting_GetWidth(PyObject*, PyObject*) { + unsigned width = 0; + UIRenderGetSize(&width, nullptr); + return Py_BuildValue("i", width ? int(width) : 800); +} +PyObject* stub_systemSetting_GetHeight(PyObject*, PyObject*) { + unsigned height = 0; + UIRenderGetSize(nullptr, &height); + return Py_BuildValue("i", height ? int(height) : 600); +} PyObject* stub_systemSetting_SetInterfaceHandler(PyObject*, PyObject*) { return stub_call_named("systemSetting.SetInterfaceHandler"); } PyObject* stub_systemSetting_DestroyInterfaceHandler(PyObject*, PyObject*) { return stub_call_named("systemSetting.DestroyInterfaceHandler"); } PyObject* stub_systemSetting_ReserveResource(PyObject*, PyObject*) { return stub_call_named("systemSetting.ReserveResource"); } diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index 1e870217..dbcf0079 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -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 #include +#include +#include namespace { @@ -18,6 +22,8 @@ namespace std::unique_ptr g_launcher; std::unique_ptr g_window_manager; std::unique_ptr g_resource; +// 40250 CPythonApplication::m_timer; CSlotWindow's cool time and Process() read it via Instance(). +std::unique_ptr 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 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 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 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(); // 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(); g_resource = std::make_unique(); g_window_manager = std::make_unique(); 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 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(); } diff --git a/extension/src/platform/ScriptLib/PythonBoot.h b/extension/src/platform/ScriptLib/PythonBoot.h index df5ec836..9824f9e6 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.h +++ b/extension/src/platform/ScriptLib/PythonBoot.h @@ -29,7 +29,18 @@ bool IsRunning(); // __COMMAND_LINE__ and RunFile("system.py"). 2V0-e registers temporary observable gameplay modules; // they are replaced by the real modules in later 2V slices. // False leaves *error set to what the launcher reported through LogBox (platform/EterBase/LogBox.h). +// +// PORT: system.py runs on a script thread. RunMainScript returns once prototype.RunApp() has reached +// app.Loop() (IsAppLooping) or the script has ended; from then on every AppFrame() runs exactly one +// CPythonApplication::Process() while the host thread waits, so the two never run concurrently. bool RunMainScript(const char* lpCmdLine, std::string* error); +bool IsAppLooping(); +bool AppFrame(); + +// Used by the platform CPythonApplication: Loop() calls AppLoopWait() before each Process() and stops +// when it returns false; Exit()/Abort() call AppRequestQuit() (40250: PostQuitMessage). +bool AppLoopWait(); +void AppRequestQuit(); // 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 @@ -42,6 +53,7 @@ void SetUISize(int width, int height); void UIMouseMove(int x, int y); void UIMouseButton(int button, bool pressed, int x, int y); void UIKey(int key, bool pressed); +// While IsAppLooping(), UIUpdate is AppFrame() and UIRender keeps the commands that Process() drew. void UIUpdate(); void UIRender(); diff --git a/extension/src/platform/UserInterface/PythonApplication.cpp b/extension/src/platform/UserInterface/PythonApplication.cpp index 1031d73a..6c48a8de 100644 --- a/extension/src/platform/UserInterface/PythonApplication.cpp +++ b/extension/src/platform/UserInterface/PythonApplication.cpp @@ -3,9 +3,18 @@ #include "UserInterface/MarkManager.h" #include "GameLib/GameUtil.h" #include "EterLib/Camera.h" +#include "EterBase/Timer.h" +#include "EterLib/ResourceManager.h" #include "../PlatformStub.h" +#include "../EterLib/UIRenderCommands.h" +#include "../ScriptLib/PythonBoot.h" -// 2V0-d link surface. The application event loop and UI rendering are implemented in 2V0-f. +// 2V0-d link surface; 2V0-f adds the application lifecycle (Create/Loop/Process/Exit) that +// prototype.RunApp() drives. The rest of the members still wait for the game slices (2V1-2V3). +// +// PORT: there is no CPythonApplication object yet (its members are the whole game: background, +// network stream, player, ...), so these functions only use the singletons PythonBoot owns +// (UI::CWindowManager, CTimer, CResourceManager) and never touch a data member. CPythonApplication* CPythonApplication::ms_pInstance = nullptr; void CPythonApplication::ShowWebPage(const char*, const RECT&) { MT_PLATFORM_STUB(); } @@ -13,21 +22,84 @@ void CPythonApplication::MoveWebPage(const RECT&) { MT_PLATFORM_STUB(); } void CPythonApplication::HideWebPage() { MT_PLATFORM_STUB(); } bool CPythonApplication::IsWebPageMode() { MT_PLATFORM_STUB(); return false; } void CPythonApplication::GetInfo(UINT, std::string* out) { MT_PLATFORM_STUB(); if (out) out->clear(); } -void CPythonApplication::Loop() { MT_PLATFORM_STUB(); } -void CPythonApplication::Exit() { MT_PLATFORM_STUB(); } -void CPythonApplication::Abort() { MT_PLATFORM_STUB(); } +// In 40250, Loop() is: while (1) { if (IsMessage()) { if (!MessageProcess()) break; } else { if (!Process()) break; } } +// PORT: Godot owns the message pump and forwards input to the window manager between frames +// (PythonBoot::UIMouseMove & co.), so the loop here only waits for the host's next frame. It runs +// on PythonBoot's script thread; WM_QUIT is PythonBoot::AppRequestQuit. +void CPythonApplication::Loop() +{ + while (PythonBoot::AppLoopWait()) + { + if (!Process()) + break; + } +} + +// In 40250 this is PostQuitMessage(0). +void CPythonApplication::Exit() { PythonBoot::AppRequestQuit(); } + +// 40250 also writes a TraceError banner first; the caller (system.py) has already logged the reason. +void CPythonApplication::Abort() +{ + TraceError("============================================================================================================"); + TraceError("Abort!!!!\n\n"); + PythonBoot::AppRequestQuit(); +} void CPythonApplication::SetMinFog(float) { MT_PLATFORM_STUB(); } void CPythonApplication::SetFrameSkip(bool) { MT_PLATFORM_STUB(); } -bool CPythonApplication::Create(PyObject*, const char*, int, int, int) { - MT_PLATFORM_STUB(); - // appCreate returns NULL on failure. Give Python the exception that - // prototype.RunApp expects rather than a SystemError with no exception set. - PyErr_SetString(PyExc_RuntimeError, "CREATE_DEVICE"); - return false; +// PORT: 40250 creates the Win32 window, the D3D8 device, cursors, sound, keyboard, the net device, +// fonts and the IME here. The Godot window and its canvas are the device on this platform and they +// already exist when the host starts the script, so the window size is the canvas size +// (PythonBoot::SetUISize) rather than the width/height passed from metin2.cfg. What remains of +// Create belongs to the subsystems that are not ported yet (sound 2V4, net 2V1, game render 2V3). +bool CPythonApplication::Create(PyObject*, const char*, int, int, int) +{ + unsigned width = 0, height = 0; + UIRenderGetSize(&width, &height); + if (!width || !height) + { + // appCreate returns NULL on failure; RunApp turns this into the original LogBox. + TraceError("CreateDevice: GraphicDevice create failed"); + PyErr_SetString(PyExc_RuntimeError, "CREATE_DEVICE"); + return false; + } + return true; } void CPythonApplication::UpdateGame() { MT_PLATFORM_STUB(); } void CPythonApplication::RenderGame() { MT_PLATFORM_STUB(); } -bool CPythonApplication::Process() { MT_PLATFORM_STUB(); return false; } + +// 40250 PythonApplication.cpp:399. Kept in order: timer, network (2V1), input polling (Godot pushes +// input instead), camera (2V3), resource GC, mouse/UI update, then the render block's interface pass. +// PORT: the frame-skip bookkeeping and the game render pass are left out; the Godot frame clock +// decides when this runs, and RenderGame belongs to 2V3. +bool CPythonApplication::Process() +{ + ELTimer_SetFrameMSec(); + + CTimer& rkTimer=CTimer::Instance(); + rkTimer.Advance(); + + CResourceManager::Instance().Update(); + + OnUIUpdate(); + + UIRenderBeginFrame(); + OnUIRender(); + return true; +} + +// 40250 PythonApplicationEvent.cpp:15-25 +void CPythonApplication::OnUIUpdate() +{ + UI::CWindowManager& rkUIMgr=UI::CWindowManager::Instance(); + rkUIMgr.Update(); +} + +void CPythonApplication::OnUIRender() +{ + UI::CWindowManager& rkUIMgr=UI::CWindowManager::Instance(); + rkUIMgr.Render(); +} BOOL CPythonApplication::SetCursorNum(int) { MT_PLATFORM_STUB(); return FALSE; } void CPythonApplication::SetCursorVisible(BOOL, bool) { MT_PLATFORM_STUB(); } BOOL CPythonApplication::GetCursorVisible() { MT_PLATFORM_STUB(); return FALSE; } diff --git a/extension/src/port/CMakeLists.txt b/extension/src/port/CMakeLists.txt index 69d8a72b..1463a426 100644 --- a/extension/src/port/CMakeLists.txt +++ b/extension/src/port/CMakeLists.txt @@ -181,6 +181,14 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) COMMAND $ ${MT_PYTHON_STDLIB_ZIP}) set_tests_properties(port.window_tree PROPERTIES SKIP_RETURN_CODE 77) + # 批次 2V0-f: the real system.py through app.Loop() on PythonBoot's script thread; needs the pack. + add_executable(port_app_loop_test ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_app_loop_test.cpp) + target_link_libraries(port_app_loop_test PRIVATE port_platform) + add_dependencies(port_app_loop_test mtpython_stdlib) + add_test(NAME port.app_loop + COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP}) + set_tests_properties(port.app_loop PROPERTIES SKIP_RETURN_CODE 77) + add_executable(port_python_ui_modules_test ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_python_ui_modules_test.cpp) target_link_libraries(port_python_ui_modules_test PRIVATE port_platform) add_dependencies(port_python_ui_modules_test mtpython_stdlib) diff --git a/extension/src/python_host_node.cpp b/extension/src/python_host_node.cpp index d926be9a..21421db7 100644 --- a/extension/src/python_host_node.cpp +++ b/extension/src/python_host_node.cpp @@ -55,6 +55,8 @@ String Metin2PythonHost::run_line(const String &source) { return String(); } +bool Metin2PythonHost::is_app_looping() { return PythonBoot::IsAppLooping(); } + void Metin2PythonHost::set_ui_size(int width, int height) { PythonBoot::SetUISize(width, height); } void Metin2PythonHost::ui_mouse_move(int x, int y) { PythonBoot::UIMouseMove(x, y); } void Metin2PythonHost::ui_mouse_button(int button, bool pressed, int x, int y) { @@ -81,6 +83,15 @@ Array Metin2PythonHost::ui_render_commands() { item["clip_y2"] = command.clip_y2; if (command.kind == UIRenderCommand::Text || command.kind == UIRenderCommand::Image) item["text"] = String::utf8(command.text.c_str()); + if (command.quad) { + PackedVector2Array quad; + for (int i = 0; i < 4; ++i) + quad.push_back(Vector2(command.qx[i], command.qy[i])); + item["quad"] = quad; + item["uv"] = PackedVector2Array({Vector2(command.su, command.sv), Vector2(command.eu, command.sv), + Vector2(command.su, command.ev), Vector2(command.eu, command.ev)}); + item["blend"] = command.blend; + } out.push_back(item); } return out; @@ -110,6 +121,7 @@ String Metin2PythonHost::run_line(const String &) { return String("this build has no embedded Python (-DMTGODOT_EMBED_PYTHON=ON)"); } +bool Metin2PythonHost::is_app_looping() { return false; } void Metin2PythonHost::set_ui_size(int, int) {} void Metin2PythonHost::ui_mouse_move(int, int) {} void Metin2PythonHost::ui_mouse_button(int, bool, int, int) {} @@ -129,6 +141,7 @@ void Metin2PythonHost::_bind_methods() { 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("is_app_looping"), &Metin2PythonHost::is_app_looping); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("set_ui_size", "width", "height"), &Metin2PythonHost::set_ui_size); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_move", "x", "y"), &Metin2PythonHost::ui_mouse_move); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_mouse_button", "button", "pressed", "x", "y"), &Metin2PythonHost::ui_mouse_button); diff --git a/extension/src/python_host_node.h b/extension/src/python_host_node.h index 0cd72965..5c311bc7 100644 --- a/extension/src/python_host_node.h +++ b/extension/src/python_host_node.h @@ -30,6 +30,8 @@ public: 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); + // True while system.py is inside app.Loop(); ui_update() then runs one CPythonApplication::Process(). + static bool is_app_looping(); static void set_ui_size(int width, int height); static void ui_mouse_move(int x, int y); static void ui_mouse_button(int button, bool pressed, int x, int y); diff --git a/extension/tests/port_app_loop_test.cpp b/extension/tests/port_app_loop_test.cpp new file mode 100644 index 00000000..5f285de9 --- /dev/null +++ b/extension/tests/port_app_loop_test.cpp @@ -0,0 +1,262 @@ +// 批次 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> +// +// 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 // DIK_ESCAPE + +#include +#include +#include +#include + +#include + +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; +} diff --git a/project/client_main.gd b/project/client_main.gd index 6dac593f..97c2241b 100644 --- a/project/client_main.gd +++ b/project/client_main.gd @@ -35,6 +35,18 @@ func _ready() -> void: printerr("%d check(s) failed" % fail) get_tree().quit(1 if fail else 0) return + # MT_TEST_MODE=python_ui:40250 system.py 的界面直接画在 Godot Canvas 上(PORT-PLAN 2V0-f), + # Logo → 登录阶段,可用鼠标键盘操作;app.Exit() 退出进程。 + if OS.get_environment("MT_TEST_MODE") == "python_ui": + AssetPack.ensure() + var surface: Control = preload("res://python_ui_surface.gd").new() + surface.name = "PythonUISurface" + add_child(surface) + var err: String = surface.run_app() + if err != "": + printerr("python_ui: " + err) + get_tree().quit(1) + 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 index a1d276bb..2ccaa678 100644 --- a/project/python_host_check.gd +++ b/project/python_host_check.gd @@ -3,7 +3,8 @@ # 和 ctest 的 `port.python_launcher` 跑的是同一条序列,区别只有一个:那边是独立可执行文件,这边在 # Godot 进程里,由 GDScript 发起 —— step 4 要验的就是这个差别。2V0 把 C++ 模块注册上之前, # 2V0-e 的玩法桩让 system.py 真正走到 prototype.RunApp(); -# 2V0-f 的应用 adapter 尚未创建图形设备,RunApp 捕获 CREATE_DEVICE 并给出原版提示。 +# 没设画布尺寸时 app.Create 没有设备,RunApp 捕获 CREATE_DEVICE 并给出原版提示;设了尺寸以后 +# system.py 停在 app.Loop() 里,由 ui_update 逐帧推进(_run_app_loop)。 # # 真机上通常没有 40250 的 Client/pack(APK 里只有 python27.zip),所以分两段:解释器 + 标准库这一段 # 不需要 pack,任何平台都跑;system.py 这一段需要 pack,没有就跳过并说清楚。 @@ -119,6 +120,16 @@ wndMgr.Show(_ui_clip) var segment: PackedVector2Array = surface._clip_line(Vector2(-10, 10), Vector2(50, 10), Rect2(0, 0, 30, 30)) _ck(segment.size() == 2 and segment[0] == Vector2(0, 10) and segment[1] == Vector2(30, 10), "Godot Canvas 线段裁剪保留可见区间") + # ExpandedImage 的四边形(TL, TR, BL, BR)被 viewport 裁掉右半边,UV 随位置按仿射关系重算。 + var quad := PackedVector2Array([Vector2(0, 0), Vector2(20, 0), Vector2(0, 10), Vector2(20, 10)]) + var quad_uv := PackedVector2Array([Vector2(0, 0), Vector2(1, 0), Vector2(0, 1), Vector2(1, 1)]) + var clipped: Array = surface._clip_quad(quad, quad_uv, Rect2(0, 0, 10, 10)) + var max_u := 0.0 + for i in clipped[1].size(): + max_u = maxf(max_u, clipped[1][i].x) + if not is_equal_approx(clipped[1][i].x, clipped[0][i].x / 20.0): + max_u = -1.0 + _ck(clipped[0].size() >= 3 and is_equal_approx(max_u, 0.5), "ExpandedImage 四边形裁剪后 UV 同步收缩") surface.free() _ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_bar)") == "", "Bar 窗口已销毁") _ck(Metin2PythonHost.run_line("grp.RestoreViewport(); wndMgr.Destroy(_ui_clip)") == "", "裁剪窗口已销毁") @@ -159,4 +170,39 @@ wndMgr.Show(_ui_clip) Metin2PythonHost.stop() _ck(not Metin2PythonHost.is_running(), "stop() 之后 is_running() 为假") + if has_pack: + _run_app_loop() return fail + +# 2V0-f:有画布尺寸时 app.Create 成功,system.py 停在 app.Loop() 里(脚本线程),每次 ui_update 就是 +# 一次 CPythonApplication::Process()。Logo 阶段 → LoginWindow → 弹窗 ESC 关闭 → stop 走完 RunApp 收尾。 +func _run_app_loop() -> void: + _ck(Metin2PythonHost.start("") == "", "第二次 start()") + Metin2PythonHost.set_ui_size(800, 600) + var err: String = Metin2PythonHost.run_main_script("") + _ck(err == "", "system.py 进入 app.Loop():" + err) + _ck(Metin2PythonHost.is_app_looping(), "is_app_looping() 为真") + _ck(Metin2PythonHost.run_line("import __main__, gc, networkModule, introLogo\n__main__._stream = [o for o in gc.get_objects() if isinstance(o, networkModule.MainStream)][0]\nassert isinstance(_stream.curPhaseWindow, introLogo.LogoWindow)") == "", + "Logo 阶段已打开") + var reached := false + for i in 600: + if not Metin2PythonHost.is_app_looping(): + break + Metin2PythonHost.ui_update() + if Metin2PythonHost.run_line("import introLogin\nif not isinstance(_stream.curPhaseWindow, introLogin.LoginWindow): raise SystemError") == "": + reached = true + break + _ck(reached, "帧推进后进入 LoginWindow") + _ck(Metin2PythonHost.run_line("_stream.popupWindow.Open('POPUP_PROBE')\nassert _stream.popupWindow.IsShow()") == "", "PopupDialog 已打开") + Metin2PythonHost.ui_update() + var has_popup := false + for draw in Metin2PythonHost.ui_render_commands(): + if draw["kind"] == "text" and draw["text"] == "POPUP_PROBE": + has_popup = true + _ck(has_popup, "弹窗文字进入 Godot Canvas 命令队列") + Metin2PythonHost.ui_key(1, true) # DIK_ESCAPE + Metin2PythonHost.ui_key(1, false) + Metin2PythonHost.ui_update() + _ck(Metin2PythonHost.run_line("assert not _stream.popupWindow.IsShow()") == "", "ESC 关闭弹窗") + Metin2PythonHost.stop() + _ck(not Metin2PythonHost.is_running() and not Metin2PythonHost.is_app_looping(), "stop() 结束 app.Loop() 并收尾") diff --git a/project/python_ui_surface.gd b/project/python_ui_surface.gd index 81253dc7..0350da52 100644 --- a/project/python_ui_surface.gd +++ b/project/python_ui_surface.gd @@ -20,6 +20,30 @@ const KEY_TO_DIK := { KEY_RIGHT: 0xcd, KEY_DOWN: 0xd0, } +# True when this surface started system.py itself (run_app) and so owns the interpreter's lifetime. +var _owns_app := false + +# 40250 WinMain → RunMainScript: start the interpreter, give it the canvas size (app.Create reads it), +# run system.py until it parks in app.Loop(). Returns "" or the error RunApp reported. +func run_app() -> String: + if not AssetRoot.pack_ready(): + return "no 40250 Client/pack at %s" % AssetRoot.client_path() + var err: String = Metin2PythonHost.start("") + if err != "": + return err + _sync_size() + err = Metin2PythonHost.run_main_script("") + if err != "": + Metin2PythonHost.stop() + return err + _owns_app = true + return "" + +func _exit_tree() -> void: + if _owns_app: + _owns_app = false + Metin2PythonHost.stop() + func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_STOP @@ -31,9 +55,17 @@ func _notification(what: int) -> void: func _sync_size() -> void: if ClassDB.class_exists("Metin2PythonHost"): - Metin2PythonHost.set_ui_size(int(size.x), int(size.y)) + # Before the first layout pass a full-rect Control is still 0x0; it will fill the viewport. + var canvas := size if size.x > 0 and size.y > 0 else get_viewport_rect().size + Metin2PythonHost.set_ui_size(int(canvas.x), int(canvas.y)) func _process(_delta: float) -> void: + # app.Exit()/Abort(): Loop() returned and RunApp() finished; 40250 WinMain then ends the process. + if _owns_app and not Metin2PythonHost.is_app_looping(): + _owns_app = false + Metin2PythonHost.stop() + get_tree().quit(0) + return if Metin2PythonHost.is_running(): Metin2PythonHost.ui_update() queue_redraw() @@ -84,12 +116,35 @@ func _draw() -> void: elif command["kind"] == "image": var texture: Texture2D = UiAssets.load_tex(AssetRoot.path(), command["text"]) if texture != null: - var dest := Rect2(p1, p2 - p1) - var visible := dest.intersection(clip) - if visible.has_area() and dest.has_area(): - var scale := texture.get_size() / dest.size - var source := Rect2((visible.position - dest.position) * scale, visible.size * scale) - draw_texture_rect_region(texture, visible, source, color) + var clipped := _clip_quad(command["quad"], command["uv"], clip) + if clipped[0].size() >= 3: + draw_polygon(clipped[0], PackedColorArray([color]), clipped[1], texture) + +# 40250's image quad (TL, TR, BL, BR) clipped to the viewport. The quad is a +# parallelogram, so texture coordinates are an affine function of position and are recomputed for +# the clipped outline. Returns [points, uvs]; empty points when nothing is visible. +# PORT: 40250's SCREEN / COLOR_DODGE / MODULATE blend states (command["blend"]) are not applied yet; +# the canvas draws every quad with normal alpha blending. +func _clip_quad(quad: PackedVector2Array, uv: PackedVector2Array, clip: Rect2) -> Array: + var outline := PackedVector2Array([quad[0], quad[1], quad[3], quad[2]]) + var edge_u := quad[1] - quad[0] + var edge_v := quad[2] - quad[0] + var det := edge_u.cross(edge_v) + if is_zero_approx(det): + return [PackedVector2Array(), PackedVector2Array()] + var rect := PackedVector2Array([clip.position, Vector2(clip.end.x, clip.position.y), + clip.end, Vector2(clip.position.x, clip.end.y)]) + var pieces := Geometry2D.intersect_polygons(outline, rect) + if pieces.is_empty(): + return [PackedVector2Array(), PackedVector2Array()] + var points: PackedVector2Array = pieces[0] + var uvs := PackedVector2Array() + for point in points: + var d := point - quad[0] + var a := d.cross(edge_v) / det + var b := edge_u.cross(d) / det + uvs.push_back(uv[0] + (uv[1] - uv[0]) * a + (uv[2] - uv[0]) * b) + return [points, uvs] func _clip_line(a: Vector2, b: Vector2, clip: Rect2) -> PackedVector2Array: var delta := b - a