Compare commits
2
Commits
e0c1853909
...
1ab68bd06e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab68bd06e | ||
|
|
cea79fe17c |
@@ -68,6 +68,7 @@ void Metin2Model::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture);
|
||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
||||
ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb);
|
||||
ClassDB::bind_method(D_METHOD("get_ground_offset"), &Metin2Model::get_ground_offset);
|
||||
ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options);
|
||||
ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2);
|
||||
@@ -83,6 +84,11 @@ void Metin2Model::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone);
|
||||
ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone);
|
||||
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
|
||||
// A renderer consumer may have framed the model before an animation switches
|
||||
// it from the CPU mesh to GPU skinning. That switch updates the effective
|
||||
// AABB, so expose a small notification instead of making each scene guess a
|
||||
// number of frames to wait.
|
||||
ADD_SIGNAL(MethodInfo("visual_bounds_changed"));
|
||||
|
||||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "gr2_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msm"),
|
||||
"set_gr2_path", "get_gr2_path");
|
||||
@@ -391,6 +397,8 @@ void Metin2Model::reload() {
|
||||
surf_mats.clear();
|
||||
cpu_mesh.unref();
|
||||
gpu_skin_active = false;
|
||||
gpu_visual_bounds_ready = false;
|
||||
have_visual_bounds = false;
|
||||
bones_img.unref();
|
||||
bones_tex.unref();
|
||||
current_skin.clear();
|
||||
@@ -476,6 +484,8 @@ void Metin2Model::reload() {
|
||||
}
|
||||
}
|
||||
mi->set_custom_aabb(bounds.grow(0.01));
|
||||
visual_bounds = bounds;
|
||||
have_visual_bounds = true;
|
||||
}
|
||||
|
||||
materials = gr2::dump_materials(**file);
|
||||
@@ -1373,6 +1383,10 @@ void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
for (uint32_t t = 0; t < ic; ++t) {
|
||||
iw[t] = (int)m.indices[ib + t];
|
||||
}
|
||||
// Match build_mesh(): CPU animation must preserve the configured winding.
|
||||
if (flip_winding)
|
||||
for (uint32_t t = 0; t + 2 < ic; t += 3)
|
||||
std::swap(iw[t + 1], iw[t + 2]);
|
||||
Array arrays;
|
||||
arrays.resize(Mesh::ARRAY_MAX);
|
||||
arrays[Mesh::ARRAY_VERTEX] = mesh_pos[rp.mesh];
|
||||
@@ -1434,6 +1448,14 @@ void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
nn[2] /= ln;
|
||||
}
|
||||
pw[i] = Vector3(p[0], p[1], p[2]);
|
||||
if (std::isfinite(p[0]) && std::isfinite(p[1]) && std::isfinite(p[2])) {
|
||||
if (have_animated_bounds) {
|
||||
animated_bounds = animated_bounds.expand(pw[i]);
|
||||
} else {
|
||||
animated_bounds = AABB(pw[i], Vector3());
|
||||
have_animated_bounds = true;
|
||||
}
|
||||
}
|
||||
nw[i] = Vector3(nn[0], nn[1], nn[2]);
|
||||
uw[i] = Vector2(v.uv0[0], v.uv0[1]);
|
||||
}
|
||||
@@ -1452,6 +1474,9 @@ void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
int32_t *iw = idx.ptrw();
|
||||
for (uint32_t t = 0; t < ic; ++t)
|
||||
iw[t] = (int)m.indices[ib + t];
|
||||
if (flip_winding)
|
||||
for (uint32_t t = 0; t + 2 < ic; t += 3)
|
||||
std::swap(iw[t + 1], iw[t + 2]);
|
||||
Array a;
|
||||
a.resize(Mesh::ARRAY_MAX);
|
||||
a[Mesh::ARRAY_VERTEX] = hp[rp.mesh];
|
||||
@@ -1480,9 +1505,55 @@ void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
mi->set_surface_override_material(body_surfaces + s, hair_mat);
|
||||
if (mi && have_animated_bounds) {
|
||||
mi->set_custom_aabb(animated_bounds.grow(0.01));
|
||||
visual_bounds = animated_bounds;
|
||||
have_visual_bounds = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool Metin2Model::_update_skinned_bounds(const std::vector<gr2::Mat4> &skin) {
|
||||
const gr2::FileInfo *afi = active_fi();
|
||||
if (!afi) {
|
||||
return false;
|
||||
}
|
||||
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
|
||||
AABB bounds;
|
||||
bool have_bounds = false;
|
||||
auto expand_mesh = [&](const gr2::FileInfo &fi, const std::vector<int> *remap) {
|
||||
for (const gr2::Mesh &m : fi.meshes) {
|
||||
for (const gr2::Vertex &v : m.vertices) {
|
||||
gr2::Mat4 blend{};
|
||||
float sum = 0.0f;
|
||||
for (int k = 0; k < 4; ++k) sum += v.bone_weight[k];
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
const float w = sum > 0.0f ? v.bone_weight[k] / sum : (k == 0 ? 1.0f : 0.0f);
|
||||
if (w == 0.0f) continue;
|
||||
int bone = (v.bone_index[k] >= 0 && v.bone_index[k] < (int)m.bone_bindings.size())
|
||||
? m.bone_bindings[v.bone_index[k]] : -1;
|
||||
if (remap && bone >= 0 && bone < (int)remap->size()) bone = (*remap)[bone];
|
||||
const gr2::Mat4 &mat = bone >= 0 && bone < (int)skin.size() ? skin[bone] : I;
|
||||
for (int e = 0; e < 16; ++e) blend[e] += w * mat[e];
|
||||
}
|
||||
float p[3];
|
||||
xform_pt(blend, v.pos, p);
|
||||
if (!std::isfinite(p[0]) || !std::isfinite(p[1]) || !std::isfinite(p[2])) continue;
|
||||
const Vector3 point(p[0], p[1], p[2]);
|
||||
bounds = have_bounds ? bounds.expand(point) : AABB(point, Vector3());
|
||||
have_bounds = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
expand_mesh(*afi, nullptr);
|
||||
if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) {
|
||||
expand_mesh((**hair_file).file_info(), &hair_bone_remap);
|
||||
}
|
||||
if (have_bounds) {
|
||||
visual_bounds = bounds;
|
||||
have_visual_bounds = true;
|
||||
if (mi) mi->set_custom_aabb(bounds.grow(0.01));
|
||||
}
|
||||
return have_bounds;
|
||||
}
|
||||
|
||||
void Metin2Model::enable_gpu_skin(bool on) {
|
||||
if (!on || gpu_skin_active || !mi || !file || !*file) {
|
||||
return;
|
||||
@@ -1508,9 +1579,8 @@ void Metin2Model::enable_gpu_skin(bool on) {
|
||||
if (skel) {
|
||||
skel->set_show_rest_only(true);
|
||||
}
|
||||
// The mesh AABB is bind-pose; the shader moves verts, so widen the cull box
|
||||
// (gr2-skin-space units, ~100/m) or arms-up poses get frustum/shadow-culled.
|
||||
mi->set_custom_aabb(AABB(Vector3(-400, -400, -400), Vector3(800, 800, 800)));
|
||||
// gpu_skin() supplies the first actual posed bounds once the animation
|
||||
// sampler has produced its skin matrices.
|
||||
gpu_skin_active = true;
|
||||
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
|
||||
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
||||
@@ -1521,6 +1591,13 @@ void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
return;
|
||||
}
|
||||
current_skin = skin;
|
||||
// The shader owns deformation after this point. Sample the posed bounds
|
||||
// once for culling and initial camera framing; doing the full vertex walk on
|
||||
// every GPU frame would erase the CPU saving this path is intended to make.
|
||||
if (!gpu_visual_bounds_ready && _update_skinned_bounds(skin)) {
|
||||
gpu_visual_bounds_ready = true;
|
||||
emit_signal("visual_bounds_changed");
|
||||
}
|
||||
const int nbones = bones_img->get_height();
|
||||
PackedByteArray buf;
|
||||
buf.resize(nbones * 3 * 4 * 4); // rows * 3 texels * RGBA * float
|
||||
@@ -1542,6 +1619,19 @@ String Metin2Model::get_info() const {
|
||||
return last_info;
|
||||
}
|
||||
|
||||
AABB Metin2Model::get_visual_aabb() {
|
||||
// Consumers such as CharSelect ask only while framing. Refresh on demand
|
||||
// so their final camera fit reflects the current GPU pose without adding a
|
||||
// per-frame CPU vertex walk to every world actor.
|
||||
if (gpu_skin_active && !current_skin.empty()) {
|
||||
_update_skinned_bounds(current_skin);
|
||||
}
|
||||
if (have_visual_bounds) {
|
||||
return visual_bounds;
|
||||
}
|
||||
return mi && mi->get_mesh().is_valid() ? mi->get_mesh()->get_aabb() : AABB();
|
||||
}
|
||||
|
||||
String Metin2Model::probe_gr2(const String &path) const {
|
||||
gr2::LoadError err;
|
||||
auto f = mtgodot::gr2_from_file(path, &err);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
#include <godot_cpp/templates/hash_map.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/aabb.hpp>
|
||||
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
@@ -126,6 +127,9 @@ public:
|
||||
|
||||
// One-line summary (bones / meshes / verts / bounds).
|
||||
godot::String get_info() const;
|
||||
// Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike
|
||||
// MeshInstance3D::get_aabb(), this follows the GPU shader pose as well.
|
||||
godot::AABB get_visual_aabb();
|
||||
// Parent-space Y adjustment that places the bind-pose model's lowest point
|
||||
// on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres.
|
||||
double get_ground_offset() const { return ground_offset; }
|
||||
@@ -237,12 +241,16 @@ private:
|
||||
std::vector<godot::Ref<godot::Material>> surf_mats; // resolved once; re-assigned each cpu_skin frame
|
||||
godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode
|
||||
bool gpu_skin_active = false;
|
||||
bool gpu_visual_bounds_ready = false;
|
||||
bool have_visual_bounds = false;
|
||||
godot::AABB visual_bounds;
|
||||
std::vector<gr2::Mat4> current_skin;
|
||||
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
|
||||
godot::Ref<godot::ImageTexture> bones_tex;
|
||||
|
||||
void _clear_children();
|
||||
void _apply_materials();
|
||||
bool _update_skinned_bounds(const std::vector<gr2::Mat4> &skin);
|
||||
godot::String _guess_texture_dir() const;
|
||||
// Resolve a "d:/ymir work/..." path referenced from `base` (.msm/.msa) to an
|
||||
// absolute file. Passes existing absolute paths through.
|
||||
|
||||
@@ -12,15 +12,29 @@ func _initialize() -> void:
|
||||
|
||||
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": 0, "level": 105,
|
||||
"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": 20009, "hair_part": 0,
|
||||
"main_part": main_part, "hair_part": hair_part,
|
||||
}])
|
||||
for i in 40:
|
||||
await process_frame
|
||||
@@ -28,19 +42,80 @@ func _run() -> void:
|
||||
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")
|
||||
ua.load_dds_image(String(screen._pv.model.get("hair_skin"))).save_png("/tmp/metin2-hair.png")
|
||||
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 = []
|
||||
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))
|
||||
if not String(screen._pv.model.get("gr2_path")).to_lower().contains("warrior_5_1"):
|
||||
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 ="))
|
||||
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 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
|
||||
root.get_texture().get_image().save_png("/tmp/metin2-char-select.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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Detect CPU animation dropping winding (including accidentally loading an old dylib).
|
||||
extends SceneTree
|
||||
|
||||
var failures := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func indices(model: Node) -> Array:
|
||||
var meshes := model.find_children("*", "MeshInstance3D", true, false)
|
||||
var out := []
|
||||
for mi in meshes:
|
||||
for s in mi.mesh.get_surface_count():
|
||||
out.append(mi.mesh.surface_get_arrays(s)[Mesh.ARRAY_INDEX])
|
||||
return out
|
||||
|
||||
func check_reversed(a: Array, b: Array, stage: String) -> void:
|
||||
if a.is_empty() or a.size() != b.size():
|
||||
failures += 1
|
||||
printerr("FAIL: missing surfaces at ", stage)
|
||||
return
|
||||
for s in a.size():
|
||||
if a[s].size() != b[s].size():
|
||||
failures += 1
|
||||
continue
|
||||
for t in range(0, a[s].size(), 3):
|
||||
if a[s][t] != b[s][t] or a[s][t + 1] != b[s][t + 2] or a[s][t + 2] != b[s][t + 1]:
|
||||
failures += 1
|
||||
printerr("FAIL: winding lost at ", stage, " surface ", s)
|
||||
break
|
||||
|
||||
func run() -> void:
|
||||
var base := AssetRoot.path().path_join("PC/ymir work/pc/warrior")
|
||||
var models := []
|
||||
var animations := []
|
||||
for flip in [false, true]:
|
||||
var model: Node = ClassDB.instantiate("Metin2Model")
|
||||
model.set("gr2_path", base.path_join("warrior_novice.gr2"))
|
||||
model.set("hair_gr2", base.path_join("hair/hair_1_1.gr2"))
|
||||
model.set("flip_winding", flip)
|
||||
model.set("lod_enabled", false)
|
||||
root.add_child(model)
|
||||
models.append(model)
|
||||
check_reversed(indices(models[0]), indices(models[1]), "bind")
|
||||
for model in models:
|
||||
var anim: Node = ClassDB.instantiate("Metin2AnimPlayer")
|
||||
anim.set("model_path", model.get_path())
|
||||
anim.set("anim_path", base.path_join("general/wait.msa"))
|
||||
anim.set("playing", false)
|
||||
root.add_child(anim)
|
||||
animations.append(anim)
|
||||
for time in [0.0, 0.5, 1.0]:
|
||||
for anim in animations:
|
||||
anim.set("time", time)
|
||||
check_reversed(indices(models[0]), indices(models[1]), "animated %s" % time)
|
||||
if indices(models[0]).size() != 4:
|
||||
failures += 1
|
||||
printerr("FAIL: body and hair surfaces must both be exercised")
|
||||
print("character_winding_test: failures=", failures)
|
||||
for node in animations + models:
|
||||
node.queue_free()
|
||||
await process_frame
|
||||
quit(1 if failures else 0)
|
||||
+3
-17
@@ -46,7 +46,9 @@ func setup(world: Node, player: Node3D) -> void:
|
||||
_map_h_m = float(rep["map_size_y"]) * 256.0
|
||||
_build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"]))
|
||||
_build_status()
|
||||
_build_hotbar()
|
||||
# 技能快捷栏由 ui/quickbar.gd 管理:它恢复服务端的 36 个槽位、显示
|
||||
# 技能图标并处理冷却。这里以前画的静态空槽会和它重叠,让实际技能看起来
|
||||
# 没有加载,因此 HUD 不再创建第二条快捷栏。
|
||||
_build_inventory()
|
||||
_build_dungeon_compass()
|
||||
|
||||
@@ -368,22 +370,6 @@ func _build_target_panel() -> void:
|
||||
_target_fill.size = Vector2(180, 12)
|
||||
slot.add_child(_target_fill)
|
||||
|
||||
func _build_hotbar() -> void:
|
||||
var row := HBoxContainer.new()
|
||||
row.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
row.position = Vector2(-8 * 23, -56)
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
add_child(row)
|
||||
for i in 8:
|
||||
var slot := _nine("thinboard", 16, 16)
|
||||
slot.custom_minimum_size = Vector2(42, 42)
|
||||
var n := Label.new()
|
||||
n.text = str(i + 1)
|
||||
n.position = Vector2(4, 2)
|
||||
n.add_theme_font_size_override("font_size", 11)
|
||||
slot.add_child(n)
|
||||
row.add_child(slot)
|
||||
|
||||
func _build_inventory() -> void:
|
||||
_inv = _nine("board", 32, 128)
|
||||
_inv.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
|
||||
|
||||
@@ -19,10 +19,14 @@ class FakeClient extends Node:
|
||||
class StubModel extends Node3D:
|
||||
var gr2_path := ""
|
||||
var weapon_gr2 := ""
|
||||
var hair_gr2 := ""
|
||||
var hair_skin := ""
|
||||
var surf := {}
|
||||
func _set(p, v):
|
||||
if p == "gr2_path": gr2_path = v; return true
|
||||
if p == "weapon_gr2": weapon_gr2 = v; return true
|
||||
if p == "hair_gr2": hair_gr2 = v; return true
|
||||
if p == "hair_skin": hair_skin = v; return true
|
||||
return false
|
||||
func set_surface_texture(s, path): surf[s] = path
|
||||
|
||||
@@ -33,7 +37,10 @@ func _ck(c: bool, m: String) -> void:
|
||||
printerr("FAIL: " + m)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
call_deferred("_run_and_finish")
|
||||
|
||||
func _run_and_finish() -> void:
|
||||
await _run()
|
||||
if _fail == 0:
|
||||
print("PASS: race_spec_test (msm parse + body swap)")
|
||||
quit(0)
|
||||
@@ -60,6 +67,7 @@ func _run() -> void:
|
||||
|
||||
var rs := RaceSpec.new()
|
||||
_ck(rs.load_file(spec_path), "race spec parsed: %s" % spec_path)
|
||||
_ck(not rs.base_model.begins_with(String.chr(1)), "quoted tokens do not retain lexer sentinel")
|
||||
_ck(rs.shapes.size() >= 5, "shapes parsed (%d)" % rs.shapes.size())
|
||||
_ck(rs.base_model.to_lower().contains("warrior_novice"), "base_model = warrior_novice (%s)" % rs.base_model)
|
||||
var s0: Dictionary = rs.shape(0)
|
||||
@@ -72,6 +80,15 @@ func _run() -> void:
|
||||
if not h3.is_empty():
|
||||
_ck(h3.get("model", "").contains("hair"), "hair 3 model has 'hair' (%s)" % h3.get("model"))
|
||||
_ck(h3.get("target_skin", "").contains("red"), "hair 3 target_skin = red variant (%s)" % h3.get("target_skin"))
|
||||
# 用户出错的角色用 HairIndex 0;它不是推测的默认路径,必须由 MSM
|
||||
# 明确解析到 hair_1_1,且保持可换的 TargetSkin。
|
||||
var h0: Dictionary = rs.hair(0)
|
||||
_ck(h0.get("path", "") == rs.hair_path and rs.hair_path != "",
|
||||
"empty SpecialPath inherits HairData PathName")
|
||||
_ck(h0.get("model", "").to_lower() == "hair/hair_1_1.gr2",
|
||||
"hair 0 model = hair/hair_1_1.gr2 (%s)" % h0.get("model", ""))
|
||||
_ck(h0.get("target_skin", "").to_lower().contains("warrior_hair_01"),
|
||||
"hair 0 target skin is warrior_hair_01 (%s)" % h0.get("target_skin", ""))
|
||||
|
||||
# --- equip_model 用 race_spec 换身体 ---
|
||||
var fc := FakeClient.new()
|
||||
@@ -95,3 +112,29 @@ func _run() -> void:
|
||||
if not rs.shape(1).is_empty():
|
||||
_ck(model.gr2_path.to_lower().contains("novice"), "shape 1 -> novice body")
|
||||
_ck(model.surf.has(0), "shape 1 recolour -> set_surface_texture(0, ...)")
|
||||
|
||||
# 标准发型有两段编号:0..5 是 hair_1 的换色,1001..1012 才是
|
||||
# hair_2..4 的不同几何。它们不是道具 vnum,远端/选角不能把它们
|
||||
# 丢给 item_list 后静默回落到 PlayerView 的 hair_1_1 默认模型。
|
||||
var remote_model := StubModel.new()
|
||||
get_root().add_child(remote_model)
|
||||
var remote: Node = EquipModel.new()
|
||||
get_root().add_child(remote)
|
||||
remote.setup_remote(null, func() -> Node: return remote_model, assets, 0,
|
||||
[0, 0, 0, 1001])
|
||||
await process_frame
|
||||
var h1001: Dictionary = rs.hair(1001)
|
||||
_ck(not h1001.is_empty(), "hair 1001 exists in warrior.msm")
|
||||
var remote_h1001: Dictionary = remote._spec_hair(1001)
|
||||
_ck(not remote_h1001.is_empty(), "remote actor resolves hair 1001 (%s)" % remote_h1001)
|
||||
_ck(remote._hair_part() == 1001, "remote actor preserves hair part 1001 (%s)" % str(remote._remote_parts))
|
||||
var remote_hair_model: String = remote._resolve_spec_asset(remote_h1001, String(remote_h1001.get("model", "")))
|
||||
_ck(remote_hair_model != "", "hair 1001 source model path resolves (%s)" % remote_hair_model)
|
||||
# setup_remote refreshes synchronously; this direct retry distinguishes a
|
||||
# setup ordering bug from asset/spec resolution failure.
|
||||
remote._apply_hair_from_parts(remote_model, true)
|
||||
if not h1001.is_empty():
|
||||
_ck(remote_model.hair_gr2.to_lower().ends_with(String(h1001.get("model", "")).to_lower()),
|
||||
"standard hair 1001 resolves MSM geometry (%s)" % remote_model.hair_gr2)
|
||||
_ck(remote_model.hair_skin.to_lower().ends_with(String(h1001.get("target_skin", "")).to_lower()),
|
||||
"standard hair 1001 resolves MSM skin (%s)" % remote_model.hair_skin)
|
||||
|
||||
@@ -319,7 +319,11 @@ func _run() -> void:
|
||||
fc.quickslots = [{"pos": 2, "type": 2, "ref": 16}, {"pos": 3, "type": 1, "ref": 9},
|
||||
{"pos": 4, "type": 3, "ref": 11}]
|
||||
qb2.setup(fc, st, ui, func() -> Node: return pl) # setup 里自动 restore_from_server
|
||||
_ck(qb2._root.size == Vector2(400, 56) and qb2._root.z_index > 0,
|
||||
"quickbar has a real bottom-anchor extent and stays above legacy HUD")
|
||||
_ck(qb2._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16")
|
||||
_ck(qb2._slots[2].icon.texture != null and qb2._slots[2].icon.visible,
|
||||
"server-restored skill slot renders its icon")
|
||||
_ck(qb2._state[3].kind == "item" and int(qb2._state[3].id) == 9, "quickslot 恢复:格3=道具9")
|
||||
_ck(qb2._state[4].kind == "emote" and int(qb2._state[4].id) == 11, "quickslot 恢复:格4=表情11")
|
||||
qb2.activate(4)
|
||||
|
||||
@@ -238,7 +238,7 @@ func _build_stage() -> void:
|
||||
root.add_child(we)
|
||||
|
||||
_pivot = Node3D.new()
|
||||
_pivot.rotation.y = -PI * 0.5
|
||||
_pivot.rotation.y = PI * 0.5
|
||||
root.add_child(_pivot)
|
||||
|
||||
_cam = Camera3D.new()
|
||||
@@ -254,10 +254,20 @@ func _frame_model() -> void:
|
||||
return
|
||||
var aabb := AABB()
|
||||
var first := true
|
||||
# GPU skinning happens in the vertex shader, so MeshInstance3D's mesh AABB
|
||||
# remains in bind pose. Prefer the extension's matching deformed bounds for
|
||||
# the main body; accessories continue through their own mesh bounds below.
|
||||
if _pv.model and _pv.model.has_method("get_visual_aabb"):
|
||||
var posed: AABB = _pv.model.call("get_visual_aabb")
|
||||
if posed.size.length_squared() > 0.0:
|
||||
aabb = (_pv.model as Node3D).global_transform * posed
|
||||
first = false
|
||||
for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false):
|
||||
var m := mi as MeshInstance3D
|
||||
if m.mesh == null:
|
||||
continue
|
||||
if m.get_parent() == _pv.model and m.name == "MeshInstance3D":
|
||||
continue # already covered by Metin2Model.get_visual_aabb()
|
||||
var wb: AABB = m.get_global_transform() * m.get_aabb()
|
||||
aabb = wb if first else aabb.merge(wb)
|
||||
first = false
|
||||
@@ -274,6 +284,13 @@ func _frame_model() -> void:
|
||||
_cam.position = target + Vector3(0, dist * sin(deg_to_rad(6.0)), dist * cos(deg_to_rad(6.0)))
|
||||
_cam.look_at(target, Vector3.UP)
|
||||
|
||||
# Metin2AnimPlayer enables the optional GPU skin path on its first animation
|
||||
# sample. Its material/mesh swap changes the MeshInstance AABB after the
|
||||
# initial CPU framing pass, so frame again after the extension confirms it.
|
||||
func _on_model_visual_bounds_changed(pv: Node) -> void:
|
||||
if pv == _pv and is_instance_valid(pv):
|
||||
call_deferred("_frame_model")
|
||||
|
||||
func _rebuild_model() -> void:
|
||||
if _pivot == null:
|
||||
return
|
||||
@@ -290,6 +307,8 @@ func _rebuild_model() -> void:
|
||||
_pivot.add_child(pv)
|
||||
if pv.build(_assets, job, pump):
|
||||
_pv = pv
|
||||
if pv.model and pv.model.has_signal("visual_bounds_changed"):
|
||||
pv.model.visual_bounds_changed.connect(_on_model_visual_bounds_changed.bind(pv))
|
||||
if _proto == null and ClassDB.class_exists("Metin2Proto"):
|
||||
_proto = ClassDB.instantiate("Metin2Proto")
|
||||
add_child(_proto)
|
||||
|
||||
@@ -346,11 +346,19 @@ func _resolve_spec_asset(e: Dictionary, rel: String) -> String:
|
||||
var byp := _resolve_asset(String(e.get("path", "")) + rel)
|
||||
if byp != "":
|
||||
return byp
|
||||
# 按 basename 在 pc/<cls>/ 和 pc2/<cls>/ 下找
|
||||
# 规格覆盖层(例如 root/msm/warrior_m.msm)可能只给 Model 名而没有
|
||||
# PathName。按 basename 回退时除职业根目录外,还必须查 hair/;否则
|
||||
# HairIndex 1001..1012 能解析却加载失败,并静默保留 PlayerView 的
|
||||
# hair_1_1 默认模型,造成选角发型遮脸。
|
||||
var bn := rel.get_file()
|
||||
if race >= 0:
|
||||
var cls: String = CLASS_OF[race & 3]
|
||||
for sub in ["PC/ymir work/pc/%s/%s" % [cls, bn], "pc2/ymir work/pc2/%s/%s" % [cls, bn]]:
|
||||
for sub in [
|
||||
"PC/ymir work/pc/%s/%s" % [cls, bn],
|
||||
"PC/ymir work/pc/%s/hair/%s" % [cls, bn],
|
||||
"pc2/ymir work/pc2/%s/%s" % [cls, bn],
|
||||
"pc2/ymir work/pc2/%s/hair/%s" % [cls, bn],
|
||||
]:
|
||||
var q := _resolve_asset(sub)
|
||||
if q != "":
|
||||
return q
|
||||
|
||||
@@ -43,6 +43,8 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
|
||||
model = ClassDB.instantiate("Metin2Model")
|
||||
model.name = "Metin2Model"
|
||||
# PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses.
|
||||
model.set("flip_winding", true)
|
||||
model.set("texture_dir", base)
|
||||
if pump.is_valid(): pump.call()
|
||||
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
|
||||
|
||||
@@ -339,7 +339,12 @@ func _swap(local_a: int, local_b: int) -> void:
|
||||
func _build(parent: Node) -> void:
|
||||
_root = Control.new()
|
||||
_root.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
_root.position = Vector2(-SLOTS_PER_PAGE * 23, -54)
|
||||
# Give the root a real extent. A zero-sized Control happened to work with
|
||||
# some viewport sizes, but makes anchoring and hit testing fragile on Retina
|
||||
# windows. The old HUD's empty row then made this look like missing skills.
|
||||
_root.size = Vector2(400, 56)
|
||||
_root.position = Vector2(-200, -62)
|
||||
_root.z_index = 10
|
||||
parent.add_child(_root)
|
||||
var prev := Button.new()
|
||||
prev.text = "‹"
|
||||
|
||||
@@ -57,7 +57,9 @@ func shape(idx: int) -> Dictionary:
|
||||
if e.is_empty():
|
||||
return {}
|
||||
e = e.duplicate()
|
||||
e["path"] = String(e.get("special_path", shape_path))
|
||||
e["path"] = String(e.get("special_path", ""))
|
||||
if e["path"] == "":
|
||||
e["path"] = shape_path
|
||||
e["spec_dir"] = _dir
|
||||
return e
|
||||
|
||||
@@ -66,7 +68,9 @@ func hair(idx: int) -> Dictionary:
|
||||
if e.is_empty():
|
||||
return {}
|
||||
e = e.duplicate()
|
||||
e["path"] = String(e.get("special_path", hair_path))
|
||||
e["path"] = String(e.get("special_path", ""))
|
||||
if e["path"] == "":
|
||||
e["path"] = hair_path
|
||||
e["spec_dir"] = _dir
|
||||
return e
|
||||
|
||||
@@ -150,6 +154,8 @@ func _parse_block(toks: Array, i: Array, top := false) -> Dictionary:
|
||||
return d
|
||||
|
||||
func _tokval(t):
|
||||
if t is String and t.begins_with(String.chr(1)):
|
||||
return t.substr(1)
|
||||
if t is Dictionary and t.has("str"):
|
||||
return t["str"]
|
||||
return str(t)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local macOS visual tests must load the library that was just built.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build --target mtgodot -j8
|
||||
cp project/bin/libmtgodot.macos.template_release.dylib project/bin/libmtgodot.macos.template_debug.dylib
|
||||
codesign --force --sign - project/bin/libmtgodot.macos.template_debug.dylib
|
||||
shasum -a 256 project/bin/libmtgodot.macos.template_debug.dylib
|
||||
"${GODOT:-godot}" --headless --path project --script character_winding_test.gd
|
||||
"${GODOT:-godot}" --path project --script char_select_visual_test.gd -- "$@"
|
||||
@@ -1,9 +1,11 @@
|
||||
# 渲染回归入口
|
||||
|
||||
第一批用例针对 R1 场景装配,2026-09-08 开始执行。未宣称 40250 像素一致、发型正确或最终包通过。
|
||||
第一批用例针对 R1 场景装配与 R2 角色选择,2026-09-08 开始执行。未宣称 40250 像素一致或最终包通过。
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
本地 macOS 修改 C++ 后先执行 `bash script/rendering_test.sh`:它构建并同步 Godot 实际加载的 debug 路径,重签名后运行 CPU 绕序回归与选角截图。仅构建 Release 不会更新本地 Godot 的 debug 扩展。可传 `--hair=1001`;截图与哈希报告在 `build/rendering/selection-<timestamp>-<pid>/`。`--job=0..7` 与 `--main-part=0` 可复现无装备的八种族选择矩阵;`MTGODOT_GPUSKIN=1` 验证 GPU 路径。`--no-hair` 和 `--solid-body` 仅用于隔离诊断,其画面不能当作普通角色验收。
|
||||
|
||||
```bash
|
||||
godot --headless --path project --script main_character_sync_test.gd
|
||||
godot --headless --path project --script gamescene_test.gd
|
||||
|
||||
Reference in New Issue
Block a user