CGraphicText, CGraphicFontTexture and CGraphicDib are the 40250 bodies verbatim; CGraphicTextInstance is verbatim except that its D3D draws become UI render commands (one image quad per glyph, Bar for cursor/underline). - platform/Win32Gdi.cpp: the GDI subset those files call, over vendored FreeType 2.13.3, following GDI's LOGFONT cell height, win metrics and gasp rules; system font lookup with substitute and CJK fallback faces - Win32Crt: MultiByteToWideChar / WideCharToMultiByte (UTF-8 and 1252 by hand, other code pages through iconv) - CGraphicImageTexture memory textures: glyph pages reach Godot as "mem:<id>@<revision>"; Metin2PythonHost.memory_texture + Canvas cache - Util.cpp code page / font face table, SetDefaultCodePage at boot, DefaultFont_* block, fnt resource type, GetMaxTextureWidth/Height - TextTag, StringCodec, Arabic, Vietnamese copied from 40250 - tests: port.text (Tahoma 12 metrics, alignment, CP949), port.app_loop and python_host_test.gd now check glyph quads instead of text commands Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
13 KiB
GDScript
232 lines
13 KiB
GDScript
# python_host_check —— 在 app 进程里把内嵌解释器起来并跑 system.py(docs/PORT-PLAN.md 批次 2P step 4)。
|
||
#
|
||
# 和 ctest 的 `port.python_launcher` 跑的是同一条序列,区别只有一个:那边是独立可执行文件,这边在
|
||
# Godot 进程里,由 GDScript 发起 —— step 4 要验的就是这个差别。2V0 把 C++ 模块注册上之前,
|
||
# 2V0-e 的玩法桩让 system.py 真正走到 prototype.RunApp();
|
||
# 没设画布尺寸时 app.Create 没有设备,RunApp 捕获 CREATE_DEVICE 并给出原版提示;设了尺寸以后
|
||
# system.py 停在 app.Loop() 里,由 ui_update 逐帧推进(_run_app_loop)。
|
||
#
|
||
# 真机上通常没有 40250 的 Client/pack(APK 里只有 python27.zip),所以分两段:解释器 + 标准库这一段
|
||
# 不需要 pack,任何平台都跑;system.py 这一段需要 pack,没有就跳过并说清楚。
|
||
#
|
||
# 桌面:python_host_test.gd(--script);真机:MT_TEST_MODE=python 从 client_main.gd 进来。
|
||
extends RefCounted
|
||
|
||
const PythonUISurface = preload("res://python_ui_surface.gd")
|
||
|
||
const EXPECTED_STOP := "Sorry, Your system does not support 3D graphics"
|
||
|
||
var fail := 0
|
||
|
||
func _ck(ok: bool, message: String) -> void:
|
||
if not ok:
|
||
fail += 1
|
||
printerr("FAIL: " + message)
|
||
else:
|
||
print("ok: " + message)
|
||
|
||
# 返回失败数;0 表示落在预期的停止点上(或按上面的规则跳过)。
|
||
func run() -> int:
|
||
if not ClassDB.class_exists("Metin2PythonHost"):
|
||
printerr("FAIL: Metin2PythonHost extension is not registered")
|
||
return 1
|
||
if not FileAccess.file_exists("res://python27.zip"):
|
||
print("SKIP: python_host_check(没有 res://python27.zip,需要 -DMTGODOT_EMBED_PYTHON=ON 的构建)")
|
||
return 0
|
||
|
||
# 40250 Main() 先注册 pack,再造 CPythonLauncher —— system.py 是从 pack 里读出来的。
|
||
var has_pack := AssetRoot.pack_ready()
|
||
|
||
var start_error: String = Metin2PythonHost.start("")
|
||
_ck(start_error == "", "解释器在 app 进程里起来了:" + start_error)
|
||
if start_error != "":
|
||
return fail
|
||
_ck(Metin2PythonHost.is_running(), "is_running() 为真")
|
||
_ck(Metin2PythonHost.start("") == "", "重复 start() 是空操作")
|
||
|
||
# 标准库真的在 sys.path 上(真机是沙盒里那份 python27.zip),codec 注册表也重建过。
|
||
_ck(Metin2PythonHost.run_line("import os, string, types, codecs") == "", "标准库可 import")
|
||
_ck(Metin2PythonHost.run_line("assert u'\\xe4'.encode('cp1252') == '\\xe4'") == "", "codec 注册表可用")
|
||
_ck(Metin2PythonHost.run_line("import sys; assert sys.path") == "", "sys.path 非空")
|
||
|
||
if has_pack:
|
||
# RunMainScript:initpack() + __DEBUG__ + __COMMAND_LINE__ + RunFile("system.py")。
|
||
var err: String = Metin2PythonHost.run_main_script("")
|
||
print("system.py -> %s" % err)
|
||
_ck(err.contains(EXPECTED_STOP),
|
||
"system.py 进入 prototype.RunApp(),Create() 报告待实现的图形设备")
|
||
_ck(Metin2PythonHost.run_line("import net, player, item; assert net.__port_stub__ == 1 and player.__port_stub__ == 1 and item.__port_stub__ == 1") == "",
|
||
"2V0 玩法模块桩已注册,调用会记录到 stderr")
|
||
# 真正从 Godot 入口转发事件给 40250 的 CWindowManager,再由窗口树回调 Python。
|
||
Metin2PythonHost.set_ui_size(800, 600)
|
||
var probe_source := """
|
||
import wndMgr
|
||
_ui_events = []
|
||
class _UIProbe:
|
||
def OnMouseOverIn(self): _ui_events.append('in')
|
||
def OnMouseOverOut(self): _ui_events.append('out')
|
||
def OnMouseOver(self): pass
|
||
def OnMouseLeftButtonDown(self): _ui_events.append('down')
|
||
def OnMouseLeftButtonUp(self): _ui_events.append('up'); return 1
|
||
def OnSetFocus(self): _ui_events.append('focus')
|
||
def OnKillFocus(self): _ui_events.append('blur')
|
||
def OnUpdate(self): _ui_events.append('update')
|
||
def OnKeyDown(self, key): _ui_events.append('key:%d' % key); return 1
|
||
def OnKeyUp(self, key): return 1
|
||
_ui_probe = _UIProbe()
|
||
_ui_window = wndMgr.Register(_ui_probe, 'UI')
|
||
wndMgr.SetWindowSize(_ui_window, 100, 50)
|
||
wndMgr.SetWindowPosition(_ui_window, 10, 20)
|
||
wndMgr.Show(_ui_window)
|
||
wndMgr.SetFocus(_ui_window)
|
||
"""
|
||
_ck(Metin2PythonHost.run_line(probe_source) == "", "40250 窗口测试对象已在 Godot 进程中创建")
|
||
Metin2PythonHost.ui_mouse_move(15, 25)
|
||
Metin2PythonHost.ui_mouse_button(1, true, 15, 25)
|
||
Metin2PythonHost.ui_mouse_button(1, false, 15, 25)
|
||
Metin2PythonHost.ui_key(42, true)
|
||
Metin2PythonHost.ui_update()
|
||
_ck(Metin2PythonHost.run_line("assert 'in' in _ui_events and 'down' in _ui_events and 'up' in _ui_events and 'focus' in _ui_events and 'key:42' in _ui_events and 'update' in _ui_events") == "",
|
||
"Godot 输入、焦点和更新事件抵达 40250 Python 窗口")
|
||
_ck(Metin2PythonHost.run_line("_ui_bar = wndMgr.RegisterBar(_UIProbe(), 'UI'); wndMgr.SetWindowSize(_ui_bar, 20, 10); wndMgr.SetWindowPosition(_ui_bar, 30, 40); wndMgr.SetColor(_ui_bar, 0x7f336699); wndMgr.Show(_ui_bar)") == "",
|
||
"40250 Bar 窗口已创建")
|
||
var draws: Array = Metin2PythonHost.ui_render_commands()
|
||
var has_bar := false
|
||
for draw in draws:
|
||
if draw["kind"] == "bar" and draw["x1"] == 30.0 and draw["y1"] == 40.0 and draw["x2"] == 50.0 and draw["y2"] == 50.0 and draw["argb"] == 0x7f336699:
|
||
has_bar = true
|
||
_ck(has_bar, "40250 Bar 绘制调用进入 Godot Canvas 命令队列")
|
||
var clip_source := """
|
||
import grp
|
||
class _ClipProbe(_UIProbe):
|
||
def OnRender(self):
|
||
_ui_events.append('renderclip')
|
||
grp.SetViewport(0.0, 0.0, 0.05, 0.08)
|
||
_ui_clip_probe = _ClipProbe()
|
||
_ui_clip = wndMgr.Register(_ui_clip_probe, 'UI')
|
||
wndMgr.SetWindowSize(_ui_clip, 800, 600)
|
||
wndMgr.SetParent(_ui_bar, _ui_clip)
|
||
wndMgr.Show(_ui_clip)
|
||
"""
|
||
_ck(Metin2PythonHost.run_line(clip_source) == "", "40250 裁剪窗口已创建")
|
||
draws = Metin2PythonHost.ui_render_commands()
|
||
_ck(Metin2PythonHost.run_line("assert 'renderclip' in _ui_events") == "", "父窗口 OnRender 已执行")
|
||
var has_clip := false
|
||
for draw in draws:
|
||
if draw["kind"] == "bar" and draw["clip_x2"] == 40.0 and draw["clip_y2"] == 48.0:
|
||
has_clip = true
|
||
_ck(has_clip, "40250 viewport 裁剪范围传给 Godot Canvas")
|
||
var surface := PythonUISurface.new()
|
||
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)") == "", "裁剪窗口已销毁")
|
||
_ck(Metin2PythonHost.run_line("_ui_text_probe = _UIProbe(); _ui_text = wndMgr.RegisterTextLine(_ui_text_probe, 'UI'); wndMgr.SetWindowPosition(_ui_text, 70, 80); wndMgr.SetFontName(_ui_text, 'Tahoma:12'); wndMgr.SetText(_ui_text, 'Hello'); wndMgr.SetFontColor(_ui_text, 1.0, 0.5, 0.0); wndMgr.Show(_ui_text)") == "",
|
||
"40250 TextLine 已创建(和 ui.TextLine.__init__ 一样先 SetFontName)")
|
||
draws = Metin2PythonHost.ui_render_commands()
|
||
var glyphs: Array = draws.filter(func(d): return d["kind"] == "image" and String(d["text"]).begins_with("mem:"))
|
||
var min_x := INF
|
||
for draw in glyphs:
|
||
min_x = minf(min_x, draw["quad"][0].x)
|
||
_ck(glyphs.size() == 5 and absf(min_x - 70.0) <= 1.5,
|
||
"40250 TextLine 经 CGraphicFontTexture 字形页画成 5 个字形四边形(%d 个,左边 %.1f)" % [glyphs.size(), min_x])
|
||
if not glyphs.is_empty():
|
||
var page: Image = Metin2PythonHost.memory_texture(glyphs[0]["text"], -1)
|
||
var inked := false
|
||
if page != null:
|
||
for y in page.get_height():
|
||
for x in page.get_width():
|
||
if page.get_pixel(x, y).a > 0.0:
|
||
inked = true
|
||
break
|
||
if inked:
|
||
break
|
||
_ck(inked, "字形页(mem: 内存纹理)有字形像素")
|
||
var page_surface := preload("res://python_ui_surface.gd").new()
|
||
var page_texture: Texture2D = page_surface._texture(glyphs[0]["text"])
|
||
_ck(page_texture != null and page_texture == page_surface._texture(glyphs[0]["text"]),
|
||
"Canvas 按 mem:<id> 缓存字形页纹理")
|
||
page_surface.free()
|
||
_ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_text)") == "", "TextLine 已销毁")
|
||
var image_path := "locale/en/ui/vkey/key_backspace.tga"
|
||
if Metin2Pack.exists(image_path):
|
||
var packed_texture: Texture2D = preload("res://ui/ui_assets.gd").load_tex(AssetRoot.path(), image_path)
|
||
_ck(packed_texture != null and packed_texture.get_width() > 0,
|
||
"40250 pack 图片可解码为 Godot 纹理")
|
||
var image_source := "_ui_image_probe = _UIProbe(); _ui_image = wndMgr.RegisterImageBox(_ui_image_probe, 'UI'); wndMgr.LoadImage(_ui_image, '%s'); wndMgr.SetWindowPosition(_ui_image, 120, 130); wndMgr.Show(_ui_image)" % image_path
|
||
_ck(Metin2PythonHost.run_line(image_source) == "", "40250 pack 图片已加载到 ImageBox")
|
||
draws = Metin2PythonHost.ui_render_commands()
|
||
var has_image := false
|
||
for draw in draws:
|
||
if draw["kind"] == "image" and String(draw["text"]).replace("\\", "/") == image_path and draw["x1"] == 120.0 and draw["y1"] == 130.0 and draw["x2"] > draw["x1"] and draw["y2"] > draw["y1"]:
|
||
has_image = true
|
||
_ck(has_image, "40250 ImageBox 图片进入 Godot Canvas 命令队列")
|
||
_ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_image)") == "", "ImageBox 已销毁")
|
||
var sub_path := "d:/ymir work/ui/cursor/cursor.sub"
|
||
if Metin2Pack.exists(sub_path):
|
||
_ck(Metin2PythonHost.run_line("import grpImage; _ui_sub = grpImage.Generate('%s'); assert _ui_sub; grpImage.Delete(_ui_sub)" % sub_path) == "",
|
||
"40250 .sub 子图可创建并释放")
|
||
_ck(Metin2PythonHost.run_line("_ui_expanded = grpImage.GenerateExpanded('%s'); assert _ui_expanded; grpImage.DeleteExpanded(_ui_expanded)" % image_path) == "",
|
||
"40250 ExpandedImage 可创建并释放")
|
||
else:
|
||
print("SKIP: 40250 image test(pack 缺少 %s)" % image_path)
|
||
_ck(Metin2PythonHost.run_line("wndMgr.Destroy(_ui_window)") == "", "窗口已销毁")
|
||
else:
|
||
print("SKIP: system.py 这一段(没有 40250 Client/pack:%s)" % AssetRoot.client_path())
|
||
|
||
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")
|
||
var glyphs_before := _glyph_quads()
|
||
_ck(Metin2PythonHost.run_line("_stream.popupWindow.Open('POPUP_PROBE')\nassert _stream.popupWindow.IsShow()") == "", "PopupDialog 已打开")
|
||
Metin2PythonHost.ui_update()
|
||
_ck(_glyph_quads() >= glyphs_before + 11, "弹窗文字的字形进入 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() 并收尾")
|
||
|
||
# CGraphicTextInstance draws one image quad per glyph from a "mem:<id>@<rev>" font page.
|
||
func _glyph_quads() -> int:
|
||
var count := 0
|
||
for draw in Metin2PythonHost.ui_render_commands():
|
||
if draw["kind"] == "image" and String(draw["text"]).begins_with("mem:"):
|
||
count += 1
|
||
return count
|