客户端问题修改

This commit is contained in:
shen
2026-09-07 14:53:36 +08:00
parent b19d5b5dd3
commit 61272b75e9
32 changed files with 1023 additions and 95 deletions
+1
View File
@@ -33,6 +33,7 @@ compile_commands.json
*.tmp
*.o
*.a
.env.live-smoke.local
# clangd
/.cache/
+13 -3
View File
@@ -56,9 +56,19 @@ test -f "$APP/Contents/Resources/assets/asset_index.txt"
echo "== 4. 签名(默认 ad-hocCODESIGN_IDENTITY 可切正式证书)=="
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
find "$APP/Contents" -type f \( -name '*.dylib' -o -name '*.so' -o -name '*.a' \) -exec \
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \; 2>/dev/null || true
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" "$APP" 2>/dev/null || true
ENTITLEMENTS="$REPO/macos.entitlements"
test -f "$ENTITLEMENTS"
# The exported Godot executable loads the GDExtension from Contents/Frameworks.
# Because this package uses hardened runtime, the main executable must carry
# disable-library-validation or macOS rejects the ad-hoc/formally signed dylib
# at runtime even when the bundle passes a static codesign verification.
find "$APP/Contents/Frameworks" -type f \( -name '*.dylib' -o -name '*.so' \) -exec \
codesign --force --options runtime --timestamp=none --sign "$CODESIGN_IDENTITY" {} \;
codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \
--sign "$CODESIGN_IDENTITY" "$APP/Contents/MacOS/mtgodot-poc"
codesign --force --options runtime --timestamp=none --entitlements "$ENTITLEMENTS" \
--sign "$CODESIGN_IDENTITY" "$APP"
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
echo
+16
View File
@@ -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_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);
ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2);
@@ -455,6 +456,10 @@ void Metin2Model::reload() {
base_parts = parts;
AABB bounds;
Ref<ArrayMesh> mesh = build_mesh(fi, parts, flip_winding, bounds);
// build_mesh keeps the source GR2 coordinate frame (Z-up, centimetres);
// make_conv() below maps it to Godot Y-up and applies unit_scale. Keep the
// actor root on the terrain while translating only the visual subtree.
ground_offset = -static_cast<double>(bounds.position.z) * unit_scale;
if (mesh->get_surface_count() > 0) {
mi = memnew(MeshInstance3D);
mi->set_name("MeshInstance3D");
@@ -1085,6 +1090,17 @@ void Metin2Model::_apply_materials() {
return _load_dds(dir.path_join(dds_files[i]));
}
}
// Expansion armors share face textures with the base PC pack.
// Resolve that exact name before falling back to unrelated local DDS.
String cls = gr2_path.get_base_dir().get_file();
for (const String &pc : { String("pc"), String("pc2") }) {
String spec = "d:/ymir work/" + pc + "/" + cls + "/" + want + ".dds";
String resolved = resolve_rel_gr2(gr2_path, spec);
Ref<ImageTexture> shared = _load_dds(resolved);
if (shared.is_valid()) {
return shared;
}
}
return Ref<ImageTexture>();
};
+4
View File
@@ -126,6 +126,9 @@ public:
// One-line summary (bones / meshes / verts / bounds).
godot::String get_info() const;
// 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; }
// --- C++ accessors for Metin2AnimPlayer (same extension) ---
const gr2::Skeleton *gr2_skeleton() const;
@@ -210,6 +213,7 @@ private:
godot::Skeleton3D *skel = nullptr;
godot::MeshInstance3D *mi = nullptr;
godot::String last_info;
double ground_offset = 0.0;
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
// §2.10 LOD
@@ -1497,6 +1497,15 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
return true;
}
case HDR_GC_QUEST_SCRIPT:
// Optional extended quest UI data. Consume it even when the UI is absent.
return true;
case HDR_GC_EXTENDED_STATUS:
// Optional server status payload; framing is sufficient for connection safety.
return true;
case HDR_GC_EXTENDED_CHARACTER:
// Optional extended character payload; consume without requiring its UI.
return true;
default:
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
std::to_string(header);
+49 -1
View File
@@ -21,13 +21,55 @@ std::string ipv4_from_slot_addr(int32_t addr) {
std::to_string((value >> 16) & 0xffu) + "." +
std::to_string((value >> 24) & 0xffu);
}
ClassicStream::RawPacketResult consume_mark_packet(uint8_t header, const uint8_t *packet,
size_t available) {
// 40250 guild-mark packets use a one-byte header followed by a u32 whole
// packet size. They can be pushed on the game connection while the map is
// loading, so they must be drained before the normal GC static-size table is
// consulted. The dedicated mark client owns decoding; the game session only
// needs to preserve framing here.
size_t head_size = 0;
if (header == HDR_GC_MARK_IDXLIST) {
head_size = sizeof(GCMarkIDXList);
} else if (header == HDR_GC_MARK_BLOCK) {
head_size = sizeof(GCMarkBlock);
} else {
return {ClassicStream::RawPacketStatus::NotHandled, 0};
}
if (available < head_size) {
return {ClassicStream::RawPacketStatus::NeedMore, 0};
}
uint32_t total = 0;
std::memcpy(&total, packet + 1, sizeof(total));
constexpr uint32_t kMaxMarkPacket = 4u * 1024u * 1024u;
// Some 40250 builds emit an empty mark response as just the fixed head
// ([buf_size]=0, [count]=0) instead of filling buf_size with head_size.
// Consume that ten/seven-byte marker so the following GC packet remains
// aligned.
if (total == 0) {
return {ClassicStream::RawPacketStatus::Consumed, head_size};
}
if (total < head_size || total > kMaxMarkPacket) {
return {ClassicStream::RawPacketStatus::Error, 0};
}
if (available < total) {
return {ClassicStream::RawPacketStatus::NeedMore, 0};
}
return {ClassicStream::RawPacketStatus::Consumed, total};
}
} // namespace
ClassicSession::ClassicSession() : m_parser(m_world) {
m_stream.set_trace_name("game");
m_auth_stream.set_trace_name("auth");
m_stream.on_phase = [this](uint8_t p) { on_phase(p); };
m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) {
return on_packet(h, b, l);
};
m_stream.on_raw_packet = [](uint8_t h, const uint8_t *packet, size_t available) {
return consume_mark_packet(h, packet, available);
};
m_stream.on_error = [this](const std::string &e) {
if (m_auth_transition) {
return;
@@ -108,6 +150,7 @@ void ClassicSession::start_offline(const std::string &id, const std::string &pw)
m_auth_login_sent = false;
m_login_sent = false;
m_entergame_sent = false;
m_loading_phase_seen = false;
m_version_sent = false;
m_game_connection_seen = false;
m_direct_enter = false;
@@ -206,6 +249,7 @@ bool ClassicSession::connect_direct_enter(int slot) {
m_game_port = character.port;
m_login_sent = false;
m_entergame_sent = false;
m_loading_phase_seen = false;
m_version_sent = false;
m_stream.set_time_sync_mode(false);
m_auth_transition = true;
@@ -242,6 +286,7 @@ bool ClassicSession::connect_warp(const std::string &game_host, uint16_t game_po
m_game_port = game_port;
m_login_sent = false;
m_entergame_sent = false;
m_loading_phase_seen = false;
m_version_sent = false;
m_stream.set_time_sync_mode(false);
m_auth_transition = true;
@@ -276,6 +321,7 @@ void ClassicSession::pump() {
m_game_started = true;
m_login_sent = false;
m_entergame_sent = false;
m_loading_phase_seen = false;
m_version_sent = false;
m_stream.set_time_sync_mode(false);
m_auth_transition = true;
@@ -293,7 +339,8 @@ void ClassicSession::pump() {
}
// auto CG_ENTERGAME a beat into PHASE_LOADING (the m2dev client waits ~1.5s;
// sending it too early makes the server drop us mid spawn-burst).
if (m_stage == Stage::Loading && !m_entergame_sent && m_entergame_delay > 0 &&
if (m_stage == Stage::Loading && m_loading_phase_seen && !m_entergame_sent &&
m_entergame_delay > 0 &&
m_now - m_loading_since >= m_entergame_delay) {
if (!enter_game()) {
m_last_error = m_stream.last_error().empty() ? "failed to send CG_ENTERGAME" :
@@ -536,6 +583,7 @@ void ClassicSession::on_phase(uint8_t phase) {
break;
case PHASE_LOADING:
set_stage(Stage::Loading);
m_loading_phase_seen = true;
m_direct_enter = false;
m_direct_enter_slot = -1;
m_loading_since = m_now;
@@ -204,6 +204,10 @@ private:
uint32_t m_now = 0;
uint32_t m_loading_since = 0;
uint32_t m_entergame_delay = 8000;
// DirectEnter reports a UI loading state while the server is still in
// PHASE_SELECT. Keep the auto-enter timer gated on the actual
// PHASE_LOADING packet so CG_ENTERGAME is never sent early.
bool m_loading_phase_seen = false;
bool m_entergame_sent = false;
bool m_login_sent = false;
bool m_auth_login_sent = false;
+44 -7
View File
@@ -235,7 +235,7 @@ bool ClassicStream::send_fixed(const void *struct_bytes, size_t n) {
emit_bytes(struct_bytes, n);
append_sequence_if_needed(header);
if (m_trace) {
std::fprintf(stderr, "[classic] send hdr=%u n=%zu seq=%d\n", header, n,
std::fprintf(stderr, "[classic:%s] send hdr=%u n=%zu seq=%d\n", m_trace_name.c_str(), header, n,
(m_seq_on && is_sequence_cg(header)));
}
return true;
@@ -284,7 +284,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) {
on_server_handshake();
}
if (m_trace) {
std::fprintf(stderr, "[classic] handshake echo time=%u\n", hs.time);
std::fprintf(stderr, "[classic:%s] handshake echo time=%u\n", m_trace_name.c_str(), hs.time);
}
return true;
}
@@ -309,7 +309,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) {
consumed = true;
m_phase = p.phase;
if (m_trace) {
std::fprintf(stderr, "[classic] phase -> %u\n", p.phase);
std::fprintf(stderr, "[classic:%s] phase -> %u\n", m_trace_name.c_str(), p.phase);
}
if (on_phase) {
on_phase(p.phase);
@@ -357,7 +357,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) {
m_cipher.decrypt(m_recv.mutable_unread(), m_recv.readable());
}
if (m_trace) {
std::fprintf(stderr, "[classic] cipher activated\n");
std::fprintf(stderr, "[classic:%s] cipher activated\n", m_trace_name.c_str());
}
if (on_cipher_active) {
on_cipher_active();
@@ -403,7 +403,7 @@ bool ClassicStream::handle_key_agreement() {
// reply with our blob — still plaintext (peer activates on its own COMPLETED)
emit_bytes(&out, sizeof(out));
if (m_trace) {
std::fprintf(stderr, "[classic] key agreement: replied, keys ready\n");
std::fprintf(stderr, "[classic:%s] key agreement: replied, keys ready\n", m_trace_name.c_str());
}
return true;
}
@@ -493,7 +493,7 @@ void ClassicStream::dispatch() {
uint32_t body_len = dh.size - sizeof(DynHead);
const uint8_t *body = m_recv.read_ptr();
if (m_trace) {
std::fprintf(stderr, "[classic] recv hdr=%u n=%u dynamic=1\n", header, dh.size);
std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%u dynamic=1\n", m_trace_name.c_str(), header, dh.size);
}
bool ok = !on_packet || on_packet(header, body, body_len);
discard_recv(body_len);
@@ -517,6 +517,34 @@ void ClassicStream::dispatch() {
m_last_error = "unknown GC header " + std::to_string(header) +
" (last: " + std::to_string(m_last_pkt[0]) + "," +
std::to_string(m_last_pkt[1]) + ")";
if (m_trace) {
std::fprintf(stderr, "[classic:%s] unknown hdr=%u buffered=%zu bytes:",
m_trace_name.c_str(), header, m_recv.readable());
const size_t dump = std::min<size_t>(m_recv.readable(), 32);
for (size_t i = 0; i < dump; ++i) {
std::fprintf(stderr, " %02x", m_recv.read_ptr()[i]);
}
std::fprintf(stderr, "\n");
if (m_recv.readable() >= 3) {
uint16_t candidate_size = 0;
std::memcpy(&candidate_size, m_recv.read_ptr() + 1, sizeof(candidate_size));
const int after = candidate_size < m_recv.readable() ? m_recv.read_ptr()[candidate_size] : -1;
if (after < 0) {
std::fprintf(stderr, "[classic:%s] unknown candidate_size=%u after=none\n",
m_trace_name.c_str(), candidate_size);
} else {
std::fprintf(stderr, "[classic:%s] unknown candidate_size=%u after=0x%02x\n",
m_trace_name.c_str(), candidate_size, after);
}
if (candidate_size >= 3 && candidate_size <= m_recv.readable() && candidate_size <= 2048) {
std::fprintf(stderr, "[classic:%s] unknown candidate bytes:", m_trace_name.c_str());
for (size_t i = 0; i < candidate_size; ++i) {
std::fprintf(stderr, " %02x", m_recv.read_ptr()[i]);
}
std::fprintf(stderr, "\n");
}
}
}
if (on_error) {
on_error(m_last_error);
}
@@ -528,7 +556,16 @@ void ClassicStream::dispatch() {
}
const uint8_t *p = m_recv.read_ptr();
if (m_trace) {
std::fprintf(stderr, "[classic] recv hdr=%u n=%d dynamic=0\n", header, total);
std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%d dynamic=0\n", m_trace_name.c_str(), header, total);
if (header == HDR_GC_LOGIN_SUCCESS_NEWSLOT || header == HDR_GC_SKILL_COOLTIME_END) {
const int next = m_recv.readable() > static_cast<size_t>(total) ? p[total] : -1;
if (next < 0) {
std::fprintf(stderr, "[classic:%s] boundary hdr=%u next=none\n", m_trace_name.c_str(), header);
} else {
std::fprintf(stderr, "[classic:%s] boundary hdr=%u next=0x%02x\n", m_trace_name.c_str(), header,
next);
}
}
}
bool ok = !on_packet || on_packet(header, p + 1, static_cast<uint32_t>(total - 1));
discard_recv(static_cast<size_t>(total));
@@ -23,6 +23,7 @@
#include <cstdint>
#include <functional>
#include <string>
#include <utility>
namespace mtnet::classic {
@@ -94,6 +95,7 @@ public:
void set_time_sync_mode(bool on) { m_time_sync_mode = on; }
void set_wire_trace(bool on) { m_trace = on; }
void set_trace_name(std::string name) { m_trace_name = std::move(name); }
// _IMPROVED_PACKET_ENCRYPTION_ (docs §3). Client polarity = true.
bool cipher_active() const { return m_cipher.activated(); }
@@ -135,6 +137,7 @@ private:
bool m_handshake_seen = false;
bool m_time_sync_mode = false;
bool m_trace = false;
std::string m_trace_name = "stream";
uint8_t m_phase = PHASE_HANDSHAKE;
uint8_t m_last_pkt[2] = {0, 0};
+9
View File
@@ -276,6 +276,12 @@ enum : uint8_t {
HDR_GC_EMPIRE = 90,
HDR_GC_PARTY_LINK = 91,
HDR_GC_PARTY_UNLINK = 92,
// 40250 extended quest/script notification; payload is dynamically framed.
HDR_GC_QUEST_SCRIPT = 148,
// Extended servers also emit a dynamically framed mount/status notice here.
HDR_GC_EXTENDED_STATUS = 59,
// 40250 extended character/status packet (dynamic framing).
HDR_GC_EXTENDED_CHARACTER = 105,
HDR_GC_REFINE_INFORMATION_OLD = 95,
HDR_GC_OBSERVER_ADD = 96,
HDR_GC_OBSERVER_REMOVE = 97,
@@ -1459,6 +1465,9 @@ constexpr bool is_dynamic_gc(uint8_t h) {
case HDR_GC_GUILD:
case HDR_GC_MESSENGER:
case HDR_GC_QUEST_INFO:
case HDR_GC_QUEST_SCRIPT:
case HDR_GC_EXTENDED_STATUS:
case HDR_GC_EXTENDED_CHARACTER:
case HDR_GC_DUEL_START:
case HDR_GC_SYNC_POSITION:
// client CMainPacketHeaderMap registers GC_WHISPER STATIC, but its
+2
View File
@@ -496,6 +496,7 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
// then connect to game_host with the returned login ticket.
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
// §1.5 (W1 G1 wiring): CG_CLIENT_VERSION2 reports the running binary's
// filename; feed it the real executable name rather than the built-in
// default so the server-side version gate sees what ClientVS22 sends.
@@ -2875,6 +2876,7 @@ void M2Client::warp_to_game_server(const String &host, int port) {
mall_open_seen = false;
dead_seen.clear();
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
bool connected = false;
if (last_login_key != 0) {
connected = classic_sess->connect_with_login_key(std::string(host.utf8().get_data()),
+8 -3
View File
@@ -76,6 +76,7 @@ int main(int argc, char **argv) {
session.set_auto_entergame_delay(enter_delay_ms);
session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr);
const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != nullptr;
const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr;
const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ?
std::atoi(std::getenv("MT_CLASSIC_AUTH_PORT")) : 11000;
const bool connected = use_auth ?
@@ -119,12 +120,16 @@ int main(int argc, char **argv) {
session.disconnect();
return 3;
}
if (!session.select_char(slot)) {
std::fprintf(stderr, "FAIL: select_char(%d)\n", slot);
const bool enter_ok = use_direct_enter ? session.connect_direct_enter(slot) : session.select_char(slot);
if (!enter_ok) {
std::fprintf(stderr, "FAIL: %s(%d): %s\n",
use_direct_enter ? "connect_direct_enter" : "select_char", slot,
session.last_error().c_str());
session.disconnect();
return 4;
}
std::printf("[e2e] selected slot=%d\n", slot);
std::printf("[e2e] %s slot=%d\n",
use_direct_enter ? "direct-entered" : "selected", slot);
selected = true;
}
+4
View File
@@ -22,4 +22,8 @@ if(BUILD_TESTING)
add_executable(libgr2_loader_errors_test tests/loader_errors.cpp)
target_link_libraries(libgr2_loader_errors_test PRIVATE xrender::libgr2)
add_test(NAME libgr2.loader_errors COMMAND libgr2_loader_errors_test)
add_executable(libgr2_animation_test tests/animation.cpp)
target_link_libraries(libgr2_animation_test PRIVATE xrender::libgr2)
add_test(NAME libgr2.animation COMMAND libgr2_animation_test)
endif()
+5 -1
View File
@@ -179,7 +179,11 @@ void Curve::eval(float t, float* out) const {
const int first = (degree == 1) ? (i - 1) : (i - 2);
// 拷本 span 的控制点(越界按端点 clamp),四元数做半球连续化
float cp[3 * 4]; // 最多 3 点 × dim4
// Metin2 的 ScaleShearCurve 是 9 维;旧的 3*4 缓冲区会在这里发生
// 栈溢出(degree=2 时写入 27 个 float),随后被 __stack_chk_fail 终止。
// 目前支持的曲线维度为 position=3、orientation=4、scale/shear=9。
if (dim > 9) return;
float cp[3 * 9];
for (int s = 0; s < span; ++s) {
int ci = first + s;
ci = ci < 0 ? 0 : (ci >= (int)n ? (int)n - 1 : ci);
+28
View File
@@ -0,0 +1,28 @@
#include <gr2/types.h>
#include <cmath>
#include <cstdio>
int main() {
// ScaleShearCurve uses 9 values per control point. A quadratic span reads
// three control points, so this is the regression case for the former 3*4
// stack buffer in Curve::eval().
gr2::Curve curve;
curve.degree = 2;
curve.dim = 9;
curve.knots = {0.0f, 1.0f, 2.0f};
curve.controls.resize(27);
for (size_t i = 0; i < curve.controls.size(); ++i) {
curve.controls[i] = 1.0f + static_cast<float>(i) * 0.01f;
}
float out[9] = {};
curve.eval(0.5f, out);
for (float value : out) {
if (!std::isfinite(value)) {
std::fprintf(stderr, "non-finite 9D curve output\n");
return 1;
}
}
return 0;
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+11
View File
@@ -41,6 +41,12 @@ var _reconnecting_game := false
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty()
var _auto_char_slot := -1 # MT_CHAR_SLOT:自动登录时优先选择的角色槽位。
func _init() -> void:
var raw_slot := OS.get_environment("MT_CHAR_SLOT").strip_edges()
if raw_slot.is_valid_int():
_auto_char_slot = int(raw_slot)
func start(assets_root: String = "", injected_client: Node = null) -> void:
_assets = assets_root
@@ -110,6 +116,11 @@ func _on_char_list(list: Array) -> void:
_goto_select()
if auto_login and not _chars.is_empty():
var idx := int(_chars[0].get("index", 0))
if _auto_char_slot >= 0:
for character in _chars:
if int(character.get("index", -1)) == _auto_char_slot:
idx = _auto_char_slot
break
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
+43
View File
@@ -0,0 +1,43 @@
# 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 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,
"guild_name": "[GM-TEAM]", "play_minutes": 2180,
"ht": 90, "iq": 15, "st": 90, "dx": 90,
"main_part": 20009, "hair_part": 0,
}])
for i in 40:
await process_frame
if screen._pv == null:
printerr("FAIL: selection model did not load")
quit(1)
return
print("Selected armor: ", screen._pv.model.get("gr2_path"))
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
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"):
printerr("FAIL: server armor was not applied")
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")
print("PASS: selection model, server armor and UI assembly")
quit()
+6
View File
@@ -6,6 +6,7 @@
extends Node
const AppFlow = preload("res://app_flow.gd")
const LiveSmokeTest = preload("res://live_smoke_test.gd")
func _ready() -> void:
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
@@ -21,3 +22,8 @@ func _ready() -> void:
af.name = "AppFlow"
add_child(af)
af.start(root)
if OS.get_environment("MT_TEST_MODE") == "smoke":
var smoke := LiveSmokeTest.new()
smoke.name = "LiveSmokeTest"
add_child(smoke)
smoke.setup(af, af.client)
+4 -3
View File
@@ -15,8 +15,8 @@ enum CameraMode {
# ClientVS22 stores camera settings in centimetres/degrees. The Godot scene
# is metres/radians, so keep the conversion at this boundary instead of
# leaking mixed units into the orbit controls.
const DEFAULT_DISTANCE_CM := 1550.0
const DEFAULT_PITCH_DEG := 27.0
const DEFAULT_DISTANCE_CM := 2100.0
const DEFAULT_PITCH_DEG := 34.0
const DEFAULT_ROTATION_DEG := 0.0
const DEFAULT_HEIGHT_CM := 100.0
@@ -74,7 +74,8 @@ const FADE_ALPHA := 0.72 # 挡住玩家时的透明度
var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D
func _ready() -> void:
fov = 55.0
# Match the original third-person framing: farther camera and narrower lens.
fov = 45.0
far = 4000.0
_apply_setting(_normal_setting())
if target:
+20 -2
View File
@@ -183,6 +183,11 @@ func setup(m2client: Node, assets_root: String,
net_world.set_camera(cam)
net_world.set_local_node(player)
net_world.entity_added.connect(func(node: Node3D, _vid: int):
# Model-factory upgrades replace an earlier placeholder node in place.
# Drop queued-for-free entries before registering the rebuilt pick target.
for old in pc.pickables.duplicate():
if old == null or not is_instance_valid(old):
pc.pickables.erase(old)
if node not in pc.pickables:
pc.pickables.append(node))
net_world.entity_removed.connect(func(vid: int):
@@ -272,7 +277,7 @@ func setup(m2client: Node, assets_root: String,
add_child(quickbar)
quickbar.item_mouse = mouse_controller
quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文
quickbar.setup(client, skill_table, ui, func() -> Node: return player)
quickbar.setup(client, skill_table, ui, func() -> Node: return player, assets_root)
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
if net_play:
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
@@ -1333,12 +1338,25 @@ func _build_lighting() -> void:
e.background_color = Color(0.55, 0.62, 0.72)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.5, 0.55, 0.62)
e.ambient_light_energy = 0.6
e.ambient_light_energy = 0.42
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
e.tonemap_exposure = 0.82
e.adjustment_enabled = true
e.adjustment_saturation = 1.05
e.adjustment_contrast = 1.08
e.adjustment_brightness = 0.88
we.environment = e
add_child(we)
_env = e
func _on_main_set(vid: int) -> void:
# GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID
# as local before building its view so NetWorld removes (and never recreates)
# the generic remote-player capsule for our own character.
if net_world and net_world.has_method("set_local_vid"):
net_world.set_local_vid(vid)
if net_world and net_world.has_method("set_local_node") and player:
net_world.set_local_node(player)
var e: Dictionary = client.get_entity(vid)
if e.is_empty() or player == null:
return
+276
View File
@@ -0,0 +1,276 @@
# live_smoke_test —— 真实服务器端到端冒烟测试。
#
# 由 script/live_smoke_test.sh 以 MT_TEST_MODE=smoke 启动。测试流程:
# 自动登录 → 角色列表 → 进入游戏 → 世界/属性读取 → 移动/停止 → 聊天
# → 可选重连 → 输出 JSON 报告并以 PASS/FAIL 退出。
#
# 只使用可逆的常规操作,不执行删角、丢弃、强化、交易等破坏性动作。
extends Node
const FUNC_WAIT := 0
const FUNC_MOVE := 1
const CHAT_TALKING := 0
const SETTLE_SECONDS := 2.5
const STEP_TIMEOUT_SECONDS := 25.0
const TOTAL_TIMEOUT_SECONDS := 120.0
var _flow: Node
var _client: Node
var _state := "boot"
var _state_since_ms := 0
var _started_ms := 0
var _entered_count := 0
var _phase_history: Array[String] = []
var _checks: Array[Dictionary] = []
var _failures: Array[String] = []
var _events := {
"char_list": 0,
"entered_game": 0,
"entity_moved": 0,
"chat": 0,
"disconnected": 0,
}
var _origin := Vector3.ZERO
var _origin_valid := false
var _mutations := true
var _reconnect := true
var _finished := false
var _finish_queued := false
var _report_path := ""
func setup(flow: Node, m2client: Node) -> void:
_flow = flow
_client = m2client
_started_ms = Time.get_ticks_msec()
_state_since_ms = _started_ms
_mutations = OS.get_environment("MT_SMOKE_MUTATIONS") != "0"
_reconnect = OS.get_environment("MT_SMOKE_RECONNECT") != "0"
_report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
if _report_path.is_empty():
_report_path = "user://live_smoke_report.json"
if _client == null:
_fail("M2Client 未创建")
_finish_later()
return
if _client.has_signal("stage_changed"):
_client.stage_changed.connect(_on_stage_changed)
if _client.has_signal("phase_changed"):
_client.phase_changed.connect(_on_phase_changed)
if _client.has_signal("char_list"):
_client.char_list.connect(_on_char_list)
if _client.has_signal("entered_game"):
_client.entered_game.connect(_on_entered_game)
if _client.has_signal("login_failed"):
_client.login_failed.connect(_on_login_failed)
if _client.has_signal("disconnected"):
_client.disconnected.connect(_on_disconnected)
if _client.has_signal("entity_moved"):
_client.entity_moved.connect(_on_entity_moved)
if _client.has_signal("chat"):
_client.chat.connect(_on_chat)
_state = "waiting_login"
_log("START mutations=%s reconnect=%s report=%s" % [_mutations, _reconnect, _report_path])
_check(OS.get_environment("MT_PROTOCOL") == "classic", "classic 协议已启用")
_check(OS.get_environment("MT_ACCOUNT") != "", "账号环境变量已提供")
_check(OS.get_environment("MT_PASSWORD") != "", "密码环境变量已提供")
func _process(_delta: float) -> void:
if _finished:
return
var now := Time.get_ticks_msec()
var total_s := float(now - _started_ms) / 1000.0
if total_s > TOTAL_TIMEOUT_SECONDS:
_fail("总超时,当前状态=%s" % _state)
_finish_later()
return
match _state:
"settle_first":
if _seconds_in_state(now) >= SETTLE_SECONDS:
_run_world_checks()
"wait_move":
if _seconds_in_state(now) >= 1.5:
_send_stop()
"wait_chat":
if _seconds_in_state(now) >= 0.5:
_send_chat()
"wait_reconnect":
if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
_fail("重连后未重新进入游戏")
_finish_later()
"settle_second":
if _seconds_in_state(now) >= SETTLE_SECONDS:
_check(_client != null and _client.is_in_game(), "重连后客户端仍处于游戏状态")
_finish_later()
"waiting_login", "waiting_first_game":
if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
_fail("阶段超时:%s" % _state)
_finish_later()
func _seconds_in_state(now: int) -> float:
return float(now - _state_since_ms) / 1000.0
func _set_state(next_state: String) -> void:
_state = next_state
_state_since_ms = Time.get_ticks_msec()
_log("STATE %s" % _state)
func _on_stage_changed(stage: String) -> void:
_log("stage=%s" % stage)
func _on_phase_changed(phase: String) -> void:
_phase_history.append(phase)
_log("phase=%s" % phase)
func _on_char_list(characters: Array) -> void:
_events["char_list"] += 1
_check(not characters.is_empty(), "角色列表非空")
if characters.is_empty():
_fail("服务器返回空角色列表")
_finish_later()
else:
_log("char_list count=%d" % characters.size())
func _on_entered_game() -> void:
_entered_count += 1
_events["entered_game"] = _entered_count
_check(true, "%d 次进入游戏" % _entered_count)
if _entered_count == 1:
_set_state("settle_first")
elif _state == "wait_reconnect":
_set_state("settle_second")
func _on_login_failed(reason: String) -> void:
_fail("登录失败:%s" % reason)
_finish_later()
func _on_disconnected(reason: String) -> void:
_events["disconnected"] += 1
_log("disconnected=%s" % reason)
if _state != "wait_reconnect" and _state != "settle_second":
_fail("测试期间意外断线:%s" % reason)
_finish_later()
func _on_entity_moved(_vid: int) -> void:
_events["entity_moved"] += 1
func _on_chat(_type: int, _vid: int, _text: String) -> void:
_events["chat"] += 1
func _run_world_checks() -> void:
var in_game: bool = _client != null and _client.has_method("is_in_game") and _client.is_in_game()
_check(in_game, "客户端处于 in_game")
var main_vid := int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0
_check(main_vid != 0, "主角色 VID 有效")
if main_vid == 0:
_fail("主角色不存在,无法继续常规测试")
_finish_later()
return
var main_entity: Dictionary = _client.get_entity(main_vid)
_check(not main_entity.is_empty(), "主角色实体可读取")
var entities: Array = _client.get_entities()
_check(not entities.is_empty(), "世界实体列表可读取")
var points: Dictionary = _client.get_points()
_check(not points.is_empty(), "属性数据可读取")
_check(_has_method_and_array("get_inventory"), "背包接口可读取")
_check(_has_method_and_array("get_skills"), "技能接口可读取")
_check(_has_method_and_array("get_quickslots"), "快捷栏接口可读取")
_check(_has_method_and_array("get_affects"), "状态接口可读取")
var p: Variant = main_entity.get("pos_cm", null)
if p is Vector3:
_origin = p
_origin_valid = true
_log("main_vid=%d pos_cm=%s entities=%d" % [main_vid, p, entities.size()])
else:
_fail("主角色缺少 pos_cm")
if not _mutations:
_finish_later()
return
_send_move()
func _has_method_and_array(method_name: String) -> bool:
if _client == null or not _client.has_method(method_name):
return false
var value: Variant = _client.call(method_name)
return value is Array
func _send_move() -> void:
if not _origin_valid:
_finish_later()
return
var ok := bool(_client.move(FUNC_MOVE, 0, 0.0, int(_origin.x) + 100, int(_origin.y)))
_check(ok, "发送 1 米移动包")
_set_state("wait_move")
func _send_stop() -> void:
var ok := bool(_client.move(FUNC_WAIT, 0, 0.0, int(_origin.x), int(_origin.y)))
_check(ok, "发送停止移动包")
_set_state("wait_chat")
func _send_chat() -> void:
var ok := bool(_client.say(CHAT_TALKING, "[smoke] client live smoke test"))
_check(ok, "发送普通聊天包")
if not _reconnect:
_finish_later()
return
var reconnect_ok := bool(_client.reconnect())
_check(reconnect_ok, "调用客户端重连")
if reconnect_ok:
_set_state("wait_reconnect")
else:
_finish_later()
func _check(ok: bool, name: String) -> void:
_checks.append({"name": name, "ok": ok})
if ok:
print("LIVE_SMOKE PASS: " + name)
else:
_fail(name)
func _fail(message: String) -> void:
if not _failures.has(message):
_failures.append(message)
printerr("LIVE_SMOKE FAIL: " + message)
func _log(message: String) -> void:
print("LIVE_SMOKE: " + message)
func _finish_later() -> void:
if _finish_queued or _finished:
return
_finish_queued = true
call_deferred("_finish")
func _finish() -> void:
if _finished:
return
_finished = true
var passed := _failures.is_empty()
var report := {
"status": "PASS" if passed else "FAIL",
"started_at_ms": _started_ms,
"duration_seconds": float(Time.get_ticks_msec() - _started_ms) / 1000.0,
"entered_game_count": _entered_count,
"phase_history": _phase_history,
"events": _events,
"checks": _checks,
"failures": _failures,
"mutations": _mutations,
"reconnect": _reconnect,
}
var file := FileAccess.open(_report_path, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(report, " "))
file.close()
else:
printerr("LIVE_SMOKE: 无法写入报告:" + _report_path)
if passed:
print("LIVE_SMOKE RESULT: PASS")
else:
printerr("LIVE_SMOKE RESULT: FAIL (%d failures)" % _failures.size())
get_tree().quit(0 if passed else 1)
+1
View File
@@ -0,0 +1 @@
uid://d4kxv31wxrhj5
+17
View File
@@ -213,6 +213,23 @@ func catch_up() -> void:
func set_model_factory(f: Callable) -> void:
_model_factory = f
# The game server can send the initial actor burst before GameScene has
# finished loading mob/item proto tables. Those actors were necessarily made
# as capsules; once the real factory is available, replace them from the
# authoritative EntityStore rows instead of leaving mixed placeholder/GR2
# models on screen.
_refresh_existing_model_views()
func _refresh_existing_model_views() -> void:
if not _model_factory.is_valid() or client == null or not client.has_method("get_entity"):
return
for vid in _by_vid.keys().duplicate():
var id := int(vid)
if id == _local_vid:
continue
var d: Dictionary = client.get_entity(id)
if not d.is_empty():
_on_spawn(d)
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
func set_local_vid(vid: int) -> void:
+8
View File
@@ -5,6 +5,7 @@ extends SceneTree
const SkillTable = preload("res://ui/skill_table.gd")
const SkillUI = preload("res://ui/skill_ui.gd")
const Quickbar = preload("res://ui/quickbar.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const UiManager = preload("res://ui/ui_manager.gd")
class FakeClient extends Node:
@@ -74,6 +75,11 @@ func _run() -> void:
# 列偏移修正后:skill 1 'Three-Way Cut' motion_name=samyeon motion_idx=1, ATTACK_SKILL
_ck(st.motion_idx_of(1) == 1, "skill 1 motion_idx == 1 (列偏移已修)")
_ck(String(s1.get("motion", "")) == "samyeon", "skill 1 motion_name == samyeon")
var skill_icon := UiAssets.load_tex(assets, "ETC/ymir work/ui/skill/warrior/samyeon_01.sub")
_ck(skill_icon != null, "skill 1 icon loads from .sub + shared SkillWarrior.dds")
if skill_icon:
_ck(skill_icon.get_width() == 32 and skill_icon.get_height() == 32,
"skill 1 icon region is 32x32")
_ck(st.is_attack(1), "skill 1 是 ATTACK_SKILL")
_ck(st.is_ranged(46), "skill 46 bow skill is ranged")
_ck(not st.is_ranged(1), "skill 1 melee skill is not ranged")
@@ -261,6 +267,8 @@ func _run() -> void:
get_root().add_child(qb)
qb.setup(fc, st, ui, func() -> Node: return pl)
qb.assign(0, "skill", 1)
_ck(qb._slots[0].icon.texture != null, "quickbar skill slot shows skill icon")
_ck(String(qb._slots[0].lbl.text) == "", "quickbar hides text when skill icon is available")
_ck(fc.quickslot_ops == [["add", 0, 2, 1]], "assign -> CG_QUICKSLOT_ADD slot 0")
qb.activate(0)
_ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000],
+151 -67
View File
@@ -15,6 +15,8 @@ extends Control
const UiAssets = preload("res://ui/ui_assets.gd")
const UiKit = preload("res://ui_kit.gd")
const PlayerView = preload("res://ui/player_view.gd")
const EquipModel = preload("res://ui/equip_model.gd")
const DESIGN_SIZE := Vector2(1472, 854)
signal select_requested(index: int)
signal delete_requested(index: int, private_code: String)
@@ -61,9 +63,11 @@ var _class_img: TextureRect
var _class_lbl: Label
var _status: Label
var _slot_lbl: Label
var _spin := 0.0
var _last_empire := -1
var _rename_pending := false
var _layout: Control
var _board: Control
var _proto: Node
func setup(client: Node, assets_root: String, chars: Array) -> void:
_client = client
@@ -72,9 +76,15 @@ func setup(client: Node, assets_root: String, chars: Array) -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_background()
_build_stage()
_layout = Control.new()
_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_layout)
_build_class_name()
_build_panel()
_build_navigation()
_build_status()
resized.connect(_resize_layout)
_resize_layout()
_last_empire = _empire_id()
set_process(true)
_select(_first_occupied())
@@ -181,7 +191,8 @@ func _build_background() -> void:
tr.texture = tex
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
# The original stretches the 1024x768 atlas region to the window.
tr.stretch_mode = TextureRect.STRETCH_SCALE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tr)
@@ -206,12 +217,12 @@ func _build_stage() -> void:
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-32, 28, 0)
key.light_energy = 1.7
key.light_energy = 0.9
key.light_color = Color(1.0, 0.96, 0.90)
root.add_child(key)
var rim := DirectionalLight3D.new()
rim.rotation_degrees = Vector3(-6, 200, 0)
rim.light_energy = 0.7
rim.light_energy = 0.25
rim.light_color = Color(0.75, 0.83, 1.0)
root.add_child(rim)
@@ -220,17 +231,18 @@ func _build_stage() -> void:
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0, 0, 0, 0)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.42, 0.45, 0.55)
e.ambient_light_energy = 0.9
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
e.ambient_light_color = Color(0.8, 0.8, 0.8)
e.ambient_light_energy = 0.65
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
we.environment = e
root.add_child(we)
_pivot = Node3D.new()
_pivot.rotation.y = -PI * 0.5
root.add_child(_pivot)
_cam = Camera3D.new()
_cam.fov = 30.0
_cam.fov = 10.0
_cam.position = Vector3(0.0, 1.0, 4.0)
root.add_child(_cam)
_cam.make_current()
@@ -251,29 +263,56 @@ func _frame_model() -> void:
first = false
if first:
return
var c := aabb.get_center()
var r: float = maxf(aabb.size.length() * 0.5, 0.9)
var dist: float = r / sin(deg_to_rad(_cam.fov * 0.5)) * 1.12 # 12% 余量
var off := Vector3(-r * 0.5, 0.0, 0.0)
_cam.position = c + off + Vector3(0.0, r * 0.04, dist)
_cam.look_at(c + off, Vector3.UP)
var height := maxf(aabb.size.y, 1.0)
# Reference: head at 14%, feet at 85%, center at 59% of the window.
var view_height := height / 0.71
var dist := view_height / (2.0 * tan(deg_to_rad(_cam.fov * 0.5)))
var aspect := size.x / maxf(size.y, 1.0)
var target := aabb.get_center()
target.x -= view_height * aspect * 0.09
target.y -= height * 0.007
_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)
func _rebuild_model() -> void:
if _pivot == null:
return
if _pv != null and is_instance_valid(_pv):
_pv.hide()
_pv.queue_free()
_pv = null
var c := _cur()
if c.is_empty() or String(c.get("name", "")) == "":
return
var job := clampi(int(c.get("job", 0)), 0, 3)
var job := clampi(int(c.get("job", 0)), 0, 7)
var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable()
var pv := PlayerView.new()
_pivot.add_child(pv)
if pv.build(_assets, job, pump):
_pv = pv
pv.set_anim_state("wait")
if _proto == null and ClassDB.class_exists("Metin2Proto"):
_proto = ClassDB.instantiate("Metin2Proto")
add_child(_proto)
for lang in ["en", "common"]:
var ip := _assets.path_join("locale/locale/%s/item_proto" % lang)
if FileAccess.file_exists(ip):
_proto.call("load_item_proto", ip)
break
var equip := EquipModel.new()
pv.add_child(equip)
equip.setup_remote(null, func() -> Node: return pv, _assets, job,
[int(c.get("main_part", 0)), 0, 0, int(c.get("hair_part", 0))], _proto)
# Expansion armor textures live beside their GR2, outside the base PC pack.
var body_path := String(pv.model.get("gr2_path"))
pv.model.set("texture_dir", body_path.get_base_dir())
# Shape zero uses the head/hair already included in the armor mesh.
if int(c.get("hair_part", 0)) == 0:
pv.model.set("hair_gr2", "")
pv._ground_model()
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
if pv.anim and FileAccess.file_exists(intro):
pv.anim.set("anim_path", intro)
pv.anim.set("loop", true)
# 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒
for i in 4:
await get_tree().process_frame
@@ -287,22 +326,22 @@ func _rebuild_model() -> void:
func _build_class_name() -> void:
_class_img = TextureRect.new()
_class_img.position = Vector2(48, 60)
_class_img.position = Vector2(92, 164)
_class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_class_img)
_layout.add_child(_class_img)
_class_lbl = Label.new()
_class_lbl.position = Vector2(50, 54)
_class_lbl.position = Vector2(120, 180)
_class_lbl.add_theme_font_size_override("font_size", 84)
_class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09))
_class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02))
_class_lbl.add_theme_constant_override("outline_size", 10)
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_class_lbl)
_layout.add_child(_class_lbl)
func _refresh_class_name() -> void:
var job := clampi(int(_cur().get("job", 0)), 0, 3)
var job := int(_cur().get("job", 0)) & 3
var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != ""
var tex: Texture2D = null
if has_char:
@@ -319,27 +358,29 @@ func _refresh_class_name() -> void:
# --- 左侧信息板 --------------------------------------------------------------
func _build_panel() -> void:
var board := UiKit.board(_assets, "board", 24, 96)
board.position = Vector2(56, 168)
board.size = Vector2(300, 396)
var board := UiKit.board(_assets, "thinboard", 16, 16)
_board = board
board.position = Vector2(119, 313)
board.size = Vector2(208, 323)
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(board)
_layout.add_child(board)
# 板底纹太透就垫一层
var tint := ColorRect.new()
tint.color = Color(0.03, 0.05, 0.06, 0.62)
tint.position = board.position + Vector2(10, 10)
tint.size = board.size - Vector2(20, 20)
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tint)
_layout.add_child(tint)
_panel_box = VBoxContainer.new()
_panel_box.position = board.position + Vector2(22, 22)
_panel_box.custom_minimum_size = Vector2(256, 0)
_panel_box.add_theme_constant_override("separation", 9)
add_child(_panel_box)
_panel_box.position = board.position + Vector2(14, 12)
_panel_box.custom_minimum_size = Vector2(180, 0)
_panel_box.add_theme_constant_override("separation", 6)
_layout.add_child(_panel_box)
func _refresh_panel() -> void:
for c in _panel_box.get_children():
_panel_box.remove_child(c)
c.queue_free()
var d := _cur()
var has_char := not d.is_empty() and String(d.get("name", "")) != ""
@@ -349,7 +390,8 @@ func _refresh_panel() -> void:
head.add_theme_constant_override("separation", 10)
_panel_box.add_child(head)
var crest := TextureRect.new()
crest.custom_minimum_size = Vector2(56, 56)
crest.custom_minimum_size = Vector2(70, 40)
crest.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
var gid := int(d.get("guild_id", 0))
if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"):
@@ -365,9 +407,9 @@ func _refresh_panel() -> void:
head.add_child(crest)
var gbox := VBoxContainer.new()
head.add_child(gbox)
gbox.add_child(_plain(_empire_name() if has_char else "", 14, Color(0.95, 0.9, 0.7)))
gbox.add_child(_plain(_empire_name() if has_char else "", 10, Color(0.95, 0.9, 0.7)))
var gname := String(d.get("guild_name", "")).strip_edges()
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 13, Color(0.8, 0.82, 0.86)))
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 10, Color(0.8, 0.82, 0.86)))
_panel_box.add_child(_sep())
@@ -413,21 +455,6 @@ func _refresh_panel() -> void:
)
erow.add_child(eb)
# 槽位切换
if _chars.size() > 1:
var nav := HBoxContainer.new()
nav.alignment = BoxContainer.ALIGNMENT_CENTER
nav.add_theme_constant_override("separation", 12)
_panel_box.add_child(nav)
var prev := Button.new(); prev.text = ""; prev.custom_minimum_size = Vector2(38, 28)
prev.pressed.connect(func(): _select(wrapi(_sel - 1, 0, _chars.size())))
nav.add_child(prev)
_slot_lbl = _plain("%d / %d" % [_sel + 1, _chars.size()], 14, Color(1, 1, 1))
nav.add_child(_slot_lbl)
var nxt := Button.new(); nxt.text = ""; nxt.custom_minimum_size = Vector2(38, 28)
nxt.pressed.connect(func(): _select(wrapi(_sel + 1, 0, _chars.size())))
nav.add_child(nxt)
# 按钮
var brow := GridContainer.new()
brow.columns = 2
@@ -436,28 +463,78 @@ func _refresh_panel() -> void:
_panel_box.add_child(brow)
var start := Button.new()
start.text = "开始"
start.custom_minimum_size = Vector2(122, 34)
start.custom_minimum_size = Vector2(180, 22)
_style_button(start)
start.disabled = not has_char or empire_required
start.pressed.connect(_do_start)
brow.add_child(start)
_panel_box.add_child(start)
_panel_box.move_child(start, _panel_box.get_child_count() - 2)
start.visible = has_char
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(122, 34)
create.custom_minimum_size = Vector2(180, 22)
_style_button(create)
create.disabled = has_char or empire_required
create.pressed.connect(_open_create_dialog)
brow.add_child(create)
_panel_box.add_child(create)
_panel_box.move_child(create, _panel_box.get_child_count() - 2)
create.visible = not has_char
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(122, 34)
del.custom_minimum_size = Vector2(86, 20)
_style_button(del)
del.disabled = not has_char or empire_required
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
var quit := Button.new()
quit.text = "退出"
quit.custom_minimum_size = Vector2(122, 34)
quit.custom_minimum_size = Vector2(86, 20)
_style_button(quit)
quit.pressed.connect(func(): back_requested.emit())
brow.add_child(quit)
func _style_button(button: Button) -> void:
button.add_theme_font_size_override("font_size", 10)
for state in ["normal", "hover", "pressed", "disabled"]:
var suffix := "02" if state == "hover" else "03" if state == "pressed" else "01"
var prefix := "XLarge" if button.custom_minimum_size.x > 100 else "Large"
var texture := UiAssets.load_tex(_assets,
"d:/ymir work/ui/public/%s_Button_%s.sub" % [prefix, suffix])
if texture:
var textured := StyleBoxTexture.new()
textured.texture = texture
textured.set_texture_margin_all(3)
button.add_theme_stylebox_override(state, textured)
continue
var style := StyleBoxFlat.new()
style.bg_color = Color(0.22, 0.22, 0.20, 0.95)
if state == "hover":
style.bg_color = Color(0.32, 0.31, 0.27)
style.border_color = Color(0.48, 0.46, 0.39)
style.set_border_width_all(1)
style.set_corner_radius_all(3)
button.add_theme_stylebox_override(state, style)
func _build_navigation() -> void:
for direction in [-1, 1]:
var button := TextureButton.new()
var side := "left" if direction < 0 else "right"
var base := "d:/ymir work/ui/intro/select/dragon_%s_button_" % side
button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub")
button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub")
button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub")
button.position = Vector2(714 if direction < 0 else 1024, 712)
button.custom_minimum_size = Vector2(160, 44)
button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色"
button.pressed.connect(func(): _select(wrapi(_sel + direction, 0, _chars.size())))
_layout.add_child(button)
func _resize_layout() -> void:
if _layout:
_layout.size = DESIGN_SIZE
_layout.scale = size / DESIGN_SIZE
_frame_model()
# --- 底部状态 --------------------------------------------------------------
func _build_status() -> void:
@@ -746,15 +823,11 @@ func _empire_flag_tex() -> Texture2D:
return null
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
func _process(dt: float) -> void:
_spin += dt
func _process(_dt: float) -> void:
var empire := _empire_id()
if empire != _last_empire:
_last_empire = empire
_refresh_panel()
if _pivot:
# 正面朝相机 + 轻微来回摆
_pivot.rotation.y = PI + sin(_spin * 0.3) * 0.35
# --- 小工具 --------------------------------------------------------------
@@ -780,21 +853,32 @@ func _sep() -> Control:
func _kv(k: String, v: String) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var kl := _plain(k, 13, Color(0.72, 0.78, 0.85))
kl.custom_minimum_size = Vector2(64, 0)
row.custom_minimum_size.y = 20
var kl := _plain(k, 10, Color(0.82, 0.82, 0.80))
kl.custom_minimum_size = Vector2(42, 0)
row.add_child(kl)
row.add_child(_plain(v, 14, Color(1, 1, 1)))
var value := _plain(v, 10, Color(1, 1, 1))
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var style := StyleBoxFlat.new()
style.bg_color = Color(0, 0, 0, 0.85)
style.border_color = Color(0.4, 0.4, 0.37)
style.set_border_width_all(1)
value.add_theme_stylebox_override("normal", style)
row.add_child(value)
return row
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var kl := _plain(label, 13, Color(0.72, 0.78, 0.85))
kl.custom_minimum_size = Vector2(44, 0)
row.custom_minimum_size.y = 20
var kl := _plain(label, 10, Color(0.82, 0.82, 0.80))
kl.custom_minimum_size = Vector2(26, 0)
row.add_child(kl)
var track := ColorRect.new()
track.color = Color(0, 0, 0, 0.5)
track.custom_minimum_size = Vector2(150, 14)
track.custom_minimum_size = Vector2(98, 8)
track.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(track)
var fill := ColorRect.new()
fill.color = col
@@ -804,7 +888,7 @@ func _stat(label: String, value: int, col: Color) -> HBoxContainer:
fill.offset_left = 1
fill.offset_top = 1
fill.offset_bottom = -1
fill.offset_right = 1 + int(clampf(value / 25.0, 0.0, 1.0) * 148.0)
fill.offset_right = 1 + int(clampf(value / 90.0, 0.0, 1.0) * 96.0)
track.add_child(fill)
row.add_child(_plain(str(value), 13, Color(1, 1, 1)))
row.add_child(_plain(str(value), 10, Color(1, 1, 1)))
return row
+6
View File
@@ -102,6 +102,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
if pump.is_valid(): pump.call()
add_child(model)
model.ready.connect(_ground_model)
_ground_model()
_load_motlist()
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
@@ -160,6 +162,10 @@ func set_display_name(n: String) -> void:
add_child(tag)
tag.text = n
func _ground_model() -> void:
if model and model.has_method("get_ground_offset"):
model.position.y = float(model.call("get_ground_offset"))
# --- 内部 -------------------------------------------------------------------
func _folder_candidates(proto: Node, race: int) -> Array:
+8
View File
@@ -54,6 +54,8 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格
if pump.is_valid(): pump.call()
add_child(model)
model.ready.connect(_ground_model)
_ground_model()
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
@@ -196,9 +198,15 @@ func _refresh_sound_script(motion_path: String) -> void:
func _set(prop: StringName, val: Variant) -> bool:
if String(prop) in _forward and model:
model.set(prop, val)
if String(prop) == "gr2_path":
call_deferred("_ground_model")
return true
return false
func _ground_model() -> void:
if model and model.has_method("get_ground_offset"):
model.position.y = float(model.call("get_ground_offset"))
# --- 路径解析 -------------------------------------------------------------
func _resolve_dir(assets_root: String, rel: String) -> String:
+103 -4
View File
@@ -15,6 +15,14 @@
extends Node
const PlayerSkill := preload("res://player_skill.gd")
const UiAssets := preload("res://ui/ui_assets.gd")
const JOB_DIR := {
"WARRIOR": "warrior",
"ASSASSIN": "assassin",
"SURA": "sura",
"SHAMAN": "shaman",
}
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案
@@ -33,16 +41,19 @@ var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1
var _player_getter: Callable
var _root: Control
var item_mouse: Node
var _slots := [] # 当前页 UI[{btn, cd, lbl}]
var _assets_root := ""
var _slots := [] # 当前页 UI[{btn, cd, lbl, icon, grade}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _page := 0
var _move_from := -1
var _mobile_mode := false
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable) -> void:
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable,
assets_root_override := "") -> void:
client = m2client
table = skill_table
_player_getter = player_getter
_assets_root = assets_root_override if assets_root_override != "" else AssetRoot.path()
_skill_gate = PlayerSkill.new()
_skill_gate.setup(client, table)
for _i in SLOT_COUNT:
@@ -52,6 +63,8 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
client.skill_cooldown_end.connect(_on_cd_end)
if client.has_signal("quickslots_changed"):
client.quickslots_changed.connect(restore_from_server)
if client.has_signal("skills_changed"):
client.skills_changed.connect(_refresh_page)
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
@@ -314,17 +327,40 @@ func _build(parent: Node) -> void:
num.text = str(i + 1)
num.position = Vector2(3, 1)
num.add_theme_font_size_override("font_size", 10)
num.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(num)
var icon := TextureRect.new()
icon.name = "icon"
icon.position = Vector2(4, 4)
icon.size = Vector2(32, 32)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(icon)
var lbl := Label.new()
lbl.name = "lbl"
lbl.position = Vector2(3, 15)
lbl.add_theme_font_size_override("font_size", 9)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(lbl)
var grade := Label.new()
grade.name = "grade"
grade.position = Vector2(25, 25)
grade.size = Vector2(13, 13)
grade.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
grade.add_theme_font_size_override("font_size", 9)
grade.add_theme_color_override("font_color", Color(1.0, 0.9, 0.45))
grade.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
grade.add_theme_constant_override("shadow_offset_x", 1)
grade.add_theme_constant_override("shadow_offset_y", 1)
grade.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(grade)
var cd := ColorRect.new()
cd.name = "cd"
cd.color = Color(0, 0, 0, 0.55)
cd.set_anchors_preset(Control.PRESET_FULL_RECT)
cd.visible = false
cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(cd)
var idx := i
slot.gui_input.connect(func(e: InputEvent):
@@ -334,7 +370,7 @@ func _build(parent: Node) -> void:
elif e.button_index == MOUSE_BUTTON_RIGHT:
_clear(idx))
row.add_child(slot)
_slots.append({"btn": slot, "cd": cd, "lbl": lbl})
_slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade})
if item_mouse and item_mouse.has_method("register_target"):
var local_idx: int = i
item_mouse.register_target(slot,
@@ -378,11 +414,74 @@ func _refresh_slot(slot: int) -> void:
return
var s: Dictionary = _state[_global_slot(slot)]
var txt := ""
var tex: Texture2D = null
var grade_text := ""
if s.kind == "skill" and table:
txt = table.name_of(s.id).substr(0, 5)
tex = _skill_icon(int(s.id))
grade_text = _skill_grade_text(int(s.id))
elif s.kind == "item":
txt = "#%d" % s.id
_slots[slot].lbl.text = txt
var icon: TextureRect = _slots[slot].icon
icon.texture = tex
icon.visible = tex != null
_slots[slot].lbl.text = txt if tex == null else ""
_slots[slot].grade.text = grade_text
_slots[slot].grade.visible = grade_text != "" and tex != null
func _skill_grade(skill_id: int) -> int:
if client and client.has_method("get_skills"):
for skill in client.get_skills():
if int(skill.get("id", 0)) == skill_id:
return clampi(int(skill.get("master", 0)), 0, 3)
return 0
func _skill_grade_text(skill_id: int) -> String:
match _skill_grade(skill_id):
1: return "M"
2: return "G"
3: return "P"
_: return ""
func _skill_icon(skill_id: int) -> Texture2D:
if _assets_root == "" or table == null or not table.has_method("entry"):
return null
var data: Dictionary = table.entry(skill_id)
var job := String(data.get("job", "")).to_upper()
var motion := String(data.get("motion", "")).strip_edges()
if motion == "":
return null
var candidates: Array[String] = []
if JOB_DIR.has(job):
var dir := String(JOB_DIR[job])
for suffix in _skill_icon_suffixes(skill_id):
candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, suffix])
elif job == "SUPPORT":
for suffix in _skill_icon_suffixes(skill_id):
candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, suffix])
candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion)
elif job == "HORSE":
candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion)
elif job == "GUILD":
candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion)
for rel in candidates:
var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
if tex != null:
return tex
return null
func _skill_icon_suffixes(skill_id: int) -> Array[String]:
var grade := _skill_grade(skill_id)
var first := "_01"
if grade == 1:
first = "_02"
elif grade >= 2:
first = "_03"
var out: Array[String] = [first]
for suffix in ["_03", "_02", "_01"]:
if not out.has(suffix):
out.append(suffix)
return out
func _refresh_page() -> void:
if _root and _root.has_node("page"):
+17 -3
View File
@@ -100,9 +100,23 @@ static func _load_sub(path: String) -> Texture2D:
"bottom": b = int(parts[1])
if image_name == "":
return null
var img_path := path.get_base_dir().path_join(image_name)
if not FileAccess.file_exists(img_path):
img_path = path.get_base_dir().path_join(image_name.get_basename() + ".tga")
# 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:
for candidate in [image_name, image_name.get_basename() + ".tga",
image_name.get_basename() + ".png", image_name.get_basename() + ".dds"]:
var candidate_path := search_dir.path_join(candidate)
if FileAccess.file_exists(candidate_path):
img_path = candidate_path
break
if img_path != "":
break
var parent_dir := search_dir.get_base_dir()
if parent_dir == search_dir:
break
search_dir = parent_dir
if not FileAccess.file_exists(img_path):
return null
var base := _load_image_file(img_path)
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# 真实 40250 服务器端到端冒烟测试。
#
# 用法:
# ./script/live_smoke_test.sh
# ./script/live_smoke_test.sh --no-build
# ./script/live_smoke_test.sh --no-mutations
# ./script/live_smoke_test.sh --no-reconnect
#
# 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
APP="$REPO/build/export/mtgodot-poc.app"
EXECUTABLE="$APP/Contents/MacOS/mtgodot-poc"
ARTIFACT_DIR="$REPO/build/live-smoke"
LOG="$ARTIFACT_DIR/live-smoke.log"
REPORT="$ARTIFACT_DIR/live-smoke-report.json"
CONFIG_FILE="${MT_SMOKE_CONFIG:-$REPO/.env.live-smoke.local}"
BUILD=1
MUTATIONS=1
RECONNECT=1
usage() {
cat <<'EOF'
用法: ./script/live_smoke_test.sh [选项]
选项:
--no-build 使用现有 APP,不重新打包
--no-mutations 只做登录、进游戏和数据读取,不发送移动/聊天/重连包
--no-reconnect 做移动和聊天,但跳过重连
--help 显示帮助
环境变量:
MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入
MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--no-build) BUILD=0 ;;
--no-mutations) MUTATIONS=0 ;;
--no-reconnect) RECONNECT=0 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
cd "$REPO"
mkdir -p "$ARTIFACT_DIR"
if [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
fi
if [ -z "${MT_ACCOUNT:-}" ]; then
read -r -p "账号: " MT_ACCOUNT
fi
if [ -z "${MT_PASSWORD:-}" ]; then
read -r -s -p "密码: " MT_PASSWORD
echo
fi
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
echo "账号和密码不能为空" >&2
exit 2
fi
if [ "$BUILD" -eq 1 ]; then
echo "== build release =="
./build-macos-client.sh release
fi
if [ ! -x "$EXECUTABLE" ]; then
echo "找不到可执行 APP$EXECUTABLE" >&2
echo "请先运行 ./build-macos-client.sh release" >&2
exit 2
fi
echo "== check server ports =="
for port in 11000 13002; do
if ! nc -G 3 -z 192.168.21.203 "$port" >/dev/null 2>&1; then
echo "服务器端口不可达:192.168.21.203:$port" >&2
exit 3
fi
done
pkill -x mtgodot-poc 2>/dev/null || true
export MT_PROTOCOL=classic
export MT_AUTOLOGIN=1
export MT_TEST_MODE=smoke
export MT_ACCOUNT
export MT_PASSWORD
export MT_SMOKE_MUTATIONS="$MUTATIONS"
export MT_SMOKE_RECONNECT="$RECONNECT"
export MT_TEST_REPORT="$REPORT"
rm -f "$REPORT"
echo "== run live smoke =="
echo "log: $LOG"
echo "report: $REPORT"
set +e
"$EXECUTABLE" 2>&1 | tee "$LOG"
APP_STATUS=${PIPESTATUS[0]}
set -e
if [ ! -f "$REPORT" ]; then
echo "未生成本次测试报告,APP 可能在测试状态机启动前崩溃。" >&2
echo "LIVE_SMOKE RESULT: FAIL (missing report)" >&2
exit 4
fi
echo "== report =="
if command -v jq >/dev/null 2>&1; then
REPORT_STATUS=$(jq -r '.status // empty' "$REPORT")
jq -r '"status=" + .status, "duration=" + (.duration_seconds|tostring) + "s", ( .failures[]? | "failure=" + . )' "$REPORT"
else
REPORT_STATUS=""
if grep -q '"status"[[:space:]]*:[[:space:]]*"PASS"' "$REPORT"; then
REPORT_STATUS=PASS
fi
grep -E '"status"|"failures"' "$REPORT" || true
fi
if [ "$APP_STATUS" -ne 0 ]; then
echo "LIVE_SMOKE RESULT: FAIL (APP exit=$APP_STATUS)" >&2
echo "最近的崩溃报告:"
ls -1t "$HOME/Library/Logs/DiagnosticReports/mtgodot-poc-"*.ips 2>/dev/null | head -n 3 || true
exit "$APP_STATUS"
fi
if [ "$REPORT_STATUS" != "PASS" ]; then
echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2
exit 1
fi
echo "LIVE_SMOKE RESULT: PASS"
+2 -1
View File
@@ -47,7 +47,8 @@ def run_godot(env_extra, timeout=120):
env = {**os.environ, **env_extra}
try:
p = subprocess.run([godot, "--path", str(REPO / "project"),
"--rendering-driver", "metal", "--quit-after", "45"],
"res://main.tscn", "--rendering-driver", "metal",
"--quit-after", "45"],
capture_output=True, text=True, env=env, timeout=timeout)
except subprocess.TimeoutExpired:
return None, "timeout", None