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
|
||||
|
||||
Reference in New Issue
Block a user