Implement playable Mac client and rendering validation

This commit is contained in:
shen
2026-09-11 14:58:22 +08:00
parent 1ab68bd06e
commit 374d4165d8
233 changed files with 6546 additions and 284 deletions
+144 -28
View File
@@ -10,6 +10,7 @@
#include <cmath>
#include <string>
#include <list>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
@@ -25,10 +26,102 @@ using namespace godot;
namespace mtgodot {
namespace {
struct ClipEntry {
String path;
uint64_t modified;
size_t bytes;
std::shared_ptr<const gr2::File> file;
};
// Immutable decoded clips are shared, not playback time/pose/event cursors.
// Main-thread animation loading only; no Godot objects survive in this cache.
std::list<ClipEntry> clip_cache;
size_t clip_bytes = 0;
uint64_t clip_hits = 0, clip_misses = 0;
constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024;
struct MsaEntry {
String path;
uint64_t modified;
fmt::Msa metadata;
};
std::list<MsaEntry> msa_cache;
uint64_t msa_hits = 0, msa_misses = 0;
// Keep events immutable in the cache; reload copies them into each actor.
bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) {
const uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++msa_hits;
metadata = it->metadata;
msa_cache.splice(msa_cache.begin(), msa_cache, it);
return true;
}
msa_cache.erase(it);
break;
}
++msa_misses;
if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false;
if (msa_cache.size() >= 128) msa_cache.pop_back();
msa_cache.push_front({path, modified, metadata});
return true;
}
std::shared_ptr<const gr2::File> cached_clip(const String &path, gr2::LoadError *err) {
uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++clip_hits;
clip_cache.splice(clip_cache.begin(), clip_cache, it);
return clip_cache.front().file;
}
clip_bytes -= it->bytes;
clip_cache.erase(it);
break;
}
++clip_misses;
auto loaded = gr2_from_file(path, err);
if (!loaded) return {};
size_t bytes = 0;
for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size();
auto file = std::make_shared<const gr2::File>(std::move(*loaded));
if (bytes <= CLIP_BYTE_LIMIT) {
while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) {
clip_bytes -= clip_cache.back().bytes;
clip_cache.pop_back();
}
clip_cache.push_front({path, modified, bytes, file});
clip_bytes += bytes;
}
return file;
}
}
Dictionary Metin2AnimPlayer::get_clip_cache_stats() {
Dictionary d;
d["hits"] = clip_hits;
d["misses"] = clip_misses;
d["entries"] = static_cast<int64_t>(clip_cache.size());
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
d["msa_hits"] = msa_hits;
d["msa_misses"] = msa_misses;
d["msa_entries"] = static_cast<int64_t>(msa_cache.size());
return d;
}
void Metin2AnimPlayer::clear_clip_cache() {
clip_cache.clear();
clip_bytes = 0;
clip_hits = clip_misses = 0;
msa_cache.clear();
msa_hits = msa_misses = 0;
}
Metin2AnimPlayer::Metin2AnimPlayer() {}
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats);
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache);
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path);
@@ -49,6 +142,7 @@ void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
ClassDB::bind_static_method("Metin2AnimPlayer",
@@ -63,6 +157,7 @@ void Metin2AnimPlayer::_bind_methods() {
PropertyInfo(Variant::STRING, "sound"),
PropertyInfo(Variant::VECTOR3, "pos")));
ADD_SIGNAL(MethodInfo("playback_finished"));
ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event")));
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
"set_anim_path", "get_anim_path");
@@ -132,17 +227,11 @@ static String strip_dpath(const String &raw) {
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
}
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) {
String low = spec.to_lower();
if (!low.ends_with(".msa")) {
return spec; // already a .gr2 path
}
fmt::Msa m;
std::string e;
if (!fmt::parse_msa_file(std::string(spec.utf8().get_data()), m, &e)) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa parse: ") + String(e.c_str()));
return spec;
}
const String motion = String(m.motion_gr2.c_str());
const String base = motion.replace("\\", "/").get_file();
const String msa_dir = spec.get_base_dir();
@@ -171,16 +260,45 @@ String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
return cand;
}
static Dictionary motion_event_dict(const fmt::MotionEvent &ev) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["effect"] = String(ev.effect_file.c_str());
d["sound"] = String(ev.sound_file.c_str());
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
d["bone"] = String(ev.attaching_bone.c_str());
d["attaching"] = ev.attaching;
d["following"] = ev.following;
d["independent"] = ev.independent;
d["fishing_effect"] = ev.fishing_effect;
return d;
}
Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) {
Dictionary result;
Metin2Model *model = resolve_model();
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
if (!sk || !anim_file) return result;
for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) {
if (String(sk->bones[i].name.c_str()) == bone) {
result["transform"] = gr2_to_godot(world_buf[i]);
break;
}
}
return result;
}
void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2]));
emit_signal("motion_event_detailed", motion_event_dict(ev));
}
Array Metin2AnimPlayer::get_events() const {
Array a;
for (const fmt::MotionEvent &ev : events) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["effect"] = String(ev.effect_file.c_str());
d["sound"] = String(ev.sound_file.c_str());
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
a.push_back(d);
a.push_back(motion_event_dict(ev));
}
return a;
}
@@ -280,7 +398,7 @@ void Metin2AnimPlayer::_ready() {
}
void Metin2AnimPlayer::reload() {
anim_file = std::nullopt;
anim_file.reset();
duration = 0.0;
time = 0.0;
prev_time = 0.0;
@@ -294,16 +412,18 @@ void Metin2AnimPlayer::reload() {
}
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1";
String gr2_spec = anim_path;
if (anim_path.to_lower().ends_with(".msa")) {
fmt::Msa m;
std::string e;
if (fmt::parse_msa_file(std::string(anim_path.utf8().get_data()), m, &e)) {
if (cached_msa(anim_path, m, &e)) {
msa_metadata = m;
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
events = m.events;
gr2_spec = resolve_anim_gr2(anim_path);
UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
// Reuse the metadata just parsed; path resolution must not parse it again.
gr2_spec = resolve_anim_gr2(anim_path, m);
if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
m.loop_start_time, m.loop_end_time) : String("none")));
@@ -314,7 +434,7 @@ void Metin2AnimPlayer::reload() {
}
gr2::LoadError err;
auto f = mtgodot::gr2_from_file(gr2_spec, &err);
auto f = cached_clip(gr2_spec, &err);
if (!f) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
@@ -330,7 +450,7 @@ void Metin2AnimPlayer::reload() {
duration = an.duration;
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
if (Metin2Model *model = resolve_model()) {
if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) {
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
int matched = 0;
godot::String unmatched;
@@ -356,7 +476,7 @@ void Metin2AnimPlayer::reload() {
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
String(an.name.c_str()), duration, (int)an.tracks.size(),
(int)anim_file->file_info().animations.size());
UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
apply_pose(0.0);
}
@@ -480,7 +600,7 @@ void Metin2AnimPlayer::_process(double delta) {
if (prev_anim_file) {
blend_elapsed += delta; // real seconds, independent of time_scale
if (blend_elapsed >= blend_time) {
prev_anim_file = std::nullopt;
prev_anim_file.reset();
}
}
double next = time + delta * time_scale;
@@ -508,9 +628,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) {
double b = std::fmax(0.0, std::fmin(to, duration));
for (const fmt::MotionEvent &ev : events) {
if (a < ev.start_time && ev.start_time <= b) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
emit_motion_event(ev);
}
}
return;
@@ -522,9 +640,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) {
double t = ev.start_time;
bool hit = (a < t && t <= b) || (b > duration && t <= b - duration);
if (hit) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
emit_motion_event(ev);
}
}
}