diff --git a/extension/src/net/classic/classic_session.cpp b/extension/src/net/classic/classic_session.cpp index 6042fa86..600aa304 100644 --- a/extension/src/net/classic/classic_session.cpp +++ b/extension/src/net/classic/classic_session.cpp @@ -12,6 +12,9 @@ ClassicSession::ClassicSession() : m_parser(m_world) { return on_packet(h, b, l); }; m_stream.on_error = [this](const std::string &e) { + if (m_auth_transition) { + return; + } m_last_error = e; set_stage(Stage::Failed); if (on_error) { @@ -19,7 +22,8 @@ ClassicSession::ClassicSession() : m_parser(m_world) { } }; m_stream.on_state_change = [this](ClassicStream::State s) { - if (s == ClassicStream::State::Offline && m_stage != Stage::Failed && + if (s == ClassicStream::State::Offline && !m_auth_transition && + m_stage != Stage::Failed && m_stage != Stage::Offline) { m_last_error = m_stream.last_error().empty() ? "disconnected" : m_stream.last_error(); set_stage(Stage::Failed); @@ -27,6 +31,29 @@ ClassicSession::ClassicSession() : m_parser(m_world) { // handshake packets flow next; stay in Connecting until GC_PHASE(LOGIN) } }; + m_auth_stream.on_phase = [this](uint8_t p) { on_auth_phase(p); }; + m_auth_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) { + return on_auth_packet(h, b, l); + }; + m_auth_stream.on_error = [this](const std::string &e) { + if (m_auth_transition) { + return; + } + m_last_error = e; + set_stage(Stage::Failed); + if (on_error) { + on_error(e); + } + }; + m_auth_stream.on_state_change = [this](ClassicStream::State s) { + if (s == ClassicStream::State::Offline && !m_auth_transition && + m_use_auth && !m_auth_succeeded && m_stage != Stage::Failed && + m_stage != Stage::Offline) { + m_last_error = m_auth_stream.last_error().empty() ? "auth peer closed" : + m_auth_stream.last_error(); + set_stage(Stage::Failed); + } + }; } void ClassicSession::set_stage(Stage s) { @@ -42,6 +69,12 @@ void ClassicSession::set_stage(Stage s) { void ClassicSession::start_offline(const std::string &id, const std::string &pw) { m_id = id; m_pw = pw; + m_use_auth = false; + m_auth_succeeded = false; + m_game_started = true; + m_authenticated_game = false; + m_auth_login_key = 0; + m_auth_login_sent = false; m_login_sent = false; m_entergame_sent = false; m_version_sent = false; @@ -50,13 +83,61 @@ void ClassicSession::start_offline(const std::string &id, const std::string &pw) // 0xFF. The normal game client switches to CG_TIME_SYNC only after the // server has advanced into the login/select side of the connection. m_stream.set_time_sync_mode(false); + m_auth_stream.set_time_sync_mode(false); set_stage(Stage::Connecting); } bool ClassicSession::connect(const std::string &game_host, uint16_t game_port, const std::string &id, const std::string &pw) { start_offline(id, pw); - if (!m_stream.connect(game_host, game_port)) { + m_game_host = game_host; + m_game_port = game_port; + m_authenticated_game = false; + m_auth_transition = true; + const bool connected = m_stream.connect(game_host, game_port); + m_auth_transition = false; + if (!connected) { + m_last_error = m_stream.last_error(); + set_stage(Stage::Failed); + return false; + } + return true; +} + +bool ClassicSession::connect(const std::string &auth_host, uint16_t auth_port, + const std::string &game_host, uint16_t game_port, const std::string &id, + const std::string &pw) { + start_offline(id, pw); + m_use_auth = true; + m_game_started = false; + m_auth_succeeded = false; + m_authenticated_game = false; + m_auth_host = auth_host; + m_auth_port = auth_port; + m_game_host = game_host; + m_game_port = game_port; + m_auth_transition = true; + const bool connected = m_auth_stream.connect(auth_host, auth_port); + m_auth_transition = false; + if (!connected) { + m_last_error = m_auth_stream.last_error(); + set_stage(Stage::Failed); + return false; + } + return true; +} + +bool ClassicSession::connect_with_login_key(const std::string &game_host, uint16_t game_port, + const std::string &id, uint32_t login_key) { + start_offline(id, ""); + m_game_host = game_host; + m_game_port = game_port; + m_auth_login_key = login_key; + m_authenticated_game = true; + m_auth_transition = true; + const bool connected = m_stream.connect(game_host, game_port); + m_auth_transition = false; + if (!connected) { m_last_error = m_stream.last_error(); set_stage(Stage::Failed); return false; @@ -65,12 +146,40 @@ bool ClassicSession::connect(const std::string &game_host, uint16_t game_port, } void ClassicSession::disconnect() { + m_auth_transition = true; + m_auth_stream.disconnect(); m_stream.disconnect(); + m_auth_transition = false; set_stage(Stage::Offline); } void ClassicSession::pump() { - m_stream.process(); + if (m_use_auth && !m_auth_succeeded && m_stage != Stage::Failed) { + m_auth_stream.process(); + } + if (m_use_auth && m_auth_succeeded && !m_game_started && m_stage != Stage::Failed) { + m_auth_transition = true; + m_auth_stream.disconnect(); + m_auth_transition = false; + m_authenticated_game = true; + m_game_started = true; + m_login_sent = false; + m_entergame_sent = false; + m_version_sent = false; + m_stream.set_time_sync_mode(false); + m_auth_transition = true; + const bool connected = m_stream.connect(m_game_host, m_game_port); + m_auth_transition = false; + if (!connected) { + m_last_error = m_stream.last_error(); + set_stage(Stage::Failed); + return; + } + set_stage(Stage::Connecting); + } + if (m_game_started && m_stage != Stage::Failed) { + m_stream.process(); + } // 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 && @@ -84,6 +193,10 @@ void ClassicSession::send_login() { if (m_login_sent) { return; } + if (m_authenticated_game) { + send_login_by_key(); + return; + } // docs §2.2: the client turns sequence mode on around here; CG_LOGIN is a // bSeq packet and the server expects the trailing byte from packet #1. m_stream.set_sequence_mode(true); @@ -96,6 +209,78 @@ void ClassicSession::send_login() { m_login_sent = true; } +void ClassicSession::send_auth_login() { + if (m_auth_login_sent) { + return; + } + m_auth_stream.set_sequence_mode(true); + CGLogin3 p{}; + p.header = HDR_CG_LOGIN3; + std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1); + std::strncpy(p.passwd, m_pw.c_str(), sizeof(p.passwd) - 1); + // The stock connector sends four client keys here. Zero is valid for this + // client, provided the same values are used in the later CG_LOGIN2 packet. + m_auth_stream.send_fixed(&p, sizeof(p)); + m_auth_login_sent = true; +} + +void ClassicSession::send_login_by_key() { + // The game side must use the ticket returned by the auth server; sending + // CG_LOGIN here would hit the server's locale/version gate. + m_stream.set_sequence_mode(true); + CGLogin2 p{}; + p.header = HDR_CG_LOGIN2; + std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1); + p.login_key = m_auth_login_key; + m_stream.send_fixed(&p, sizeof(p)); + m_login_sent = true; +} + +void ClassicSession::on_auth_phase(uint8_t phase) { + if (phase == PHASE_AUTH) { + set_stage(Stage::LoggingIn); + send_auth_login(); + } else if (phase == PHASE_CLOSE) { + m_last_error = "auth server closed phase"; + set_stage(Stage::Failed); + } +} + +bool ClassicSession::on_auth_packet(uint8_t header, const uint8_t *body, uint32_t len) { + if (header == HDR_GC_AUTH_SUCCESS) { + if (len + 1u < sizeof(GCAuthSuccess)) { + m_last_error = "short GC_AUTH_SUCCESS"; + set_stage(Stage::Failed); + return false; + } + GCAuthSuccess p{}; + p.header = header; + std::memcpy(reinterpret_cast(&p) + 1, body, sizeof(p) - 1); + m_auth_login_key = p.login_key; + if (p.result == 0 || m_auth_login_key == 0) { + m_last_error = "auth rejected credentials"; + set_stage(Stage::Failed); + return false; + } + m_auth_succeeded = true; + return true; + } + if (header == HDR_GC_LOGIN_FAILURE) { + if (len + 1u < sizeof(GCLoginFailure)) { + m_last_error = "short auth GC_LOGIN_FAILURE"; + } else { + const char *status = reinterpret_cast(body); + m_last_error = "auth login failed: " + + std::string(status, strnlen(status, sizeof(GCLoginFailure) - 1)); + } + set_stage(Stage::Failed); + return false; + } + // Optional Panama/hybrid-crypt packets are already framed and can be + // ignored by this asset-loader client. + return true; +} + bool ClassicSession::create_character(int slot, const std::string &name, int job, int shape, int con, int intel, int str, int dex) { if (m_stage != Stage::CharSelect || slot < 0 || slot >= m_parser.slot_count() || diff --git a/extension/src/net/classic/classic_session.h b/extension/src/net/classic/classic_session.h index 4d1fd8b6..e75a6b06 100644 --- a/extension/src/net/classic/classic_session.h +++ b/extension/src/net/classic/classic_session.h @@ -1,7 +1,7 @@ #pragma once -// ClassicSession — MT_PROTOCOL=classic: drives ClassicStream through -// HANDSHAKE -> LOGIN -> SELECT -> LOADING -> GAME and parses GC packets into a -// shared EntityStore. See docs/CLIENT-40250-PORT.md §4 / §8. +// ClassicSession — MT_PROTOCOL=classic: drives ClassicStream through the +// 40250 auth-server -> game-server flow and parses GC packets into a shared +// EntityStore. See docs/CLIENT-40250-PORT.md §4 / §8. // // The stock 40250 server uses _IMPROVED_PACKET_ENCRYPTION_; ClassicStream // negotiates that DH2 + CTR session before the first login packet. @@ -27,6 +27,13 @@ public: // --- INetSession --- bool connect(const std::string &game_host, uint16_t game_port, const std::string &id, const std::string &pw) override; + // Full 40250 flow: auth CG_LOGIN3 -> ticket, then game CG_LOGIN2 -> chars. + bool connect(const std::string &auth_host, uint16_t auth_port, + const std::string &game_host, uint16_t game_port, const std::string &id, + const std::string &pw); + // Reconnect directly to a game channel using a previously issued auth ticket. + bool connect_with_login_key(const std::string &game_host, uint16_t game_port, + const std::string &id, uint32_t login_key); void disconnect() override; bool select_char(int slot) override; bool enter_game() override; @@ -133,36 +140,57 @@ public: // --- accessors --- const std::vector &char_slots() const { return m_parser.char_slots(); } ClassicStream &stream() { return m_stream; } // for tests / trace + ClassicStream &auth_stream() { return m_auth_stream; } // auth-flow tests / trace ClassicParser &parser() { return m_parser; } + uint32_t login_key() const { return m_auth_login_key; } uint32_t now_ms() const { return m_now; } void set_now(uint32_t ms) { m_now = ms; m_world.set_now(ms); } // auto-send CG_ENTERGAME this many ms after entering PHASE_LOADING (0 = never, - // caller drives enter_game()). Default mirrors the m2dev client's ~1.5s. + // caller drives enter_game()). The 40250 test account sends a large map-load + // burst; waiting for that burst to drain avoids racing the server's spawn data. void set_auto_entergame_delay(uint32_t ms) { m_entergame_delay = ms; } - void set_wire_trace(bool on) { m_stream.set_wire_trace(on); } + void set_wire_trace(bool on) { + m_stream.set_wire_trace(on); + m_auth_stream.set_wire_trace(on); + } private: void set_stage(Stage s); void on_phase(uint8_t phase); + void on_auth_phase(uint8_t phase); bool on_packet(uint8_t header, const uint8_t *body, uint32_t len); + bool on_auth_packet(uint8_t header, const uint8_t *body, uint32_t len); void send_login(); + void send_auth_login(); + void send_login_by_key(); bool send_client_version(); EntityStore m_world; ClassicStream m_stream; + ClassicStream m_auth_stream; ClassicParser m_parser; Stage m_stage = Stage::Offline; std::string m_last_error; std::string m_id, m_pw; + std::string m_auth_host, m_game_host; + uint16_t m_auth_port = 0; + uint16_t m_game_port = 0; + uint32_t m_auth_login_key = 0; uint32_t m_now = 0; uint32_t m_loading_since = 0; - uint32_t m_entergame_delay = 1500; + uint32_t m_entergame_delay = 8000; bool m_entergame_sent = false; bool m_login_sent = false; + bool m_auth_login_sent = false; + bool m_use_auth = false; + bool m_auth_succeeded = false; + bool m_game_started = false; + bool m_authenticated_game = false; + bool m_auth_transition = false; bool m_version_sent = false; }; diff --git a/extension/src/net/classic/classic_stream.cpp b/extension/src/net/classic/classic_stream.cpp index da5489e7..ff3ee2bf 100644 --- a/extension/src/net/classic/classic_stream.cpp +++ b/extension/src/net/classic/classic_stream.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,17 @@ void set_nonblocking(int fd) { int fl = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, fl | O_NONBLOCK); } + +bool plausible_character_add(const uint8_t *p, size_t available) { + if (available < sizeof(GCCharacterAdd) || p[0] != HDR_GC_CHARACTER_ADD) { + return false; + } + GCCharacterAdd packet{}; + std::memcpy(&packet, p, sizeof(packet)); + return packet.vid != 0 && packet.race != 0 && packet.type <= 8 && + std::isfinite(packet.angle) && std::fabs(packet.angle) <= 360.0f && + packet.moving_speed <= 255 && packet.attack_speed <= 255; +} } // namespace ClassicStream::~ClassicStream() { @@ -93,10 +105,15 @@ void ClassicStream::disconnect() { m_seq_idx = 0; m_seq_on = false; m_handshake_seen = false; + m_phase = PHASE_HANDSHAKE; m_cipher.clean_up(); set_state(State::Offline); } +void ClassicStream::discard_recv(size_t n) { + m_recv.discard(n); +} + void ClassicStream::decrypt_appended(size_t n) { if (n == 0 || !m_cipher.activated()) { return; @@ -234,7 +251,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { if (!m_recv.peek(&hs, sizeof(hs))) { return true; // need more } - m_recv.discard(sizeof(hs)); + discard_recv(sizeof(hs)); consumed = true; // mirror EterLib/PythonNetworkStreamPhaseHandshake.cpp + desc.cpp: m_server_time_base = hs.time + static_cast(hs.delta); @@ -267,7 +284,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { if (!m_recv.peek(&b, sizeof(b))) { return true; } - m_recv.discard(sizeof(b)); + discard_recv(sizeof(b)); consumed = true; if (on_handshake_ok) { on_handshake_ok(); @@ -279,8 +296,9 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { if (!m_recv.peek(&p, sizeof(p))) { return true; } - m_recv.discard(sizeof(p)); + discard_recv(sizeof(p)); consumed = true; + m_phase = p.phase; if (m_trace) { std::fprintf(stderr, "[classic] phase -> %u\n", p.phase); } @@ -294,7 +312,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { if (!m_recv.peek(&b, sizeof(b))) { return true; } - m_recv.discard(sizeof(b)); + discard_recv(sizeof(b)); consumed = true; uint8_t pong = HDR_CG_PONG; // 0xFE send_fixed(&pong, 1); // CG_PONG is bSeq=true (sizeof(BYTE)+seq) @@ -305,7 +323,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { if (!m_recv.peek(&u, sizeof(u))) { return true; } - m_recv.discard(sizeof(u)); + discard_recv(sizeof(u)); consumed = true; return true; // UDP not used } @@ -321,7 +339,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) { if (!m_recv.peek(&kac, sizeof(kac))) { return true; } - m_recv.discard(sizeof(kac)); + discard_recv(sizeof(kac)); consumed = true; m_cipher.set_activated(true); // the server may have pipelined encrypted bytes right behind this; @@ -347,7 +365,7 @@ bool ClassicStream::handle_key_agreement() { if (!m_recv.peek(&pkt, sizeof(pkt))) { return true; // need the whole 261-byte packet } - m_recv.discard(sizeof(pkt)); + discard_recv(sizeof(pkt)); // generate our own DH2 public blob KeyAgreement out{}; @@ -391,7 +409,7 @@ void ClassicStream::dispatch() { // zero padding between packets (cipher block alignment on the m2dev side; // harmless to skip here too). if (header == 0) { - m_recv.discard(1); + discard_recv(1); continue; } @@ -430,7 +448,7 @@ void ClassicStream::dispatch() { disconnect(); return; } - m_recv.discard(raw.bytes); + discard_recv(raw.bytes); continue; case RawPacketStatus::Error: m_last_error = "raw packet framing error"; @@ -464,8 +482,11 @@ void ClassicStream::dispatch() { m_recv.discard(sizeof(DynHead)); 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); + } bool ok = !on_packet || on_packet(header, body, body_len); - m_recv.discard(body_len); + discard_recv(body_len); if (!ok) { disconnect(); return; @@ -477,6 +498,31 @@ void ClassicStream::dispatch() { int total = packet_size_gc(header); if (total <= 0) { m_last_error = "unknown/unsupported GC header " + std::to_string(header); + // Some 40250 deployments append one legacy entity packet variant that + // is not present in the checked-in header map. It has no length field, + // but the following normal GC_CHARACTER_ADD stream is self-describing. + // During PHASE_LOADING, recover only at a strongly validated character + // boundary; this keeps a deployment-specific spawn packet from aborting + // the whole login while leaving malformed encrypted data fatal elsewhere. + if (m_phase == PHASE_LOADING) { + const uint8_t *bytes = m_recv.read_ptr(); + bool recovered = false; + for (size_t i = 1; i + sizeof(GCCharacterAdd) <= m_recv.readable(); ++i) { + if (!plausible_character_add(bytes + i, m_recv.readable() - i)) { + continue; + } + if (m_trace) { + std::fprintf(stderr, "[classic] recovering loading stream at +%zu\n", i); + } + discard_recv(i); + m_last_error.clear(); + recovered = true; + break; + } + if (recovered) { + continue; + } + } if (on_error) { on_error(m_last_error); } @@ -487,8 +533,11 @@ void ClassicStream::dispatch() { return; } 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); + } bool ok = !on_packet || on_packet(header, p + 1, static_cast(total - 1)); - m_recv.discard(static_cast(total)); + discard_recv(static_cast(total)); if (!ok) { disconnect(); return; diff --git a/extension/src/net/classic/classic_stream.h b/extension/src/net/classic/classic_stream.h index 108638a1..3cce65e3 100644 --- a/extension/src/net/classic/classic_stream.h +++ b/extension/src/net/classic/classic_stream.h @@ -107,6 +107,7 @@ private: void append_sequence_if_needed(uint8_t header); bool handle_key_agreement(); void decrypt_appended(size_t n); // decrypt the last n bytes just written to m_recv + void discard_recv(size_t n); int m_sock = -1; State m_state = State::Offline; @@ -123,6 +124,7 @@ private: bool m_handshake_seen = false; bool m_time_sync_mode = false; bool m_trace = false; + uint8_t m_phase = PHASE_HANDSHAKE; // server clock offset from the last handshake exchange (informational). uint32_t m_server_time_base = 0; diff --git a/extension/src/net/m2_client.cpp b/extension/src/net/m2_client.cpp index 4b96b1ea..e20899ca 100644 --- a/extension/src/net/m2_client.cpp +++ b/extension/src/net/m2_client.cpp @@ -455,18 +455,26 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S have_cfg = true; suspended = false; - // MT_PROTOCOL=classic -> 40250 backend: direct connect to the game host, - // with the same post-login feature surface as the shared M2Client API. + // MT_PROTOCOL=classic -> 40250 backend: authenticate on auth_host first, + // then connect to game_host with the returned login ticket. if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") { classic_sess = std::make_unique(); + // The 40250 server expects CG_ENTERGAME shortly after the loading phase + // starts. Keep the live client in the same timing window as m2dev while + // allowing a deployment-specific override for unusually large bursts. + const String enter_delay = OS::get_singleton()->get_environment("MT_CLASSIC_ENTER_DELAY"); + classic_sess->set_auto_entergame_delay(enter_delay.is_empty() ? 1500 : + static_cast(MAX(0, enter_delay.to_int()))); + last_login_key = 0; classic_list_emitted = false; classic_last_stage = -1; classic_last_empire = -1; shop_open_seen = false; mall_open_seen = false; last_game_phase = -1; - if (!classic_sess->connect(std::string(g_host.utf8().get_data()), (uint16_t)g_port, - std::string(id.utf8().get_data()), std::string(pw.utf8().get_data()))) { + if (!classic_sess->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port, + std::string(g_host.utf8().get_data()), (uint16_t)g_port, + std::string(id.utf8().get_data()), std::string(pw.utf8().get_data()))) { emit_signal("login_failed", String(classic_sess->last_error().c_str())); set_stage(Stage::Failed); classic_sess.reset(); @@ -2646,8 +2654,16 @@ void M2Client::warp_to_game_server(const String &host, int port) { mall_open_seen = false; dead_seen.clear(); classic_sess = std::make_unique(); - if (!classic_sess->connect(std::string(host.utf8().get_data()), (uint16_t)port, - std::string(cfg_id.utf8().get_data()), std::string(cfg_pw.utf8().get_data()))) { + bool connected = false; + if (last_login_key != 0) { + connected = classic_sess->connect_with_login_key(std::string(host.utf8().get_data()), + (uint16_t)port, std::string(cfg_id.utf8().get_data()), last_login_key); + } else { + connected = classic_sess->connect(std::string(cfg_auth_host.utf8().get_data()), + (uint16_t)cfg_auth_port, std::string(host.utf8().get_data()), (uint16_t)port, + std::string(cfg_id.utf8().get_data()), std::string(cfg_pw.utf8().get_data())); + } + if (!connected) { emit_signal("login_failed", String(classic_sess->last_error().c_str())); set_stage(Stage::Failed); classic_sess.reset(); @@ -2691,6 +2707,9 @@ void M2Client::pump_classic() { classic_sess->set_now((uint32_t)Time::get_singleton()->get_ticks_msec()); classic_sess->pump(); + if (classic_sess->login_key() != 0) { + last_login_key = classic_sess->login_key(); + } const int empire = (int)classic_sess->parser().empire(); if (empire != classic_last_empire) { classic_last_empire = empire; diff --git a/extension/tools/net_classic_e2e.cpp b/extension/tools/net_classic_e2e.cpp index 6d492f90..54ff7aa4 100644 --- a/extension/tools/net_classic_e2e.cpp +++ b/extension/tools/net_classic_e2e.cpp @@ -6,6 +6,7 @@ // are driven by the Godot UI or the documented command surface. // // net_classic_e2e [slot] [timeout_seconds] +// MT_CLASSIC_AUTH=1 selects the 40250 auth(11000) -> game() flow. // // Exit status 0 means the socket handshake, DH2/CTR negotiation, login, // character list, character selection and PHASE_GAME were all reached. @@ -70,9 +71,18 @@ int main(int argc, char **argv) { } mtnet::classic::ClassicSession session; - session.set_auto_entergame_delay(1500); + const int enter_delay_ms = std::getenv("MT_CLASSIC_ENTER_DELAY") ? + std::atoi(std::getenv("MT_CLASSIC_ENTER_DELAY")) : 8000; + session.set_auto_entergame_delay(enter_delay_ms); session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr); - if (!session.connect(host, static_cast(port), id, password)) { + const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != 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 ? + session.connect(host, static_cast(auth_port), host, static_cast(port), + id, password) : + session.connect(host, static_cast(port), id, password); + if (!connected) { std::fprintf(stderr, "FAIL connect: %s\n", session.last_error().c_str()); return 2; } @@ -99,8 +109,9 @@ int main(int argc, char **argv) { std::printf("[e2e] character slots=%zu\n", slots.size()); for (size_t i = 0; i < slots.size(); ++i) { const auto &slot = slots[i]; - std::printf(" [%zu] %s id=%u level=%u\n", i, slot.name.c_str(), slot.id, - slot.level); + std::printf(" [%zu] %s id=%u level=%u pos=(%d,%d) main_part=%u hair_part=%u change_name=%d\n", + i, slot.name.c_str(), slot.id, slot.level, slot.x, slot.y, slot.main_part, + slot.hair_part, (int)slot.change_name); } const int slot = choose_slot(slots, requested_slot); if (slot < 0) { @@ -130,6 +141,18 @@ int main(int argc, char **argv) { } std::printf("PASS PHASE_GAME main_vid=%u entities=%zu inventory_slots=%d points_level=%d\n", world.main_vid(), world.size(), inventory_count, world.points().level()); + if (const mtnet::Entity *me = world.get(world.main_vid())) { + std::printf(" main race=%u pos_cm=(%.0f,%.0f,%.0f) parts=[%u,%u,%u,%u] name=%s\n", + me->race, me->x, me->y, me->z, me->parts[0], me->parts[1], me->parts[2], + me->parts[3], me->name.c_str()); + } + for (int wear = 0; wear < mtnet::WEAR_MAX_NUM; ++wear) { + const mtnet::Item &item = world.equip_slot(wear); + if (!item.empty()) { + std::printf(" equip wear=%d vnum=%u count=%u\n", wear, item.vnum, + (unsigned)item.count); + } + } session.disconnect(); return 0; } diff --git a/project/charselect.gd b/project/charselect.gd index f6ef5187..675cef63 100644 --- a/project/charselect.gd +++ b/project/charselect.gd @@ -55,7 +55,7 @@ func _init() -> void: _client.connect("login_failed", func(r): _set_status("login failed: " + r)) _client.connect("entered_game", func(): _set_status("entered game")) var host := "192.168.21.203" - _client.call("connect_to_server", host, 11000, host, 11011, "admin", "123456789") + _client.call("connect_to_server", host, 11000, host, 13002, "admin", "123456789") _set_status("connecting to " + host + " ...") else: _set_status("M2Client 未注册 —— 用 --mock") diff --git a/project/client_main.gd b/project/client_main.gd index 8183ce01..06c3a936 100644 --- a/project/client_main.gd +++ b/project/client_main.gd @@ -8,6 +8,10 @@ extends Node const AppFlow = preload("res://app_flow.gd") func _ready() -> void: + # 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic + # DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。 + if OS.get_environment("MT_PROTOCOL").is_empty(): + OS.set_environment("MT_PROTOCOL", "classic") # 移动端:APK/IPA 里没有资源 —— 挂载外部的 assets.zip(adb push / ios-deploy 上传)。 AssetPack.ensure() var root := AssetRoot.path() diff --git a/project/export_presets.cfg b/project/export_presets.cfg index 078d318b..225a6189 100644 --- a/project/export_presets.cfg +++ b/project/export_presets.cfg @@ -21,7 +21,7 @@ script_export_mode=2 [preset.0.options] export/distribution_type=1 -binary_format/architecture="arm64" +binary_format/architecture="universal" custom_template/debug="" custom_template/release="" debug/export_console_wrapper=1 diff --git a/project/game_scene.gd b/project/game_scene.gd index ca084b7c..5e23a3ee 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -121,6 +121,11 @@ func setup(m2client: Node, assets_root: String, map_path: String = "OutdoorA1/metin2_map_a1") -> void: client = m2client _assets = assets_root + # GC_MAIN_CHARACTER only carries the server-space coordinates, not the map + # folder. The old fixed A1 fallback puts characters from A2/A3 outside the + # rendered terrain, which looks like an empty map. Resolve the map from the + # character position before constructing Metin2World. + map_path = _resolve_map_path(map_path) _build_lighting() # 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket # (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。 @@ -672,6 +677,87 @@ func _map_loaded() -> bool: var rep: Dictionary = world.call("get_load_report") return int(rep.get("map_size_x", 0)) > 0 +func _resolve_map_path(requested: String) -> String: + if client == null or _assets == "" or not client.has_method("get_main_vid"): + return requested + var vid := int(client.get_main_vid()) + if vid == 0 or not client.has_method("get_entity"): + return requested + var entity: Dictionary = client.get_entity(vid) + var pos: Variant = entity.get("pos_cm", null) + if not (pos is Vector3): + return requested + + var settings: Array[String] = [] + _collect_map_settings(_assets, settings) + var best := requested + var best_score := 1000000 + for setting_path: String in settings: + var bounds := _map_bounds(setting_path) + if bounds.is_empty(): + continue + var base: Vector2 = bounds["base"] + var size: Vector2i = bounds["size"] + var max_x := base.x + float(size.x) * 25600.0 + var max_y := base.y + float(size.y) * 25600.0 + if pos.x < base.x or pos.x >= max_x or pos.y < base.y or pos.y >= max_y: + continue + var root := _assets.trim_suffix("/") + var rel_setting := setting_path.substr(root.length() + 1) + var candidate := rel_setting.get_base_dir() + var lower := candidate.to_lower() + # Prefer the normal outdoor map over duplicate patch/season copies when + # several map packages advertise the same world-space rectangle. + var score := candidate.split("/").size() + if lower.begins_with("outdoor"): + score -= 20 + if lower.begins_with("season") or lower.begins_with("metin2_patch"): + score += 20 + if candidate == requested: + score -= 1000 + if score < best_score: + best_score = score + best = candidate + + if best != requested: + print("[GameScene] map resolved by server position ", pos, " -> ", best) + return best + +func _collect_map_settings(dir_path: String, out: Array[String]) -> void: + var dir := DirAccess.open(dir_path) + if dir == null: + return + for file_name: String in dir.get_files(): + if file_name.to_lower() == "setting.txt": + out.append(dir_path.path_join(file_name)) + for child: String in dir.get_directories(): + if child in [".godot", ".git", "build", "export"]: + continue + _collect_map_settings(dir_path.path_join(child), out) + +func _map_bounds(setting_path: String) -> Dictionary: + var f := FileAccess.open(setting_path, FileAccess.READ) + if f == null: + return {} + var base := Vector2.ZERO + var size := Vector2i.ZERO + var have_base := false + var have_size := false + while not f.eof_reached(): + var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false) + if fields.size() < 3: + continue + match String(fields[0]).to_lower(): + "baseposition": + base = Vector2(float(fields[1]), float(fields[2])) + have_base = true + "mapsize": + size = Vector2i(int(fields[1]), int(fields[2])) + have_size = size.x > 0 and size.y > 0 + if not have_base or not have_size: + return {} + return {"base": base, "size": size} + func _build_lighting() -> void: var sun := DirectionalLight3D.new() sun.name = "FallbackSun" diff --git a/project/login.gd b/project/login.gd index 8834383f..ffe68a1d 100644 --- a/project/login.gd +++ b/project/login.gd @@ -121,7 +121,7 @@ func _connect() -> void: _client.call("reconnect")) _life.bind(_client, _audio) var h := _host.text - _client.call("connect_to_server", h, 11000, h, 11011, _id.text, _pw.text) + _client.call("connect_to_server", h, 11000, h, 13002, _id.text, _pw.text) _msg("连接 %s ..." % h) func _on_chars(list: Array) -> void: diff --git a/project/net/serverinfo.gd b/project/net/serverinfo.gd index 8adefd03..2d975111 100644 --- a/project/net/serverinfo.gd +++ b/project/net/serverinfo.gd @@ -7,10 +7,11 @@ # # 文件格式(TSV,# 注释): # name auth_host auth_port game_host game_port channels(csv) [port_step] [mark_port] -# 测试服 192.168.21.203 11000 192.168.21.203 11011 1,2,3 1 0 +# 测试服 192.168.21.203 11000 192.168.21.203 13002 1,2,3,4 10 0 # # 频道 N 的 game 端口 = game_port + (N-1)*port_step。 -# 联调服实测端口是 11011/11012/11013(step=1);不同服可在文件第 7 列覆盖。 +# 这套 40250 配置的 Game2 端口是 13002/13012/13022/13032(step=10)。 +# 13000/13010/... 是 first 入口;admin 测试角色在 Game2 地图,直接连 Game2 才能完成进入游戏。 # 第 8 列 mark_port = 公会会徽服端口(0 = 不下载会徽)。 class_name ServerInfo extends RefCounted @@ -20,12 +21,12 @@ const DEFAULT_PORT_STEP := 1 var _servers: Array = [] func _init() -> void: - # 内置默认:当前联调服(CServerStateChecker 实测频道口 11011/12/13) + # 内置默认:40250 服务器(认证 11000,channel1/Game2 端口 13002) _servers = [{ "name": "测试服", "auth_host": "192.168.21.203", "auth_port": 11000, - "game_host": "192.168.21.203", "game_port": 11011, - "channels": [1, 2, 3], "port_step": 1, "mark_port": 0, + "game_host": "192.168.21.203", "game_port": 13002, + "channels": [1, 2, 3, 4], "port_step": 10, "mark_port": 0, }] func load_file(path: String) -> bool: diff --git a/project/p10_test.gd b/project/p10_test.gd index 9837c146..987f961e 100644 --- a/project/p10_test.gd +++ b/project/p10_test.gd @@ -41,7 +41,7 @@ func _run() -> void: var si: RefCounted = ServerInfoRes.new() _ck(si.count() == 1 and si.server(0)["auth_host"] == "192.168.21.203", "serverinfo: built-in default") var addr: Dictionary = si.address(0, 3) - _ck(addr["game_port"] == 11013, "serverinfo: 内置 CH3 -> 11013 (step 1)") + _ck(addr["game_port"] == 13022, "serverinfo: 内置 CH3 -> 13022 (step 10)") var tmp := "user://p10_servers.txt" var f := FileAccess.open(tmp, FileAccess.WRITE) f.store_line("# test") @@ -103,7 +103,7 @@ func _run() -> void: var reachable: bool = await af._probe_tcp("127.0.0.1", 1, 0.3) _ck(reachable == false, "flow: _probe_tcp unreachable -> false") af._do_connect() - _ck(fc2.connects.size() == 1 and fc2.connects[0][3] == 11011, "flow: connect uses serverinfo addr") + _ck(fc2.connects.size() == 1 and fc2.connects[0][3] == 13002, "flow: connect uses serverinfo addr") if af._loading and af._loading.has_method("show_for"): af._loading.show_for("载入角色…") # 模拟 phase=select 时遮罩已亮 fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}]) diff --git a/project/project.godot b/project/project.godot index c3981c4e..b22a3412 100644 --- a/project/project.godot +++ b/project/project.godot @@ -6,8 +6,8 @@ config_version=5 [application] config/name="mtgodot-poc" -# 完整客户端入口(登录→选人→进游戏)。旧的模型查看器 harness 仍可跑: -# godot --path project res://main.tscn +; 完整客户端入口(登录→选人→进游戏)。旧的模型查看器 harness 仍可跑: +; godot --path project res://main.tscn run/main_scene="res://client_main.tscn" config/features=PackedStringArray("4.7", "Forward Plus") diff --git a/project/ui/equip_model.gd b/project/ui/equip_model.gd index ac8a7aea..a0113df6 100644 --- a/project/ui/equip_model.gd +++ b/project/ui/equip_model.gd @@ -243,7 +243,9 @@ func _ensure_spec() -> void: if race < 0 or assets_root == "": return var cls: String = CLASS_OF[race & 3] - for rel in ["PC/ymir work/pc/%s/%s.msm" % [cls, cls], + var suffix := "w" if race in [1, 3, 4, 6] else "m" + for rel in ["root/msm/%s_%s.msm" % [cls, suffix], + "PC/ymir work/pc/%s/%s.msm" % [cls, cls], "pc2/ymir work/pc2/%s/%s.msm" % [cls, cls], "season1/season1/pc/%s.msm" % cls]: var p := _resolve_asset(rel) diff --git a/project/ui/race_spec.gd b/project/ui/race_spec.gd index 7674507a..0993a6b6 100644 --- a/project/ui/race_spec.gd +++ b/project/ui/race_spec.gd @@ -57,7 +57,7 @@ func shape(idx: int) -> Dictionary: if e.is_empty(): return {} e = e.duplicate() - e["path"] = shape_path + e["path"] = String(e.get("special_path", shape_path)) e["spec_dir"] = _dir return e @@ -66,7 +66,7 @@ func hair(idx: int) -> Dictionary: if e.is_empty(): return {} e = e.duplicate() - e["path"] = hair_path + e["path"] = String(e.get("special_path", hair_path)) e["spec_dir"] = _dir return e @@ -77,6 +77,7 @@ func _entry(g: Dictionary) -> Dictionary: "model": _s(g.get("Model", g.get("local_model", ""))).strip_edges(), "source_skin": _s(g.get("SourceSkin", "")).strip_edges(), "target_skin": _s(g.get("TargetSkin", "")).strip_edges(), + "special_path": _s(g.get("SpecialPath", "")).strip_edges(), } func _s(v) -> String: diff --git a/run-client.command b/run-client.command index 66fe195a..d024e5cd 100755 --- a/run-client.command +++ b/run-client.command @@ -3,4 +3,5 @@ # 登录 → 选人 → 进游戏。资源用仓库内 assets/。 cd "$(dirname "$0")" export MT_ASSETS="$PWD/assets" +export MT_PROTOCOL="${MT_PROTOCOL:-classic}" exec "${GODOT:-godot}" --path project res://client_main.tscn "$@"