feat: complete native client rendering parity updates

This commit is contained in:
shen
2026-09-27 19:44:41 -07:00
parent 9a9dc6d224
commit 25dd65f2ce
99 changed files with 21121 additions and 921 deletions
+140
View File
@@ -0,0 +1,140 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
for i in 60:
await process_frame
print("=== CAMERA WORLD & HEIGHT PROFILE ===")
var draws: Array = Metin2PythonHost.render3d_draws()
if not draws.is_empty():
var vm: PackedFloat32Array = draws[0]["view"]
var cam_inv: Transform3D = Python3DSurface.d3d_transform(vm).affine_inverse()
print("Camera world origin: ", cam_inv.origin)
print("Camera world basis: ", cam_inv.basis)
print("Camera looking direction: ", -cam_inv.basis.z)
for y in range(68200, 66200, -100):
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(59700.0, %f))" % float(y))
var terrain_h = float(world.terrain.call("sample_height", 597.0, float(y) / 100.0)) * 100.0 if world.terrain else 0.0
print("Profile y=%d: bg_h=%s terrain_h=%.2f" % [y, bg_h, terrain_h])
print("Total draws: ", draws.size())
for d in draws:
var tex: String = d.get("texture0", "")
if tex.contains("warrior") or tex.contains("stray_dog") or tex.contains("goods"):
var wm: PackedFloat32Array = d["world"]
var vm: PackedFloat32Array = d["view"]
var xform: Transform3D = Python3DSurface.d3d_transform(wm)
var cam_xform: Transform3D = Python3DSurface.d3d_transform(Python3DSurface.multiply(wm, vm))
var min_z := 1e9
var max_z := -1e9
var min_local_z := 1e9
var max_local_z := -1e9
for pos in d["positions"]:
min_local_z = minf(min_local_z, pos.z)
max_local_z = maxf(max_local_z, pos.z)
var wp: Vector3 = xform * pos
min_z = minf(min_z, wp.z)
max_z = maxf(max_z, wp.z)
# Map position: (xform.origin.x, -xform.origin.y)
var map_x := xform.origin.x
var map_y := -xform.origin.y
var actor_z := xform.origin.z
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [map_x, map_y])
var terrain_h = float(world.terrain.call("sample_height", map_x / 100.0, map_y / 100.0)) * 100.0 if world.terrain else 0.0
print("Actor [%s]:" % tex.get_file())
print(" World origin: ", xform.origin, " (map_x=%.1f, map_y=%.1f, origin_z=%.1f)" % [map_x, map_y, actor_z])
print(" Local vert Z range: [%.2f, %.2f]" % [min_local_z, max_local_z])
print(" World vert Z range: [%.2f, %.2f]" % [min_z, max_z])
print(" Background Height: %s, Terrain Height: %.2f" % [bg_h, terrain_h])
print(" Vert_Z min vs Height: diff = %.2f cm" % (min_z - float(bg_h)))
print(" Origin_Z vs Height: diff = %.2f cm" % (actor_z - float(bg_h)))
print(" Camera-space origin: ", cam_xform.origin)
if tex.contains("stray_dog"):
print("--- DOG TERRAIN DETAIL ---")
var from_map := Transform3D(Basis(Vector3(100, 0, 0), Vector3(0, 0, 100), Vector3(0, -100, 0)), Vector3.ZERO)
var dog_world := xform.origin # (59700, -66400, 19851.5)
print("Dog 40250 world: ", dog_world)
for child in world.terrain.find_children("*", "MeshInstance3D", true, false):
var mi := child as MeshInstance3D
if mi.mesh is ArrayMesh:
var am := mi.mesh as ArrayMesh
var arrays := am.surface_get_arrays(0)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for v in verts:
var world_v: Vector3 = from_map * (mi.transform * v)
if abs(world_v.x - dog_world.x) < 300.0 and abs(world_v.y - dog_world.y) < 300.0:
print("Terrain local vert: ", v, " -> mi.xform*v: ", mi.transform * v, " -> from_map: ", world_v)
print("Diff Z (terrain world Z - dog world Z): ", world_v.z - dog_world.z)
break
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://b5vx3h3lipsuf
+116
View File
@@ -0,0 +1,116 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
ui = PythonUISurface.new()
root.add_child(ui)
root.add_child(world)
ui.run_app()
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
for i in 60:
await process_frame
print("=== INVESTIGATING ALL 518 ACTORS ===")
var draws: Array = Metin2PythonHost.render3d_draws()
var actor_draws := {}
for d in draws:
var wm: PackedFloat32Array = d["world"]
var key := "%.1f,%.1f,%.1f" % [wm[12], wm[13], wm[14]]
if not actor_draws.has(key):
actor_draws[key] = []
actor_draws[key].append(d)
print("Unique actor positions: ", actor_draws.size())
var anomalies := []
for key in actor_draws:
var ds: Array = actor_draws[key]
var wm: PackedFloat32Array = ds[0]["world"]
var map_x := wm[12]
var map_y := -wm[13]
var actor_z := wm[14]
var bg_h = float(Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [map_x, map_y]))
var terrain_h = float(world.terrain.call("sample_height", map_x / 100.0, map_y / 100.0)) * 100.0 if world.terrain else 0.0
var diff_bg = actor_z - bg_h
var diff_terrain = actor_z - terrain_h
# Find textures
var texs := []
var min_vert_z := 1e9
for d in ds:
var t: String = d.get("texture0", "")
if not t.is_empty():
texs.append(t.get_file())
for p in d["positions"]:
min_vert_z = minf(min_vert_z, actor_z + p.z)
if absf(diff_bg) > 5.0 or absf(diff_terrain) > 5.0:
anomalies.append({
"pos": [map_x, map_y, actor_z],
"bg_h": bg_h,
"terrain_h": terrain_h,
"diff_bg": diff_bg,
"diff_terrain": diff_terrain,
"vert_min_diff": min_vert_z - bg_h,
"textures": texs
})
print("Found %d anomalies with gap > 5cm!" % anomalies.size())
for a in anomalies.slice(0, 30):
print("Anomaly: pos=(%.1f, %.1f, %.1f), bg_h=%.1f, terrain_h=%.1f, diff_bg=%.1f, diff_terrain=%.1f, vert_min_diff=%.1f, tex=%s" % [
a["pos"][0], a["pos"][1], a["pos"][2], a["bg_h"], a["terrain_h"], a["diff_bg"], a["diff_terrain"], a["vert_min_diff"], a["textures"]
])
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://xn0yhjq7h8d
+143
View File
@@ -0,0 +1,143 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app failed: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
_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()"
)
_type(OS.get_environment("MT_LIVE_LOGIN"))
_press(KEY_TAB)
_type(OS.get_environment("MT_LIVE_PASSWORD"))
_py("del _env, _w")
_press(KEY_ENTER)
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
for i in 30:
await process_frame
print("=== DEBUG HEIGHT REPORT ===")
var test_points = [
Vector2(59700, 68200),
Vector2(59700, 66400),
Vector2(60100, 66400),
Vector2(50000, 50000),
Vector2(30000, 30000),
Vector2(70000, 70000),
]
for pt in test_points:
var bg_h = Metin2PythonHost.evaluate("str(background.GetHeight(%f, %f))" % [pt.x, pt.y])
var tm_h = float(world.terrain.call("sample_height", pt.x / 100.0, pt.y / 100.0)) * 100.0 if world.terrain else 0.0
print("Point (%.0f, %.0f): 40250_bg_h=%s, Metin2World_h=%.1f, diff=%.1f" % [
pt.x, pt.y, bg_h, tm_h, float(bg_h) - tm_h
])
# Inspect terrain mesh triangles around dog
print("=== TERRAIN MESH VERTICES AROUND DOG (59700, -66400) ===")
# In camera space (since Camera3D is at origin):
# Dog world pos in 40250: (59700, -66400, 19851.5)
# What is dog in camera space?
var draws = Metin2PythonHost.render3d_draws()
for i in draws.size():
var d = draws[i]
var tex: String = d.get("texture0", "")
if tex.contains("stray_dog"):
var wm: PackedFloat32Array = d.get("world", PackedFloat32Array())
var vm: PackedFloat32Array = d.get("view", PackedFloat32Array())
var xform: Transform3D = Python3DSurface.d3d_transform(Python3DSurface.multiply(wm, vm))
print("DOG camera-space transform origin: ", xform.origin)
# Now let's check terrain mesh global transform and vertices:
for child in world.terrain.find_children("*", "MeshInstance3D", true, false):
var mi := child as MeshInstance3D
if mi.mesh is ArrayMesh:
var am := mi.mesh as ArrayMesh
var arrays := am.surface_get_arrays(0)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for v in verts:
var gv: Vector3 = mi.global_transform * v
# Dog in camera space is around xform.origin. Let's find terrain vertices within 200cm:
if abs(gv.x - xform.origin.x) < 200.0 and abs(gv.z - xform.origin.z) < 200.0:
print("Terrain vertex near dog in camera-space: ", gv, " dog origin: ", xform.origin, " diff_y (height in cam space): ", gv.y - xform.origin.y)
break
break
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://mpcddwfiipkc
+71
View File
@@ -0,0 +1,71 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
print("=== IN INTRO SELECT PHASE ===")
for i in 60:
await process_frame
var cmds: Array = Metin2PythonHost.ui_render_commands()
print("UI commands count: ", cmds.size())
var file = FileAccess.open("/tmp/select_ui_cmds.json", FileAccess.WRITE)
file.store_string(JSON.stringify(cmds, " "))
file.close()
print("Saved /tmp/select_ui_cmds.json")
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://c0k854rmu55u7
+70
View File
@@ -0,0 +1,70 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
var world = Python3DSurface.new()
var ui = PythonUISurface.new()
root.add_child(ui)
root.add_child(world)
ui.run_app()
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py("assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n_stream.curPhaseWindow.Connect('%s', '%s')" % [host, game_port, host, auth_port, login, pwd])
await _pump_until(30, func(): return _py("assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py("assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
for i in 60:
await process_frame
print("=== INSPECTING STONE TILES AROUND PLAYER (59700, 68200) ===")
var draws: Array = Metin2PythonHost.render3d_draws()
var player_z := 19851.5
for d in draws:
var tex: String = d.get("texture0", "")
if tex.contains("stone") or tex.contains("floor") or tex.contains("tile") or tex.contains("sign"):
var wm: PackedFloat32Array = d["world"]
var xform: Transform3D = Python3DSurface.d3d_transform(wm)
# check if this object is near player (within 2000cm):
if absf(xform.origin.x - 59700.0) < 3000.0 and absf(-xform.origin.y - 68200.0) < 3000.0:
var min_z := 1e9
var max_z := -1e9
for p in d["positions"]:
var wp: Vector3 = xform * p
min_z = minf(min_z, wp.z)
max_z = maxf(max_z, wp.z)
print("Object [%s] at (%.1f, %.1f, %.1f), Z range: [%.2f, %.2f], diff vs player_z(19851.5): min=%.2f, max=%.2f" % [
tex.get_file(), xform.origin.x, -xform.origin.y, xform.origin.z, min_z, max_z, min_z - player_z, max_z - player_z
])
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://cy6rh17pn00hd
+115
View File
@@ -0,0 +1,115 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr, app\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
print("=== IN-GAME LOADED ===")
for i in 10:
await process_frame
print("--- EQUIPPING WEAPON 10 ---")
_py("chr.SelectInstance(player.GetMainCharacterIndex())\nchr.SetWeapon(10)")
for i in 10:
await process_frame
var check_draws = func(label: String):
var draws: Array = Metin2PythonHost.render3d_draws()
var body_draw = null
var weapon_draws = []
for d in draws:
var tex: String = d.get("texture0", "").to_lower()
if tex.contains("warrior") and not tex.contains("hair"):
body_draw = d
elif tex.contains("weapon") or tex.contains("sword") or tex.contains("00010") or tex.contains("00040"):
weapon_draws.append(d)
print("[%s] Total draws=%d, weapon_draws=%d" % [label, draws.size(), weapon_draws.size()])
var b_xf: Transform3D = Transform3D()
if body_draw != null:
var wm: PackedFloat32Array = body_draw["world"]
b_xf = Python3DSurface.d3d_transform(wm)
print(" Body origin: ", b_xf.origin, " tex=", body_draw.get("texture0", ""))
else:
print(" Body draw not found!")
for w in weapon_draws:
var wm: PackedFloat32Array = w["world"]
var xf: Transform3D = Python3DSurface.d3d_transform(wm)
var dist := xf.origin.distance_to(b_xf.origin) if body_draw != null else 0.0
print(" Weapon origin: ", xf.origin, " dist_to_body=%.2f cm tex=%s" % [dist, w.get("texture0", "")])
check_draws.call("IDLE_FRAME_0")
# Start moving!
print("--- STARTING MOVEMENT (DIK_UP) ---")
_py("player.SetSingleDIKKeyState(app.DIK_UP, True)")
for frame in range(1, 40):
await process_frame
if frame % 5 == 0:
check_draws.call("MOVING_FRAME_%d" % frame)
_py("player.SetSingleDIKKeyState(app.DIK_UP, False)")
for frame in range(1, 15):
await process_frame
check_draws.call("STOPPED")
quit(0)
+164 -29
View File
@@ -12,11 +12,13 @@
# in the recorded D3D camera's coordinate frame, and its .msenv supplies the character light.
extends Node3D
const AssetRoot = preload("res://asset_root.gd")
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 D3DCULL_CCW := 3
const D3DBLEND_ONE := 2
const D3DBLEND_SRCALPHA := 5
const D3DBLEND_INVSRCALPHA := 6
@@ -31,6 +33,11 @@ var _atlas: Array = []
var _terrain_attempted := false
var _last_terrain_focus := Vector2(INF, INF)
var _meshes: Array[MeshInstance3D] = []
var _geometry_cache := {}
var _geometry_frame := 0
var _bg_mesh: MeshInstance3D
var _bg_mat: StandardMaterial3D
var _bg_quad: QuadMesh
# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame.
var _materials := {}
@@ -39,6 +46,7 @@ var draw_count := 0
var unlit_stand_in_count := 0 # Compatibility report field; real lighting keeps this at zero.
func _ready() -> void:
get_viewport().transparent_bg = true
camera = Camera3D.new()
camera.name = "GameCamera"
camera.keep_aspect = Camera3D.KEEP_HEIGHT
@@ -53,8 +61,7 @@ func _ready() -> void:
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.background_mode = Environment.BG_CLEAR_COLOR
environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.environment.ambient_light_color = Color.BLACK
environment.environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR
@@ -62,7 +69,13 @@ func _ready() -> void:
func _process(_delta: float) -> void:
if Metin2PythonHost.is_running():
var profile_start := Time.get_ticks_usec() if _profile_frame else 0
update_frame()
if profile_start != 0:
profile_update_ms = float(Time.get_ticks_usec() - profile_start) / 1000.0
var _profile_frame := OS.has_environment("MT_PROFILE_FRAME")
var profile_update_ms := 0.0
# 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.
@@ -70,18 +83,8 @@ 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:
_geometry_frame += 1
var draws: Array = Metin2PythonHost.render3d_draws()
var native_map: String = Metin2PythonHost.current_map_name()
if terrain != null and not native_map.is_empty() and native_map != map_path:
@@ -93,6 +96,7 @@ func update_frame() -> void:
var shown := 0
var camera_set := false
var light_set := false
var terrain_view := PackedFloat32Array()
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():
@@ -102,21 +106,25 @@ func update_frame() -> void:
if proj[11] != -1.0:
continue
if not camera_set:
_apply_projection(proj)
_apply_projection(proj, draw)
camera_set = true
var view: PackedFloat32Array = draw["view"]
if terrain == null and not _terrain_attempted:
_load_terrain(draw)
if terrain != null:
_place_terrain(view)
terrain_view = view
if not light_set and draw["light0"]:
_apply_light(draw, view)
light_set = true
var instance := _mesh_instance(shown)
instance.transform = d3d_transform(multiply(draw["world"], view))
instance.mesh = _build_mesh(draw)
instance.transform = d3d_transform(view) * d3d_transform(draw["world"])
instance.mesh = _mesh_for_draw(draw)
instance.material_override = _material(draw)
instance.sorting_offset = float(shown) * 0.01
instance.visible = true
shown += 1
if terrain != null and not terrain_view.is_empty():
_place_terrain(terrain_view)
for i in range(shown, _meshes.size()):
_meshes[i].visible = false
_meshes[i].mesh = null
@@ -124,17 +132,75 @@ func update_frame() -> void:
light.visible = false
draw_count = shown
unlit_stand_in_count = 0
_update_background()
if _geometry_frame % 120 == 0:
for key in _geometry_cache.keys():
if _geometry_frame - int(_geometry_cache[key][2]) > 120:
_geometry_cache.erase(key)
func _update_background() -> void:
var bg_cmd: Dictionary = {}
for cmd in Metin2PythonHost.ui_render_commands_batched():
if cmd.get("behind_3d", false) and cmd.get("kind", "") == "image":
bg_cmd = cmd
break
if bg_cmd.is_empty():
if _bg_mesh != null:
_bg_mesh.visible = false
return
if _bg_mesh == null:
_bg_mesh = MeshInstance3D.new()
_bg_mesh.name = "BackgroundQuad"
_bg_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_bg_quad = QuadMesh.new()
_bg_mesh.mesh = _bg_quad
_bg_mat = StandardMaterial3D.new()
_bg_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_bg_mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_ALWAYS
_bg_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
_bg_mesh.material_override = _bg_mat
add_child(_bg_mesh)
var bg_tex_name: String = bg_cmd.get("text", "")
var tex := UiAssets.load_tex(AssetRoot.path(), bg_tex_name)
if tex is AtlasTexture:
var at: AtlasTexture = tex
_bg_mat.albedo_texture = at.atlas
var asize: Vector2 = at.atlas.get_size()
var r: Rect2 = at.region
_bg_mat.uv1_scale = Vector3(r.size.x / asize.x, r.size.y / asize.y, 1.0)
_bg_mat.uv1_offset = Vector3(r.position.x / asize.x, r.position.y / asize.y, 0.0)
else:
_bg_mat.albedo_texture = tex
if bg_cmd.has("uv"):
var uvs: PackedVector2Array = bg_cmd["uv"]
_bg_mat.uv1_scale = Vector3(uvs[3].x - uvs[0].x, uvs[3].y - uvs[0].y, 1.0)
_bg_mat.uv1_offset = Vector3(uvs[0].x, uvs[0].y, 0.0)
else:
_bg_mat.uv1_scale = Vector3(1.0, 1.0, 1.0)
_bg_mat.uv1_offset = Vector3(0.0, 0.0, 0.0)
var vp_size := get_viewport().get_visible_rect().size
var dist := 2800.0
var v_size := 2.0 * dist * tan(deg_to_rad(camera.fov) / 2.0)
var h_size := v_size * (float(vp_size.x) / float(vp_size.y))
_bg_quad.size = Vector2(h_size, v_size)
_bg_mesh.transform = Transform3D(Basis(), Vector3(0.0, 0.0, -dist))
_bg_mesh.visible = true
func _load_terrain(draw: Dictionary) -> void:
if not ClassDB.class_exists("Metin2World"):
return
var native_map: String = Metin2PythonHost.current_map_name()
if native_map.is_empty():
return
var camera_world := d3d_transform(draw["view"]).affine_inverse().origin
var focus := Vector2(camera_world.x, -camera_world.y)
if focus.distance_to(_last_terrain_focus) < 1000.0:
return
_last_terrain_focus = focus
var native_map: String = Metin2PythonHost.current_map_name()
map_path = native_map if not native_map.is_empty() else _map_for_position(focus)
map_path = native_map
terrain = ClassDB.instantiate("Metin2World")
terrain.name = "PackTerrain"
terrain.set("auto_load", false)
@@ -212,11 +278,38 @@ func _place_terrain(view: PackedFloat32Array) -> void:
Vector3(0, -100, 0)), Vector3.ZERO)
terrain.transform = d3d_transform(view) * from_map
func _apply_projection(proj: PackedFloat32Array) -> void:
func _apply_projection(proj: PackedFloat32Array, draw: Dictionary = {}) -> 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)
var fov_deg := rad_to_deg(2.0 * atan(1.0 / proj[5]))
var zn: float = proj[14] / proj[10]
var zf: float = proj[14] / (proj[10] + 1.0)
camera.fov = fov_deg
camera.near = zn
camera.far = zf
var vp_offset := Vector2.ZERO
if draw.has("viewport"):
var vp: PackedFloat32Array = draw["viewport"]
var vp_x: float = vp[0]
var vp_y: float = vp[1]
var vp_w: float = vp[2]
var vp_h: float = vp[3]
var vp_size := get_viewport().get_visible_rect().size
if vp_w > 0.0 and vp_h > 0.0 and (vp_w < vp_size.x or vp_h < vp_size.y or vp_x > 0.0 or vp_y > 0.0):
var offset_px_x: float = (vp_x + vp_w / 2.0) - (vp_size.x / 2.0)
var offset_px_y: float = (vp_y + vp_h / 2.0) - (vp_size.y / 2.0)
if absf(offset_px_x) > 0.5 or absf(offset_px_y) > 0.5:
var v_size_near := 2.0 * zn * tan(deg_to_rad(fov_deg) / 2.0)
var h_size_near := v_size_near * (vp_size.x / vp_size.y)
var off_near_x := (offset_px_x / vp_size.x) * h_size_near
var off_near_y := -(offset_px_y / vp_size.y) * v_size_near
vp_offset = Vector2(-off_near_x, off_near_y)
var v_size_near := 2.0 * zn * tan(deg_to_rad(fov_deg) / 2.0)
if vp_offset != Vector2.ZERO:
camera.set_frustum(v_size_near, vp_offset, zn, zf)
else:
camera.set_perspective(fov_deg, zn, zf)
func _apply_light(draw: Dictionary, view: PackedFloat32Array) -> void:
var view_basis := d3d_transform(view).basis
@@ -253,6 +346,19 @@ func _mesh_instance(index: int) -> MeshInstance3D:
_meshes.append(instance)
return _meshes[index]
func _mesh_for_draw(draw: Dictionary) -> ArrayMesh:
var key: int = draw.get("geometry_key", 0)
if key == 0:
return _build_mesh(draw)
var revision: int = draw.get("geometry_revision", 0)
var cached: Array = _geometry_cache.get(key, [])
if not cached.is_empty() and int(cached[0]) == revision:
cached[2] = _geometry_frame
return cached[1]
var mesh := _build_mesh(draw)
_geometry_cache[key] = [revision, mesh, _geometry_frame]
return mesh
func _build_mesh(draw: Dictionary) -> ArrayMesh:
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
@@ -266,15 +372,27 @@ func _build_mesh(draw: Dictionary) -> ArrayMesh:
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"]
var z_write: int = draw.get("z_write", 1)
var tf: int = int(draw.get("texture_factor", 0xFFFFFFFF))
var tf_a: float = float((tf >> 24) & 0xFF) / 255.0
var tf_r: float = float((tf >> 16) & 0xFF) / 255.0
var tf_g: float = float((tf >> 8) & 0xFF) / 255.0
var tf_b: float = float(tf & 0xFF) / 255.0
var factor_color := Color(tf_r, tf_g, tf_b, tf_a)
var uses_tf: bool = bool(draw.get("uses_tf", false))
var has_tf: bool = uses_tf and (tf != 0xFFFFFFFF and tf != -1)
var tf_key := "%02x%02x%02x%02x" % [int(tf_r * 63.0), int(tf_g * 63.0), int(tf_b * 63.0), int(tf_a * 63.0)] if has_tf else ""
var has_diffuse: bool = draw.has("diffuse")
var is_alpha: bool = draw["alpha_blend"] or (has_tf and factor_color.a < 0.99)
# alpha_blend / alpha_test / lit are bools: %d needs them as ints.
var key := "%s|%d|%d|%d|%d|%d|%d|%d|%s|%s" % [draw["texture0"], int(draw["alpha_blend"]), draw["src_blend"],
var key := "%s|%d|%d|%d|%d|%d|%d|%d|%d|%d|%s|%s|%s" % [draw["texture0"], int(draw["alpha_blend"]), draw["src_blend"],
draw["dest_blend"], int(draw["alpha_test"]), draw["alpha_ref"], draw["cull_mode"], int(lit),
draw["material_diffuse"], draw["material_emissive"]]
int(has_diffuse), z_write, draw["material_diffuse"], draw["material_emissive"], tf_key]
if _materials.has(key):
return _materials[key]
var material := StandardMaterial3D.new()
@@ -282,7 +400,14 @@ func _material(draw: Dictionary) -> StandardMaterial3D:
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
var base_diffuse: Color = draw["material_diffuse"] if lit else Color.WHITE
if has_tf:
material.albedo_color = Color(base_diffuse.r * factor_color.r, base_diffuse.g * factor_color.g,
base_diffuse.b * factor_color.b, base_diffuse.a * factor_color.a)
else:
material.albedo_color = base_diffuse
material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0)
if material.emission_enabled:
material.emission = draw["material_emissive"]
@@ -296,18 +421,28 @@ func _material(draw: Dictionary) -> StandardMaterial3D:
else BaseMaterial3D.SHADING_MODE_UNSHADED
material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
material.roughness = 1.0
material.vertex_color_use_as_albedo = draw.has("diffuse")
material.vertex_color_use_as_albedo = has_diffuse
match int(draw["cull_mode"]):
D3DCULL_NONE:
material.cull_mode = BaseMaterial3D.CULL_DISABLED
D3DCULL_CW:
material.cull_mode = BaseMaterial3D.CULL_BACK
D3DCULL_CCW:
material.cull_mode = BaseMaterial3D.CULL_FRONT
_:
material.cull_mode = BaseMaterial3D.CULL_BACK
if draw["alpha_blend"]:
if z_write == 0:
material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
elif is_alpha:
material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_ALWAYS
else:
material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY
if is_alpha:
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
if int(draw["dest_blend"]) == D3DBLEND_ONE:
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
else:
material.blend_mode = BaseMaterial3D.BLEND_MODE_MIX
elif draw["alpha_test"]:
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
material.alpha_scissor_threshold = float(draw["alpha_ref"]) / 255.0
+126 -11
View File
@@ -2,6 +2,7 @@
# 登录 → 选人 → 进游戏,把 RenderGame 的绘制命令(python_3d_surface.gd)画出来并截图。
#
# godot --path project --script res://python_game_render_test.gd
# MT_FAKE_MOB_COUNT=24 MT_PROFILE_FRAME=1 MT_PROFILE_COMBAT=1 script/python_game_render_test.sh
#
# 服务器与账号只从环境变量读(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 取,不进源码文本。
@@ -72,8 +73,12 @@ func run() -> void:
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())
elif not output.is_absolute_path():
output = ProjectSettings.globalize_path("res://../" + output)
DirAccess.make_dir_recursive_absolute(output)
var report := {"output": output, "headless": DisplayServer.get_name() == "headless"}
if OS.has_environment("MT_FAKE_MOB_COUNT"):
report["fake_mob_count"] = int(OS.get_environment("MT_FAKE_MOB_COUNT"))
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():
@@ -132,9 +137,63 @@ func run() -> void:
# 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:
# Let the WAIT motion run a little before the picture, and let GamePhase (3 packets/frame)
# finish draining the entergame burst + GC_PING when MT_FAKE_MOB_COUNT is large.
var warmup_frames := maxi(30, int(report.get("fake_mob_count", 1)) + 20)
for i in warmup_frames:
await process_frame
if OS.has_environment("MT_PROFILE_FRAME"):
var frame_ms: Array[float] = []
var ui_update_ms: Array[float] = []
var ui_draw_ms: Array[float] = []
var world_update_ms: Array[float] = []
var previous := Time.get_ticks_usec()
for i in 240:
await process_frame
var current := Time.get_ticks_usec()
frame_ms.append(float(current - previous) / 1000.0)
ui_update_ms.append(ui.profile_ui_update_ms)
ui_draw_ms.append(ui.profile_draw_ms)
world_update_ms.append(world.profile_update_ms)
previous = current
frame_ms.sort()
ui_update_ms.sort()
ui_draw_ms.sort()
world_update_ms.sort()
var total := 0.0
for sample in frame_ms:
total += sample
report["frame_profile"] = {
"samples": frame_ms.size(),
"mean_ms": total / frame_ms.size(),
"p50_ms": frame_ms[119],
"p95_ms": frame_ms[227],
"ui_update_p50_ms": ui_update_ms[119],
"ui_draw_p50_ms": ui_draw_ms[119],
"world_update_p50_ms": world_update_ms[119],
}
var command_kinds := {}
var image_texts := {}
for command in Metin2PythonHost.ui_render_commands():
var kind: String = str(command.get("kind", "unknown"))
command_kinds[kind] = int(command_kinds.get(kind, 0)) + 1
if kind == "image":
var image_name: String = str(command.get("text", ""))
image_texts[image_name] = int(image_texts.get(image_name, 0)) + 1
report["frame_profile"]["ui_commands"] = command_kinds
report["frame_profile"]["image_texts"] = image_texts
var batched_commands := 0
var glyph_batches := 0
var batched_glyphs := 0
for command in Metin2PythonHost.ui_render_commands_batched():
batched_commands += 1
if command.get("kind", "") == "glyph_batch":
glyph_batches += 1
batched_glyphs += int(command.get("glyph_count", 0))
report["frame_profile"]["batched_commands"] = batched_commands
report["frame_profile"]["glyph_batches"] = glyph_batches
report["frame_profile"]["batched_glyphs"] = batched_glyphs
print("FRAME_PROFILE ", report["frame_profile"])
report["draw_count"] = world.draw_count
report["unlit_stand_in_count"] = world.unlit_stand_in_count
report["terrain"] = world.terrain_report
@@ -170,14 +229,25 @@ func run() -> void:
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
# Identify character, monster and NPC draws (excluding map objects, buildings, trees and roofs).
var actor_draws: Array = []
for draw in draws:
var tex: String = String(draw.get("texture0", "")).to_lower().replace("\\", "/")
for k in ["pc/", "pc2/", "monster/", "npc/"]:
if k in tex:
actor_draws.append(draw)
break
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 actor_foot_cm := INF
var foot_xy := Vector2.ZERO
for draw in actor_draws:
var world_xform: Transform3D = world.d3d_transform(draw["world"])
for vertex in draw["positions"]:
var p: Vector3 = world_xform * vertex
@@ -187,14 +257,14 @@ func run() -> void:
# 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:
for draw in actor_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:
if world.terrain and not actor_low.is_empty():
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
@@ -244,7 +314,7 @@ func run() -> void:
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)
var material: StandardMaterial3D = instance.get_active_material(0)
if material and material.albedo_texture:
surfaces_textured += 1
distinct_textures[material.albedo_texture] = true
@@ -353,7 +423,7 @@ func run() -> void:
for frame in 600:
await process_frame
now = dog_screen.call()
if now.distance_to(last) < 0.5:
if now.x >= 0.0 and now.distance_to(last) < 0.5:
break
last = now
return now
@@ -377,15 +447,60 @@ func run() -> void:
await process_frame
button.call(true, at)
var mid_shot := false
var dead := await _pump_until(20, func():
var combat_condition := 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":
if not mid_shot and Metin2PythonHost.evaluate("33 in _target_hp") == "True" and DisplayServer.get_name() != "headless" and not OS.has_environment("MT_PROFILE_COMBAT"):
mid_shot = true
root.get_texture().get_image().save_png(output.path_join("combat_mid.png"))
return Metin2PythonHost.evaluate("_target_hp[-1:] == [0]") == "True")
return Metin2PythonHost.evaluate("_target_hp[-1:] == [0]") == "True"
var dead := false
if OS.has_environment("MT_PROFILE_COMBAT"):
var combat_frames: Array[float] = []
var combat_ui_update: Array[float] = []
var combat_ui_draw: Array[float] = []
var combat_world_update: Array[float] = []
var combat_draw_counts: Array[int] = []
var deadline := Time.get_ticks_msec() + 20000
var previous := Time.get_ticks_usec()
while Time.get_ticks_msec() < deadline:
if combat_condition.call():
dead = true
break
await process_frame
var current := Time.get_ticks_usec()
combat_frames.append(float(current - previous) / 1000.0)
combat_ui_update.append(ui.profile_ui_update_ms)
combat_ui_draw.append(ui.profile_draw_ms)
combat_world_update.append(world.profile_update_ms)
combat_draw_counts.append(world.draw_count)
previous = current
combat_frames.sort()
combat_ui_update.sort()
combat_ui_draw.sort()
combat_world_update.sort()
combat_draw_counts.sort()
if not combat_frames.is_empty():
var n := combat_frames.size()
report["combat_profile"] = {
"samples": n,
"frame_p50_ms": combat_frames[n / 2],
"frame_p95_ms": combat_frames[mini(n - 1, int(n * 0.95))],
"frame_max_ms": combat_frames[n - 1],
"ui_update_p50_ms": combat_ui_update[n / 2],
"ui_update_p95_ms": combat_ui_update[mini(n - 1, int(n * 0.95))],
"ui_draw_p50_ms": combat_ui_draw[n / 2],
"ui_draw_p95_ms": combat_ui_draw[mini(n - 1, int(n * 0.95))],
"world_update_p50_ms": combat_world_update[n / 2],
"world_update_p95_ms": combat_world_update[mini(n - 1, int(n * 0.95))],
"draw_count_p50": combat_draw_counts[n / 2],
"draw_count_max": combat_draw_counts[n - 1],
}
print("COMBAT_PROFILE ", report["combat_profile"])
else:
dead = await _pump_until(20, combat_condition)
button.call(false, at)
if DisplayServer.get_name() != "headless":
root.get_texture().get_image().save_png(output.path_join("combat.png"))
+284 -70
View File
@@ -8,16 +8,26 @@ const UiAssets = preload("res://ui/ui_assets.gd")
const KEY_TO_DIK := {
KEY_ESCAPE: 0x01, KEY_1: 0x02, KEY_2: 0x03, KEY_3: 0x04,
KEY_4: 0x05, KEY_5: 0x06, KEY_6: 0x07, KEY_7: 0x08,
KEY_8: 0x09, KEY_9: 0x0a, KEY_0: 0x0b, KEY_BACKSPACE: 0x0e,
KEY_TAB: 0x0f, KEY_Q: 0x10, KEY_W: 0x11, KEY_E: 0x12,
KEY_R: 0x13, KEY_T: 0x14, KEY_Y: 0x15, KEY_U: 0x16,
KEY_I: 0x17, KEY_O: 0x18, KEY_P: 0x19, KEY_ENTER: 0x1c,
KEY_A: 0x1e, KEY_S: 0x1f, KEY_D: 0x20, KEY_F: 0x21,
KEY_G: 0x22, KEY_H: 0x23, KEY_J: 0x24, KEY_K: 0x25,
KEY_L: 0x26, KEY_Z: 0x2c, KEY_X: 0x2d, KEY_C: 0x2e,
KEY_8: 0x09, KEY_9: 0x0a, KEY_0: 0x0b, KEY_MINUS: 0x0c,
KEY_EQUAL: 0x0d, KEY_BACKSPACE: 0x0e, KEY_TAB: 0x0f,
KEY_Q: 0x10, KEY_W: 0x11, KEY_E: 0x12, KEY_R: 0x13,
KEY_T: 0x14, KEY_Y: 0x15, KEY_U: 0x16, KEY_I: 0x17,
KEY_O: 0x18, KEY_P: 0x19, KEY_BRACKETLEFT: 0x1a, KEY_BRACKETRIGHT: 0x1b,
KEY_ENTER: 0x1c, KEY_CTRL: 0x1d, KEY_A: 0x1e, KEY_S: 0x1f,
KEY_D: 0x20, KEY_F: 0x21, KEY_G: 0x22, KEY_H: 0x23,
KEY_J: 0x24, KEY_K: 0x25, KEY_L: 0x26, KEY_SEMICOLON: 0x27,
KEY_APOSTROPHE: 0x28, KEY_QUOTELEFT: 0x29, KEY_SHIFT: 0x2a,
KEY_BACKSLASH: 0x2b, KEY_Z: 0x2c, KEY_X: 0x2d, KEY_C: 0x2e,
KEY_V: 0x2f, KEY_B: 0x30, KEY_N: 0x31, KEY_M: 0x32,
KEY_SPACE: 0x39, KEY_UP: 0xc8, KEY_LEFT: 0xcb,
KEY_RIGHT: 0xcd, KEY_DOWN: 0xd0,
KEY_COMMA: 0x33, KEY_PERIOD: 0x34, KEY_SLASH: 0x35,
KEY_ALT: 0x38, KEY_SPACE: 0x39,
KEY_F1: 0x3b, KEY_F2: 0x3c, KEY_F3: 0x3d, KEY_F4: 0x3e,
KEY_F5: 0x3f, KEY_F6: 0x40, KEY_F7: 0x41, KEY_F8: 0x42,
KEY_F9: 0x43, KEY_F10: 0x44, KEY_F11: 0x57, KEY_F12: 0x58,
KEY_HOME: 0xc7, KEY_UP: 0xc8, KEY_PAGEUP: 0xc9,
KEY_LEFT: 0xcb, KEY_RIGHT: 0xcd, KEY_END: 0xcf,
KEY_DOWN: 0xd0, KEY_PAGEDOWN: 0xd1, KEY_INSERT: 0xd2,
KEY_DELETE: 0xd3,
}
# Win32 virtual-key codes of the keys EditLine.OnIMEKeyDown handles (WM_KEYDOWN → OnIMEKeyDown);
@@ -36,6 +46,11 @@ const KEY_TO_CHAR := {
# "mem:<id>" → [revision, ImageTexture]: the CGraphicFontTexture glyph pages, refetched when the
# "@<revision>" of a command's name moves on (a glyph was added to the page).
var _memory_textures := {}
var _tex_info_cache := {}
var _color_cache := {}
var _white_color_array := PackedColorArray([Color.WHITE])
var _asset_root_path := ""
var _segments_used := 0
# True when this surface started system.py itself (run_app) and so owns the interpreter's lifetime.
var _owns_app := false
@@ -80,6 +95,8 @@ func _exit_tree() -> void:
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED
_asset_root_path = AssetRoot.path()
_sync_size()
func _notification(what: int) -> void:
@@ -100,7 +117,10 @@ func _process(_delta: float) -> void:
get_tree().quit(0)
return
if Metin2PythonHost.is_running():
var profile_start := Time.get_ticks_usec() if _profile_frame else 0
Metin2PythonHost.ui_update()
if profile_start != 0:
profile_ui_update_ms = float(Time.get_ticks_usec() - profile_start) / 1000.0
queue_redraw()
func _gui_input(event: InputEvent) -> void:
@@ -112,10 +132,30 @@ func _gui_input(event: InputEvent) -> void:
if event.button_index >= MOUSE_BUTTON_LEFT and event.button_index <= MOUSE_BUTTON_MIDDLE:
Metin2PythonHost.ui_mouse_button(event.button_index, event.pressed,
int(event.position.x), int(event.position.y))
elif event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
var factor: float = event.factor if event.factor > 0.0 else 1.0
Metin2PythonHost.ui_mouse_wheel(int(round(120.0 * factor)))
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
var factor: float = event.factor if event.factor > 0.0 else 1.0
Metin2PythonHost.ui_mouse_wheel(-int(round(120.0 * factor)))
elif event is InputEventPanGesture:
# macOS trackpad two-finger scroll: scrolling up has negative delta.y, zooming in
var delta: int = -int(round(event.delta.y * 30.0))
if delta != 0:
Metin2PythonHost.ui_mouse_wheel(delta)
elif event is InputEventMagnifyGesture:
# macOS trackpad pinch to zoom: factor > 1.0 is zoom in
var delta: int = int(round((event.factor - 1.0) * 600.0))
if delta != 0:
Metin2PythonHost.ui_mouse_wheel(delta)
func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey and Metin2PythonHost.is_running():
var dik: int = KEY_TO_DIK.get(event.physical_keycode, 0)
var code: int = event.physical_keycode
var dik: int = KEY_TO_DIK.get(code, 0)
if dik == 0 and event.keycode != 0:
dik = KEY_TO_DIK.get(event.keycode, 0)
if dik != 0:
Metin2PythonHost.ui_key(dik, event.pressed)
if event.pressed:
@@ -159,79 +199,247 @@ var _segments: Array[RID] = []
var _materials: Array[RID] = []
var _mask_shader := RID()
var _white: ImageTexture
var _profile_frame := OS.has_environment("MT_PROFILE_FRAME")
var profile_ui_update_ms := 0.0
var profile_draw_ms := 0.0
func _get_color_array(argb: int) -> PackedColorArray:
if argb == 0xFFFFFFFF or argb == -1:
return _white_color_array
var arr: PackedColorArray = _color_cache.get(argb, PackedColorArray())
if not arr.is_empty():
return arr
var c := Color8((argb >> 16) & 255, (argb >> 8) & 255, argb & 255, (argb >> 24) & 255)
arr = PackedColorArray([c])
_color_cache[argb] = arr
return arr
func _get_tex_draw_info(name: String) -> Array:
var info: Array = _tex_info_cache.get(name, [])
if not info.is_empty():
return info
var texture: Texture2D = _texture(name)
if texture == null:
_tex_info_cache[name] = []
return []
var tex_rid: RID = texture.get_rid()
var is_atlas := false
var u0 := 0.0
var v0 := 0.0
var du := 1.0
var dv := 1.0
var default_uv := PackedVector2Array()
if texture is AtlasTexture:
var at: AtlasTexture = texture
if at.atlas != null:
tex_rid = at.atlas.get_rid()
var asize: Vector2 = at.atlas.get_size()
if asize.x > 0 and asize.y > 0:
is_atlas = true
var r: Rect2 = at.region
u0 = r.position.x / asize.x
v0 = r.position.y / asize.y
du = r.size.x / asize.x
dv = r.size.y / asize.y
default_uv = PackedVector2Array([
Vector2(u0, v0),
Vector2(u0 + du, v0),
Vector2(u0 + du, v0 + dv),
Vector2(u0, v0 + dv)
])
if default_uv.is_empty():
default_uv = PackedVector2Array([
Vector2(0, 0),
Vector2(1, 0),
Vector2(1, 1),
Vector2(0, 1)
])
info = [tex_rid, is_atlas, u0, v0, du, dv, default_uv, texture]
_tex_info_cache[name] = info
return info
func _draw() -> void:
var used := 0
var profile_start := Time.get_ticks_usec() if _profile_frame else 0
var fg_used := 0
var fg_plain := false
var masked := 0
var plain := false
for segment in _segments:
RenderingServer.canvas_item_clear(segment)
for i in _segments_used:
RenderingServer.canvas_item_clear(_segments[i])
_segments_used = 0
if not Metin2PythonHost.is_running():
return
for command in Metin2PythonHost.ui_render_commands():
var argb: int = command["argb"]
var color := Color8((argb >> 16) & 255, (argb >> 8) & 255,
argb & 255, (argb >> 24) & 255)
var p1 := Vector2(command["x1"], command["y1"])
var p2 := Vector2(command["x2"], command["y2"])
var clip := Rect2(Vector2(command["clip_x1"], command["clip_y1"]),
Vector2(command["clip_x2"] - command["clip_x1"],
command["clip_y2"] - command["clip_y1"]))
if command["kind"] == "bar":
var visible := Rect2(p1, p2 - p1).intersection(clip)
if visible.has_area():
if not plain:
used = _segment(used, RID())
plain = true
RenderingServer.canvas_item_add_rect(_segments[used - 1], visible, color)
elif command["kind"] == "gradient_bar":
var visible := Rect2(p1, p2 - p1).intersection(clip)
if visible.has_area():
if not plain:
used = _segment(used, RID())
plain = true
var bottom_argb: int = command["end_argb"]
var has_3d: bool = Metin2PythonHost.has_3d_draws()
for command in Metin2PythonHost.ui_render_commands_batched():
# Skip background UI commands when 3D is active, as the 3D surface renders the background Quad
if has_3d and bool(command.get(&"behind_3d", false)):
continue
var kind: Variant = command[&"kind"]
if kind == &"glyph_batch":
var info := _get_tex_draw_info(command[&"text"])
if info.is_empty():
continue
if not fg_plain:
fg_used = _segment(fg_used, RID())
fg_plain = true
RenderingServer.canvas_item_add_triangle_array(_segments[fg_used - 1],
command[&"indices"], command[&"points"], command[&"colors"],
command[&"uvs"], PackedInt32Array(), PackedFloat32Array(), info[0])
continue
var x1: float = command[&"x1"]
var y1: float = command[&"y1"]
var x2: float = command[&"x2"]
var y2: float = command[&"y2"]
var cx1: float = command[&"clip_x1"]
var cy1: float = command[&"clip_y1"]
var cx2: float = command[&"clip_x2"]
var cy2: float = command[&"clip_y2"]
# 1) Completely culled by scissor?
if x2 <= cx1 or x1 >= cx2 or y2 <= cy1 or y1 >= cy2:
continue
var argb: int = command[&"argb"]
var target_segments := _segments
if kind == &"image":
var text_name: String = command.get(&"text", "")
var mask_name: String = command.get(&"mask", "")
var mask_tex: Texture2D = _texture(mask_name) if not mask_name.is_empty() else null
if text_name.is_empty() and mask_tex != null:
text_name = "__white__"
var info := _get_tex_draw_info(text_name)
if info.is_empty():
continue
var tex_rid: RID = info[0]
var is_atlas: bool = info[1]
var u0: float = info[2]
var v0: float = info[3]
var du: float = info[4]
var dv: float = info[5]
var quad: PackedVector2Array = command[&"quad"]
var quad_uv: PackedVector2Array = command[&"uv"]
var needs_clip := x1 < cx1 or x2 > cx2 or y1 < cy1 or y2 > cy2
var poly_pts: PackedVector2Array
var poly_uv: PackedVector2Array
if not needs_clip:
poly_pts = PackedVector2Array([quad[0], quad[1], quad[3], quad[2]])
var is_full_uv := quad_uv[0] == Vector2.ZERO and quad_uv[1] == Vector2(1, 0) and quad_uv[2] == Vector2(0, 1) and quad_uv[3] == Vector2.ONE
if is_full_uv:
poly_uv = info[6]
elif is_atlas:
poly_uv = PackedVector2Array([
Vector2(u0 + quad_uv[0].x * du, v0 + quad_uv[0].y * dv),
Vector2(u0 + quad_uv[1].x * du, v0 + quad_uv[1].y * dv),
Vector2(u0 + quad_uv[3].x * du, v0 + quad_uv[3].y * dv),
Vector2(u0 + quad_uv[2].x * du, v0 + quad_uv[2].y * dv)
])
else:
poly_uv = PackedVector2Array([quad_uv[0], quad_uv[1], quad_uv[3], quad_uv[2]])
else:
var mapped_uv: PackedVector2Array
if is_atlas:
mapped_uv = PackedVector2Array([
Vector2(u0 + quad_uv[0].x * du, v0 + quad_uv[0].y * dv),
Vector2(u0 + quad_uv[1].x * du, v0 + quad_uv[1].y * dv),
Vector2(u0 + quad_uv[2].x * du, v0 + quad_uv[2].y * dv),
Vector2(u0 + quad_uv[3].x * du, v0 + quad_uv[3].y * dv)
])
else:
mapped_uv = quad_uv
var clip_rect := Rect2(cx1, cy1, cx2 - cx1, cy2 - cy1)
var clipped := _clip_quad(quad, mapped_uv, clip_rect)
if clipped[0].size() < 3:
continue
poly_pts = clipped[0]
poly_uv = clipped[1]
if mask_tex != null:
var mat_rid := _mask_material(masked, mask_tex, quad_uv, command[&"mask_uv"])
fg_used = _segment(fg_used, mat_rid)
fg_plain = false
masked += 1
elif not fg_plain:
fg_used = _segment(fg_used, RID())
fg_plain = true
var seg_idx := fg_used - 1
RenderingServer.canvas_item_add_polygon(target_segments[seg_idx], poly_pts,
_get_color_array(argb), poly_uv, tex_rid)
elif kind == &"bar":
var bx1 := maxf(x1, cx1)
var by1 := maxf(y1, cy1)
var bx2 := minf(x2, cx2)
var by2 := minf(y2, cy2)
if bx2 > bx1 and by2 > by1:
if not fg_plain:
fg_used = _segment(fg_used, RID())
fg_plain = true
var seg_idx := fg_used - 1
var color := Color8((argb >> 16) & 255, (argb >> 8) & 255,
argb & 255, (argb >> 24) & 255)
RenderingServer.canvas_item_add_rect(target_segments[seg_idx],
Rect2(bx1, by1, bx2 - bx1, by2 - by1), color)
elif kind == &"gradient_bar":
var bx1 := maxf(x1, cx1)
var by1 := maxf(y1, cy1)
var bx2 := minf(x2, cx2)
var by2 := minf(y2, cy2)
if bx2 > bx1 and by2 > by1:
if not fg_plain:
fg_used = _segment(fg_used, RID())
fg_plain = true
var seg_idx := fg_used - 1
var color := Color8((argb >> 16) & 255, (argb >> 8) & 255,
argb & 255, (argb >> 24) & 255)
var bottom_argb: int = command[&"end_argb"]
var bottom := Color8((bottom_argb >> 16) & 255, (bottom_argb >> 8) & 255,
bottom_argb & 255, (bottom_argb >> 24) & 255)
var top_factor := (visible.position.y - p1.y) / (p2.y - p1.y)
var bottom_factor := (visible.end.y - p1.y) / (p2.y - p1.y)
RenderingServer.canvas_item_add_polygon(_segments[used - 1],
PackedVector2Array([visible.position, Vector2(visible.end.x, visible.position.y),
visible.end, Vector2(visible.position.x, visible.end.y)]),
var top_factor := (by1 - y1) / (y2 - y1) if y2 != y1 else 0.0
var bottom_factor := (by2 - y1) / (y2 - y1) if y2 != y1 else 1.0
RenderingServer.canvas_item_add_polygon(target_segments[seg_idx],
PackedVector2Array([Vector2(bx1, by1), Vector2(bx2, by1),
Vector2(bx2, by2), Vector2(bx1, by2)]),
PackedColorArray([color.lerp(bottom, top_factor), color.lerp(bottom, top_factor),
color.lerp(bottom, bottom_factor), color.lerp(bottom, bottom_factor)]))
elif command["kind"] == "line":
var segment := _clip_line(p1, p2, clip)
elif kind == &"line":
var clip_rect := Rect2(cx1, cy1, cx2 - cx1, cy2 - cy1)
var p1 := Vector2(x1, y1)
var p2 := Vector2(x2, y2)
var segment := _clip_line(p1, p2, clip_rect)
if segment.size() == 2:
if not plain:
used = _segment(used, RID())
plain = true
RenderingServer.canvas_item_add_line(_segments[used - 1], segment[0], segment[1], color)
elif command["kind"] == "image":
var texture: Texture2D = _texture(command["text"])
var mask: Texture2D = _texture(command["mask"]) if command.has("mask") else null
if mask != null and texture == null:
texture = _white_texture()
if texture != null:
var clipped := _clip_quad(command["quad"], command["uv"], clip)
if clipped[0].size() >= 3:
if mask != null:
used = _segment(used, _mask_material(masked, mask, command["uv"], command["mask_uv"]))
masked += 1
plain = false
elif not plain:
used = _segment(used, RID())
plain = true
RenderingServer.canvas_item_add_polygon(_segments[used - 1], clipped[0],
PackedColorArray([color]), clipped[1], texture.get_rid())
if not fg_plain:
fg_used = _segment(fg_used, RID())
fg_plain = true
var seg_idx := fg_used - 1
var color := Color8((argb >> 16) & 255, (argb >> 8) & 255,
argb & 255, (argb >> 24) & 255)
RenderingServer.canvas_item_add_line(target_segments[seg_idx], segment[0], segment[1], color)
_segments_used = fg_used
if profile_start != 0:
profile_draw_ms = float(Time.get_ticks_usec() - profile_start) / 1000.0
# Opens the next pooled child item (draw order = index) with the given material; returns the count used.
func _segment(used: int, material: RID) -> int:
if used == _segments.size():
var pool := _segments
var parent_item: RID = get_canvas_item()
if used == pool.size():
var item := RenderingServer.canvas_item_create()
RenderingServer.canvas_item_set_parent(item, get_canvas_item())
_segments.push_back(item)
var item := _segments[used]
RenderingServer.canvas_item_set_parent(item, parent_item)
RenderingServer.canvas_item_set_default_texture_repeat(item, RenderingServer.CANVAS_ITEM_TEXTURE_REPEAT_ENABLED)
pool.push_back(item)
var item := pool[used]
RenderingServer.canvas_item_set_draw_index(item, used)
RenderingServer.canvas_item_set_material(item, material)
return used + 1
@@ -260,8 +468,11 @@ func _white_texture() -> Texture2D:
return _white
func _texture(name: String) -> Texture2D:
if name == "__white__":
return _white_texture()
if not name.begins_with("mem:"):
return UiAssets.load_tex(AssetRoot.path(), name)
var root := _asset_root_path if not _asset_root_path.is_empty() else AssetRoot.path()
return UiAssets.load_tex(root, name)
var at := name.find("@")
var key := name.substr(0, at) if at >= 0 else name
var revision := int(name.substr(at + 1)) if at >= 0 else 0
@@ -292,6 +503,9 @@ func _clip_quad(quad: PackedVector2Array, uv: PackedVector2Array, clip: Rect2) -
var det := edge_u.cross(edge_v)
if is_zero_approx(det):
return [PackedVector2Array(), PackedVector2Array()]
if clip.has_point(quad[0]) and clip.has_point(quad[1]) \
and clip.has_point(quad[2]) and clip.has_point(quad[3]):
return [outline, PackedVector2Array([uv[0], uv[1], uv[3], uv[2]])]
var rect := PackedVector2Array([clip.position, Vector2(clip.end.x, clip.position.y),
clip.end, Vector2(clip.position.x, clip.end.y)])
var pieces := Geometry2D.intersect_polygons(outline, rect)
+14
View File
@@ -0,0 +1,14 @@
@tool
extends SceneTree
func _init() -> void:
var tw = Metin2World.new()
tw.set("assets_root", "pack://")
tw.set("map_path", "metin2_map_a1")
tw.set("auto_load", false)
tw.call("set_focus_tile", Vector2i(2, 2))
if tw.call("load_map"):
for y in range(68200, 66000, -100):
var h = float(tw.call("sample_height", 59700.0 / 100.0, float(y) / 100.0)) * 100.0
print("y = %d -> height = %.2f" % [y, h])
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://bt4n4goaei8mi
+107
View File
@@ -0,0 +1,107 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
print("ERROR: run_app: ", err)
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect, game, player, background, chr, chrmgr\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
var host = OS.get_environment("MT_LIVE_HOST")
var login = OS.get_environment("MT_LIVE_LOGIN")
var pwd = OS.get_environment("MT_LIVE_PASSWORD")
var auth_port = int(OS.get_environment("MT_LIVE_AUTH_PORT"))
var game_port = int(OS.get_environment("MT_LIVE_GAME_PORT"))
_py("_stream.SetConnectInfo('%s', %d, '%s', %d)\n" % [host, game_port, host, auth_port]
+ "_stream.curPhaseWindow.Connect('%s', '%s')" % [login, pwd])
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
_py("_stream.curPhaseWindow.SelectSlot(0)\n_stream.curPhaseWindow.StartGame()")
await _pump_until(60, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"
))
await _pump_until(10, func(): return world.draw_count > 0)
for i in 30:
await process_frame
var draws: Array = Metin2PythonHost.render3d_draws()
print("Game 3D draws count: ", draws.size())
# Find actual actors
var actor_draws: Array = []
for draw in draws:
var tex = draw.get("texture0", "").to_lower()
var is_actor = false
for k in ["pc/", "pc2/", "monster/", "npc/", "warrior", "assassin", "sura", "shaman", "dog", "wolf"]:
if k in tex:
is_actor = true
break
if is_actor:
actor_draws.append(draw)
print("Actual actor draws count: ", actor_draws.size())
var actor_low := {}
for draw in actor_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
print("Distinct actors count: ", actor_low.size())
if world.terrain:
var worst_actor_gap_cm := 0.0
for key in actor_low:
var low: Vector3 = actor_low[key]
var ground := float(world.terrain.call("sample_height", low.x / 100.0, -low.y / 100.0)) * 100.0
var gap = absf(low.z - ground)
print("Actor at (%f, %f): foot_z=%f, ground_z=%f, gap=%f cm" % [low.x, low.y, low.z, ground, gap])
worst_actor_gap_cm = maxf(worst_actor_gap_cm, gap)
print("WORST ACTUAL ACTOR GAP: ", worst_actor_gap_cm, " cm")
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://cmum40u4abuep
+13
View File
@@ -0,0 +1,13 @@
@tool
extends SceneTree
func _init() -> void:
var b := Basis(Vector3(1, 2, 3), Vector3(4, 5, 6), Vector3(7, 8, 9))
print("b.x = ", b.x)
print("b.y = ", b.y)
print("b.z = ", b.z)
var v := Vector3(1, 0, 0)
print("b * Vector3(1, 0, 0) = ", b * v)
var v2 := Vector3(0, 1, 0)
print("b * Vector3(0, 1, 0) = ", b * v2)
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://d17rcsnfyx418
+95
View File
@@ -0,0 +1,95 @@
extends SceneTree
const PythonUISurface = preload("res://python_ui_surface.gd")
const Python3DSurface = preload("res://python_3d_surface.gd")
var ui: Control
var world: Node3D
func _initialize() -> void:
call_deferred("run")
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 run() -> void:
root.size = Vector2i(1024, 768)
world = Python3DSurface.new()
world.name = "Python3DSurface"
ui = PythonUISurface.new()
ui.name = "PythonUISurface"
root.add_child(ui)
root.add_child(world)
var err: String = ui.run_app()
if err != "":
quit(1)
return
await _pump_until(15, func(): return _py(
"import __main__, os, networkModule, introLogin, introSelect\n"
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"
))
await _pump_until(20, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"
))
_py(
"_env = os.environ\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()"
)
_type(OS.get_environment("MT_LIVE_LOGIN"))
_press(KEY_TAB)
_type(OS.get_environment("MT_LIVE_PASSWORD"))
_py("del _env, _w")
_press(KEY_ENTER)
await _pump_until(30, func(): return _py(
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"
))
for i in 15:
await process_frame
var img_final = root.get_texture().get_image()
img_final.save_png("/tmp/select_character_final.png")
print("Saved /tmp/select_character_final.png")
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://dpi4fhed4wc28
+99 -20
View File
@@ -1,10 +1,10 @@
# UiAssets (P1) —— 解析 uiscript 里的图片路径(`.sub` / `.tga` / `.png` / `.jpg`)。
# UiAssets (P1) —— 解析 uiscript 里的图片路径(`.sub` / `.tga` / `.png` / `.jpg` / `.dds`)。
#
# 路径形如 `d:/ymir work/ui/public/middle_button_01.sub`。解析:
# 1) 去掉盘符前缀
# 2) 依次试: <assets>/<rel> · <assets>/<pack>/<rel>(散包目录)
# 3) `.sub` = 子图描述(title/image/left/top/right/bottom)→ 载入其 image + 裁剪成 AtlasTexture
# `.dds` 走 Metin2World.load_dds(C++ dxt 解码)运行时解成 Image。
# 1) 优先从 40250 Client/pack(Metin2Pack)取(保持 d:/ 盘符或规范相对路径)
# 2) 支持 pack 内 .sub 子图切片(AtlasTexture),支持 version 1.0 / 2.0 及公用 Public.dds / IntroEmpire.dds 母图寻址
# 3) 若 pack 找不到且指定了 assets_root,回退到散文件目录
# 4) .dds 走 Metin2World.load_dds(C++ dxt 解码)运行时解成 Image。
extends RefCounted
static var _cache := {}
@@ -19,21 +19,20 @@ static func load_dds_image(path: String) -> Image:
return null
if not ClassDB.class_exists("Metin2World"):
return null
# Metin2World is a Node, not RefCounted; a static reference never frees it.
# The decoder returns an independently owned Image, so release the helper
# after each decode (textures themselves remain cached by load_tex).
var helper: Object = ClassDB.instantiate("Metin2World")
var img = helper.call("load_dds", path)
helper.free()
return img if img is Image else null
static func load_tex(assets_root: String, vpath: String) -> Texture2D:
if vpath == "" or assets_root == "":
if vpath == "":
return null
var key := assets_root + "|" + vpath
var key := (assets_root if assets_root != "" else "pack") + "|" + vpath
if _cache.has(key):
return _cache[key]
var tex: Texture2D = _load_uncached(assets_root, vpath)
var tex: Texture2D = _load_pack_any(vpath)
if tex == null and assets_root != "":
tex = _load_uncached(assets_root, vpath)
_cache[key] = tex
return tex
@@ -45,8 +44,7 @@ static func load_pack_tex(vpath: String) -> Texture2D:
var key := "pack|" + vpath
if _cache.has(key):
return _cache[key]
# 40250 的包名带盘符(d:/ymir work/...),原样交给 CEterPackManager。
var tex: Texture2D = _load_pack_image(vpath.replace("\\", "/"))
var tex: Texture2D = _load_pack_any(vpath)
_cache[key] = tex
return tex
@@ -56,6 +54,93 @@ static func _strip_drive(p: String) -> String:
s = s.substr(2)
return s.lstrip("/")
static func _find_pack_path(vpath: String) -> String:
if not ClassDB.class_exists("Metin2Pack") or not Metin2Pack.is_ready():
return ""
var norm := vpath.replace("\\", "/").strip_edges()
var stripped := _strip_drive(norm)
var candidates: Array[String] = [
norm,
stripped,
"d:/" + stripped,
norm.to_lower(),
stripped.to_lower(),
("d:/" + stripped).to_lower(),
]
for c in candidates:
if Metin2Pack.exists(c):
return c
return ""
static func _load_pack_any(vpath: String) -> Texture2D:
var pack_path := _find_pack_path(vpath)
if pack_path == "":
return null
var ext := pack_path.get_extension().to_lower()
if ext == "sub":
return _load_pack_sub(pack_path)
return _load_pack_image(pack_path)
static func _load_pack_sub(pack_path: String) -> Texture2D:
if not ClassDB.class_exists("Metin2Pack") or not Metin2Pack.is_ready():
return null
if not Metin2Pack.exists(pack_path):
return null
var bytes: PackedByteArray = Metin2Pack.get_bytes(pack_path)
if bytes.is_empty():
return null
var txt := bytes.get_string_from_ascii()
var image_name := ""
var version := ""
var l := 0
var t := 0
var r := -1
var b := -1
for line in txt.split("\n"):
var parts := line.strip_edges().split(" ", false)
if parts.size() < 2:
continue
match parts[0].to_lower():
"version": version = parts[1].strip_edges().trim_prefix('"').trim_suffix('"')
"image": image_name = parts[1].strip_edges().trim_prefix('"').trim_suffix('"')
"left": l = int(parts[1])
"top": t = int(parts[1])
"right": r = int(parts[1])
"bottom": b = int(parts[1])
if image_name == "":
return null
var sub_dir := pack_path.get_base_dir()
var candidates: Array[String] = []
if version == "2.0":
candidates.append(sub_dir.path_join(image_name))
candidates.append("d:/ymir work/ui/".path_join(image_name))
else:
candidates.append("d:/ymir work/ui/".path_join(image_name))
candidates.append(sub_dir.path_join(image_name))
var search_dir := sub_dir
for _i in 4:
var parent := search_dir.get_base_dir()
if parent == search_dir or parent.is_empty():
break
candidates.append(parent.path_join(image_name))
search_dir = parent
var base_tex: Texture2D = null
for cand in candidates:
base_tex = _load_pack_any(cand)
if base_tex != null:
break
if base_tex == null:
return null
if r <= l or b <= t:
return base_tex
var at := AtlasTexture.new()
at.atlas = base_tex
at.region = Rect2(l, t, r - l, b - t)
return at
static func _resolve(assets_root: String, rel: String) -> String:
var direct := assets_root.path_join(rel)
if FileAccess.file_exists(direct):
@@ -67,20 +152,18 @@ static func _resolve(assets_root: String, rel: String) -> String:
var cand := assets_root.path_join(sub).path_join(rel)
if FileAccess.file_exists(cand):
return cand
# 大小写不敏感兜底:<assets>/**/ymir work/ui/... —— 只按 basename 找
return ""
static func _load_uncached(assets_root: String, vpath: String) -> Texture2D:
var rel := _strip_drive(vpath)
var real := _resolve(assets_root, rel)
if real == "":
# 试把 .sub 换成 .tga / .png
for ext: String in [".tga", ".png", ".jpg"]:
real = _resolve(assets_root, rel.get_basename() + ext)
if real != "":
break
if real == "":
return _load_pack_image(rel)
return _load_pack_any(vpath)
if real.get_extension().to_lower() == "sub":
return _load_sub(real)
return _load_image_file(real)
@@ -112,8 +195,6 @@ static func _load_pack_image(rel: String) -> Texture2D:
return ImageTexture.create_from_image(image) if image != null and not image.is_empty() else null
static func _load_image_file(path: String) -> Texture2D:
# .sub 文件可能引用了未随当前资源包发布的共享贴图(例如 Public.tga)。
# 先做存在性检查,避免 headless/UI fallback 因缺失可选贴图刷错误日志。
if not FileAccess.file_exists(path):
return null
var ext := path.get_extension().to_lower()
@@ -144,8 +225,6 @@ static func _load_sub(path: String) -> Texture2D:
"bottom": b = int(parts[1])
if image_name == "":
return null
# Skill .sub files live below ui/skill/<job>, while their shared DDS
# lives in ui/. Search the containing directory and its parents.
var img_path := ""
var search_dir := path.get_base_dir()
for _i in 6: