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:
shenlei
2026-09-02 15:13:01 +09:00
co-authored by Claude Sonnet 5
parent 70f200e885
commit f32064c74a
36 changed files with 4473 additions and 202 deletions
+21 -8
View File
@@ -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
+177 -22
View File
@@ -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 ---------------------------------------------------------
+15 -3
View File
@@ -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
+27 -36
View File
@@ -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