- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
147 lines
6.8 KiB
GDScript
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
|