Files
mtgodot-poc/project/python_game_render_test.gd
T
shenleiandClaude Opus 5.5 6ea0fa9889 port 4-a: text input through CPythonIME (login typed, in-game chat)
- UserInterface/PythonIME(+Module) ported verbatim; ime module stubs removed, initime() after initdbg()
- EterLib/IME.cpp in platform/: 40250 buffer/WMChar bodies, IMM/TSF processors no-op; committed
  multi-byte text via PythonBoot::UIChar emulating GCS_RESULTSTR
- GameApplicationAdapter RunIME* -> CWindowManager; Godot keys -> ui_ime_key/ui_char
- port.login_flow/login_live type the account through the IME; chat CG_CHAT round-trip on the fake server
- python_game_render_test logs in with Godot key events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 08:35:53 +09:00

315 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# python_game_render_test — 2V2-e:Godot 里跑 40250 的 system.py,按 port_login_flow_test 的同一条路径
# 登录 → 选人 → 进游戏,把 RenderGame 的绘制命令(python_3d_surface.gd)画出来并截图。
#
# godot --path project --script res://python_game_render_test.gd
#
# 服务器与账号只从环境变量读(MT_LIVE_HOST / MT_LIVE_LOGIN / MT_LIVE_PASSWORD,可选 MT_LIVE_AUTH_PORT
# 11000、MT_LIVE_GAME_PORT 13000、MT_LIVE_SLOT 0);Python 侧也用 os.environ 取,不进源码文本。
# 离线由 script/python_game_render_test.sh 起 port_fake_login_server 并把这些变量指向它。
# 截图与 report.json 写到 MT_RENDER_OUTPUT(默认 build/rendering/python-game-<时间>)。
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var failed := false
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
func _check(ok: bool, message: String) -> void:
if ok:
print("ok: " + message)
else:
failed = true
printerr("FAIL: " + message)
func _py(source: String) -> bool:
return Metin2PythonHost.run_line(source) == ""
func _key(keycode: Key, unicode: int, pressed: bool) -> void:
var event := InputEventKey.new()
event.keycode = keycode
event.physical_keycode = keycode
event.unicode = unicode
event.pressed = pressed
root.push_input(event)
func _press(keycode: Key) -> void:
_key(keycode, 0, true)
_key(keycode, 0, false)
func _type(text: String) -> void:
for i in text.length():
var ch := text.unicode_at(i)
var keycode := OS.find_keycode_from_string(text[i].to_upper())
_key(keycode, ch, true)
_key(keycode, ch, false)
func _pump_until(seconds: float, condition: Callable) -> bool:
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
while Time.get_ticks_msec() < deadline:
if condition.call():
return true
await process_frame
return condition.call()
func _finish(output: String, report: Dictionary) -> void:
report["passed"] = not failed
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(report, " "))
file.close()
print("python_game_render_test: %s (%s)" % ["FAIL" if failed else "PASS", output])
ui.queue_free()
await process_frame
quit(1 if failed else 0)
func run() -> void:
root.size = Vector2i(1024, 768)
var output := OS.get_environment("MT_RENDER_OUTPUT")
if output.is_empty():
output = ProjectSettings.globalize_path("res://../build/rendering/python-game-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(output)
var report := {"output": output, "headless": DisplayServer.get_name() == "headless"}
var host := OS.get_environment("MT_LIVE_HOST")
if host.is_empty() or OS.get_environment("MT_LIVE_LOGIN").is_empty() or OS.get_environment("MT_LIVE_PASSWORD").is_empty():
printerr("python_game_render_test: needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD")
quit(2)
return
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
# The UI surface runs CPythonApplication::Process in its _process; the 3D surface reads the draws
# of that frame after it, so it comes second in the tree.
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
_check(err == "", "system.py reached app.Loop() (%s)" % err)
if err != "":
await _finish(output, report)
return
_check(_py("import __main__, os, networkModule, introLogin, introSelect, introLoading, game\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"),
"found networkModule.MainStream")
var at_login := await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"))
_check(at_login, "login window")
# The account from the environment is typed into the login board as key events (WM_KEYDOWN/WM_CHAR
# through PythonUISurface → CPythonIME); Return runs __OnClickLoginButton → LoginWindow.Connect.
_check(_py("_env = os.environ\n"
+ "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n"
+ "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), "
+ "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n"
+ "_w = _stream.curPhaseWindow\n"
+ "_w._LoginWindow__OpenLoginBoard()\n"
+ "_w.idEditLine.SetText('')\n"
+ "_w.pwdEditLine.SetText('')\n"
+ "_w.idEditLine.SetFocus()"), "login board open")
_type(OS.get_environment("MT_LIVE_LOGIN"))
_press(KEY_TAB)
_type(OS.get_environment("MT_LIVE_PASSWORD"))
_check(_py("assert _w.idEditLine.GetText() == _env['MT_LIVE_LOGIN']\n"
+ "assert _w.pwdEditLine.GetText() == _env['MT_LIVE_PASSWORD']"), "account typed through the IME")
_check(_py("del _env, _w"), "login locals dropped")
_press(KEY_ENTER)
var selecting := await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
_check(selecting, "character select")
if selecting:
_check(_py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()"), "StartGame")
var in_game := selecting and await _pump_until(60, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
_check(in_game, "game window")
# The main character's draws reach the 3D surface.
var drawn := in_game and await _pump_until(10, func(): return world.draw_count > 0)
_check(drawn, "RenderGame draws shown (%d)" % world.draw_count)
# Let the WAIT motion run a little before the picture.
for i in 30:
await process_frame
report["draw_count"] = world.draw_count
report["unlit_stand_in_count"] = world.unlit_stand_in_count
report["terrain"] = world.terrain_report
report["atlas_entries"] = world._atlas.size()
_check(not Metin2PythonHost.current_map_name().is_empty(), "native AtlasInfo chooses the current map")
if world.terrain:
report["terrain_base_cm"] = str(world.terrain.call("get_map_base_cm"))
report["native_map_name"] = Metin2PythonHost.current_map_name()
_check(world.map_path == Metin2PythonHost.current_map_name(), "terrain uses native Warp map")
var first_draws: Array = Metin2PythonHost.render3d_draws()
if not first_draws.is_empty():
var camera_world: Vector3 = world.d3d_transform(first_draws[0]["view"]).affine_inverse().origin
report["camera_world_cm"] = str(camera_world)
report["terrain_height_at_camera_cm"] = float(world.terrain.call("sample_height",
camera_world.x / 100.0, -camera_world.y / 100.0)) * 100.0
var tile_bounds := []
for tile in world.terrain.find_children("Terrain*", "MeshInstance3D", true, false):
if tile_bounds.size() >= 4:
break
tile_bounds.append(str(tile.global_transform * tile.mesh.get_aabb()))
report["terrain_camera_bounds"] = tile_bounds
_check(world.terrain != null and int(world.terrain_report.get("chunks_built", 0)) > 0,
"40250 terrain height tiles loaded")
_check(int(world.terrain_report.get("chunks_splatted", 0)) > 0,
"40250 terrain textures applied")
_check(bool(world.terrain_report.get("env_ok", false)) and world.light.visible,
"map .msenv lights the character")
_check(world.unlit_stand_in_count == 0, "unlit stand-in removed")
report["camera_fov"] = world.camera.fov
report["camera_near"] = world.camera.near
report["camera_far"] = world.camera.far
var textures := []
var draws: Array = Metin2PythonHost.render3d_draws()
var native_lit_draws := 0
var view_finite := true
var actor_foot_cm := INF
var foot_xy := Vector2.ZERO
for draw in draws:
if draw["light0"]:
native_lit_draws += 1
for value in draw["view"]:
if not is_finite(value):
view_finite = false
var world_xform: Transform3D = world.d3d_transform(draw["world"])
for vertex in draw["positions"]:
var p: Vector3 = world_xform * vertex
if p.z < actor_foot_cm:
actor_foot_cm = p.z
foot_xy = Vector2(p.x, -p.y)
# Every actor, not only the main one, stands on the map. An actor's parts (body, head, hair, teeth)
# share its world matrix; the lowest vertex over those parts meets sample_height.
var actor_low := {}
for draw in draws:
var draw_xform: Transform3D = world.d3d_transform(draw["world"])
var key := str(draw["world"])
for vertex in draw["positions"]:
var p: Vector3 = draw_xform * vertex
if not actor_low.has(key) or p.z < actor_low[key].z:
actor_low[key] = p
if world.terrain:
var worst_actor_gap_cm := 0.0
for low in actor_low.values():
var ground := float(world.terrain.call("sample_height", low.x / 100.0, -low.y / 100.0)) * 100.0
worst_actor_gap_cm = maxf(worst_actor_gap_cm, absf(low.z - ground))
report["actors"] = actor_low.size()
report["worst_actor_gap_cm"] = worst_actor_gap_cm
_check(worst_actor_gap_cm < 50.0, "every actor stands on the map (%d actors, worst gap %.1f cm)"
% [actor_low.size(), worst_actor_gap_cm])
report["native_lit_draws"] = native_lit_draws
report["view_finite"] = view_finite
_check(view_finite, "native camera view stays finite")
if world.terrain and not is_inf(actor_foot_cm):
var ground_cm := float(world.terrain.call("sample_height", foot_xy.x / 100.0, foot_xy.y / 100.0)) * 100.0
report["actor_foot_cm"] = actor_foot_cm
report["ground_at_foot_cm"] = ground_cm
_check(absf(actor_foot_cm - ground_cm) < 120.0,
"native actor feet meet map height (delta %.1f cm)" % absf(actor_foot_cm - ground_cm))
_check(not drawn or native_lit_draws == draws.size(),
"native RenderGame light0 comes from map .msenv (%d/%d)" % [native_lit_draws, draws.size()])
if not draws.is_empty():
report["proj"] = Array(draws[0]["proj"])
report["view"] = Array(draws[0]["view"])
# Each draw's render state (everything but the arrays and matrices).
var states := []
for draw in draws:
var state := {}
for key in draw:
if typeof(draw[key]) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_COLOR, TYPE_VECTOR3]:
state[key] = str(draw[key])
state["vertices"] = draw["positions"].size()
if draw.has("uv0"):
var lo := Vector2(INF, INF)
var hi := -lo
for uv in draw["uv0"]:
lo = lo.min(uv)
hi = hi.max(uv)
state["uv0_range"] = str([lo, hi])
state["indices"] = draw["indices"].size()
states.append(state)
report["draw_states"] = states
for draw in draws:
if not draw["texture0"].is_empty() and not textures.has(draw["texture0"]):
textures.append(draw["texture0"])
report["textures"] = textures
var surfaces_textured := 0
var lit_surfaces := 0
var distinct_textures := {}
for instance in world.find_children("Draw*", "MeshInstance3D", false, false):
if instance.visible and instance.mesh:
var material: StandardMaterial3D = instance.mesh.surface_get_material(0)
if material and material.albedo_texture:
surfaces_textured += 1
distinct_textures[material.albedo_texture] = true
if material and material.shading_mode == BaseMaterial3D.SHADING_MODE_PER_VERTEX:
lit_surfaces += 1
report["surfaces_textured"] = surfaces_textured
report["lit_surfaces"] = lit_surfaces
report["distinct_textures"] = distinct_textures.size()
# Every texture name gets its own texture (face, body, hair are not one material).
_check(distinct_textures.size() == textures.size(),
"one texture per texture name (%d of %d)" % [distinct_textures.size(), textures.size()])
# CPythonMiniMap renders the outdoor map's minimap.dds tiles through minimap_image_filter.dds;
# the orthographic draws reach the UI stream as masked images.
var minimap_tiles := 0
for command in Metin2PythonHost.ui_render_commands():
if command.has("mask") and String(command["text"]).ends_with("minimap.dds"):
minimap_tiles += 1
report["minimap_tiles"] = minimap_tiles
_check(minimap_tiles > 0, "minimap tiles drawn through the filter mask (%d)" % minimap_tiles)
var bounds := []
for instance in world.find_children("Draw*", "MeshInstance3D", false, false):
if instance.visible and instance.mesh:
bounds.append(str(instance.transform * instance.mesh.get_aabb()))
report["camera_space_bounds"] = bounds
_check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured)
_check(lit_surfaces > 0, "character surfaces use directional lighting (%d)" % lit_surfaces)
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
var image := root.get_texture().get_image()
_check(image.save_png(output.path_join("game.png")) == OK, "screenshot saved")
# The character is centred by __UpdateCamera: the middle of the picture is not background.
var centre := Rect2i(image.get_width() * 2 / 5, image.get_height() * 3 / 10,
image.get_width() / 5, image.get_height() * 2 / 5)
var lit := 0
for y in range(centre.position.y, centre.end.y, 4):
for x in range(centre.position.x, centre.end.x, 4):
if image.get_pixel(x, y).get_luminance() > 0.05:
lit += 1
report["centre_lit_samples"] = lit
_check(lit > 50, "character pixels in the middle of the screen (%d samples)" % lit)
# The 3D pass alone, for looking at the character without the UI.
ui.visible = false
await RenderingServer.frame_post_draw
var game_image := root.get_texture().get_image()
game_image.save_png(output.path_join("game_3d.png"))
var textured_pairs := 0
for y in range(game_image.get_height() * 3 / 4, game_image.get_height() - 20, 13):
for x in range(30, game_image.get_width() - 35, 17):
var left := game_image.get_pixel(x, y).get_luminance()
var right := game_image.get_pixel(x + 5, y).get_luminance()
if absf(left - right) > 0.04:
textured_pairs += 1
report["ground_texture_pairs"] = textured_pairs
_check(textured_pairs > 20, "textured ground visible in screenshot (%d local contrasts)" % textured_pairs)
# The camera looks 27 degrees down, so the map behind the actor fills the top of the picture
# too (legacy metre-unit fog once washed it out to flat sky colour).
var far_pairs := 0
for y in range(game_image.get_height() / 10, game_image.get_height() * 3 / 10, 13):
for x in range(30, game_image.get_width() - 35, 17):
var left := game_image.get_pixel(x, y).get_luminance()
var right := game_image.get_pixel(x + 5, y).get_luminance()
if absf(left - right) > 0.04:
far_pairs += 1
report["far_ground_texture_pairs"] = far_pairs
_check(far_pairs > 20, "map behind the actor visible (%d local contrasts)" % far_pairs)
ui.visible = true
await _finish(output, report)