40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)
Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.
Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:
- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
(§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).
Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
70f200e885
commit
f32064c74a
@@ -23,6 +23,8 @@ void ClassicParser::parse_login_success(const SimplePlayer *players, const uint3
|
||||
s.play_minutes = sp.play_minutes;
|
||||
s.x = sp.x;
|
||||
s.y = sp.y;
|
||||
s.addr = sp.addr;
|
||||
s.port = sp.port;
|
||||
s.main_part = sp.main_part;
|
||||
s.hair_part = sp.hair_part;
|
||||
s.skill_group = sp.skill_group;
|
||||
@@ -37,6 +39,7 @@ void ClassicParser::parse_login_success(const SimplePlayer *players, const uint3
|
||||
}
|
||||
|
||||
bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
m_last_error.clear();
|
||||
switch (header) {
|
||||
case HDR_GC_LOGIN_SUCCESS_NEWSLOT: { // 32 — what 40250 actually sends
|
||||
GCLoginSuccess p;
|
||||
@@ -74,10 +77,12 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
case HDR_GC_XTRAP_CS1_REQUEST:
|
||||
case HDR_GC_PANAMA_PACK:
|
||||
// These packets belong to optional account/anti-cheat integrations.
|
||||
// Their static sizes are still registered in wire_classic.h so they
|
||||
// cannot desynchronise the game stream; this client has no matching
|
||||
// Godot UI or vendor runtime to act on them.
|
||||
return true;
|
||||
// Their static sizes are still registered in wire_classic.h, but this
|
||||
// client has no matching Godot UI or vendor runtime to act on them.
|
||||
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
|
||||
std::to_string(header);
|
||||
m_unhandled_headers.push_back(header);
|
||||
return false;
|
||||
case HDR_GC_CHARACTER_CREATE_SUCCESS: {
|
||||
GCPlayerCreateSuccess p;
|
||||
if (!fill(p, header, body, len)) {
|
||||
@@ -178,6 +183,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
m_world.mut_spawn_main(p.vid, p.race,
|
||||
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||
(float)p.z);
|
||||
// §9.1: route the map's background-music track; volume unspecified.
|
||||
m_world.mut_map_bgm(
|
||||
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))), -1.0f);
|
||||
return true;
|
||||
}
|
||||
case HDR_GC_MAIN_CHARACTER4_BGM_VOL: {
|
||||
@@ -188,6 +196,10 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
m_world.mut_spawn_main(p.vid, p.race,
|
||||
std::string(p.name, strnlen(p.name, sizeof(p.name))), (float)p.x, (float)p.y,
|
||||
(float)p.z);
|
||||
// §9.1: track + server-authored volume (0..1).
|
||||
m_world.mut_map_bgm(
|
||||
std::string(p.bgm_name, strnlen(p.bgm_name, sizeof(p.bgm_name))),
|
||||
p.bgm_volume);
|
||||
return true;
|
||||
}
|
||||
case HDR_GC_CHARACTER_ADD: { // 1
|
||||
@@ -1244,7 +1256,7 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!fill(packet, header, body, len)) {
|
||||
return false;
|
||||
}
|
||||
m_world.mut_warp(packet.x, packet.y, packet.addr, packet.port);
|
||||
m_world.mut_warp(packet.x, packet.y, packet.addr, packet.port, true);
|
||||
return true;
|
||||
}
|
||||
case HDR_GC_TIME: {
|
||||
@@ -1400,9 +1412,10 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
}
|
||||
|
||||
default:
|
||||
// not handled yet — harmless, keep going. classic_stream already
|
||||
// rejected unknown *static* headers via the size table.
|
||||
return true;
|
||||
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
|
||||
std::to_string(header);
|
||||
m_unhandled_headers.push_back(header);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace mtnet::classic {
|
||||
@@ -27,6 +28,8 @@ struct CharSlot {
|
||||
uint8_t st = 0, ht = 0, dx = 0, iq = 0;
|
||||
uint32_t play_minutes = 0;
|
||||
int32_t x = 0, y = 0;
|
||||
int32_t addr = 0;
|
||||
uint16_t port = 0;
|
||||
uint16_t main_part = 0, hair_part = 0;
|
||||
uint8_t skill_group = 0;
|
||||
// Server requires this character to be renamed before entering the game.
|
||||
@@ -62,6 +65,13 @@ public:
|
||||
const std::string &login_failure() const { return m_login_failure; }
|
||||
uint32_t login_key() const { return m_login_key; } // GC_LOGIN_KEY (auth path)
|
||||
uint8_t empire() const { return m_empire; }
|
||||
void set_phase_name(const std::string &name) { m_phase_name = name; }
|
||||
const std::string &last_error() const { return m_last_error; }
|
||||
std::vector<uint8_t> drain_unhandled_headers() {
|
||||
auto v = std::move(m_unhandled_headers);
|
||||
m_unhandled_headers.clear();
|
||||
return v;
|
||||
}
|
||||
std::vector<CharEvent> drain_char_events() {
|
||||
auto v = std::move(m_char_events);
|
||||
m_char_events.clear();
|
||||
@@ -93,6 +103,10 @@ private:
|
||||
uint32_t rkey);
|
||||
|
||||
EntityStore &m_world;
|
||||
// §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD stages a bare Entity here
|
||||
// keyed by vid; GC_CHAR_ADDITIONAL_INFO merges its fields and flushes the
|
||||
// combined record via m_world.mut_spawn_full(). W1 owns the merge logic.
|
||||
std::unordered_map<uint32_t, Entity> m_pending_actor;
|
||||
std::vector<CharSlot> m_slots;
|
||||
int m_slot_count = 0;
|
||||
uint32_t m_handle = 0;
|
||||
@@ -104,6 +118,9 @@ private:
|
||||
int m_guild_make_requests = 0;
|
||||
bool m_char_list_ready = false;
|
||||
std::string m_login_failure;
|
||||
std::string m_phase_name = "Offline";
|
||||
std::string m_last_error;
|
||||
std::vector<uint8_t> m_unhandled_headers;
|
||||
};
|
||||
|
||||
} // namespace mtnet::classic
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
|
||||
namespace mtnet::classic {
|
||||
|
||||
namespace {
|
||||
std::string ipv4_from_slot_addr(int32_t addr) {
|
||||
const uint32_t value = static_cast<uint32_t>(addr);
|
||||
return std::to_string(value & 0xffu) + "." +
|
||||
std::to_string((value >> 8) & 0xffu) + "." +
|
||||
std::to_string((value >> 16) & 0xffu) + "." +
|
||||
std::to_string((value >> 24) & 0xffu);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ClassicSession::ClassicSession() : m_parser(m_world) {
|
||||
m_stream.on_phase = [this](uint8_t p) { on_phase(p); };
|
||||
m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) {
|
||||
@@ -22,6 +32,9 @@ ClassicSession::ClassicSession() : m_parser(m_world) {
|
||||
}
|
||||
};
|
||||
m_stream.on_state_change = [this](ClassicStream::State s) {
|
||||
if (s == ClassicStream::State::Online && m_game_started) {
|
||||
m_game_connection_seen = true;
|
||||
}
|
||||
if (s == ClassicStream::State::Offline && !m_auth_transition &&
|
||||
m_stage != Stage::Failed &&
|
||||
m_stage != Stage::Offline) {
|
||||
@@ -61,6 +74,17 @@ void ClassicSession::set_stage(Stage s) {
|
||||
return;
|
||||
}
|
||||
m_stage = s;
|
||||
const char *phase = "Offline";
|
||||
switch (s) {
|
||||
case Stage::Connecting: phase = "HandShake"; break;
|
||||
case Stage::LoggingIn: phase = "Login"; break;
|
||||
case Stage::CharSelect: phase = "Select"; break;
|
||||
case Stage::Loading: phase = "Loading"; break;
|
||||
case Stage::InGame: phase = "Game"; break;
|
||||
case Stage::Failed: phase = "Failed"; break;
|
||||
case Stage::Offline: break;
|
||||
}
|
||||
m_parser.set_phase_name(phase);
|
||||
if (on_stage_change) {
|
||||
on_stage_change(s);
|
||||
}
|
||||
@@ -78,6 +102,10 @@ void ClassicSession::start_offline(const std::string &id, const std::string &pw)
|
||||
m_login_sent = false;
|
||||
m_entergame_sent = false;
|
||||
m_version_sent = false;
|
||||
m_game_connection_seen = false;
|
||||
m_direct_enter = false;
|
||||
m_direct_enter_slot = -1;
|
||||
m_selected_slot = -1;
|
||||
m_last_error.clear();
|
||||
// During PHASE_HANDSHAKE even retry handshakes must be answered with
|
||||
// 0xFF. The normal game client switches to CG_TIME_SYNC only after the
|
||||
@@ -145,6 +173,82 @@ bool ClassicSession::connect_with_login_key(const std::string &game_host, uint16
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClassicSession::connect_direct_enter(int slot) {
|
||||
if (slot < 0 || slot >= static_cast<int>(m_parser.char_slots().size())) {
|
||||
m_last_error = "direct enter slot out of range";
|
||||
return false;
|
||||
}
|
||||
const CharSlot &character = m_parser.char_slots()[slot];
|
||||
if (character.id == 0 || character.addr == 0 || character.port == 0 ||
|
||||
m_auth_login_key == 0) {
|
||||
m_last_error = "direct enter slot has no game address or login key";
|
||||
return false;
|
||||
}
|
||||
|
||||
m_auth_transition = true;
|
||||
m_stream.disconnect();
|
||||
m_auth_transition = false;
|
||||
m_use_auth = false;
|
||||
m_auth_succeeded = true;
|
||||
m_game_started = true;
|
||||
m_authenticated_game = true;
|
||||
m_direct_enter = true;
|
||||
m_direct_enter_slot = slot;
|
||||
m_selected_slot = slot;
|
||||
m_game_host = ipv4_from_slot_addr(character.addr);
|
||||
m_game_port = character.port;
|
||||
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 false;
|
||||
}
|
||||
set_stage(Stage::Connecting);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClassicSession::connect_warp(const std::string &game_host, uint16_t game_port) {
|
||||
if (m_selected_slot < 0 || m_auth_login_key == 0 || game_host.empty() || game_port == 0) {
|
||||
m_last_error = "warp has no selected character, login key, or game address";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep the same ClassicSession so the login ticket and the selected slot
|
||||
// survive the reconnect. This is the important distinction from the old
|
||||
// warp_to_game_server() path, which rebuilt the parser and lost DirectEnter.
|
||||
m_auth_transition = true;
|
||||
m_stream.disconnect();
|
||||
m_auth_transition = false;
|
||||
m_use_auth = false;
|
||||
m_auth_succeeded = true;
|
||||
m_game_started = true;
|
||||
m_authenticated_game = true;
|
||||
m_direct_enter = true;
|
||||
m_direct_enter_slot = m_selected_slot;
|
||||
m_game_host = game_host;
|
||||
m_game_port = game_port;
|
||||
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 false;
|
||||
}
|
||||
set_stage(Stage::Connecting);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ClassicSession::disconnect() {
|
||||
m_auth_transition = true;
|
||||
m_auth_stream.disconnect();
|
||||
@@ -184,18 +288,21 @@ void ClassicSession::pump() {
|
||||
// sending it too early makes the server drop us mid spawn-burst).
|
||||
if (m_stage == Stage::Loading && !m_entergame_sent && m_entergame_delay > 0 &&
|
||||
m_now - m_loading_since >= m_entergame_delay) {
|
||||
enter_game();
|
||||
if (!enter_game()) {
|
||||
m_last_error = m_stream.last_error().empty() ? "failed to send CG_ENTERGAME" :
|
||||
m_stream.last_error();
|
||||
set_stage(Stage::Failed);
|
||||
}
|
||||
}
|
||||
m_world.tick();
|
||||
}
|
||||
|
||||
void ClassicSession::send_login() {
|
||||
bool ClassicSession::send_login() {
|
||||
if (m_login_sent) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (m_authenticated_game) {
|
||||
send_login_by_key();
|
||||
return;
|
||||
return send_login_by_key();
|
||||
}
|
||||
// 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.
|
||||
@@ -205,13 +312,17 @@ void ClassicSession::send_login() {
|
||||
p.header = HDR_CG_LOGIN;
|
||||
std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1);
|
||||
std::strncpy(p.passwd, m_pw.c_str(), sizeof(p.passwd) - 1);
|
||||
m_stream.send_fixed(&p, sizeof(p));
|
||||
if (!m_stream.send_fixed(&p, sizeof(p))) {
|
||||
m_last_error = "failed to send CG_LOGIN";
|
||||
return false;
|
||||
}
|
||||
m_login_sent = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ClassicSession::send_auth_login() {
|
||||
bool ClassicSession::send_auth_login() {
|
||||
if (m_auth_login_sent) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
m_auth_stream.set_sequence_mode(true);
|
||||
CGLogin3 p{};
|
||||
@@ -220,11 +331,15 @@ void ClassicSession::send_auth_login() {
|
||||
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));
|
||||
if (!m_auth_stream.send_fixed(&p, sizeof(p))) {
|
||||
m_last_error = "failed to send CG_LOGIN3";
|
||||
return false;
|
||||
}
|
||||
m_auth_login_sent = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ClassicSession::send_login_by_key() {
|
||||
bool 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);
|
||||
@@ -232,14 +347,20 @@ void ClassicSession::send_login_by_key() {
|
||||
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));
|
||||
if (!m_stream.send_fixed(&p, sizeof(p))) {
|
||||
m_last_error = "failed to send CG_LOGIN2";
|
||||
return false;
|
||||
}
|
||||
m_login_sent = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ClassicSession::on_auth_phase(uint8_t phase) {
|
||||
if (phase == PHASE_AUTH) {
|
||||
set_stage(Stage::LoggingIn);
|
||||
send_auth_login();
|
||||
if (!send_auth_login()) {
|
||||
set_stage(Stage::Failed);
|
||||
}
|
||||
} else if (phase == PHASE_CLOSE) {
|
||||
m_last_error = "auth server closed phase";
|
||||
set_stage(Stage::Failed);
|
||||
@@ -276,8 +397,16 @@ bool ClassicSession::on_auth_packet(uint8_t header, const uint8_t *body, uint32_
|
||||
set_stage(Stage::Failed);
|
||||
return false;
|
||||
}
|
||||
// Optional Panama/hybrid-crypt packets are already framed and can be
|
||||
// ignored by this asset-loader client.
|
||||
if (header == HDR_GC_AUTH_SUCCESS_OPENID || header == HDR_GC_MATRIX_CARD ||
|
||||
header == HDR_GC_RUNUP_MATRIX_QUIZ || header == HDR_GC_REQUEST_PASSPOD ||
|
||||
header == HDR_GC_REQUEST_PASSPOD_FAILED || header == HDR_GC_HS_REQUEST ||
|
||||
header == HDR_GC_XTRAP_CS1_REQUEST || header == HDR_GC_PANAMA_PACK ||
|
||||
header == HDR_GC_HYBRIDCRYPT_KEYS || header == HDR_GC_HYBRIDCRYPT_SDB) {
|
||||
m_last_error = "auth phase does not handle GC header " + std::to_string(header);
|
||||
return false;
|
||||
}
|
||||
// Optional hybrid-crypt packets are deliberately unsupported as well; the
|
||||
// stream still frames them, then surfaces the unsupported integration.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -357,16 +486,27 @@ void ClassicSession::on_phase(uint8_t phase) {
|
||||
switch (phase) {
|
||||
case PHASE_LOGIN:
|
||||
set_stage(Stage::LoggingIn);
|
||||
send_login();
|
||||
if (!send_login()) {
|
||||
set_stage(Stage::Failed);
|
||||
}
|
||||
break;
|
||||
case PHASE_SELECT:
|
||||
set_stage(Stage::CharSelect);
|
||||
if (m_parser.char_list_ready() && on_char_list) {
|
||||
if (m_direct_enter) {
|
||||
set_stage(Stage::Loading);
|
||||
if (!send_select_char(m_direct_enter_slot)) {
|
||||
set_stage(Stage::Failed);
|
||||
}
|
||||
} else {
|
||||
set_stage(Stage::CharSelect);
|
||||
}
|
||||
if (!m_direct_enter && m_parser.char_list_ready() && on_char_list) {
|
||||
on_char_list(m_parser.char_slots());
|
||||
}
|
||||
break;
|
||||
case PHASE_LOADING:
|
||||
set_stage(Stage::Loading);
|
||||
m_direct_enter = false;
|
||||
m_direct_enter_slot = -1;
|
||||
m_loading_since = m_now;
|
||||
m_entergame_sent = false;
|
||||
break;
|
||||
@@ -387,12 +527,15 @@ void ClassicSession::on_phase(uint8_t phase) {
|
||||
|
||||
bool ClassicSession::on_packet(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!m_parser.on_gc(header, body, len)) {
|
||||
m_last_error = "parse error on GC header " + std::to_string(header);
|
||||
m_last_error = m_parser.last_error().empty() ?
|
||||
"parse error on GC header " + std::to_string(header) : m_parser.last_error();
|
||||
m_stream.set_last_error(m_last_error);
|
||||
return false;
|
||||
}
|
||||
if ((header == HDR_GC_MAIN_CHARACTER || header == HDR_GC_MAIN_CHARACTER3_BGM ||
|
||||
header == HDR_GC_MAIN_CHARACTER4_BGM_VOL) && !send_client_version()) {
|
||||
m_last_error = "failed to send client version";
|
||||
m_stream.set_last_error(m_last_error);
|
||||
return false;
|
||||
}
|
||||
if (!m_parser.login_failure().empty() && m_stage == Stage::LoggingIn) {
|
||||
@@ -409,14 +552,24 @@ bool ClassicSession::select_char(int slot) {
|
||||
if (m_stage != Stage::CharSelect) {
|
||||
return false;
|
||||
}
|
||||
if (slot < 0 || slot >= m_parser.slot_count()) {
|
||||
return false;
|
||||
}
|
||||
if (!send_select_char(slot)) {
|
||||
return false;
|
||||
}
|
||||
m_selected_slot = slot;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClassicSession::send_select_char(int slot) {
|
||||
if (slot < 0 || slot >= m_parser.slot_count()) {
|
||||
return false;
|
||||
}
|
||||
CGPlayerSelect p{};
|
||||
p.header = HDR_CG_CHARACTER_SELECT;
|
||||
p.player_index = static_cast<uint8_t>(slot);
|
||||
m_stream.send_fixed(&p, sizeof(p));
|
||||
return true;
|
||||
return m_stream.send_fixed(&p, sizeof(p));
|
||||
}
|
||||
|
||||
bool ClassicSession::enter_game() {
|
||||
@@ -425,9 +578,11 @@ bool ClassicSession::enter_game() {
|
||||
}
|
||||
CGEnterGame p{};
|
||||
p.header = HDR_CG_ENTERGAME;
|
||||
m_stream.send_fixed(&p, sizeof(p));
|
||||
if (!m_stream.send_fixed(&p, sizeof(p))) {
|
||||
return false;
|
||||
}
|
||||
m_entergame_sent = true;
|
||||
return true;
|
||||
return m_stream.flush_internal();
|
||||
}
|
||||
|
||||
// --- in-game intents ---------------------------------------------------------
|
||||
|
||||
@@ -37,6 +37,12 @@ public:
|
||||
void disconnect() override;
|
||||
bool select_char(int slot) override;
|
||||
bool enter_game() override;
|
||||
// ClientVS22 DirectEnter: reconnect to the slot's lAddr:wPort, then the
|
||||
// loading phase sends CG_CHARACTER_SELECT on the new game connection.
|
||||
bool connect_direct_enter(int slot);
|
||||
// ClientVS22 GC_WARP: every warp is a new game connection to the address
|
||||
// carried by the packet. The selected slot and login ticket are retained.
|
||||
bool connect_warp(const std::string &game_host, uint16_t game_port);
|
||||
bool create_character(int slot, const std::string &name, int job, int shape,
|
||||
int con, int intel, int str, int dex);
|
||||
bool delete_character(int slot, const std::string &private_code);
|
||||
@@ -143,6 +149,7 @@ public:
|
||||
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; }
|
||||
bool was_online_lost() const { return m_game_connection_seen; }
|
||||
uint32_t now_ms() const { return m_now; }
|
||||
void set_now(uint32_t ms) {
|
||||
m_now = ms;
|
||||
@@ -163,10 +170,11 @@ private:
|
||||
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_login();
|
||||
bool send_auth_login();
|
||||
bool send_login_by_key();
|
||||
bool send_client_version();
|
||||
bool send_select_char(int slot);
|
||||
|
||||
EntityStore m_world;
|
||||
ClassicStream m_stream;
|
||||
@@ -192,6 +200,10 @@ private:
|
||||
bool m_authenticated_game = false;
|
||||
bool m_auth_transition = false;
|
||||
bool m_version_sent = false;
|
||||
bool m_game_connection_seen = false;
|
||||
bool m_direct_enter = false;
|
||||
int m_direct_enter_slot = -1;
|
||||
int m_selected_slot = -1;
|
||||
};
|
||||
|
||||
} // namespace mtnet::classic
|
||||
|
||||
@@ -29,16 +29,6 @@ void set_nonblocking(int fd) {
|
||||
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() {
|
||||
@@ -114,6 +104,11 @@ void ClassicStream::discard_recv(size_t n) {
|
||||
m_recv.discard(n);
|
||||
}
|
||||
|
||||
void ClassicStream::remember_packet(uint8_t header) {
|
||||
m_last_pkt[0] = m_last_pkt[1];
|
||||
m_last_pkt[1] = header;
|
||||
}
|
||||
|
||||
void ClassicStream::decrypt_appended(size_t n) {
|
||||
if (n == 0 || !m_cipher.activated()) {
|
||||
return;
|
||||
@@ -209,6 +204,10 @@ size_t ClassicStream::take_outgoing(void *dst, size_t cap) {
|
||||
return n;
|
||||
}
|
||||
|
||||
bool ClassicStream::flush_internal() {
|
||||
return m_sock < 0 || flush_send();
|
||||
}
|
||||
|
||||
void ClassicStream::append_sequence_if_needed(uint8_t header) {
|
||||
if (!m_seq_on || !is_sequence_cg(header)) {
|
||||
return;
|
||||
@@ -422,6 +421,7 @@ void ClassicStream::dispatch() {
|
||||
return;
|
||||
}
|
||||
if (consumed) {
|
||||
remember_packet(header);
|
||||
continue;
|
||||
}
|
||||
// control handler said "need more bytes" for a control header it owns?
|
||||
@@ -488,41 +488,25 @@ void ClassicStream::dispatch() {
|
||||
bool ok = !on_packet || on_packet(header, body, body_len);
|
||||
discard_recv(body_len);
|
||||
if (!ok) {
|
||||
if (m_last_error.empty()) {
|
||||
m_last_error = "packet handler rejected GC header " + std::to_string(header);
|
||||
}
|
||||
if (on_error) {
|
||||
on_error(m_last_error);
|
||||
}
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
remember_packet(header);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- static-size GC packet --- (packet_size_gc = FULL size incl. header)
|
||||
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;
|
||||
}
|
||||
}
|
||||
m_last_error = "unknown GC header " + std::to_string(header) +
|
||||
" (last: " + std::to_string(m_last_pkt[0]) + "," +
|
||||
std::to_string(m_last_pkt[1]) + ")";
|
||||
if (on_error) {
|
||||
on_error(m_last_error);
|
||||
}
|
||||
@@ -539,9 +523,16 @@ void ClassicStream::dispatch() {
|
||||
bool ok = !on_packet || on_packet(header, p + 1, static_cast<uint32_t>(total - 1));
|
||||
discard_recv(static_cast<size_t>(total));
|
||||
if (!ok) {
|
||||
if (m_last_error.empty()) {
|
||||
m_last_error = "packet handler rejected GC header " + std::to_string(header);
|
||||
}
|
||||
if (on_error) {
|
||||
on_error(m_last_error);
|
||||
}
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
remember_packet(header);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ public:
|
||||
State state() const { return m_state; }
|
||||
bool is_online() const { return m_state == State::Online; }
|
||||
const std::string &last_error() const { return m_last_error; }
|
||||
void set_last_error(const std::string &error) { m_last_error = error; }
|
||||
|
||||
// --- socket-free framing (tests / alternative transports) ---
|
||||
void feed(const void *data, size_t n); // append inbound bytes, run dispatch
|
||||
@@ -78,6 +79,8 @@ public:
|
||||
bool send_fixed(const void *struct_bytes, size_t n);
|
||||
// Caller supplies the full dynamic packet incl. [header][uint16 size][...].
|
||||
bool send_dynamic(const void *bytes, size_t n);
|
||||
// Flush immediately for a real socket. Socket-free tests retain queued bytes.
|
||||
bool flush_internal();
|
||||
|
||||
// docs §2.2: turn on once the client would call net.SetPacketSequenceMode()
|
||||
// (LOGIN phase onward). Off during pure HANDSHAKE.
|
||||
@@ -125,10 +128,12 @@ private:
|
||||
bool m_time_sync_mode = false;
|
||||
bool m_trace = false;
|
||||
uint8_t m_phase = PHASE_HANDSHAKE;
|
||||
uint8_t m_last_pkt[2] = {0, 0};
|
||||
|
||||
// server clock offset from the last handshake exchange (informational).
|
||||
uint32_t m_server_time_base = 0;
|
||||
uint32_t m_client_time_base = 0;
|
||||
void remember_packet(uint8_t header);
|
||||
};
|
||||
|
||||
} // namespace mtnet::classic
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "entity_store.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
@@ -111,6 +112,53 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
|
||||
}
|
||||
}
|
||||
|
||||
void EntityStore::mut_spawn_full(const Entity &src) {
|
||||
bool created;
|
||||
Entity &e = touch(src.vid, created);
|
||||
// Preserve the live interpolation state if the entity is already moving and
|
||||
// the caller did not carry an explicit move (spawn packets never do).
|
||||
const bool was_moving = e.moving;
|
||||
const float keep_x = e.x, keep_y = e.y, keep_z = e.z;
|
||||
e = src;
|
||||
e.vid = src.vid;
|
||||
if (was_moving && !src.moving) {
|
||||
e.moving = true;
|
||||
e.x = keep_x;
|
||||
e.y = keep_y;
|
||||
e.z = keep_z;
|
||||
}
|
||||
if (e.is_main) {
|
||||
m_main_vid = e.vid;
|
||||
}
|
||||
m_changes.push_back({ChangeKind::Spawn, src.vid});
|
||||
}
|
||||
|
||||
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
|
||||
auto it = m_ents.find(vid);
|
||||
if (it == m_ents.end()) {
|
||||
return;
|
||||
}
|
||||
if (it->second.owner_vid != owner_vid) {
|
||||
it->second.owner_vid = owner_vid;
|
||||
m_dirty.push_back(vid);
|
||||
}
|
||||
}
|
||||
|
||||
void EntityStore::mut_map_bgm(const std::string &name, float volume) {
|
||||
bool changed = false;
|
||||
if (name != m_bgm_name) {
|
||||
m_bgm_name = name;
|
||||
changed = true;
|
||||
}
|
||||
if (volume >= 0.0f && volume != m_bgm_volume) {
|
||||
m_bgm_volume = volume;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
m_bgm_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void EntityStore::mut_despawn(uint32_t vid) {
|
||||
if (m_ents.erase(vid)) {
|
||||
m_changes.push_back({ChangeKind::Despawn, vid});
|
||||
@@ -474,7 +522,9 @@ void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uin
|
||||
e.mount_vnum = mount_vnum;
|
||||
m_mount_changes.push_back(vid);
|
||||
}
|
||||
(void)empire;
|
||||
if (empire) {
|
||||
e.empire = empire;
|
||||
}
|
||||
if (!created) {
|
||||
m_changes.push_back({ChangeKind::Info, vid});
|
||||
}
|
||||
@@ -685,8 +735,108 @@ void EntityStore::mut_quest_info(uint16_t index, uint8_t flag, bool begin, const
|
||||
m_quest_changes.push_back(index);
|
||||
}
|
||||
|
||||
void EntityStore::mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port) {
|
||||
m_warps.push_back({x, y, addr, port});
|
||||
void EntityStore::mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port, bool reconnect) {
|
||||
m_warps.push_back({x, y, addr, port, reconnect});
|
||||
}
|
||||
|
||||
void EntityStore::reset_for_map_change() {
|
||||
// CPythonNetworkStream::SetLoadingPhase() calls CPythonPlayer::Clear() and
|
||||
// destroys the network actor manager before the next connection starts.
|
||||
// Clear queued deltas as well: they describe the old connection and must not
|
||||
// be replayed after the new GC_MAIN_CHARACTER arrives.
|
||||
m_ents.clear();
|
||||
m_main_vid = 0;
|
||||
m_changes.clear();
|
||||
m_dirty.clear();
|
||||
m_vitals.clear();
|
||||
m_damage.clear();
|
||||
m_motions.clear();
|
||||
m_dig_motions.clear();
|
||||
m_fishing_events.clear();
|
||||
m_dungeon_events.clear();
|
||||
m_view_equipment.clear();
|
||||
m_mount_changes.clear();
|
||||
|
||||
m_ground.clear();
|
||||
m_ground_changes.clear();
|
||||
m_pvp.clear();
|
||||
m_pvp_changes.clear();
|
||||
m_duel_opponents.clear();
|
||||
m_duel_started = false;
|
||||
m_duel_cannot_attack = false;
|
||||
m_observers.clear();
|
||||
m_observer_events.clear();
|
||||
m_npc_marks.clear();
|
||||
m_npc_marks_dirty = true;
|
||||
m_land_areas.clear();
|
||||
m_land_dirty = true;
|
||||
m_markers.clear();
|
||||
m_markers_dirty = true;
|
||||
|
||||
// CPythonPlayer::Clear() resets local combat/UI state. Inventory is kept,
|
||||
// like the reference client: a warp does not make the server-side items
|
||||
// disappear while the new loading packets are arriving.
|
||||
m_points = PlayerPoints{};
|
||||
m_points_dirty = true;
|
||||
m_target_vid = 0;
|
||||
m_target_hp_pct = 0;
|
||||
m_target_dirty = true;
|
||||
std::memset(m_skills, 0, sizeof(m_skills));
|
||||
std::memset(m_skill_master, 0, sizeof(m_skill_master));
|
||||
m_skill_group = 0;
|
||||
m_skill_group_dirty = true;
|
||||
m_skills_dirty = true;
|
||||
std::fill(std::begin(m_quickslots), std::end(m_quickslots), QuickSlot{});
|
||||
m_quickslots_dirty = true;
|
||||
m_cooldown_ends.clear();
|
||||
m_affects.clear();
|
||||
m_affect_changes.clear();
|
||||
m_stamina_consuming = false;
|
||||
m_stamina_per_sec = 0;
|
||||
m_current_stamina = 0;
|
||||
m_stamina_current_f = 0.0f;
|
||||
m_stamina_last_ms = 0;
|
||||
m_observer_mode = false;
|
||||
m_observer_count = 0;
|
||||
m_mobile_flag = false;
|
||||
m_combo_skill_flag = false;
|
||||
|
||||
// Loading must not leave an old modal transaction or one-shot event alive.
|
||||
m_shop_open = false;
|
||||
m_shop_vid = 0;
|
||||
m_shop_items.clear();
|
||||
m_shop_tabs.clear();
|
||||
m_shop_selling_price = 0;
|
||||
m_shop_errors.clear();
|
||||
m_shop_dirty = true;
|
||||
m_exchange = ExchangeState{};
|
||||
m_exchange_dirty = true;
|
||||
for (Item &item : m_safebox) {
|
||||
item = Item{};
|
||||
}
|
||||
m_safebox_open = false;
|
||||
m_safebox_size = 0;
|
||||
m_safebox_gold = 0;
|
||||
m_safebox_dirty = true;
|
||||
for (Item &item : m_mall) {
|
||||
item = Item{};
|
||||
}
|
||||
m_mall_open = false;
|
||||
m_mall_size = 0;
|
||||
m_mall_dirty = true;
|
||||
m_cube = CubeState{};
|
||||
m_cube_events.clear();
|
||||
m_cube_last_success = CubeResultEntry{};
|
||||
m_server_commands.clear();
|
||||
m_scripts.clear();
|
||||
m_confirms.clear();
|
||||
m_item_events.clear();
|
||||
m_warps.clear();
|
||||
m_refine_cues.clear();
|
||||
m_ds_cues.clear();
|
||||
for (Item &item : m_dragon_soul) {
|
||||
item = Item{};
|
||||
}
|
||||
}
|
||||
|
||||
void EntityStore::mut_server_time(int64_t time) {
|
||||
|
||||
@@ -62,16 +62,24 @@ struct Entity {
|
||||
int32_t guild = 0;
|
||||
int16_t alignment = 0;
|
||||
uint8_t pk_mode = 0;
|
||||
|
||||
// --- W0 interface freeze (CLIENT-GAP §2.3/§2.5) ---
|
||||
uint8_t empire = 0; // 1 Shinsoo, 2 Chunjo, 3 Jinno (GC_CHAR_ADDITIONAL_INFO)
|
||||
uint64_t affect_flags = 0; // AFF_* bitset carried on the entity (buff icons / aura)
|
||||
uint32_t owner_vid = 0; // summon / horse owner; 0 = none
|
||||
uint32_t state_flags = 0; // presentation-layer latch bits (W2 defines the enum)
|
||||
};
|
||||
|
||||
// --- P9 world systems ----------------------------------------------------
|
||||
// GC_WARP — teleport target. same_server() -> just move the player; otherwise
|
||||
// the caller must reconnect to addr:port (P10).
|
||||
// GC_WARP — teleport target. The m2dev protocol uses addr==0 as an in-place
|
||||
// warp; the 40250 classic protocol always reconnects, including when lAddr is
|
||||
// zero. `reconnect` records that protocol-specific distinction at parse time.
|
||||
struct WarpCue {
|
||||
int32_t x = 0, y = 0;
|
||||
int32_t addr = 0;
|
||||
uint16_t port = 0;
|
||||
bool same_server() const { return addr == 0; }
|
||||
bool reconnect = false;
|
||||
bool same_server() const { return !reconnect && addr == 0; }
|
||||
};
|
||||
// One atlas/minimap NPC entry (GC_NPC_POSITION).
|
||||
struct NPCMark {
|
||||
@@ -466,6 +474,16 @@ public:
|
||||
uint8_t attack_speed = 0);
|
||||
void mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x, float y,
|
||||
float z);
|
||||
// Full spawn (CLIENT-GAP §2.3): merge every field of `e` into the world in one
|
||||
// shot, keyed by e.vid, so a two-packet PC/NPC add (GC_CHARACTER_ADD +
|
||||
// GC_CHAR_ADDITIONAL_INFO) lands as a single Spawn change with name/parts/
|
||||
// empire/affect_flags/owner already populated. Creates the entity if new.
|
||||
void mut_spawn_full(const Entity &e);
|
||||
// summon / horse ownership (CLIENT-GAP §2.5); no-op if vid unknown.
|
||||
void mut_ownership(uint32_t vid, uint32_t owner_vid);
|
||||
// Map background music (CLIENT-GAP §9.1). name is the resolved track id or
|
||||
// path; volume < 0 means "server did not specify, keep current".
|
||||
void mut_map_bgm(const std::string &name, float volume);
|
||||
void mut_despawn(uint32_t vid);
|
||||
// angle_deg is the already-decoded compass heading (m2dev: rot*360/256;
|
||||
// classic: rot*5 — the two wire encodings differ, so decode at the parser).
|
||||
@@ -554,7 +572,12 @@ public:
|
||||
int32_t counter_value, const std::string &icon);
|
||||
|
||||
// --- P9 world systems ---
|
||||
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port);
|
||||
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port,
|
||||
bool reconnect = false);
|
||||
// Clear the loading-phase state that ClientVS22 resets before a new map
|
||||
// connection. Persistent account data (friends, guild, quests, inventory)
|
||||
// is retained; entities and map-local/transient state are discarded.
|
||||
void reset_for_map_change();
|
||||
void mut_server_time(int64_t time);
|
||||
void mut_channel(uint8_t channel);
|
||||
void mut_npc_marks(const std::vector<NPCMark> &marks);
|
||||
@@ -968,6 +991,22 @@ public:
|
||||
m_changes.clear();
|
||||
return v;
|
||||
}
|
||||
// vids whose entity fields changed without a Spawn/Move/Info change since the
|
||||
// last drain (deduped). W2 uses this for the §2.1 visibility pass so a bare
|
||||
// ownership/affect update still re-mirrors the node.
|
||||
std::vector<uint32_t> drain_dirty() {
|
||||
auto v = std::move(m_dirty);
|
||||
m_dirty.clear();
|
||||
return v;
|
||||
}
|
||||
// Map BGM (§9.1). Returns true once after mut_map_bgm and consumes the flag.
|
||||
bool take_bgm_dirty() {
|
||||
bool d = m_bgm_dirty;
|
||||
m_bgm_dirty = false;
|
||||
return d;
|
||||
}
|
||||
const std::string &bgm_name() const { return m_bgm_name; }
|
||||
float bgm_volume() const { return m_bgm_volume; }
|
||||
std::vector<ChatMsg> drain_chat() {
|
||||
auto v = std::move(m_chat);
|
||||
m_chat.clear();
|
||||
@@ -1063,6 +1102,10 @@ private:
|
||||
uint32_t m_main_vid = 0;
|
||||
uint32_t m_now = 0;
|
||||
std::vector<Change> m_changes;
|
||||
std::vector<uint32_t> m_dirty;
|
||||
std::string m_bgm_name;
|
||||
float m_bgm_volume = -1.0f;
|
||||
bool m_bgm_dirty = false;
|
||||
std::vector<ChatMsg> m_chat;
|
||||
std::vector<uint32_t> m_vitals;
|
||||
std::vector<DamageEvent> m_damage;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include <m2_coord.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
@@ -36,6 +37,7 @@ void M2Client::_bind_methods() {
|
||||
&M2Client::connect_to_server);
|
||||
ClassDB::bind_method(D_METHOD("disconnect_from_server"), &M2Client::disconnect_from_server);
|
||||
ClassDB::bind_method(D_METHOD("select_character", "index"), &M2Client::select_character);
|
||||
ClassDB::bind_method(D_METHOD("enter_game", "index"), &M2Client::enter_game);
|
||||
ClassDB::bind_method(
|
||||
D_METHOD("create_character", "slot", "name", "job", "shape", "con", "int", "str", "dex"),
|
||||
&M2Client::create_character);
|
||||
@@ -363,6 +365,7 @@ void M2Client::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("get_stamina_state"), &M2Client::get_stamina_state);
|
||||
ADD_SIGNAL(MethodInfo("warp", PropertyInfo(Variant::VECTOR3, "pos"),
|
||||
PropertyInfo(Variant::BOOL, "same_server")));
|
||||
ADD_SIGNAL(MethodInfo("world_reset"));
|
||||
ADD_SIGNAL(MethodInfo("time_changed", PropertyInfo(Variant::INT, "server_epoch")));
|
||||
ADD_SIGNAL(MethodInfo("channel_changed", PropertyInfo(Variant::INT, "channel")));
|
||||
ADD_SIGNAL(MethodInfo("npc_marks_changed"));
|
||||
@@ -376,6 +379,10 @@ void M2Client::_bind_methods() {
|
||||
PropertyInfo(Variant::BOOL, "has_destination")));
|
||||
ADD_SIGNAL(MethodInfo("world_markers_changed"));
|
||||
ADD_SIGNAL(MethodInfo("mount_changed", PropertyInfo(Variant::INT, "vid")));
|
||||
// §9.1 map background music: name is the resolved track id/path, volume is
|
||||
// 0..1 or -1 when the server left it unspecified.
|
||||
ADD_SIGNAL(MethodInfo("bgm_changed", PropertyInfo(Variant::STRING, "name"),
|
||||
PropertyInfo(Variant::FLOAT, "volume")));
|
||||
}
|
||||
|
||||
static const char *phase_name(int p) {
|
||||
@@ -550,6 +557,17 @@ bool M2Client::select_character(int index) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool M2Client::enter_game(int index) {
|
||||
if (classic_sess) {
|
||||
const bool ok = classic_sess->connect_direct_enter(index);
|
||||
if (ok && index >= 0 && index < static_cast<int>(classic_sess->char_slots().size())) {
|
||||
selected_pid = classic_sess->char_slots()[index].id;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
return select_character(index);
|
||||
}
|
||||
|
||||
bool M2Client::create_character(int slot, const godot::String &name, int job, int shape,
|
||||
int con, int intel, int str, int dex) {
|
||||
if (classic_sess) {
|
||||
@@ -2232,6 +2250,10 @@ static Dictionary entity_dict(const mtnet::Entity &e) {
|
||||
d["guild"] = e.guild;
|
||||
d["alignment"] = (int)e.alignment;
|
||||
d["pk_mode"] = (int)e.pk_mode;
|
||||
d["empire"] = (int)e.empire;
|
||||
d["affect_flags"] = (int64_t)e.affect_flags;
|
||||
d["owner_vid"] = (int)e.owner_vid;
|
||||
d["state_flags"] = (int)e.state_flags;
|
||||
Array parts;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
parts.push_back((int)e.parts[i]);
|
||||
@@ -2734,7 +2756,15 @@ void M2Client::pump_classic() {
|
||||
}
|
||||
emit_signal("phase_changed", String(pn[idx]));
|
||||
if (classic_sess->stage() == St::Failed && stage != Stage::Failed) {
|
||||
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
||||
// ClientVS22 sends every already-established game connection loss back
|
||||
// through SetLoginPhase(), regardless of whether SELECT was reached.
|
||||
// Authentication failures happen before this flag is set and remain
|
||||
// login_failed events.
|
||||
if (classic_sess->was_online_lost()) {
|
||||
emit_signal("disconnected", String(classic_sess->last_error().c_str()));
|
||||
} else {
|
||||
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
||||
}
|
||||
set_stage(Stage::Failed);
|
||||
} else if (classic_sess->stage() == St::LoggingIn && stage == Stage::GameConnect) {
|
||||
set_stage(Stage::GameLogin);
|
||||
@@ -2806,6 +2836,17 @@ void M2Client::pump_classic() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/
|
||||
// Info change — re-mirror the node so presentation stays in sync.
|
||||
for (uint32_t vid : w.drain_dirty()) {
|
||||
if (const mtnet::Entity *e = w.get(vid)) {
|
||||
emit_signal("entity_info", (int)vid, entity_dict(*e));
|
||||
}
|
||||
}
|
||||
// §9.1: map background music.
|
||||
if (w.take_bgm_dirty()) {
|
||||
emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume());
|
||||
}
|
||||
for (const auto &m : w.drain_chat()) {
|
||||
if (m.type == mtnet::CHAT_TYPE_WHISPER) {
|
||||
emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()),
|
||||
@@ -3108,15 +3149,28 @@ void M2Client::pump_classic() {
|
||||
}
|
||||
for (const auto &warp : w.drain_warps()) {
|
||||
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(warp.x, warp.y, 0);
|
||||
emit_signal("warp", Vector3(g.x, g.y, g.z), warp.same_server());
|
||||
if (!warp.same_server()) {
|
||||
uint32_t addr = (uint32_t)warp.addr;
|
||||
char host[16];
|
||||
std::snprintf(host, sizeof(host), "%u.%u.%u.%u", addr & 0xFF,
|
||||
(addr >> 8) & 0xFF, (addr >> 16) & 0xFF, (addr >> 24) & 0xFF);
|
||||
warp_to_game_server(String(host), warp.port);
|
||||
// ClientVS22's RecvWarpPacket() always calls Connect(lAddr, wPort),
|
||||
// even for an address that points back to the current channel. Clear
|
||||
// the old loading/world state before the socket is replaced.
|
||||
w.reset_for_map_change();
|
||||
emit_signal("world_reset");
|
||||
emit_signal("warp", Vector3(g.x, g.y, g.z), false);
|
||||
|
||||
uint32_t addr = static_cast<uint32_t>(warp.addr);
|
||||
char host[16];
|
||||
std::snprintf(host, sizeof(host), "%u.%u.%u.%u", addr & 0xFF,
|
||||
(addr >> 8) & 0xFF, (addr >> 16) & 0xFF, (addr >> 24) & 0xFF);
|
||||
if (!classic_sess->connect_warp(std::string(host), warp.port)) {
|
||||
// connect_warp() marks the session Failed. Let the normal stage
|
||||
// bridge surface it as a post-login disconnect on the next pump.
|
||||
return;
|
||||
}
|
||||
classic_last_stage = -1;
|
||||
shop_open_seen = false;
|
||||
mall_open_seen = false;
|
||||
dead_seen.clear();
|
||||
set_stage(Stage::GameConnect);
|
||||
return;
|
||||
}
|
||||
if (w.take_time_dirty()) {
|
||||
emit_signal("time_changed", (int64_t)w.server_time());
|
||||
@@ -3280,6 +3334,17 @@ void M2Client::pump_game() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/
|
||||
// Info change — re-mirror the node so presentation stays in sync.
|
||||
for (uint32_t vid : w.drain_dirty()) {
|
||||
if (const mtnet::Entity *e = w.get(vid)) {
|
||||
emit_signal("entity_info", (int)vid, entity_dict(*e));
|
||||
}
|
||||
}
|
||||
// §9.1: map background music.
|
||||
if (w.take_bgm_dirty()) {
|
||||
emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume());
|
||||
}
|
||||
for (const auto &m : w.drain_chat()) {
|
||||
if (m.type == mtnet::CHAT_TYPE_WHISPER) {
|
||||
emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()),
|
||||
|
||||
@@ -58,6 +58,8 @@ public:
|
||||
|
||||
// pick a slot from the char_list payload (its "index" field).
|
||||
bool select_character(int index);
|
||||
// Enter a character through the stock two-connection direct-enter flow.
|
||||
bool enter_game(int index);
|
||||
// CG_CHARACTER_CREATE / CG_CHARACTER_DELETE (mirror SendCreate/DestroyCharacterPacket).
|
||||
bool create_character(int slot, const godot::String &name, int job, int shape,
|
||||
int con, int intel, int str, int dex);
|
||||
|
||||
Reference in New Issue
Block a user