The live run passed its checks, but the screenshot showed wolves floating over a ground that ended at the actor's feet. Native heights were right (every actor meets sample_height); two Metin2World settings written for metre units culled the terrain in this cm-scaled scene: the .msenv depth fog (120-520) and the terrain patch visibility_range_end (3500). The 3D surface now turns the legacy fog off (fog belongs to the recorded D3DRS_FOG* state, off until SetEnvironmentData is ported) and builds one mesh per tile. The render test checks that every actor (draws grouped by world matrix) stands on the map and that the map behind the actor shows texture. mouse.cfg, written by 40250 uitaskbar.py at runtime, is ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
13 KiB
GDScript
277 lines
13 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 _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")
|
||
|
||
# LoginWindow.Connect with the account from the environment.
|
||
_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"
|
||
+ "_stream.curPhaseWindow.Connect(_env['MT_LIVE_LOGIN'], _env['MT_LIVE_PASSWORD'])\n"
|
||
+ "del _env"), "LoginWindow.Connect")
|
||
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()])
|
||
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)
|