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
+153 -3
View File
@@ -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) {
+47 -4
View File
@@ -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;
+73 -8
View File
@@ -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()),
+2
View File
@@ -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);