2V2-e.2: Godot consumer of the 3D draw commands, offline screenshot

python_3d_surface.gd turns RenderGame's recorded draws into meshes under a
camera read back from SetPerspective; textures come from the 40250 packs.
GetFarClip and the CMapManager ctor ported verbatim. Until the environment
is ported, lit draws with no light/ambient are shown unshaded (stand-in).
script/python_game_render_test.sh runs system.py login -> select -> game
against port_fake_login_server (or --live via MT_LIVE_*) and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-23 20:35:36 +09:00
co-authored by Claude Opus 5.5
parent 5bfa13c5cc
commit 1fe8a4b643
12 changed files with 630 additions and 6 deletions
+208
View File
@@ -0,0 +1,208 @@
# 3D adapter for the ported game render (docs/PORT-PLAN.md 2V2-e): every frame it turns the draw calls
# CPythonApplication::RenderGame issued through CStateManager (Metin2PythonHost.render3d_draws, recorded
# by the platform IDirect3DDevice8) into meshes under a camera.
#
# The draws carry 40250's own D3D matrices. CGraphicBase::SetPerspective builds a right-handed
# projection (D3DXMatrixPerspectiveFovRH), so 40250's view space already is Godot's camera space
# (x right, y up, looking down -z): each mesh gets world * view as its transform and the Camera3D stays
# at the origin with the fov/near/far read back from the projection. No axis conversion is involved.
# D3D's default front face (clockwise on screen, D3DCULL_CCW culls the rest) is also Godot's.
#
# PORT: until the map is ported, CMapManager has no environment data (LoadMap → SetEnvironmentData),
# so CPythonBackground::SetCharacterDirLight sets no light and D3DRS_AMBIENT stays 0: D3D would draw
# every lit mesh black. Such draws (lighting on, no light, no ambient, no emissive) are shown unshaded,
# texture only, and counted in unlit_stand_in_count. Delete this with the environment port.
extends Node3D
const UiAssets = preload("res://ui/ui_assets.gd")
# D3DCULL / D3DBLEND / D3DCMPFUNC values used below (D3D8Types.h).
const D3DCULL_NONE := 1
const D3DCULL_CW := 2
const D3DBLEND_ONE := 2
const D3DBLEND_SRCALPHA := 5
const D3DBLEND_INVSRCALPHA := 6
var camera: Camera3D
var light: DirectionalLight3D
var environment: WorldEnvironment
var _meshes: Array[MeshInstance3D] = []
# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame.
var _materials := {}
# The draws the last update_frame() showed (for tests and reports).
var draw_count := 0
# How many of them used the no-environment stand-in (see the note at the top).
var unlit_stand_in_count := 0
func _ready() -> void:
camera = Camera3D.new()
camera.name = "GameCamera"
camera.keep_aspect = Camera3D.KEEP_HEIGHT
camera.current = true
add_child(camera)
# 40250's directional light (CPythonBackground::SetCharacterDirLight) arrives with the draws in
# world space; it is placed in camera space each frame like the meshes.
light = DirectionalLight3D.new()
light.name = "Light0"
light.visible = false
add_child(light)
environment = WorldEnvironment.new()
environment.environment = Environment.new()
environment.environment.background_mode = Environment.BG_COLOR
environment.environment.background_color = Color.BLACK
environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.environment.ambient_light_color = Color.BLACK
environment.environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR
add_child(environment)
func _process(_delta: float) -> void:
if Metin2PythonHost.is_running():
update_frame()
# D3D row-vector matrix (v' = v * M) as a Godot Transform3D (v' = T * v): the rows of M are the basis
# columns, row 3 is the origin.
static func d3d_transform(m: PackedFloat32Array) -> Transform3D:
return Transform3D(Basis(Vector3(m[0], m[1], m[2]), Vector3(m[4], m[5], m[6]), Vector3(m[8], m[9], m[10])),
Vector3(m[12], m[13], m[14]))
static func multiply(a: PackedFloat32Array, b: PackedFloat32Array) -> PackedFloat32Array:
var out := PackedFloat32Array()
out.resize(16)
for r in 4:
for c in 4:
var sum := 0.0
for k in 4:
sum += a[r * 4 + k] * b[k * 4 + c]
out[r * 4 + c] = sum
return out
func update_frame() -> void:
var draws: Array = Metin2PythonHost.render3d_draws()
var shown := 0
var stand_ins := 0
var camera_set := false
var light_set := false
for draw in draws:
# XYZRHW (screen-space) draws belong to the 2D pass; lines are debug geometry.
if draw["pretransformed"] or draw["lines"] or draw["indices"].is_empty():
continue
var proj: PackedFloat32Array = draw["proj"]
# _34 == -1: a perspective projection (the game pass). Ortho draws are UI-space.
if proj[11] != -1.0:
continue
if not camera_set:
_apply_projection(proj)
camera_set = true
var view: PackedFloat32Array = draw["view"]
if not light_set and draw["light0"]:
_apply_light(draw, view)
light_set = true
if _environment_missing(draw):
stand_ins += 1
var instance := _mesh_instance(shown)
instance.transform = d3d_transform(multiply(draw["world"], view))
instance.mesh = _build_mesh(draw)
instance.visible = true
shown += 1
for i in range(shown, _meshes.size()):
_meshes[i].visible = false
_meshes[i].mesh = null
if not light_set:
light.visible = false
draw_count = shown
unlit_stand_in_count = stand_ins
func _apply_projection(proj: PackedFloat32Array) -> void:
# D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf).
camera.fov = rad_to_deg(2.0 * atan(1.0 / proj[5]))
camera.near = proj[14] / proj[10]
camera.far = proj[14] / (proj[10] + 1.0)
func _apply_light(draw: Dictionary, view: PackedFloat32Array) -> void:
var view_basis := d3d_transform(view).basis
var direction: Vector3 = view_basis * draw["light0_direction"]
if direction.length_squared() < 1e-8:
light.visible = false
return
light.visible = true
# DirectionalLight3D shines along its -Z.
light.transform = Transform3D(Basis.looking_at(direction.normalized(),
Vector3.UP if absf(direction.normalized().y) < 0.99 else Vector3.RIGHT), Vector3.ZERO)
var diffuse: Color = draw["light0_diffuse"]
light.light_color = Color(diffuse.r, diffuse.g, diffuse.b)
light.light_energy = 1.0
# D3D lighting: ambient = D3DRS_AMBIENT + light ambient, both times the material ambient.
var ambient: Color = draw["ambient"]
var light_ambient: Color = draw["light0_ambient"]
environment.environment.ambient_light_color = Color(minf(ambient.r + light_ambient.r, 1.0),
minf(ambient.g + light_ambient.g, 1.0), minf(ambient.b + light_ambient.b, 1.0))
static func _environment_missing(draw: Dictionary) -> bool:
var ambient: Color = draw["ambient"]
var emissive: Color = draw["material_emissive"]
return draw["lighting"] and not draw["light0"] and ambient.r + ambient.g + ambient.b == 0.0 \
and emissive.r + emissive.g + emissive.b == 0.0
func _mesh_instance(index: int) -> MeshInstance3D:
while _meshes.size() <= index:
var instance := MeshInstance3D.new()
instance.name = "Draw%d" % _meshes.size()
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(instance)
_meshes.append(instance)
return _meshes[index]
func _build_mesh(draw: Dictionary) -> ArrayMesh:
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = draw["positions"]
if draw.has("normals"):
arrays[Mesh.ARRAY_NORMAL] = draw["normals"]
if draw.has("uv0"):
arrays[Mesh.ARRAY_TEX_UV] = draw["uv0"]
if draw.has("diffuse"):
arrays[Mesh.ARRAY_COLOR] = draw["diffuse"]
arrays[Mesh.ARRAY_INDEX] = draw["indices"]
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
mesh.surface_set_material(0, _material(draw))
return mesh
func _material(draw: Dictionary) -> StandardMaterial3D:
var lit: bool = draw["lighting"] and not _environment_missing(draw)
var key := "%s|%d|%d|%d|%d|%d|%d|%d|%s|%s" % [draw["texture0"], draw["alpha_blend"], draw["src_blend"],
draw["dest_blend"], draw["alpha_test"], draw["alpha_ref"], draw["cull_mode"], lit,
draw["material_diffuse"], draw["material_emissive"]]
if _materials.has(key):
return _materials[key]
var material := StandardMaterial3D.new()
var texture_name: String = draw["texture0"]
if not texture_name.is_empty():
# The ported client reads its models from 40250's packs; the texture must come from there too.
material.albedo_texture = UiAssets.load_pack_tex(texture_name)
material.albedo_color = draw["material_diffuse"] if lit else Color.WHITE
material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0)
if material.emission_enabled:
material.emission = draw["material_emissive"]
material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX if lit \
else BaseMaterial3D.SHADING_MODE_UNSHADED
material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
material.roughness = 1.0
material.vertex_color_use_as_albedo = draw.has("diffuse")
match int(draw["cull_mode"]):
D3DCULL_NONE:
material.cull_mode = BaseMaterial3D.CULL_DISABLED
D3DCULL_CW:
material.cull_mode = BaseMaterial3D.CULL_FRONT
_:
material.cull_mode = BaseMaterial3D.CULL_BACK
if draw["alpha_blend"]:
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
if int(draw["dest_blend"]) == D3DBLEND_ONE:
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
elif draw["alpha_test"]:
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
material.alpha_scissor_threshold = float(draw["alpha_ref"]) / 255.0
_materials[key] = material
return material
+172
View File
@@ -0,0 +1,172 @@
# 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["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()
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
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
report["surfaces_textured"] = surfaces_textured
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)
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
root.get_texture().get_image().save_png(output.path_join("game_3d.png"))
ui.visible = true
await _finish(output, report)
+13
View File
@@ -37,6 +37,19 @@ static func load_tex(assets_root: String, vpath: String) -> Texture2D:
_cache[key] = tex
return tex
# 只从 40250 Client/pack(Metin2Pack)取,不看 assets/ 散文件:移植的客户端从包里读模型,
# 贴图也必须是同一份(assets/ 里同名的 fork 贴图 UV 布局可能不同)。
static func load_pack_tex(vpath: String) -> Texture2D:
if vpath == "":
return null
var key := "pack|" + vpath
if _cache.has(key):
return _cache[key]
# 40250 的包名带盘符(d:/ymir work/...),原样交给 CEterPackManager。
var tex: Texture2D = _load_pack_image(vpath.replace("\\", "/"))
_cache[key] = tex
return tex
static func _strip_drive(p: String) -> String:
var s := p.replace("\\", "/")
if s.length() >= 2 and s[1] == ":":