Fix 40250 classic login and world loading

This commit is contained in:
shen
2026-09-02 07:03:25 +08:00
parent 8f61990a83
commit 44bdcb0781
17 changed files with 446 additions and 45 deletions
+187 -2
View File
@@ -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() {
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<uint8_t *>(&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<const char *>(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() ||
+34 -6
View File
@@ -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<CharSlot> &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;
};
+60 -11
View File
@@ -3,6 +3,7 @@
#include <arpa/inet.h>
#include <cerrno>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
@@ -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<uint32_t>(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<uint32_t>(total - 1));
m_recv.discard(static_cast<size_t>(total));
discard_recv(static_cast<size_t>(total));
if (!ok) {
disconnect();
return;
@@ -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;
+24 -5
View File
@@ -455,17 +455,25 @@ 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<mtnet::classic::ClassicSession>();
// 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<uint32_t>(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,
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);
@@ -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<mtnet::classic::ClassicSession>();
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;
+27 -4
View File
@@ -6,6 +6,7 @@
// are driven by the Godot UI or the documented command surface.
//
// net_classic_e2e <host> <port> <id> <password> [slot] [timeout_seconds]
// MT_CLASSIC_AUTH=1 selects the 40250 auth(11000) -> game(<port>) 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<uint16_t>(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<uint16_t>(auth_port), host, static_cast<uint16_t>(port),
id, password) :
session.connect(host, static_cast<uint16_t>(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;
}
+1 -1
View File
@@ -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")
+4
View File
@@ -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.zipadb push / ios-deploy 上传)。
AssetPack.ensure()
var root := AssetRoot.path()
+1 -1
View File
@@ -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
+86
View File
@@ -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"
+1 -1
View File
@@ -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:
+6 -5
View File
@@ -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/13032step=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 服务器(认证 11000channel1/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:
+2 -2
View File
@@ -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}])
+2 -2
View File
@@ -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")
+3 -1
View File
@@ -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)
+3 -2
View File
@@ -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:
+1
View File
@@ -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 "$@"