Files
mtgodot-poc/project/char_select_visual_test.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

147 lines
6.8 KiB
GDScript

# Render the selection screen with deterministic reference data; no login needed.
extends SceneTree
const Screen = preload("res://ui/char_select_screen.gd")
class Client extends Node:
func get_empire() -> int: return 3
func get_slot_count() -> int: return 4
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1472, 854)
var output := OS.get_environment("MT_RENDER_OUTPUT")
if output.is_empty():
output = ProjectSettings.globalize_path("res://../build/rendering/selection-%d-%d" % [Time.get_unix_time_from_system(), OS.get_process_id()])
DirAccess.make_dir_recursive_absolute(output)
var hair_part := 0
var job := 0
var main_part := 20009
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--hair="):
hair_part = int(arg.trim_prefix("--hair="))
elif arg.begins_with("--job="):
job = clampi(int(arg.trim_prefix("--job=")), 0, 7)
elif arg.begins_with("--main-part="):
main_part = max(0, int(arg.trim_prefix("--main-part=")))
var client := Client.new()
root.add_child(client)
var screen := Screen.new()
root.add_child(screen)
screen.setup(client, AssetRoot.path(), [{
"index": 0, "name": "[SA]Admin", "job": job, "level": 105,
"guild_name": "[GM-TEAM]", "play_minutes": 2180,
"ht": 90, "iq": 15, "st": 90, "dx": 90,
"main_part": main_part, "hair_part": hair_part,
}])
for i in 40:
await process_frame
if screen._pv == null:
printerr("FAIL: selection model did not load")
quit(1)
return
# R2 diagnostics: compare the exact same armour with the configured hair
# removed. This distinguishes an absent/incorrect face surface from a hair
# depth or alpha problem; normal visual tests keep the real HairIndex 0.
if "--no-hair" in OS.get_cmdline_user_args():
screen._pv.model.set("hair_gr2", "")
for _i in 2:
await process_frame
print("Selected armor: ", screen._pv.model.get("gr2_path"))
screen._pv.anim.set_process(false)
screen._pv.anim.set("blend_time", 0.0)
screen._pv.anim.set("time", 0.5)
# Sample after freezing the reference pose: GPU deformation lives in the
# shader, so its current bounds are requested by the same production framing
# path rather than read back from the static ArrayMesh.
screen._frame_model()
print("Hair skin: ", screen._pv.model.get("hair_skin"))
var ua = preload("res://ui/ui_assets.gd")
var hair_image: Image = ua.load_dds_image(String(screen._pv.model.get("hair_skin")))
if hair_image != null:
hair_image.save_png(output.path_join("hair-texture.png"))
var mesh_bounds: Array = []
var body_uses_blue_steel_skin := false
var blue_steel_path := AssetRoot.path().path_join("patch2/ymir work/pc/warrior/warrior_4-2.dds")
var blue_steel_image: Image = ua.load_dds_image(blue_steel_path) if main_part == 12019 else null
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
var local_aabb: AABB = mi.get_aabb()
var world_aabb: AABB = mi.get_global_transform() * local_aabb
mesh_bounds.append({
"node": String(mi.name),
"local_position": [local_aabb.position.x, local_aabb.position.y, local_aabb.position.z],
"local_size": [local_aabb.size.x, local_aabb.size.y, local_aabb.size.z],
"world_position": [world_aabb.position.x, world_aabb.position.y, world_aabb.position.z],
"world_size": [world_aabb.size.x, world_aabb.size.y, world_aabb.size.z],
})
for surface in mi.mesh.get_surface_count():
print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface))
var mat: Material = mi.get_active_material(surface)
if mat is ShaderMaterial:
print("MATERIAL mode=", mat.get_shader_parameter("mode"), " alpha_write=", mat.shader.code.contains("ALPHA ="))
var albedo := mat.get_shader_parameter("albedo_tex") as Texture2D
if albedo and blue_steel_image:
var actual := albedo.get_image()
# Character textures generate mipmaps before upload. Compare only
# level 0 with the decoded DDS or identical pixels would differ by
# the appended mip levels.
var expected_data := blue_steel_image.get_data()
var actual_data := actual.get_data() if actual else PackedByteArray()
if actual and actual.get_size() == blue_steel_image.get_size() \
and actual_data.slice(0, expected_data.size()) == expected_data:
body_uses_blue_steel_skin = true
if "--solid-body" in OS.get_cmdline_user_args():
var solid := StandardMaterial3D.new()
solid.albedo_texture = mat.get_shader_parameter("albedo_tex")
solid.cull_mode = BaseMaterial3D.CULL_DISABLED
mi.set_surface_override_material(surface, solid)
if main_part == 20009 and not String(screen._pv.model.get("gr2_path")).to_lower().contains("warrior_5_1"):
printerr("FAIL: server armor was not applied")
quit(1)
return
if main_part == 12019 and not body_uses_blue_steel_skin:
printerr("FAIL: armor 12019 geometry loaded without warrior_4-2 target skin")
quit(1)
return
if hair_part == 1001 and not String(screen._pv.model.get("hair_gr2")).to_lower().contains("hair_2_1"):
printerr("FAIL: standard HairIndex 1001 did not replace the default hair model")
quit(1)
return
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
if screen._viewport:
var viewport_image := screen._viewport.get_texture().get_image()
if viewport_image.detect_alpha() != Image.ALPHA_NONE:
printerr("FAIL: selection stage must be opaque; transparent additive particles create black quads")
quit(1)
return
viewport_image.save_png(output.path_join("viewport.png"))
if root.get_texture().get_image().save_png(output.path_join("selection.png")) != OK:
quit(1)
return
var hashes := {}
for path in ["res://ui/char_select_screen.gd", "res://ui/player_view.gd", "res://ui/equip_model.gd", "res://ui/race_spec.gd", "res://char_select_visual_test.gd"]:
hashes[path] = FileAccess.get_sha256(path)
for key in ["gr2_path", "hair_gr2", "hair_skin"]:
var path := String(screen._pv.model.get(key))
if not path.is_empty():
hashes[path] = FileAccess.get_sha256(path)
var library := "res://bin/libmtgodot.macos.template_debug.dylib"
hashes[library] = FileAccess.get_sha256(library)
var report := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
report.store_string(JSON.stringify({"sha256": hashes, "engine": Engine.get_version_info(),
"job": job, "main_part": main_part, "hair_part": hair_part, "sample_time": 0.5, "args": OS.get_cmdline_user_args(),
"gpu_skin": OS.get_environment("MTGODOT_GPUSKIN"),
"camera_position": [_cam_pos(screen).x, _cam_pos(screen).y, _cam_pos(screen).z],
"mesh_bounds": mesh_bounds,
"scope": "assembly assertions; screenshot requires visual review, not 40250 pixel parity"}, "\t"))
report.close()
print("SELECTION_OUTPUT: ", output)
print("PASS: selection model, server armor and UI assembly")
quit()
func _cam_pos(screen: Node) -> Vector3:
return screen._cam.position if screen._cam else Vector3.ZERO