The fake server spawns a stray dog outside the village's BANPK safe disc and answers CG_TARGET/CG_ATTACK with GC_TARGET/GC_DAMAGE_INFO/GC_DEAD. port.login_flow and python_game_render_test hold the left button on it (MBF_SMART -> __OnPressActor) until it dies. Adds the host/test helper PythonBoot::Evaluate / Metin2PythonHost.evaluate. python_3d_surface: emission is now multiplied by the texture, like D3D's emissive-then-MODULATE, which removes the grey click-glow quad and the grey wash on characters. Damage numbers wait on CPythonSystem (4-g). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
396 lines
18 KiB
GDScript
396 lines
18 KiB
GDScript
# 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
|
||
|
||
# Combat (批次 4-b) with Godot mouse events: hold the left button on the fake server's stray dog; the port's
|
||
# CPythonPlayer walks out of the village's safe zone and attacks, the fake server's GC_TARGET updates reach
|
||
# uiTarget.TargetBoard.SetHP (wrapped here to record them) and GC_DEAD comes with the 0 % update.
|
||
if in_game:
|
||
var dog_vid := 0x23456
|
||
_check(_py("import uiTarget\n"
|
||
+ "_target_hp = []\n"
|
||
+ "_set_hp = uiTarget.TargetBoard.SetHP\n"
|
||
+ "def _record_hp(board, hp):\n"
|
||
+ "\t_target_hp.append(hp)\n"
|
||
+ "\t_set_hp(board, hp)\n"
|
||
+ "uiTarget.TargetBoard.SetHP = _record_hp"), "target HP recorder installed")
|
||
var dog_screen := func() -> Vector2:
|
||
var parts := Metin2PythonHost.evaluate("'%%d,%%d,%%d' %% __import__('chr').GetPixelPosition(%d)" % dog_vid).split(",")
|
||
if parts.size() != 3:
|
||
return Vector2(-1, -1)
|
||
var world_pos := Vector3(float(parts[0]), -float(parts[1]), float(parts[2]) + 40.0)
|
||
for draw in Metin2PythonHost.render3d_draws():
|
||
if draw["proj"][11] == -1.0:
|
||
return world.camera.unproject_position(world.d3d_transform(draw["view"]) * world_pos)
|
||
return Vector2(-1, -1)
|
||
var move := func(position: Vector2) -> void:
|
||
var event := InputEventMouseMotion.new()
|
||
event.position = position
|
||
event.global_position = position
|
||
root.push_input(event)
|
||
var button := func(pressed: bool, position: Vector2) -> void:
|
||
var event := InputEventMouseButton.new()
|
||
event.button_index = MOUSE_BUTTON_LEFT
|
||
event.pressed = pressed
|
||
event.position = position
|
||
event.global_position = position
|
||
root.push_input(event)
|
||
# The actor may still be walking from the earlier step, and the camera follows it: wait until the dog
|
||
# stands still on screen before every click.
|
||
var settled := func() -> Vector2:
|
||
var last := Vector2(-100, -100)
|
||
var now := Vector2.ZERO
|
||
for frame in 600:
|
||
await process_frame
|
||
now = dog_screen.call()
|
||
if now.distance_to(last) < 0.5:
|
||
break
|
||
last = now
|
||
return now
|
||
var at: Vector2 = await settled.call()
|
||
# The earlier walk does not end on the same spot every run, and the dog can land under the minimap or the
|
||
# taskbar: walk part of the way first (a ground click, which __OnClickGround turns into a move).
|
||
var clear := Rect2(40, 150, 780, 520)
|
||
for attempt in 3:
|
||
if clear.has_point(at):
|
||
break
|
||
var toward: Vector2 = clear.get_center().lerp(at, 0.5).clamp(clear.position, clear.end)
|
||
move.call(toward)
|
||
await process_frame
|
||
button.call(true, toward)
|
||
await process_frame
|
||
button.call(false, toward)
|
||
at = await settled.call()
|
||
report["dog_screen"] = str(at)
|
||
_check(at.x >= 0.0, "stray dog projected on screen (%s)" % str(at))
|
||
move.call(at)
|
||
await process_frame
|
||
button.call(true, at)
|
||
var mid_shot := false
|
||
var dead := await _pump_until(20, func():
|
||
var now: Vector2 = dog_screen.call()
|
||
if now.x >= 0.0:
|
||
at = now
|
||
move.call(at)
|
||
if not mid_shot and Metin2PythonHost.evaluate("33 in _target_hp") == "True" and DisplayServer.get_name() != "headless":
|
||
mid_shot = true
|
||
root.get_texture().get_image().save_png(output.path_join("combat_mid.png"))
|
||
return Metin2PythonHost.evaluate("_target_hp[-1:] == [0]") == "True")
|
||
button.call(false, at)
|
||
if DisplayServer.get_name() != "headless":
|
||
root.get_texture().get_image().save_png(output.path_join("combat.png"))
|
||
report["target_hp"] = Metin2PythonHost.evaluate("_target_hp")
|
||
_check(dead, "stray dog killed with the held left button (target HP %s)" % report["target_hp"])
|
||
_check(_py("uiTarget.TargetBoard.SetHP = _set_hp\ndel _set_hp, _record_hp, _target_hp"), "target HP recorder removed")
|
||
await _finish(output, report)
|