Fix 40250 classic login and world loading
This commit is contained in:
@@ -12,6 +12,9 @@ ClassicSession::ClassicSession() : m_parser(m_world) {
|
|||||||
return on_packet(h, b, l);
|
return on_packet(h, b, l);
|
||||||
};
|
};
|
||||||
m_stream.on_error = [this](const std::string &e) {
|
m_stream.on_error = [this](const std::string &e) {
|
||||||
|
if (m_auth_transition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
m_last_error = e;
|
m_last_error = e;
|
||||||
set_stage(Stage::Failed);
|
set_stage(Stage::Failed);
|
||||||
if (on_error) {
|
if (on_error) {
|
||||||
@@ -19,7 +22,8 @@ ClassicSession::ClassicSession() : m_parser(m_world) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
m_stream.on_state_change = [this](ClassicStream::State s) {
|
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_stage != Stage::Offline) {
|
||||||
m_last_error = m_stream.last_error().empty() ? "disconnected" : m_stream.last_error();
|
m_last_error = m_stream.last_error().empty() ? "disconnected" : m_stream.last_error();
|
||||||
set_stage(Stage::Failed);
|
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)
|
// 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) {
|
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) {
|
void ClassicSession::start_offline(const std::string &id, const std::string &pw) {
|
||||||
m_id = id;
|
m_id = id;
|
||||||
m_pw = pw;
|
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_login_sent = false;
|
||||||
m_entergame_sent = false;
|
m_entergame_sent = false;
|
||||||
m_version_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
|
// 0xFF. The normal game client switches to CG_TIME_SYNC only after the
|
||||||
// server has advanced into the login/select side of the connection.
|
// server has advanced into the login/select side of the connection.
|
||||||
m_stream.set_time_sync_mode(false);
|
m_stream.set_time_sync_mode(false);
|
||||||
|
m_auth_stream.set_time_sync_mode(false);
|
||||||
set_stage(Stage::Connecting);
|
set_stage(Stage::Connecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ClassicSession::connect(const std::string &game_host, uint16_t game_port,
|
bool ClassicSession::connect(const std::string &game_host, uint16_t game_port,
|
||||||
const std::string &id, const std::string &pw) {
|
const std::string &id, const std::string &pw) {
|
||||||
start_offline(id, 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();
|
m_last_error = m_stream.last_error();
|
||||||
set_stage(Stage::Failed);
|
set_stage(Stage::Failed);
|
||||||
return false;
|
return false;
|
||||||
@@ -65,12 +146,40 @@ bool ClassicSession::connect(const std::string &game_host, uint16_t game_port,
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ClassicSession::disconnect() {
|
void ClassicSession::disconnect() {
|
||||||
|
m_auth_transition = true;
|
||||||
|
m_auth_stream.disconnect();
|
||||||
m_stream.disconnect();
|
m_stream.disconnect();
|
||||||
|
m_auth_transition = false;
|
||||||
set_stage(Stage::Offline);
|
set_stage(Stage::Offline);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClassicSession::pump() {
|
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();
|
m_stream.process();
|
||||||
|
}
|
||||||
// auto CG_ENTERGAME a beat into PHASE_LOADING (the m2dev client waits ~1.5s;
|
// 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).
|
// 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_entergame_sent && m_entergame_delay > 0 &&
|
||||||
@@ -84,6 +193,10 @@ void ClassicSession::send_login() {
|
|||||||
if (m_login_sent) {
|
if (m_login_sent) {
|
||||||
return;
|
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
|
// 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.
|
// bSeq packet and the server expects the trailing byte from packet #1.
|
||||||
m_stream.set_sequence_mode(true);
|
m_stream.set_sequence_mode(true);
|
||||||
@@ -96,6 +209,78 @@ void ClassicSession::send_login() {
|
|||||||
m_login_sent = true;
|
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,
|
bool ClassicSession::create_character(int slot, const std::string &name, int job, int shape,
|
||||||
int con, int intel, int str, int dex) {
|
int con, int intel, int str, int dex) {
|
||||||
if (m_stage != Stage::CharSelect || slot < 0 || slot >= m_parser.slot_count() ||
|
if (m_stage != Stage::CharSelect || slot < 0 || slot >= m_parser.slot_count() ||
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// ClassicSession — MT_PROTOCOL=classic: drives ClassicStream through
|
// ClassicSession — MT_PROTOCOL=classic: drives ClassicStream through the
|
||||||
// HANDSHAKE -> LOGIN -> SELECT -> LOADING -> GAME and parses GC packets into a
|
// 40250 auth-server -> game-server flow and parses GC packets into a shared
|
||||||
// shared EntityStore. See docs/CLIENT-40250-PORT.md §4 / §8.
|
// EntityStore. See docs/CLIENT-40250-PORT.md §4 / §8.
|
||||||
//
|
//
|
||||||
// The stock 40250 server uses _IMPROVED_PACKET_ENCRYPTION_; ClassicStream
|
// The stock 40250 server uses _IMPROVED_PACKET_ENCRYPTION_; ClassicStream
|
||||||
// negotiates that DH2 + CTR session before the first login packet.
|
// negotiates that DH2 + CTR session before the first login packet.
|
||||||
@@ -27,6 +27,13 @@ public:
|
|||||||
// --- INetSession ---
|
// --- INetSession ---
|
||||||
bool connect(const std::string &game_host, uint16_t game_port, const std::string &id,
|
bool connect(const std::string &game_host, uint16_t game_port, const std::string &id,
|
||||||
const std::string &pw) override;
|
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;
|
void disconnect() override;
|
||||||
bool select_char(int slot) override;
|
bool select_char(int slot) override;
|
||||||
bool enter_game() override;
|
bool enter_game() override;
|
||||||
@@ -133,36 +140,57 @@ public:
|
|||||||
// --- accessors ---
|
// --- accessors ---
|
||||||
const std::vector<CharSlot> &char_slots() const { return m_parser.char_slots(); }
|
const std::vector<CharSlot> &char_slots() const { return m_parser.char_slots(); }
|
||||||
ClassicStream &stream() { return m_stream; } // for tests / trace
|
ClassicStream &stream() { return m_stream; } // for tests / trace
|
||||||
|
ClassicStream &auth_stream() { return m_auth_stream; } // auth-flow tests / trace
|
||||||
ClassicParser &parser() { return m_parser; }
|
ClassicParser &parser() { return m_parser; }
|
||||||
|
uint32_t login_key() const { return m_auth_login_key; }
|
||||||
uint32_t now_ms() const { return m_now; }
|
uint32_t now_ms() const { return m_now; }
|
||||||
void set_now(uint32_t ms) {
|
void set_now(uint32_t ms) {
|
||||||
m_now = ms;
|
m_now = ms;
|
||||||
m_world.set_now(ms);
|
m_world.set_now(ms);
|
||||||
}
|
}
|
||||||
// auto-send CG_ENTERGAME this many ms after entering PHASE_LOADING (0 = never,
|
// 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_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:
|
private:
|
||||||
void set_stage(Stage s);
|
void set_stage(Stage s);
|
||||||
void on_phase(uint8_t phase);
|
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_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_login();
|
||||||
|
void send_auth_login();
|
||||||
|
void send_login_by_key();
|
||||||
bool send_client_version();
|
bool send_client_version();
|
||||||
|
|
||||||
EntityStore m_world;
|
EntityStore m_world;
|
||||||
ClassicStream m_stream;
|
ClassicStream m_stream;
|
||||||
|
ClassicStream m_auth_stream;
|
||||||
ClassicParser m_parser;
|
ClassicParser m_parser;
|
||||||
|
|
||||||
Stage m_stage = Stage::Offline;
|
Stage m_stage = Stage::Offline;
|
||||||
std::string m_last_error;
|
std::string m_last_error;
|
||||||
std::string m_id, m_pw;
|
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_now = 0;
|
||||||
uint32_t m_loading_since = 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_entergame_sent = false;
|
||||||
bool m_login_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;
|
bool m_version_sent = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
@@ -27,6 +28,17 @@ void set_nonblocking(int fd) {
|
|||||||
int fl = fcntl(fd, F_GETFL, 0);
|
int fl = fcntl(fd, F_GETFL, 0);
|
||||||
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
|
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
|
} // namespace
|
||||||
|
|
||||||
ClassicStream::~ClassicStream() {
|
ClassicStream::~ClassicStream() {
|
||||||
@@ -93,10 +105,15 @@ void ClassicStream::disconnect() {
|
|||||||
m_seq_idx = 0;
|
m_seq_idx = 0;
|
||||||
m_seq_on = false;
|
m_seq_on = false;
|
||||||
m_handshake_seen = false;
|
m_handshake_seen = false;
|
||||||
|
m_phase = PHASE_HANDSHAKE;
|
||||||
m_cipher.clean_up();
|
m_cipher.clean_up();
|
||||||
set_state(State::Offline);
|
set_state(State::Offline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ClassicStream::discard_recv(size_t n) {
|
||||||
|
m_recv.discard(n);
|
||||||
|
}
|
||||||
|
|
||||||
void ClassicStream::decrypt_appended(size_t n) {
|
void ClassicStream::decrypt_appended(size_t n) {
|
||||||
if (n == 0 || !m_cipher.activated()) {
|
if (n == 0 || !m_cipher.activated()) {
|
||||||
return;
|
return;
|
||||||
@@ -234,7 +251,7 @@ bool ClassicStream::handle_control(uint8_t header, bool &consumed) {
|
|||||||
if (!m_recv.peek(&hs, sizeof(hs))) {
|
if (!m_recv.peek(&hs, sizeof(hs))) {
|
||||||
return true; // need more
|
return true; // need more
|
||||||
}
|
}
|
||||||
m_recv.discard(sizeof(hs));
|
discard_recv(sizeof(hs));
|
||||||
consumed = true;
|
consumed = true;
|
||||||
// mirror EterLib/PythonNetworkStreamPhaseHandshake.cpp + desc.cpp:
|
// mirror EterLib/PythonNetworkStreamPhaseHandshake.cpp + desc.cpp:
|
||||||
m_server_time_base = hs.time + static_cast<uint32_t>(hs.delta);
|
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))) {
|
if (!m_recv.peek(&b, sizeof(b))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
m_recv.discard(sizeof(b));
|
discard_recv(sizeof(b));
|
||||||
consumed = true;
|
consumed = true;
|
||||||
if (on_handshake_ok) {
|
if (on_handshake_ok) {
|
||||||
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))) {
|
if (!m_recv.peek(&p, sizeof(p))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
m_recv.discard(sizeof(p));
|
discard_recv(sizeof(p));
|
||||||
consumed = true;
|
consumed = true;
|
||||||
|
m_phase = p.phase;
|
||||||
if (m_trace) {
|
if (m_trace) {
|
||||||
std::fprintf(stderr, "[classic] phase -> %u\n", p.phase);
|
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))) {
|
if (!m_recv.peek(&b, sizeof(b))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
m_recv.discard(sizeof(b));
|
discard_recv(sizeof(b));
|
||||||
consumed = true;
|
consumed = true;
|
||||||
uint8_t pong = HDR_CG_PONG; // 0xFE
|
uint8_t pong = HDR_CG_PONG; // 0xFE
|
||||||
send_fixed(&pong, 1); // CG_PONG is bSeq=true (sizeof(BYTE)+seq)
|
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))) {
|
if (!m_recv.peek(&u, sizeof(u))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
m_recv.discard(sizeof(u));
|
discard_recv(sizeof(u));
|
||||||
consumed = true;
|
consumed = true;
|
||||||
return true; // UDP not used
|
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))) {
|
if (!m_recv.peek(&kac, sizeof(kac))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
m_recv.discard(sizeof(kac));
|
discard_recv(sizeof(kac));
|
||||||
consumed = true;
|
consumed = true;
|
||||||
m_cipher.set_activated(true);
|
m_cipher.set_activated(true);
|
||||||
// the server may have pipelined encrypted bytes right behind this;
|
// 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))) {
|
if (!m_recv.peek(&pkt, sizeof(pkt))) {
|
||||||
return true; // need the whole 261-byte packet
|
return true; // need the whole 261-byte packet
|
||||||
}
|
}
|
||||||
m_recv.discard(sizeof(pkt));
|
discard_recv(sizeof(pkt));
|
||||||
|
|
||||||
// generate our own DH2 public blob
|
// generate our own DH2 public blob
|
||||||
KeyAgreement out{};
|
KeyAgreement out{};
|
||||||
@@ -391,7 +409,7 @@ void ClassicStream::dispatch() {
|
|||||||
// zero padding between packets (cipher block alignment on the m2dev side;
|
// zero padding between packets (cipher block alignment on the m2dev side;
|
||||||
// harmless to skip here too).
|
// harmless to skip here too).
|
||||||
if (header == 0) {
|
if (header == 0) {
|
||||||
m_recv.discard(1);
|
discard_recv(1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +448,7 @@ void ClassicStream::dispatch() {
|
|||||||
disconnect();
|
disconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_recv.discard(raw.bytes);
|
discard_recv(raw.bytes);
|
||||||
continue;
|
continue;
|
||||||
case RawPacketStatus::Error:
|
case RawPacketStatus::Error:
|
||||||
m_last_error = "raw packet framing error";
|
m_last_error = "raw packet framing error";
|
||||||
@@ -464,8 +482,11 @@ void ClassicStream::dispatch() {
|
|||||||
m_recv.discard(sizeof(DynHead));
|
m_recv.discard(sizeof(DynHead));
|
||||||
uint32_t body_len = dh.size - sizeof(DynHead);
|
uint32_t body_len = dh.size - sizeof(DynHead);
|
||||||
const uint8_t *body = m_recv.read_ptr();
|
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);
|
bool ok = !on_packet || on_packet(header, body, body_len);
|
||||||
m_recv.discard(body_len);
|
discard_recv(body_len);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
disconnect();
|
disconnect();
|
||||||
return;
|
return;
|
||||||
@@ -477,6 +498,31 @@ void ClassicStream::dispatch() {
|
|||||||
int total = packet_size_gc(header);
|
int total = packet_size_gc(header);
|
||||||
if (total <= 0) {
|
if (total <= 0) {
|
||||||
m_last_error = "unknown/unsupported GC header " + std::to_string(header);
|
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) {
|
if (on_error) {
|
||||||
on_error(m_last_error);
|
on_error(m_last_error);
|
||||||
}
|
}
|
||||||
@@ -487,8 +533,11 @@ void ClassicStream::dispatch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const uint8_t *p = m_recv.read_ptr();
|
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));
|
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) {
|
if (!ok) {
|
||||||
disconnect();
|
disconnect();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ private:
|
|||||||
void append_sequence_if_needed(uint8_t header);
|
void append_sequence_if_needed(uint8_t header);
|
||||||
bool handle_key_agreement();
|
bool handle_key_agreement();
|
||||||
void decrypt_appended(size_t n); // decrypt the last n bytes just written to m_recv
|
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;
|
int m_sock = -1;
|
||||||
State m_state = State::Offline;
|
State m_state = State::Offline;
|
||||||
@@ -123,6 +124,7 @@ private:
|
|||||||
bool m_handshake_seen = false;
|
bool m_handshake_seen = false;
|
||||||
bool m_time_sync_mode = false;
|
bool m_time_sync_mode = false;
|
||||||
bool m_trace = false;
|
bool m_trace = false;
|
||||||
|
uint8_t m_phase = PHASE_HANDSHAKE;
|
||||||
|
|
||||||
// server clock offset from the last handshake exchange (informational).
|
// server clock offset from the last handshake exchange (informational).
|
||||||
uint32_t m_server_time_base = 0;
|
uint32_t m_server_time_base = 0;
|
||||||
|
|||||||
@@ -455,17 +455,25 @@ void M2Client::connect_to_server(const String &auth_host, int auth_port, const S
|
|||||||
have_cfg = true;
|
have_cfg = true;
|
||||||
suspended = false;
|
suspended = false;
|
||||||
|
|
||||||
// MT_PROTOCOL=classic -> 40250 backend: direct connect to the game host,
|
// MT_PROTOCOL=classic -> 40250 backend: authenticate on auth_host first,
|
||||||
// with the same post-login feature surface as the shared M2Client API.
|
// then connect to game_host with the returned login ticket.
|
||||||
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
|
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
|
||||||
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
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_list_emitted = false;
|
||||||
classic_last_stage = -1;
|
classic_last_stage = -1;
|
||||||
classic_last_empire = -1;
|
classic_last_empire = -1;
|
||||||
shop_open_seen = false;
|
shop_open_seen = false;
|
||||||
mall_open_seen = false;
|
mall_open_seen = false;
|
||||||
last_game_phase = -1;
|
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()))) {
|
std::string(id.utf8().get_data()), std::string(pw.utf8().get_data()))) {
|
||||||
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
||||||
set_stage(Stage::Failed);
|
set_stage(Stage::Failed);
|
||||||
@@ -2646,8 +2654,16 @@ void M2Client::warp_to_game_server(const String &host, int port) {
|
|||||||
mall_open_seen = false;
|
mall_open_seen = false;
|
||||||
dead_seen.clear();
|
dead_seen.clear();
|
||||||
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
||||||
if (!classic_sess->connect(std::string(host.utf8().get_data()), (uint16_t)port,
|
bool connected = false;
|
||||||
std::string(cfg_id.utf8().get_data()), std::string(cfg_pw.utf8().get_data()))) {
|
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()));
|
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
||||||
set_stage(Stage::Failed);
|
set_stage(Stage::Failed);
|
||||||
classic_sess.reset();
|
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->set_now((uint32_t)Time::get_singleton()->get_ticks_msec());
|
||||||
classic_sess->pump();
|
classic_sess->pump();
|
||||||
|
if (classic_sess->login_key() != 0) {
|
||||||
|
last_login_key = classic_sess->login_key();
|
||||||
|
}
|
||||||
const int empire = (int)classic_sess->parser().empire();
|
const int empire = (int)classic_sess->parser().empire();
|
||||||
if (empire != classic_last_empire) {
|
if (empire != classic_last_empire) {
|
||||||
classic_last_empire = empire;
|
classic_last_empire = empire;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// are driven by the Godot UI or the documented command surface.
|
// are driven by the Godot UI or the documented command surface.
|
||||||
//
|
//
|
||||||
// net_classic_e2e <host> <port> <id> <password> [slot] [timeout_seconds]
|
// 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,
|
// Exit status 0 means the socket handshake, DH2/CTR negotiation, login,
|
||||||
// character list, character selection and PHASE_GAME were all reached.
|
// character list, character selection and PHASE_GAME were all reached.
|
||||||
@@ -70,9 +71,18 @@ int main(int argc, char **argv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mtnet::classic::ClassicSession session;
|
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);
|
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());
|
std::fprintf(stderr, "FAIL connect: %s\n", session.last_error().c_str());
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
@@ -99,8 +109,9 @@ int main(int argc, char **argv) {
|
|||||||
std::printf("[e2e] character slots=%zu\n", slots.size());
|
std::printf("[e2e] character slots=%zu\n", slots.size());
|
||||||
for (size_t i = 0; i < slots.size(); ++i) {
|
for (size_t i = 0; i < slots.size(); ++i) {
|
||||||
const auto &slot = slots[i];
|
const auto &slot = slots[i];
|
||||||
std::printf(" [%zu] %s id=%u level=%u\n", i, slot.name.c_str(), slot.id,
|
std::printf(" [%zu] %s id=%u level=%u pos=(%d,%d) main_part=%u hair_part=%u change_name=%d\n",
|
||||||
slot.level);
|
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);
|
const int slot = choose_slot(slots, requested_slot);
|
||||||
if (slot < 0) {
|
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",
|
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());
|
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();
|
session.disconnect();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func _init() -> void:
|
|||||||
_client.connect("login_failed", func(r): _set_status("login failed: " + r))
|
_client.connect("login_failed", func(r): _set_status("login failed: " + r))
|
||||||
_client.connect("entered_game", func(): _set_status("entered game"))
|
_client.connect("entered_game", func(): _set_status("entered game"))
|
||||||
var host := "192.168.21.203"
|
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 + " ...")
|
_set_status("connecting to " + host + " ...")
|
||||||
else:
|
else:
|
||||||
_set_status("M2Client 未注册 —— 用 --mock")
|
_set_status("M2Client 未注册 —— 用 --mock")
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ extends Node
|
|||||||
const AppFlow = preload("res://app_flow.gd")
|
const AppFlow = preload("res://app_flow.gd")
|
||||||
|
|
||||||
func _ready() -> void:
|
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 上传)。
|
# 移动端:APK/IPA 里没有资源 —— 挂载外部的 assets.zip(adb push / ios-deploy 上传)。
|
||||||
AssetPack.ensure()
|
AssetPack.ensure()
|
||||||
var root := AssetRoot.path()
|
var root := AssetRoot.path()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ script_export_mode=2
|
|||||||
[preset.0.options]
|
[preset.0.options]
|
||||||
|
|
||||||
export/distribution_type=1
|
export/distribution_type=1
|
||||||
binary_format/architecture="arm64"
|
binary_format/architecture="universal"
|
||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ func setup(m2client: Node, assets_root: String,
|
|||||||
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
||||||
client = m2client
|
client = m2client
|
||||||
_assets = assets_root
|
_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()
|
_build_lighting()
|
||||||
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
|
# 关键:setup 是协程。每个重活之间让出一帧,好让 M2Client._process 抽 socket
|
||||||
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
|
# (否则整段 ~5s 同步阻塞会漏 PONG → 服务器 10s 后 "peer closed")。
|
||||||
@@ -672,6 +677,87 @@ func _map_loaded() -> bool:
|
|||||||
var rep: Dictionary = world.call("get_load_report")
|
var rep: Dictionary = world.call("get_load_report")
|
||||||
return int(rep.get("map_size_x", 0)) > 0
|
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:
|
func _build_lighting() -> void:
|
||||||
var sun := DirectionalLight3D.new()
|
var sun := DirectionalLight3D.new()
|
||||||
sun.name = "FallbackSun"
|
sun.name = "FallbackSun"
|
||||||
|
|||||||
+1
-1
@@ -121,7 +121,7 @@ func _connect() -> void:
|
|||||||
_client.call("reconnect"))
|
_client.call("reconnect"))
|
||||||
_life.bind(_client, _audio)
|
_life.bind(_client, _audio)
|
||||||
var h := _host.text
|
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)
|
_msg("连接 %s ..." % h)
|
||||||
|
|
||||||
func _on_chars(list: Array) -> void:
|
func _on_chars(list: Array) -> void:
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
#
|
#
|
||||||
# 文件格式(TSV,# 注释):
|
# 文件格式(TSV,# 注释):
|
||||||
# name auth_host auth_port game_host game_port channels(csv) [port_step] [mark_port]
|
# 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。
|
# 频道 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 = 不下载会徽)。
|
# 第 8 列 mark_port = 公会会徽服端口(0 = 不下载会徽)。
|
||||||
class_name ServerInfo
|
class_name ServerInfo
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
@@ -20,12 +21,12 @@ const DEFAULT_PORT_STEP := 1
|
|||||||
var _servers: Array = []
|
var _servers: Array = []
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
# 内置默认:当前联调服(CServerStateChecker 实测频道口 11011/12/13)
|
# 内置默认:40250 服务器(认证 11000,channel1/Game2 端口 13002)
|
||||||
_servers = [{
|
_servers = [{
|
||||||
"name": "测试服",
|
"name": "测试服",
|
||||||
"auth_host": "192.168.21.203", "auth_port": 11000,
|
"auth_host": "192.168.21.203", "auth_port": 11000,
|
||||||
"game_host": "192.168.21.203", "game_port": 11011,
|
"game_host": "192.168.21.203", "game_port": 13002,
|
||||||
"channels": [1, 2, 3], "port_step": 1, "mark_port": 0,
|
"channels": [1, 2, 3, 4], "port_step": 10, "mark_port": 0,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
func load_file(path: String) -> bool:
|
func load_file(path: String) -> bool:
|
||||||
|
|||||||
+2
-2
@@ -41,7 +41,7 @@ func _run() -> void:
|
|||||||
var si: RefCounted = ServerInfoRes.new()
|
var si: RefCounted = ServerInfoRes.new()
|
||||||
_ck(si.count() == 1 and si.server(0)["auth_host"] == "192.168.21.203", "serverinfo: built-in default")
|
_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)
|
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 tmp := "user://p10_servers.txt"
|
||||||
var f := FileAccess.open(tmp, FileAccess.WRITE)
|
var f := FileAccess.open(tmp, FileAccess.WRITE)
|
||||||
f.store_line("# test")
|
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)
|
var reachable: bool = await af._probe_tcp("127.0.0.1", 1, 0.3)
|
||||||
_ck(reachable == false, "flow: _probe_tcp unreachable -> false")
|
_ck(reachable == false, "flow: _probe_tcp unreachable -> false")
|
||||||
af._do_connect()
|
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"):
|
if af._loading and af._loading.has_method("show_for"):
|
||||||
af._loading.show_for("载入角色…") # 模拟 phase=select 时遮罩已亮
|
af._loading.show_for("载入角色…") # 模拟 phase=select 时遮罩已亮
|
||||||
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
|
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ config_version=5
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="mtgodot-poc"
|
config/name="mtgodot-poc"
|
||||||
# 完整客户端入口(登录→选人→进游戏)。旧的模型查看器 harness 仍可跑:
|
; 完整客户端入口(登录→选人→进游戏)。旧的模型查看器 harness 仍可跑:
|
||||||
# godot --path project res://main.tscn
|
; godot --path project res://main.tscn
|
||||||
run/main_scene="res://client_main.tscn"
|
run/main_scene="res://client_main.tscn"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ func _ensure_spec() -> void:
|
|||||||
if race < 0 or assets_root == "":
|
if race < 0 or assets_root == "":
|
||||||
return
|
return
|
||||||
var cls: String = CLASS_OF[race & 3]
|
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],
|
"pc2/ymir work/pc2/%s/%s.msm" % [cls, cls],
|
||||||
"season1/season1/pc/%s.msm" % cls]:
|
"season1/season1/pc/%s.msm" % cls]:
|
||||||
var p := _resolve_asset(rel)
|
var p := _resolve_asset(rel)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func shape(idx: int) -> Dictionary:
|
|||||||
if e.is_empty():
|
if e.is_empty():
|
||||||
return {}
|
return {}
|
||||||
e = e.duplicate()
|
e = e.duplicate()
|
||||||
e["path"] = shape_path
|
e["path"] = String(e.get("special_path", shape_path))
|
||||||
e["spec_dir"] = _dir
|
e["spec_dir"] = _dir
|
||||||
return e
|
return e
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ func hair(idx: int) -> Dictionary:
|
|||||||
if e.is_empty():
|
if e.is_empty():
|
||||||
return {}
|
return {}
|
||||||
e = e.duplicate()
|
e = e.duplicate()
|
||||||
e["path"] = hair_path
|
e["path"] = String(e.get("special_path", hair_path))
|
||||||
e["spec_dir"] = _dir
|
e["spec_dir"] = _dir
|
||||||
return e
|
return e
|
||||||
|
|
||||||
@@ -77,6 +77,7 @@ func _entry(g: Dictionary) -> Dictionary:
|
|||||||
"model": _s(g.get("Model", g.get("local_model", ""))).strip_edges(),
|
"model": _s(g.get("Model", g.get("local_model", ""))).strip_edges(),
|
||||||
"source_skin": _s(g.get("SourceSkin", "")).strip_edges(),
|
"source_skin": _s(g.get("SourceSkin", "")).strip_edges(),
|
||||||
"target_skin": _s(g.get("TargetSkin", "")).strip_edges(),
|
"target_skin": _s(g.get("TargetSkin", "")).strip_edges(),
|
||||||
|
"special_path": _s(g.get("SpecialPath", "")).strip_edges(),
|
||||||
}
|
}
|
||||||
|
|
||||||
func _s(v) -> String:
|
func _s(v) -> String:
|
||||||
|
|||||||
@@ -3,4 +3,5 @@
|
|||||||
# 登录 → 选人 → 进游戏。资源用仓库内 assets/。
|
# 登录 → 选人 → 进游戏。资源用仓库内 assets/。
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
export MT_ASSETS="$PWD/assets"
|
export MT_ASSETS="$PWD/assets"
|
||||||
|
export MT_PROTOCOL="${MT_PROTOCOL:-classic}"
|
||||||
exec "${GODOT:-godot}" --path project res://client_main.tscn "$@"
|
exec "${GODOT:-godot}" --path project res://client_main.tscn "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user