// net_classic_session_test — 40250 classic HANDSHAKE -> LOGIN -> SELECT -> // LOADING -> GAME driven socket-free: scripted server bytes into // session.stream().feed(), outgoing checked via take_outgoing(). #include "../src/net/classic/classic_session.h" #include #include #include #include #include #include #include #include #include using namespace mtnet::classic; static int g_fail = 0; #define CHECK(c, msg) \ do { \ if (!(c)) { \ std::fprintf(stderr, "FAIL: %s\n", msg); \ ++g_fail; \ } \ } while (0) template static std::vector raw(const T &s) { std::vector b(sizeof(T)); std::memcpy(b.data(), &s, sizeof(T)); return b; } static void feed(ClassicSession &s, const std::vector &b) { s.stream().feed(b.data(), b.size()); } static std::vector drain_stream(ClassicStream &stream) { std::vector out; uint8_t buf[2048]; for (;;) { size_t n = stream.take_outgoing(buf, sizeof(buf)); if (!n) { break; } out.insert(out.end(), buf, buf + n); } return out; } static std::vector drain(ClassicSession &s) { return drain_stream(s.stream()); } static std::string cjk_name(int count) { std::string value; for (int i = 0; i < count; ++i) { value += "汉"; } return value; } static std::string expected_gb2312_han(int count) { std::string value; for (int i = 0; i < count; ++i) { value += "\xBA\xBA"; } return value; } static int listen_loopback(uint16_t &port) { int fd = ::socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { return -1; } int one = 1; ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = 0; if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0 || ::listen(fd, 4) < 0) { ::close(fd); return -1; } socklen_t len = sizeof(addr); if (::getsockname(fd, reinterpret_cast(&addr), &len) < 0) { ::close(fd); return -1; } int flags = ::fcntl(fd, F_GETFL, 0); ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); port = ntohs(addr.sin_port); return fd; } static bool accept_loopback(int listener, int &accepted) { pollfd pfd{listener, POLLIN, 0}; if (::poll(&pfd, 1, 500) <= 0) { return false; } accepted = ::accept(listener, nullptr, nullptr); return accepted >= 0; } int main() { ClassicSession s; s.set_now(0); std::vector got_list; bool entered = false; s.on_char_list = [&](const std::vector &l) { got_list = l; }; s.on_entered_game = [&]() { entered = true; }; s.start_offline("admin", "123456789"); CHECK(s.stage() == mtnet::INetSession::Stage::Connecting, "start_offline -> Connecting"); // --- handshake: server HANDSHAKE -> client echoes it back --- { Handshake hs{}; hs.header = HDR_HANDSHAKE; hs.handshake = 0x1234; hs.time = 5000; hs.delta = 10; feed(s, raw(hs)); auto out = drain(s); CHECK(out.size() == sizeof(Handshake), "handshake echo (13 bytes, no seq)"); Handshake e{}; std::memcpy(&e, out.data(), sizeof(e)); CHECK(e.header == HDR_HANDSHAKE && e.time == 5000 + 20 && e.delta == 0, "handshake echo math"); } // --- GC_PHASE(LOGIN): session sends CG_LOGIN + first sequence byte --- { Phase_ p{HDR_GC_PHASE, PHASE_LOGIN}; feed(s, raw(p)); CHECK(s.stage() == mtnet::INetSession::Stage::LoggingIn, "PHASE_LOGIN -> LoggingIn"); auto out = drain(s); CHECK(out.size() == sizeof(CGLogin) + 1, "CG_LOGIN + 1 seq byte"); CHECK(out[0] == HDR_CG_LOGIN, "outgoing is CG_LOGIN"); CGLogin lg{}; std::memcpy(&lg, out.data(), sizeof(lg)); CHECK(std::strcmp(lg.login, "admin") == 0, "login field"); CHECK(std::strcmp(lg.passwd, "123456789") == 0, "passwd field"); CHECK(out[sizeof(CGLogin)] == SEQUENCE_TABLE[0], "trailing seq == table[0]"); CHECK(s.stream().sequence_index() == 1, "seq index -> 1"); CHECK(s.login_secret_cleared(), "password cleared after CG_LOGIN like ClientVS22"); } // --- network.login.phase_flow/auth-game-phase-matrix: phase framing and // repeated phase dispatch must follow the same phase handler as 40250. --- { ClassicSession half; half.start_offline("half", "secret"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; half.stream().feed(&login, 1); CHECK(half.stage() == mtnet::INetSession::Stage::Connecting, "half phase packet waits without changing phase owner"); half.stream().feed(reinterpret_cast(&login) + 1, 1); CHECK(half.stage() == mtnet::INetSession::Stage::LoggingIn, "completed half phase packet enters LoginPhase"); const auto first_login = drain(half); CHECK(first_login.size() == sizeof(CGLogin) + 1, "completed half phase emits one CG_LOGIN"); half.stream().feed(&login, sizeof(login)); const auto duplicate_login = drain(half); CHECK(duplicate_login.size() == sizeof(CGLogin) + 1, "duplicate PHASE_LOGIN re-runs 40250 SetLoginPhase"); if (duplicate_login.size() == sizeof(CGLogin) + 1) { CGLogin repeated{}; std::memcpy(&repeated, duplicate_login.data(), sizeof(repeated)); CHECK(repeated.passwd[0] == '\0', "duplicate LoginPhase uses the already-cleared network password"); } } // RecvPhasePacket(PHASE_DEAD) is an explicit no-op in ClientVS22. It must // not enable the later-handshake time-sync response before the session has // entered a phase whose setter enables that mode. { ClassicSession dead_phase; dead_phase.start_offline("dead", "secret"); Handshake first_handshake{}; first_handshake.header = HDR_HANDSHAKE; first_handshake.handshake = 0xA001; first_handshake.time = 1000; first_handshake.delta = 10; feed(dead_phase, raw(first_handshake)); drain(dead_phase); Phase_ dead{HDR_GC_PHASE, PHASE_DEAD}; feed(dead_phase, raw(dead)); Handshake second_handshake = first_handshake; second_handshake.handshake = 0xA002; second_handshake.time = 2000; feed(dead_phase, raw(second_handshake)); const auto dead_phase_handshake = drain(dead_phase); CHECK(dead_phase_handshake.size() == sizeof(Handshake) && dead_phase_handshake[0] == HDR_HANDSHAKE, "PHASE_DEAD is a no-op and keeps the plain handshake owner"); ClassicSession close_marker; close_marker.start_offline("dead-close", "secret"); Phase_ close{HDR_GC_PHASE, PHASE_CLOSE}; feed(close_marker, raw(close)); CHECK(close_marker.phase_closed(), "PHASE_CLOSE installs the explicit close owner marker"); feed(close_marker, raw(dead)); CHECK(close_marker.phase_closed(), "PHASE_DEAD does not clear the explicit close owner marker"); } // 40250 RecvPhasePacket(PHASE_HANDSHAKE) re-enters the HandShake owner // instead of leaving the session in the previous Login/Game owner. The // phase leave callback runs first, so a handshake re-entry from GamePhase // must also clear the old map world before the next PHASE_LOGIN/CG_LOGIN2. { ClassicSession handshake_reentry; bool leave_seen_after_reset = false; handshake_reentry.on_phase_leave = [&]() { leave_seen_after_reset = handshake_reentry.world().size() == 0; }; handshake_reentry.start_offline("admin", "secret"); Phase_ game{HDR_GC_PHASE, PHASE_GAME}; feed(handshake_reentry, raw(game)); handshake_reentry.world().mut_spawn(9300, 101, CHRTYPE_MONSTER, "old-handshake-map", 1.0f, 2.0f, 0.0f, 0.0f); CHECK(handshake_reentry.stage() == mtnet::INetSession::Stage::InGame && handshake_reentry.world().size() == 1, "handshake re-entry fixture owns the previous GamePhase world"); Phase_ handshake{HDR_GC_PHASE, PHASE_HANDSHAKE}; feed(handshake_reentry, raw(handshake)); CHECK(handshake_reentry.stage() == mtnet::INetSession::Stage::Connecting, "PHASE_HANDSHAKE re-enters the 40250 HandShake owner"); CHECK(handshake_reentry.world().size() == 0 && leave_seen_after_reset, "PHASE_HANDSHAKE runs the previous phase leave cleanup before ownership changes"); ClassicSession auth_handshake_reentry; std::string auth_failure; auth_handshake_reentry.on_login_failure = [&](const std::string &reason) { auth_failure = reason; }; auth_handshake_reentry.start_offline("admin", "secret"); Phase_ auth{HDR_GC_PHASE, PHASE_AUTH}; auth_handshake_reentry.auth_stream().feed(&auth, sizeof(auth)); drain_stream(auth_handshake_reentry.auth_stream()); auth_handshake_reentry.auth_stream().feed(&handshake, sizeof(handshake)); CHECK(auth_handshake_reentry.stage() == mtnet::INetSession::Stage::Connecting, "auth PHASE_HANDSHAKE re-enters the 40250 HandShake owner"); CHECK(auth_failure.empty(), "auth PHASE_HANDSHAKE re-entry does not report a stale login failure"); } // 40250's LoginPhase keeps the owner alive after GC_LOGIN_FAILURE and // reports the status to the login UI; it does not turn a valid application // response into a transport/phase failure. { ClassicSession failed_login; std::string failure_reason; failed_login.on_login_failure = [&](const std::string &reason) { failure_reason = reason; }; failed_login.start_offline("admin", "secret"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(failed_login, raw(login)); GCLoginFailure failure{}; failure.header = HDR_GC_LOGIN_FAILURE; std::strcpy(failure.status, "WRONGPWD"); feed(failed_login, raw(failure)); CHECK(failed_login.stage() == mtnet::INetSession::Stage::LoggingIn, "GC_LOGIN_FAILURE keeps the LoginPhase owner"); CHECK(failure_reason == "WRONGPWD", "GC_LOGIN_FAILURE reaches the login failure callback"); CHECK(failed_login.last_error() == "login failed: WRONGPWD", "GC_LOGIN_FAILURE keeps the reference status text"); } // CPythonNetworkStream::LoginPhase processes one application packet per // OnProcess tick and returns immediately after __RecvLoginFailurePacket. // A PHASE_LOGIN pipelined behind that failure therefore remains for the next // tick; it must not cause an immediate second CG_LOGIN in the same dispatch. { ClassicSession stale_login_phase; stale_login_phase.start_offline("admin", "secret"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(stale_login_phase, raw(login)); drain(stale_login_phase); GCLoginFailure failure{}; failure.header = HDR_GC_LOGIN_FAILURE; std::strcpy(failure.status, "WRONGPWD"); std::vector burst = raw(failure); const auto phase_bytes = raw(login); burst.insert(burst.end(), phase_bytes.begin(), phase_bytes.end()); feed(stale_login_phase, burst); CHECK(drain(stale_login_phase).empty(), "LoginPhase stops after login failure before a pipelined PHASE_LOGIN"); // The phase tail is deferred, not lost. A subsequent process/feed pass // consumes it and performs the reference SetLoginPhase transition. const uint8_t next_tick = 0; stale_login_phase.stream().feed(&next_tick, sizeof(next_tick)); const auto retried_login = drain(stale_login_phase); CHECK(retried_login.size() == sizeof(CGLogin) + 1, "deferred PHASE_LOGIN is processed on the next tick"); } // Empty character lists are a valid LOGIN_SUCCESS response: the fixed slot // array is still surfaced to SelectPhase instead of being treated as a // failed login or a missing response. { ClassicSession empty_list; std::vector empty_slots; empty_list.on_char_list = [&](const std::vector &slots) { empty_slots = slots; }; empty_list.start_offline("empty", "secret"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(empty_list, raw(login)); GCLoginSuccess success{}; success.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; feed(empty_list, raw(success)); Phase_ select{HDR_GC_PHASE, PHASE_SELECT}; feed(empty_list, raw(select)); CHECK(empty_list.stage() == mtnet::INetSession::Stage::CharSelect, "empty LOGIN_SUCCESS enters SelectPhase"); CHECK(empty_slots.size() == PLAYER_PER_ACCOUNT && empty_slots[0].empty() && empty_slots.back().empty(), "empty LOGIN_SUCCESS exposes the fixed empty slot array"); } // 40250's character-name field is 24 GB2312 bytes plus a NUL. The public // API receives UTF-8, but the actual create/rename packets must contain // twelve two-byte Chinese characters, not eight three-byte UTF-8 prefixes. { ClassicSession chinese_names; chinese_names.start_offline("admin", "secret"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(chinese_names, raw(login)); drain(chinese_names); GCLoginSuccess success{}; success.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; feed(chinese_names, raw(success)); Phase_ select{HDR_GC_PHASE, PHASE_SELECT}; feed(chinese_names, raw(select)); const std::string name12 = cjk_name(12); const std::string expected = expected_gb2312_han(12); CHECK(chinese_names.create_character(0, name12, 0, 0, 1, 1, 1, 1), "12 Chinese characters fit the 24-byte 40250 create field"); auto out = drain(chinese_names); CHECK(out.size() == sizeof(CGPlayerCreate) + 1, "Chinese create packet keeps the 40250 fixed size"); if (out.size() == sizeof(CGPlayerCreate) + 1) { CGPlayerCreate packet{}; std::memcpy(&packet, out.data(), sizeof(packet)); CHECK(std::memcmp(packet.name, expected.data(), expected.size()) == 0 && packet.name[CHARACTER_NAME_MAX_LEN] == '\0', "Chinese create packet contains GB2312 bytes and a NUL terminator"); } CHECK(!chinese_names.create_character(1, cjk_name(13), 0, 0, 1, 1, 1, 1), "13 Chinese characters are rejected above the 24-byte name limit"); CHECK(chinese_names.change_name(0, name12), "12 Chinese characters fit the 40250 rename field"); out = drain(chinese_names); CHECK(out.size() == sizeof(CGChangeName) + 1, "Chinese rename packet keeps the 40250 fixed size"); if (out.size() == sizeof(CGChangeName) + 1) { CGChangeName packet{}; std::memcpy(&packet, out.data(), sizeof(packet)); CHECK(std::memcmp(packet.name, expected.data(), expected.size()) == 0 && packet.name[CHARACTER_NAME_MAX_LEN] == '\0', "Chinese rename packet contains GB2312 bytes and a NUL terminator"); } } // AuthConnector keeps the AUTH owner alive for login rejection and reports // both the status packet and a rejected GC_AUTH_SUCCESS to the login UI. { uint16_t auth_port = 0; const int listener = listen_loopback(auth_port); if (listener >= 0) { ClassicSession auth; std::string auth_failure; auth.on_login_failure = [&](const std::string &reason) { auth_failure = reason; }; CHECK(auth.connect("127.0.0.1", auth_port, "127.0.0.1", 1, "admin", "secret"), "auth matrix connects to loopback auth owner"); int accepted = -1; CHECK(accept_loopback(listener, accepted), "auth matrix accepts auth owner"); // 40250 CAccountConnector::__HandshakeState_Process only accepts // phase/handshake/ping/crypto control packets. A login rejection before // PHASE_AUTH must not be routed to the login UI owner. GCLoginFailure premature_failure{}; premature_failure.header = HDR_GC_LOGIN_FAILURE; std::strcpy(premature_failure.status, "WRONGPWD"); auth.auth_stream().feed(&premature_failure, sizeof(premature_failure)); CHECK(auth.stage() == mtnet::INetSession::Stage::Connecting && auth_failure.empty(), "auth handshake ignores GC_LOGIN_FAILURE before PHASE_AUTH"); Phase_ auth_phase{HDR_GC_PHASE, PHASE_AUTH}; auth.auth_stream().feed(&auth_phase, sizeof(auth_phase)); const auto first_auth = drain_stream(auth.auth_stream()); CHECK(first_auth.size() == sizeof(CGLogin3) + 1, "PHASE_AUTH emits CG_LOGIN3 and sequence"); if (first_auth.size() == sizeof(CGLogin3) + 1) { CGLogin3 auth_login{}; std::memcpy(&auth_login, first_auth.data(), sizeof(auth_login)); bool has_client_key = false; for (uint32_t key : auth_login.client_key) { has_client_key = has_client_key || key != 0; } CHECK(has_client_key, "CG_LOGIN3 carries the non-zero 40250 client key words"); } auth.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(drain_stream(auth.auth_stream()).size() == sizeof(CGLogin3) + 1, "duplicate PHASE_AUTH re-runs AccountConnector auth handler"); // 40250 __AuthState_RecvPhase has no PHASE_CLOSE branch. A close // value on the auth stream therefore does not become the game's // phase-closed marker or a synthetic transport error; AUTH remains // the connector owner until the socket itself reports a failure. Phase_ auth_close{HDR_GC_PHASE, PHASE_CLOSE}; auth.auth_stream().feed(&auth_close, sizeof(auth_close)); CHECK(auth.stage() == mtnet::INetSession::Stage::LoggingIn, "auth PHASE_CLOSE keeps the AUTH owner"); CHECK(!auth.phase_closed() && auth.last_error().empty(), "auth PHASE_CLOSE is silent and does not mark game phase closure"); GCLoginFailure failure{}; failure.header = HDR_GC_LOGIN_FAILURE; std::strcpy(failure.status, "WRONGPWD"); auth.auth_stream().feed(&failure, sizeof(failure)); CHECK(auth.stage() == mtnet::INetSession::Stage::LoggingIn, "auth GC_LOGIN_FAILURE keeps AUTH owner alive"); CHECK(auth_failure == "WRONGPWD", "auth GC_LOGIN_FAILURE reaches login UI callback"); GCAuthSuccess rejected{HDR_GC_AUTH_SUCCESS, 0, 0}; auth.auth_stream().feed(&rejected, sizeof(rejected)); CHECK(auth.stage() == mtnet::INetSession::Stage::LoggingIn && auth_failure == "BESAMEKEY", "rejected GC_AUTH_SUCCESS reports BESAMEKEY without phase failure"); // 40250 branches only on bResult. The login key is copied as the // server-issued ticket and is not a second credential-rejection flag; // even a zero ticket must not be relabeled as BESAMEKEY here. auth_failure.clear(); GCAuthSuccess accepted_zero_key{HDR_GC_AUTH_SUCCESS, 0, 1}; auth.auth_stream().feed(&accepted_zero_key, sizeof(accepted_zero_key)); auth.pump(); CHECK(auth_failure.empty() && auth.auth_stream().state() == ClassicStream::State::Offline, "GC_AUTH_SUCCESS result=1 remains success even when the ticket value is zero"); if (accepted >= 0) { ::close(accepted); } auth.disconnect(); ::close(listener); } else { std::puts("SKIP: auth phase matrix probe (localhost bind unavailable)"); } } // 40250 CAccountConnector::OnConnectFailure calls __OfflineState_Set: // an auth TCP failure is retryable Offline ownership, not a terminal Failed // phase. Use a closed loopback port so the non-blocking failure path is real. { uint16_t dead_port = 0; const int dead_listener = listen_loopback(dead_port); if (dead_listener >= 0) { ::close(dead_listener); ClassicSession auth_connect_failure; CHECK(auth_connect_failure.connect("127.0.0.1", dead_port, "127.0.0.1", 1, "admin", "secret"), "auth connect-failure fixture starts nonblocking connection"); for (int i = 0; i < 20 && auth_connect_failure.stage() == mtnet::INetSession::Stage::Connecting; ++i) { auth_connect_failure.pump(); ::usleep(1000); } CHECK(auth_connect_failure.stage() == mtnet::INetSession::Stage::Offline, "auth connect failure returns to the 40250 Offline owner"); uint16_t retry_port = 0; const int retry_listener = listen_loopback(retry_port); if (retry_listener >= 0) { CHECK(auth_connect_failure.connect("127.0.0.1", retry_port, "127.0.0.1", 1, "admin", "secret"), "auth connect failure leaves a clean stream for retry"); int retry_connection = -1; CHECK(accept_loopback(retry_listener, retry_connection), "auth retry accepts a replacement connection"); Phase_ retry_phase{HDR_GC_PHASE, PHASE_AUTH}; auth_connect_failure.auth_stream().feed(&retry_phase, sizeof(retry_phase)); CHECK(auth_connect_failure.stage() == mtnet::INetSession::Stage::LoggingIn && drain_stream(auth_connect_failure.auth_stream()).size() == sizeof(CGLogin3) + 1, "auth retry re-enters PHASE_AUTH and sends one CG_LOGIN3"); if (retry_connection >= 0) { ::close(retry_connection); } ::close(retry_listener); } else { std::puts("SKIP: auth retry stream probe (localhost bind unavailable)"); } } else { std::puts("SKIP: auth connect-failure owner probe (localhost bind unavailable)"); } } // CAccountConnector::__AuthState_Process leaves an unrecognised application // header under the AUTH owner; it must not become a transport disconnect or // prevent the user from retrying credentials on the same phase owner. { uint16_t auth_port = 0; const int listener = listen_loopback(auth_port); if (listener >= 0) { ClassicSession auth_unknown; CHECK(auth_unknown.connect("127.0.0.1", auth_port, "127.0.0.1", 1, "admin", "secret"), "auth unknown-header fixture connects"); int accepted = -1; CHECK(accept_loopback(listener, accepted), "auth unknown-header fixture accepts"); Phase_ auth_phase{HDR_GC_PHASE, PHASE_AUTH}; auth_unknown.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(auth_unknown.stage() == mtnet::INetSession::Stage::LoggingIn, "auth unknown-header fixture enters AUTH owner"); const uint8_t unknown_header = 200; auth_unknown.auth_stream().feed(&unknown_header, sizeof(unknown_header)); CHECK(auth_unknown.stage() == mtnet::INetSession::Stage::LoggingIn && auth_unknown.auth_stream().state() != ClassicStream::State::Offline, "unknown auth packet keeps AUTH owner and socket alive"); if (accepted >= 0) { ::close(accepted); } auth_unknown.disconnect(); ::close(listener); } else { std::puts("SKIP: auth unknown-header owner probe (localhost bind unavailable)"); } } // CAccountConnector::Connect routes a synchronous socket/address failure // through OnConnectFailure and then __OfflineState_Set. It must be a // retryable Offline owner, not a terminal Failed stage. { ClassicSession auth_immediate_failure; const bool connected = auth_immediate_failure.connect( "this-auth-host-must-not-resolve.invalid", 1, "127.0.0.1", 1, "admin", "secret"); CHECK(!connected, "invalid auth address reports a synchronous connect failure"); CHECK(auth_immediate_failure.stage() == mtnet::INetSession::Stage::Offline, "synchronous auth connect failure returns to the 40250 Offline owner"); CHECK(auth_immediate_failure.login_retry_pending(), "synchronous auth connect failure keeps the LoginPhase retry owner"); } // 40250 CAccountConnector::OnRemoteDisconnect calls __OfflineState_Set: // an auth peer close returns the account connector to its Offline owner. It // must not be reported as a protocol/application rejection or left as a // terminal auth failure that blocks a fresh login attempt. { uint16_t auth_port = 0; const int listener = listen_loopback(auth_port); if (listener >= 0) { ClassicSession auth_close; CHECK(auth_close.connect("127.0.0.1", auth_port, "127.0.0.1", 1, "admin", "secret"), "auth peer-close fixture connects"); int accepted = -1; CHECK(accept_loopback(listener, accepted), "auth peer-close fixture accepts"); if (accepted >= 0) { ::close(accepted); } for (int i = 0; i < 20 && auth_close.stage() == mtnet::INetSession::Stage::Connecting; ++i) { auth_close.pump(); ::usleep(1000); } CHECK(auth_close.stage() == mtnet::INetSession::Stage::Offline, "auth peer close returns to the 40250 Offline owner"); auth_close.disconnect(); ::close(listener); } else { std::puts("SKIP: auth peer-close owner probe (localhost bind unavailable)"); } } // LoginWindow.Connect after an application login failure replaces only the // AccountConnector stream. The existing session/owner remains retryable and // the replacement AUTH phase emits exactly one fresh CG_LOGIN3. { uint16_t first_port = 0; uint16_t retry_port = 0; const int first_listener = listen_loopback(first_port); const int retry_listener = listen_loopback(retry_port); if (first_listener >= 0 && retry_listener >= 0) { ClassicSession login_retry; CHECK(login_retry.connect("127.0.0.1", first_port, "127.0.0.1", 1, "admin", "old-secret"), "login-failure retry fixture starts the first auth transport"); int first_connection = -1; CHECK(accept_loopback(first_listener, first_connection), "login-failure retry fixture accepts the first auth transport"); Phase_ auth_phase{HDR_GC_PHASE, PHASE_AUTH}; login_retry.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(drain_stream(login_retry.auth_stream()).size() == sizeof(CGLogin3) + 1, "first LoginPhase sends one CG_LOGIN3 before application failure"); GCLoginFailure failure{}; failure.header = HDR_GC_LOGIN_FAILURE; std::strcpy(failure.status, "WRONGPWD"); login_retry.auth_stream().feed(&failure, sizeof(failure)); CHECK(login_retry.login_retry_pending(), "application login failure arms the LoginPhase retry owner"); CHECK(login_retry.retry_login("127.0.0.1", retry_port, "127.0.0.1", 1, "admin", "new-secret"), "LoginPhase retry replaces the auth transport without rebuilding the session"); int retry_connection = -1; CHECK(accept_loopback(retry_listener, retry_connection), "LoginPhase retry accepts a replacement auth transport"); login_retry.auth_stream().feed(&auth_phase, sizeof(auth_phase)); auto retry_packet = drain_stream(login_retry.auth_stream()); CHECK(retry_packet.size() == sizeof(CGLogin3) + 1, "replacement AUTH phase emits exactly one CG_LOGIN3"); if (retry_packet.size() == sizeof(CGLogin3) + 1) { CGLogin3 packet{}; std::memcpy(&packet, retry_packet.data(), sizeof(packet)); CHECK(std::strcmp(packet.passwd, "new-secret") == 0, "replacement CG_LOGIN3 uses the newly entered password"); } if (first_connection >= 0) { ::close(first_connection); } if (retry_connection >= 0) { ::close(retry_connection); } login_retry.disconnect(); ::close(first_listener); ::close(retry_listener); } else { if (first_listener >= 0) { ::close(first_listener); } if (retry_listener >= 0) { ::close(retry_listener); } std::puts("SKIP: LoginPhase retry transport probe (localhost bind unavailable)"); } } // CAccountConnector::__AuthState_RecvAuthSuccess calls Disconnect() before // returning to the phase loop. A stale PHASE_AUTH that was pipelined behind // the auth-success frame therefore belongs to the old auth socket and must be // discarded instead of causing a second CG_LOGIN3 on that socket. { uint16_t auth_port = 0; const int listener = listen_loopback(auth_port); if (listener >= 0) { ClassicSession auth_success_boundary; CHECK(auth_success_boundary.connect("127.0.0.1", auth_port, "127.0.0.1", 1, "admin", "secret"), "auth-success boundary fixture starts the auth transport"); int accepted = -1; CHECK(accept_loopback(listener, accepted), "auth-success boundary fixture accepts the auth transport"); Phase_ auth_phase{HDR_GC_PHASE, PHASE_AUTH}; auth_success_boundary.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(drain_stream(auth_success_boundary.auth_stream()).size() == sizeof(CGLogin3) + 1, "auth-success boundary fixture sends the initial CG_LOGIN3"); GCAuthSuccess success{HDR_GC_AUTH_SUCCESS, 0x10203040u, 1}; std::vector auth_success_burst = raw(success); auto stale_phase = raw(auth_phase); auth_success_burst.insert(auth_success_burst.end(), stale_phase.begin(), stale_phase.end()); auth_success_boundary.auth_stream().feed(auth_success_burst.data(), auth_success_burst.size()); CHECK(drain_stream(auth_success_boundary.auth_stream()).empty(), "auth-success clears a pipelined stale PHASE_AUTH without a duplicate CG_LOGIN3"); if (accepted >= 0) { ::close(accepted); } auth_success_boundary.disconnect(); ::close(listener); } else { std::puts("SKIP: auth-success boundary probe (localhost bind unavailable)"); } } // 40250 CAccountConnector::__AuthState_RecvAuthSuccess starts the main // CPythonNetworkStream connection before Disconnect() clears the auth // connector. The handoff order is observable to the two TCP owners and // must not be reversed by the retry/phase bridge. { uint16_t auth_port = 0; uint16_t game_port = 0; const int auth_listener = listen_loopback(auth_port); const int game_listener = listen_loopback(game_port); if (auth_listener >= 0 && game_listener >= 0) { ClassicSession handoff_order; CHECK(handoff_order.connect("127.0.0.1", auth_port, "127.0.0.1", game_port, "admin", "secret"), "auth-success handoff fixture starts the auth transport"); int auth_connection = -1; CHECK(accept_loopback(auth_listener, auth_connection), "auth-success handoff fixture accepts the auth transport"); // Replace only the test observers after connect so the initial // Connecting transition does not affect the handoff-order assertion. std::vector handoff_events; handoff_order.auth_stream().on_state_change = [&](ClassicStream::State state) { if (state == ClassicStream::State::Offline) { handoff_events.push_back("auth:offline"); } }; handoff_order.stream().on_state_change = [&](ClassicStream::State state) { if (state == ClassicStream::State::Connecting) { handoff_events.push_back("game:connecting"); } }; Phase_ auth_phase{HDR_GC_PHASE, PHASE_AUTH}; handoff_order.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(drain_stream(handoff_order.auth_stream()).size() == sizeof(CGLogin3) + 1, "auth-success handoff fixture sends one CG_LOGIN3"); GCAuthSuccess success{HDR_GC_AUTH_SUCCESS, 0x10203040u, 1}; handoff_order.auth_stream().feed(&success, sizeof(success)); handoff_order.pump(); CHECK(handoff_events.size() >= 2 && handoff_events[0] == "game:connecting" && handoff_events[1] == "auth:offline", "auth success starts game transport before clearing auth transport like 40250"); int game_connection = -1; CHECK(accept_loopback(game_listener, game_connection), "auth-success handoff fixture accepts the replacement game transport"); if (auth_connection >= 0) { ::close(auth_connection); } if (game_connection >= 0) { ::close(game_connection); } handoff_order.disconnect(); ::close(auth_listener); ::close(game_listener); } else { if (auth_listener >= 0) { ::close(auth_listener); } if (game_listener >= 0) { ::close(game_listener); } std::puts("SKIP: auth-success handoff order probe (localhost bind unavailable)"); } } // PythonApplication::Process runs CPythonNetworkStream before // CAccountConnector. When AccountConnector receives GC_AUTH_SUCCESS it // starts the replacement main stream after the main stream's process turn // has already ended. The replacement stream must therefore not consume a // PHASE_LOGIN until the next pump. The current ClassicSession pump does // that extra process in the same frame, so this is intentionally a pre-fix // failing regression for the next handoff-order repair. { uint16_t auth_port = 0; uint16_t initial_game_port = 0; uint16_t retry_auth_port = 0; uint16_t replacement_game_port = 0; const int auth_listener = listen_loopback(auth_port); const int initial_game_listener = listen_loopback(initial_game_port); const int retry_auth_listener = listen_loopback(retry_auth_port); const int replacement_game_listener = listen_loopback(replacement_game_port); if (auth_listener >= 0 && initial_game_listener >= 0 && retry_auth_listener >= 0 && replacement_game_listener >= 0) { ClassicSession retry_handoff; CHECK(retry_handoff.connect("127.0.0.1", auth_port, "127.0.0.1", initial_game_port, "admin", "first-secret"), "game-login retry handoff fixture starts the auth transport"); int auth_connection = -1; CHECK(accept_loopback(auth_listener, auth_connection), "game-login retry handoff accepts the initial auth transport"); Phase_ auth_phase{HDR_GC_PHASE, PHASE_AUTH}; retry_handoff.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(drain_stream(retry_handoff.auth_stream()).size() == sizeof(CGLogin3) + 1, "game-login retry handoff sends the initial CG_LOGIN3"); GCAuthSuccess auth_success{HDR_GC_AUTH_SUCCESS, 0x11223344u, 1}; retry_handoff.auth_stream().feed(&auth_success, sizeof(auth_success)); retry_handoff.pump(); int initial_game_connection = -1; CHECK(accept_loopback(initial_game_listener, initial_game_connection), "game-login retry handoff accepts the initial game transport"); feed(retry_handoff, raw(Phase_{HDR_GC_PHASE, PHASE_LOGIN})); CHECK(drain_stream(retry_handoff.stream()).size() == sizeof(CGLogin2) + 1, "game-login retry handoff sends the ticket login packet"); GCLoginFailure game_failure{}; game_failure.header = HDR_GC_LOGIN_FAILURE; std::strcpy(game_failure.status, "WRONGPWD"); feed(retry_handoff, raw(game_failure)); CHECK(retry_handoff.stage() == mtnet::INetSession::Stage::LoggingIn, "game login failure retains the game LoginPhase owner"); CHECK(retry_handoff.login_retry_pending(), "game login failure arms the same-session retry owner"); CHECK(retry_handoff.stream().state() != ClassicStream::State::Offline, "game login failure keeps the old main stream until auth succeeds"); CHECK(retry_handoff.retry_login("127.0.0.1", retry_auth_port, "127.0.0.1", replacement_game_port, "admin", "second-secret"), "game-login retry replaces only the auth transport"); int retry_auth_connection = -1; CHECK(accept_loopback(retry_auth_listener, retry_auth_connection), "game-login retry accepts the replacement auth transport"); retry_handoff.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(drain_stream(retry_handoff.auth_stream()).size() == sizeof(CGLogin3) + 1, "game-login retry sends one replacement CG_LOGIN3"); bool replacement_phase_sent = false; int replacement_game_connection = -1; retry_handoff.stream().on_state_change = [&](ClassicStream::State state) { if (state != ClassicStream::State::Connecting && state != ClassicStream::State::Online || replacement_phase_sent) { return; } CHECK(accept_loopback(replacement_game_listener, replacement_game_connection), "replacement game fixture accepts the replacement game transport"); if (replacement_game_connection >= 0) { Phase_ replacement_phase{HDR_GC_PHASE, PHASE_LOGIN}; const ssize_t sent = ::send(replacement_game_connection, &replacement_phase, sizeof(replacement_phase), 0); CHECK(sent == static_cast(sizeof(replacement_phase)), "replacement game fixture sends PHASE_LOGIN before the handoff callback returns"); replacement_phase_sent = sent == static_cast(sizeof(replacement_phase)); } }; retry_handoff.auth_stream().feed(&auth_success, sizeof(auth_success)); retry_handoff.pump(); CHECK(retry_handoff.stage() == mtnet::INetSession::Stage::Connecting, "auth success starts the replacement game transport without processing it in the same frame"); CHECK(replacement_phase_sent, "replacement game fixture sends PHASE_LOGIN before the handoff callback returns"); retry_handoff.pump(); CHECK(retry_handoff.stage() == mtnet::INetSession::Stage::LoggingIn, "replacement PHASE_LOGIN is processed on the following pump"); if (auth_connection >= 0) { ::close(auth_connection); } if (initial_game_connection >= 0) { ::close(initial_game_connection); } if (retry_auth_connection >= 0) { ::close(retry_auth_connection); } if (replacement_game_connection >= 0) { ::close(replacement_game_connection); } retry_handoff.disconnect(); } else { std::puts("SKIP: game-login retry handoff frame-order probe (localhost bind unavailable)"); } if (auth_listener >= 0) { ::close(auth_listener); } if (initial_game_listener >= 0) { ::close(initial_game_listener); } if (retry_auth_listener >= 0) { ::close(retry_auth_listener); } if (replacement_game_listener >= 0) { ::close(replacement_game_listener); } } // --- GC_LOGIN_SUCCESS_NEWSLOT (header 32, 4 slots; 2 filled) --- { // The 40250 server's wire packet uses CHARACTER_NAME_MAX_LEN=24, // therefore TPacketGCLoginSuccess4 is 329 bytes, not the 489-byte // packet produced by the unrelated 64-byte name layout. This fixture is // intentionally written as raw server bytes so a wrong static size cannot // hide behind the client's own incorrectly-sized struct. ClassicSession server_shape; server_shape.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(server_shape, raw(login)); constexpr size_t k40250LoginSuccess4Size = 329; std::vector server_login(k40250LoginSuccess4Size, 0); server_login[0] = HDR_GC_LOGIN_SUCCESS_NEWSLOT; const uint32_t server_pid = 40250; std::memcpy(server_login.data() + 1, &server_pid, sizeof(server_pid)); std::strcpy(reinterpret_cast(server_login.data() + 1 + sizeof(server_pid)), "ServerShape"); feed(server_shape, server_login); CHECK(server_shape.parser().char_list_ready(), "40250 329-byte LOGIN_SUCCESS4 is accepted without waiting for a 489-byte frame"); CHECK(server_shape.char_slots().size() == PLAYER_PER_ACCOUNT && server_shape.char_slots()[0].id == server_pid, "40250 LOGIN_SUCCESS4 raw slot layout is decoded correctly"); } { GCLoginSuccess ls{}; ls.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; ls.handle = 0xAABBCCDD; ls.random_key = 0x99887766; ls.players[0].id = 1001; std::strcpy(ls.players[0].name, "Warrior"); ls.players[0].job = 0; ls.players[0].level = 42; ls.players[0].ht = 20; ls.players[0].change_name = 1; ls.players[0].addr = static_cast(0x0100007f); // 127.0.0.1, little-endian lAddr ls.players[0].port = 13002; ls.players[1].id = 1002; std::strcpy(ls.players[1].name, "Sura"); ls.players[1].job = 4; ls.players[1].level = 7; feed(s, raw(ls)); CHECK(s.parser().char_list_ready(), "login success parsed"); CHECK(s.parser().slot_count() == 4, "4 slots"); CHECK(s.parser().handle() == 0xAABBCCDD, "handle carried"); CHECK(s.parser().random_key() == 0x99887766, "random_key carried"); const auto &sl = s.char_slots(); CHECK(sl.size() == 4 && sl[0].name == "Warrior" && sl[0].level == 42 && sl[0].change_name, "slot 0 + forced rename flag"); CHECK(sl[0].addr == static_cast(0x0100007f) && sl[0].port == 13002, "slot 0 game address carried"); CHECK(sl[1].name == "Sura" && sl[1].job == 4, "slot 1"); CHECK(sl[2].empty() && sl[3].empty(), "slots 2,3 empty"); } // Legacy LOGIN_SUCCESS3 still feeds the reference's fixed four-slot // character array; only the first three entries come from the wire packet. { ClassicSession legacy; legacy.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(legacy, raw(login)); GCLoginSuccess3 ls3{}; ls3.header = HDR_GC_LOGIN_SUCCESS; ls3.players[0].id = 3001; std::strcpy(ls3.players[0].name, "Legacy"); feed(legacy, raw(ls3)); CHECK(legacy.parser().slot_count() == PLAYER_PER_ACCOUNT && legacy.char_slots().size() == PLAYER_PER_ACCOUNT && legacy.char_slots()[3].empty(), "legacy LOGIN_SUCCESS3 preserves fixed slot 3"); } // --- GC_PHASE(SELECT): char list surfaces --- { Phase_ p{HDR_GC_PHASE, PHASE_SELECT}; feed(s, raw(p)); CHECK(s.stage() == mtnet::INetSession::Stage::CharSelect, "PHASE_SELECT -> CharSelect"); CHECK(got_list.size() == 4 && got_list[0].name == "Warrior" && got_list[0].change_name, "on_char_list fired with forced-rename slot"); } // ClientVS22 SelectPhase consumes GC_PLAYER_POINT_CHANGE only as a framed // packet. It must not apply a stale point update to the character being // selected; LoadingPhase/GamePhase apply the same header normally. { GCPointChange pc{}; pc.header = HDR_GC_CHARACTER_POINT_CHANGE; pc.vid = 1001; pc.type = mtnet::POINT_HP; pc.value = 9999; feed(s, raw(pc)); CHECK(s.stage() == mtnet::INetSession::Stage::CharSelect && s.world().points().hp() == 0 && s.world().get(1001) == nullptr, "select point change is consumed without applying world state"); } // --- GC_CHANGE_NAME completes the forced rename state in SelectPhase --- { GCChangeNameNotice n{}; n.header = HDR_GC_CHANGE_NAME; n.pid = 1001; std::strcpy(n.name, "Renamed"); feed(s, raw(n)); const auto &sl = s.char_slots(); CHECK(sl[0].name == "Renamed" && !sl[0].change_name, "rename clears forced flag"); auto names = s.parser().drain_name_events(); CHECK(names.size() == 1 && names[0].pid == 1001 && names[0].name == "Renamed", "rename event surfaced"); } { ClassicSession empire; empire.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(empire, raw(login)); GCLoginSuccess ls{}; ls.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; feed(empire, raw(ls)); Phase_ p{HDR_GC_PHASE, PHASE_SELECT}; feed(empire, raw(p)); CHECK(empire.send_empire(2) && empire.parser().empire() == 2, "select empire updates local state immediately after send"); } // An unknown PID follows ClientVS22's create-window error path (100), not a // successful name event. { GCChangeNameNotice n{}; n.header = HDR_GC_CHANGE_NAME; n.pid = 0xDEADBEEF; std::strcpy(n.name, "Ghost"); feed(s, raw(n)); auto evs = s.parser().drain_char_events(); CHECK(evs.size() == 1 && evs[0].kind == ClassicParser::CharEvent::CreateFail && evs[0].fail_type == 100, "unknown rename pid -> create failure 100"); CHECK(s.parser().drain_name_events().empty(), "unknown rename pid has no name event"); } // --- GC 9 create failure: input_login.cpp:442-465 sends a zeroed 10-byte // TPacketGCLoginFailure under header 9 (blocked creation / bad name). The 40250 // client reads TPacketGCCreateFailure (2 bytes) and PythonNetworkStream.cpp:509 // skips the trailing zero bytes as blank headers. input_db.cpp:196 sends 2 bytes. --- { s.parser().drain_char_events(); std::vector cf(10, 0); cf[0] = HDR_GC_CREATE_FAILURE; GCPlayerCreateFailure dup{HDR_GC_CREATE_FAILURE, 1}; std::vector db = raw(dup); cf.insert(cf.end(), db.begin(), db.end()); feed(s, cf); auto ev = s.parser().drain_char_events(); // SelectPhase returns after the first create-failure packet. The zero // padding and the second legacy 2-byte packet are consumed on the next // OnProcess tick, after CheckPacket skips the padding run. const uint8_t next_tick = 0; s.stream().feed(&next_tick, sizeof(next_tick)); auto tail_ev = s.parser().drain_char_events(); ev.insert(ev.end(), tail_ev.begin(), tail_ev.end()); CHECK(ev.size() == 2 && ev[0].kind == ClassicParser::CharEvent::CreateFail && ev[0].fail_type == 0 && ev[1].kind == ClassicParser::CharEvent::CreateFail && ev[1].fail_type == 1, "10-byte and 2-byte GC 9 create failures both decode like 40250"); CHECK(s.last_error().empty(), "no desync after 10-byte GC 9"); } // --- select_char(0) -> CG_CHARACTER_SELECT + seq[2] (delete used seq[1]) --- { CHECK(s.delete_character(0, "123456789"), "delete character accepts and truncates oversized private code like ClientVS22"); auto del_out = drain(s); CHECK(del_out.size() == sizeof(CGPlayerDelete) + 1, "CG_CHARACTER_DELETE + seq"); if (del_out.size() == sizeof(CGPlayerDelete) + 1) { CHECK(std::memcmp(del_out.data() + 2, "1234567", 7) == 0, "delete private code copies only PRIVATE_CODE_LENGTH - 1 bytes"); CHECK(del_out[2 + PRIVATE_CODE_LENGTH - 1] == 0, "delete private code keeps the fixed-buffer terminator"); } CHECK(s.select_char(0), "select_char(0) ok"); CHECK(!s.select_char(9), "select_char(9) rejected (out of range)"); auto out = drain(s); CHECK(out.size() == sizeof(CGPlayerSelect) + 1, "CG_CHARACTER_SELECT + seq"); CHECK(out[0] == HDR_CG_CHARACTER_SELECT && out[1] == 0, "select slot 0"); CHECK(out[sizeof(CGPlayerSelect)] == SEQUENCE_TABLE[2], "seq == table[2]"); } // --- GC_PHASE(LOADING) + GC_MAIN_CHARACTER(113) + a nearby NPC --- { Phase_ p{HDR_GC_PHASE, PHASE_LOADING}; feed(s, raw(p)); CHECK(s.stage() == mtnet::INetSession::Stage::Loading, "PHASE_LOADING -> Loading"); GCMainCharacter mc{}; mc.header = HDR_GC_MAIN_CHARACTER; mc.vid = 7777; mc.race = 4; std::strcpy(mc.name, "Warrior"); mc.x = 100000; mc.y = 200000; mc.z = 3; mc.empire = 1; mc.skill_group = 2; feed(s, raw(mc)); CHECK(s.world().main_vid() == 7777, "main vid set"); const auto *me = s.world().get(7777); CHECK(me && me->is_main && me->x == 100000.f && me->name == "Warrior", "main entity"); CHECK(s.world().skill_group() == 2, "skill_group propagated to world"); CHECK(s.parser().empire() == 1, "empire propagated to parser"); auto version = drain(s); // §1.5: EUROPE-family locale sends CG_CLIENT_VERSION2 (0xf1) with the // fixed ymir epoch string; same 67-byte layout as CG_CLIENT_VERSION. CHECK(version.size() == sizeof(CGClientVersion2) + 1 + sizeof(CGEnterGame) + 1, "CG_CLIENT_VERSION2 + seq followed by event-driven CG_ENTERGAME"); CGClientVersion2 cv{}; std::memcpy(&cv, version.data(), sizeof(cv)); CHECK(cv.header == HDR_CG_CLIENT_VERSION2 && std::strcmp(cv.filename, "metin2.bin") == 0 && std::strcmp(cv.timestamp, "1215955205") == 0, "client version fields"); CHECK(version[sizeof(CGClientVersion2)] == SEQUENCE_TABLE[3], "version seq == table[3]"); CHECK(version[sizeof(CGClientVersion2) + 1] == HDR_CG_ENTERGAME, "GC_MAIN_CHARACTER triggers CG_ENTERGAME after client version"); // --- §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD only stashes; the // actor is not spawned until GC_CHAR_ADDITIONAL_INFO merges it --- GCCharacterAdd add{}; add.header = HDR_GC_CHARACTER_ADD; add.vid = 8888; add.race = 101; add.type = WIRE_CHRTYPE_NPC; add.x = 100500; add.y = 200500; add.moving_speed = 150; feed(s, raw(add)); CHECK(s.world().get(8888) == nullptr, "PC/NPC add does not spawn yet"); CHECK(s.world().size() == 1, "still just the main character"); CHECK(s.parser().pending_actor_count() == 1, "actor stashed pending its info"); GCCharAddInfo info{}; info.header = HDR_GC_CHAR_ADDITIONAL_INFO; info.vid = 8888; std::strcpy(info.name, "Shopkeeper"); info.parts[0] = 7; info.empire = 2; info.level = 9; feed(s, raw(info)); const auto *npc = s.world().get(8888); CHECK(npc && npc->ch_type == CHRTYPE_NPC && npc->race == 101, "merged actor spawned"); CHECK(npc && npc->name == "Shopkeeper" && npc->level == 9 && npc->parts[0] == 7 && npc->empire == 2, "additional-info fields merged in"); CHECK(npc && npc->x == 100500.f && npc->y == 200500.f && npc->moving_speed == 150, "stashed spawn fields survive the merge"); CHECK(s.world().size() == 2 && s.parser().pending_actor_count() == 0, "pending record flushed to the world"); // a monster spawns immediately, with no additional-info handshake GCCharacterAdd mob{}; mob.header = HDR_GC_CHARACTER_ADD; mob.vid = 8890; mob.race = 101; mob.type = WIRE_CHRTYPE_MONSTER; mob.x = 101000; mob.y = 201000; feed(s, raw(mob)); CHECK(s.world().get(8890) && s.world().get(8890)->ch_type == CHRTYPE_MONSTER, "monster spawned on GC_CHARACTER_ADD alone"); CHECK(s.world().size() == 3, "3 entities incl. the monster"); // an invisible race is dropped outright (neither stashed nor spawned) GCCharacterAdd ghost{}; ghost.header = HDR_GC_CHARACTER_ADD; ghost.vid = 8891; ghost.race = 20025; ghost.type = WIRE_CHRTYPE_MONSTER; feed(s, raw(ghost)); CHECK(s.world().get(8891) == nullptr && s.parser().pending_actor_count() == 0 && s.world().size() == 3, "invisible race dropped"); // GC_CHARACTER_ADD2 is the one-packet complete actor path. It must carry // every field that NetworkActorManager receives from the reference client. GCCharacterAdd2 add2{}; add2.header = HDR_GC_CHARACTER_ADD2; add2.vid = 8892; std::strcpy(add2.name, "FatActor"); add2.angle = 37.0f; add2.x = 102000; add2.y = 202000; add2.z = 4; add2.type = WIRE_CHRTYPE_NPC; add2.race = 103; add2.parts[0] = 301; add2.parts[1] = 302; add2.moving_speed = 180; add2.attack_speed = 140; add2.state_flag = 0x33; add2.affect_flag[0] = 0x01020304; add2.affect_flag[1] = 0x50607080; add2.empire = 3; add2.guild = 44; add2.alignment = -700; add2.pk_mode = 2; add2.mount_vnum = 20101; feed(s, raw(add2)); const auto *fat = s.world().get(8892); CHECK(fat && fat->name == "FatActor" && fat->x == 102000.f && fat->angle == 37.0f, "character_add2 identity and transform copied"); CHECK(fat && fat->parts[0] == 301 && fat->parts[1] == 302 && fat->state_flags == 0x33 && fat->affect_flags == 0x5060708001020304ULL, "character_add2 equipment, state and affect copied"); CHECK(fat && fat->empire == 3 && fat->guild == 44 && fat->alignment == -700 && fat->pk_mode == 2 && fat->mount_vnum == 20101, "character_add2 actor metadata copied"); GCCharacterAdd2 hidden2{}; hidden2.header = HDR_GC_CHARACTER_ADD2; hidden2.vid = 8893; hidden2.race = 20038; feed(s, raw(hidden2)); CHECK(s.world().get(8893) == nullptr, "character_add2 invisible race dropped"); GCCharAddInfo orphan{}; orphan.header = HDR_GC_CHAR_ADDITIONAL_INFO; orphan.vid = 9998; std::strcpy(orphan.name, "orphan"); const size_t before_orphan = s.world().size(); feed(s, raw(orphan)); CHECK(s.world().size() == before_orphan && s.world().get(9998) == nullptr, "additional-info without add is ignored without half-spawn"); GCCharacterDel add2del{HDR_GC_CHARACTER_DEL, 8892}; feed(s, raw(add2del)); CHECK(s.world().get(8892) == nullptr, "character_add2 cleanup"); // The 40250 old Add path uses one static s_kNetActorData slot. A second // PC/NPC Add replaces the first staged record rather than creating a // keyed pending queue that can merge both later AdditionalInfo packets. GCCharacterAdd staged_a{}; staged_a.header = HDR_GC_CHARACTER_ADD; staged_a.vid = 9901; staged_a.race = 101; staged_a.type = WIRE_CHRTYPE_PC; staged_a.x = 103000; staged_a.y = 203000; GCCharacterAdd staged_b = staged_a; staged_b.vid = 9902; staged_b.type = WIRE_CHRTYPE_NPC; feed(s, raw(staged_a)); feed(s, raw(staged_b)); CHECK(s.parser().pending_actor_count() == 1, "interleaved old Add packets keep only the reference staging slot"); GCCharAddInfo stale_info{}; stale_info.header = HDR_GC_CHAR_ADDITIONAL_INFO; stale_info.vid = 9901; std::strcpy(stale_info.name, "stale"); feed(s, raw(stale_info)); CHECK(s.world().get(9901) == nullptr, "AdditionalInfo for the overwritten staged VID does not spawn an actor"); GCCharAddInfo staged_info{}; staged_info.header = HDR_GC_CHAR_ADDITIONAL_INFO; staged_info.vid = 9902; std::strcpy(staged_info.name, "last-staged"); feed(s, raw(staged_info)); CHECK(s.world().get(9902) != nullptr && s.parser().pending_actor_count() == 0, "AdditionalInfo completes only the last staged actor"); GCCharacterDel staged_del{HDR_GC_CHARACTER_DEL, 9902}; feed(s, raw(staged_del)); GCCharacterAdd deleted_pending = staged_a; deleted_pending.vid = 9910; feed(s, raw(deleted_pending)); GCCharacterDel deleted_pending_del{HDR_GC_CHARACTER_DEL, 9910}; feed(s, raw(deleted_pending_del)); CHECK(s.parser().pending_actor_count() == 0, "Delete clears the reference staged actor slot"); GCCharAddInfo late_deleted_info{}; late_deleted_info.header = HDR_GC_CHAR_ADDITIONAL_INFO; late_deleted_info.vid = 9910; std::strcpy(late_deleted_info.name, "late-deleted"); feed(s, raw(late_deleted_info)); CHECK(s.world().get(9910) == nullptr, "late AdditionalInfo after Delete cannot revive an actor"); // drop the monster so the rest of the flow sees just main + NPC 8888 GCCharacterDel mobdel{HDR_GC_CHARACTER_DEL, 8890}; feed(s, raw(mobdel)); CHECK(s.world().size() == 2, "monster removed"); GCShopSign sign{}; sign.header = HDR_GC_SHOP_SIGN; sign.vid = 8888; std::strcpy(sign.sign, "Potion shop"); feed(s, raw(sign)); CHECK(s.world().get(8888)->shop_sign == "Potion shop", "shop sign set"); std::memset(sign.sign, 0, sizeof(sign.sign)); feed(s, raw(sign)); CHECK(s.world().get(8888)->shop_sign.empty(), "shop sign cleared"); } // Loading/warp reset clears both the EntityStore and the parser's static // staged Add record; a late AdditionalInfo from the old map must be inert. { ClassicSession reset; reset.start_offline("admin", "123456789"); GCCharacterAdd map_pending{}; map_pending.header = HDR_GC_CHARACTER_ADD; map_pending.vid = 9911; map_pending.race = 101; map_pending.type = WIRE_CHRTYPE_PC; feed(reset, raw(map_pending)); reset.world().mut_spawn_main(7000, 4, "OldActor", 1000, 2000, 0); reset.reset_for_map_change(); CHECK(reset.parser().pending_actor_count() == 0 && reset.world().size() == 0, "map reset clears world and staged actor state together"); GCCharAddInfo late_map_info{}; late_map_info.header = HDR_GC_CHAR_ADDITIONAL_INFO; late_map_info.vid = 9911; std::strcpy(late_map_info.name, "late-map"); feed(reset, raw(late_map_info)); CHECK(reset.world().get(9911) == nullptr, "late AdditionalInfo after map reset cannot spawn an actor"); } // --- SetLoadingPhase clears the old world before a same-read spawn burst --- { ClassicSession reset; reset.start_offline("admin", "123456789"); reset.world().mut_spawn_main(7000, 4, "OldActor", 1000, 2000, 0); bool loading_reset = false; reset.on_loading_phase = [&]() { loading_reset = reset.world().main_vid() == 7000 && reset.world().size() == 1; reset.world().reset_for_map_change(); }; Phase_ loading{HDR_GC_PHASE, PHASE_LOADING}; feed(reset, raw(loading)); GCMainCharacter replacement{}; replacement.header = HDR_GC_MAIN_CHARACTER; replacement.vid = 7001; replacement.race = 4; std::strcpy(replacement.name, "NewActor"); feed(reset, raw(replacement)); CHECK(loading_reset, "PHASE_LOADING invokes reset before replacement spawn"); CHECK(reset.world().main_vid() == 7001 && reset.world().size() == 1, "replacement main actor survives after loading reset"); } // LoadingPhase/GamePhase send unknown registered-by-server headers through // RecvErrorPacket: the reference clears the receive buffer but keeps the // active phase/socket owner. An unknown header must not turn a transient // Loading packet into a transport disconnect. { uint16_t loading_port = 0; const int loading_listener = listen_loopback(loading_port); if (loading_listener >= 0) { ClassicSession loading_unknown; CHECK(loading_unknown.connect("127.0.0.1", loading_port, "admin", "123456789"), "unknown Loading packet fixture connects a live stream"); int loading_connection = -1; CHECK(accept_loopback(loading_listener, loading_connection), "unknown Loading packet fixture accepts the live stream"); Phase_ loading{HDR_GC_PHASE, PHASE_LOADING}; feed(loading_unknown, raw(loading)); CHECK(loading_unknown.stage() == mtnet::INetSession::Stage::Loading, "unknown Loading packet fixture enters LoadingPhase"); const uint8_t unknown_header = 200; feed(loading_unknown, std::vector{unknown_header}); CHECK(loading_unknown.stage() == mtnet::INetSession::Stage::Loading, "unknown Loading packet follows RecvErrorPacket without phase loss"); if (loading_connection >= 0) { ::close(loading_connection); } ::close(loading_listener); } else { std::puts("SKIP: unknown Loading packet probe (localhost bind unavailable)"); } } // 40250 sends CG_ENTERGAME from GameWindow.LoadData after // GC_MAIN_CHARACTER, not from a fixed timer after PHASE_LOADING. This // fixture disables the compatibility timer so the current implementation // fails before the event-boundary fix. { ClassicSession event_enter; event_enter.start_offline("event-enter", "secret"); event_enter.stream().set_sequence_mode(true); Phase_ loading{HDR_GC_PHASE, PHASE_LOADING}; feed(event_enter, raw(loading)); GCMainCharacter main_character{}; main_character.header = HDR_GC_MAIN_CHARACTER; main_character.vid = 9801; main_character.race = 0; std::strcpy(main_character.name, "EventEnter"); main_character.x = 100; main_character.y = 200; feed(event_enter, raw(main_character)); const auto out = drain(event_enter); CHECK(out.size() == sizeof(CGClientVersion2) + 1 + sizeof(CGEnterGame) + 1, "GC_MAIN_CHARACTER triggers version then CG_ENTERGAME in the same loading event"); if (out.size() == sizeof(CGClientVersion2) + 1 + sizeof(CGEnterGame) + 1) { CHECK(out[sizeof(CGClientVersion2) + 1] == HDR_CG_ENTERGAME, "event-driven enter packet follows the client-version report"); } } // --- GC_PHASE(GAME) --- { Phase_ p{HDR_GC_PHASE, PHASE_GAME}; feed(s, raw(p)); CHECK(s.stage() == mtnet::INetSession::Stage::InGame, "PHASE_GAME -> InGame"); CHECK(s.in_game() && entered, "in_game() + on_entered_game"); } // --- known but unsupported integration packet is not silently accepted --- { ClassicSession unsupported; unsupported.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(unsupported, raw(login)); std::vector packet(sizeof(GCMatrixCard), 0); packet[0] = HDR_GC_MATRIX_CARD; feed(unsupported, packet); CHECK(unsupported.stage() == mtnet::INetSession::Stage::LoggingIn, "known unsupported packet follows RecvErrorPacket without phase loss"); CHECK(unsupported.last_error().find("unsupported optional security packet") != std::string::npos, "unsupported integration packet -> explicit error"); auto unsupported_headers = unsupported.parser().drain_unhandled_headers(); CHECK(unsupported_headers.size() == 1 && unsupported_headers[0] == HDR_GC_MATRIX_CARD, "unsupported integration packet -> header event"); GCLoginSuccess recovered{}; recovered.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; recovered.players[0].id = 9900; feed(unsupported, raw(recovered)); CHECK(unsupported.parser().char_list_ready(), "known handler failure keeps the phase owner available for the next tick"); } // 40250 consumes HybridCrypt keys/SDB in the active phase and hands them to // EterPack. This client cannot reproduce that vendor runtime yet, but the // deployment gap must be explicit: the frame is consumed, the phase owner is // retained, and the host receives a security-capability notification instead // of silently losing the packet. { ClassicSession hybrid_guard; std::vector security_headers; hybrid_guard.on_unsupported_security = [&](uint8_t header) { security_headers.push_back(header); }; hybrid_guard.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(hybrid_guard, raw(login)); const std::vector hybrid_keys = { HDR_GC_HYBRIDCRYPT_KEYS, 7, 0, 0xaa, 0xbb, 0xcc, 0xdd}; feed(hybrid_guard, hybrid_keys); CHECK(security_headers.size() == 1 && security_headers[0] == HDR_GC_HYBRIDCRYPT_KEYS, "HybridCrypt packet raises an explicit deployment guard"); CHECK(hybrid_guard.stage() == mtnet::INetSession::Stage::LoggingIn && hybrid_guard.last_error().find("deployment requires") != std::string::npos, "HybridCrypt deployment guard preserves the LoginPhase owner"); GCLoginSuccess recovered{}; recovered.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; feed(hybrid_guard, raw(recovered)); CHECK(hybrid_guard.parser().char_list_ready(), "HybridCrypt guard consumes only its frame and keeps the next packet aligned"); } // A registered dynamic packet whose fixed body is truncated follows the // same RecvErrorPacket path as the Windows client: consume the bad frame, // clear pipelined bytes, and keep the active phase owner alive. { ClassicSession malformed; malformed.start_offline("admin", "123456789"); Phase_ game_phase{HDR_GC_PHASE, PHASE_GAME}; feed(malformed, raw(game_phase)); CHECK(malformed.stage() == mtnet::INetSession::Stage::InGame, "malformed dynamic test enters game phase"); std::vector bad_script = {HDR_GC_SCRIPT, 3, 0}; std::vector bad_burst = bad_script; GCCharacterDel trailing{HDR_GC_CHARACTER_DEL, 7123}; auto trailing_wire = raw(trailing); bad_burst.insert(bad_burst.end(), trailing_wire.begin(), trailing_wire.end()); feed(malformed, bad_burst); CHECK(malformed.stage() == mtnet::INetSession::Stage::InGame, "truncated dynamic handler keeps game phase owner"); CHECK(!malformed.last_error().empty(), "truncated dynamic handler reports RecvErrorPacket failure"); feed(malformed, trailing_wire); CHECK(malformed.stage() == mtnet::INetSession::Stage::InGame, "next frame parses after truncated dynamic cleanup"); } // SelectPhase is the only reference owner for character CRUD replies. A // delayed create/rename packet must not mutate the list while logging in. { ClassicSession wrong_phase; wrong_phase.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(wrong_phase, raw(login)); GCChangeNameNotice n{}; n.header = HDR_GC_CHANGE_NAME; n.pid = 1001; std::strcpy(n.name, "WrongPhase"); feed(wrong_phase, raw(n)); CHECK(wrong_phase.stage() == mtnet::INetSession::Stage::LoggingIn, "character packet outside SelectPhase is consumed without phase change"); CHECK(wrong_phase.world().size() == 0, "character packet outside SelectPhase has no world side effect"); } // Login/Select use phase-local allowlists in ClientVS22. A game spawn // packet arriving before the loading/game owner must not reach EntityStore. { ClassicSession wrong_login; wrong_login.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(wrong_login, raw(login)); GCCharacterAdd early{}; early.header = HDR_GC_CHARACTER_ADD; early.vid = 9001; early.race = 101; early.type = CHRTYPE_MONSTER; GCMainCharacter trailing{}; trailing.header = HDR_GC_MAIN_CHARACTER; trailing.vid = 9002; trailing.race = 101; std::vector early_burst = raw(early); auto trailing_bytes = raw(trailing); early_burst.insert(early_burst.end(), trailing_bytes.begin(), trailing_bytes.end()); feed(wrong_login, early_burst); CHECK(wrong_login.world().size() == 0, "game spawn in LoginPhase does not mutate EntityStore"); CHECK(wrong_login.stage() == mtnet::INetSession::Stage::LoggingIn, "game spawn in LoginPhase is ignored without phase change"); ClassicSession wrong_select; wrong_select.start_offline("admin", "123456789"); Phase_ select{HDR_GC_PHASE, PHASE_SELECT}; feed(wrong_select, raw(select)); feed(wrong_select, raw(early)); CHECK(wrong_select.world().size() == 0, "game spawn in SelectPhase does not mutate EntityStore"); CHECK(wrong_select.stage() == mtnet::INetSession::Stage::CharSelect, "game spawn in SelectPhase is ignored without phase change"); } // LoadingPhase handles its bootstrap packets first, then delegates an // unrecognised header to GamePhase. A login packet therefore gets discarded, // while a valid GamePhase packet still works during map loading. { ClassicSession loading; loading.start_offline("admin", "123456789"); Phase_ phase{HDR_GC_PHASE, PHASE_LOADING}; feed(loading, raw(phase)); GCLoginSuccess late_login{}; late_login.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; late_login.players[0].id = 9100; GCCharacterAdd game_packet{}; game_packet.header = HDR_GC_CHARACTER_ADD; game_packet.vid = 9101; game_packet.race = 101; game_packet.type = CHRTYPE_MONSTER; std::vector burst = raw(late_login); auto game_bytes = raw(game_packet); burst.insert(burst.end(), game_bytes.begin(), game_bytes.end()); feed(loading, burst); CHECK(!loading.parser().char_list_ready(), "late login packet in LoadingPhase is not parsed as character list"); CHECK(loading.world().size() == 0, "LoadingPhase error clears the pipelined login/game burst"); feed(loading, raw(game_packet)); CHECK(loading.world().get(9101) != nullptr, "LoadingPhase delegates valid GamePhase packet to game parser"); ClassicSession legacy_loading; legacy_loading.start_offline("admin", "123456789"); feed(legacy_loading, raw(phase)); GCMainCharacterOld old_main{}; old_main.header = HDR_GC_MAIN_CHARACTER_OLD; old_main.vid = 9102; old_main.race = 4; old_main.x = 100; old_main.y = 200; old_main.skill_group = 1; feed(legacy_loading, raw(old_main)); CHECK(legacy_loading.world().main_vid() == 9102 && legacy_loading.world().skill_group() == 1 && legacy_loading.parser().empire() == 0, "legacy 85-byte main-character packet follows 40250 Loading layout"); } // GamePhase does not re-run LoadingPhase's main-character bootstrap switch. // A delayed bootstrap packet must not overwrite the active world or consume // the valid game packet that follows it under a new phase tick. { ClassicSession game; game.start_offline("admin", "123456789"); Phase_ phase{HDR_GC_PHASE, PHASE_GAME}; feed(game, raw(phase)); GCMainCharacter late_main{}; late_main.header = HDR_GC_MAIN_CHARACTER; late_main.vid = 9200; late_main.race = 4; GCCharacterAdd valid{}; valid.header = HDR_GC_CHARACTER_ADD; valid.vid = 9201; valid.race = 101; valid.type = CHRTYPE_MONSTER; std::vector burst = raw(late_main); auto valid_bytes = raw(valid); burst.insert(burst.end(), valid_bytes.begin(), valid_bytes.end()); feed(game, burst); CHECK(game.world().size() == 0, "Loading-only main-character packet in GamePhase is discarded with burst"); feed(game, raw(valid)); CHECK(game.world().get(9201) != nullptr, "valid GamePhase packet remains available after the rejected frame"); } // --- PHASE_CLOSE clears the old phase owner before returning control to the host --- { ClassicSession closed; closed.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(closed, raw(login)); drain(closed); Phase_ close{HDR_GC_PHASE, PHASE_CLOSE}; feed(closed, raw(close)); CHECK(closed.stage() == mtnet::INetSession::Stage::LoggingIn, "PHASE_CLOSE returns the game stream to LoginPhase"); CHECK(closed.phase_closed(), "PHASE_CLOSE is distinguished from transport failure"); CHECK(closed.login_secret_cleared(), "PHASE_CLOSE does not restore the password"); CHECK(closed.last_error() == "server closed phase", "PHASE_CLOSE keeps a deterministic close reason"); CHECK(closed.stream().state() == ClassicStream::State::Offline, "PHASE_CLOSE disconnects the old game transport before LoginWindow replacement"); CHECK(drain(closed).empty(), "PHASE_CLOSE uses ClosePhase -> SetLoginPhase without sending CG_LOGIN on the closed stream"); // ClosePhase creates the same LoginWindow owner as a peer close. A // subsequent LoginWindow.Connect must therefore be able to replace the // auth transport on this session instead of forcing a global teardown. uint16_t close_retry_port = 0; const int close_retry_listener = listen_loopback(close_retry_port); if (close_retry_listener >= 0) { const bool close_retry_started = closed.retry_login("127.0.0.1", close_retry_port, "127.0.0.1", 1, "admin", "close-retry"); CHECK(close_retry_started, "PHASE_CLOSE keeps the LoginWindow retry owner for a replacement auth transport"); if (close_retry_started) { int close_retry_connection = -1; CHECK(accept_loopback(close_retry_listener, close_retry_connection), "PHASE_CLOSE retry accepts a replacement auth transport"); Phase_ retry_auth{HDR_GC_PHASE, PHASE_AUTH}; closed.auth_stream().feed(&retry_auth, sizeof(retry_auth)); auto retry_wire = drain_stream(closed.auth_stream()); CHECK(retry_wire.size() == sizeof(CGLogin3) + 1, "PHASE_CLOSE retry sends one CG_LOGIN3 after replacement AUTH phase"); if (close_retry_connection >= 0) { ::close(close_retry_connection); } } ::close(close_retry_listener); } else { std::puts("SKIP: PHASE_CLOSE retry transport probe (localhost bind unavailable)"); } } // ClientVS22 ClosePhase leaves GamePhase before SetLoginPhase. The leave // owner destroys the old network actor manager; a phase close from an active // game must therefore not leave the old world available to the login retry. { ClassicSession closed_game; bool close_owner_notified_after_reset = false; closed_game.on_phase_close = [&]() { close_owner_notified_after_reset = closed_game.world().size() == 0; }; closed_game.start_offline("admin", "123456789"); Phase_ game_phase{HDR_GC_PHASE, PHASE_GAME}; feed(closed_game, raw(game_phase)); GCCharacterAdd old_actor{}; old_actor.header = HDR_GC_CHARACTER_ADD; old_actor.vid = 9250; old_actor.race = 101; old_actor.type = CHRTYPE_MONSTER; feed(closed_game, raw(old_actor)); CHECK(closed_game.world().get(9250) != nullptr, "PHASE_CLOSE fixture owns an old GamePhase actor"); Phase_ close{HDR_GC_PHASE, PHASE_CLOSE}; feed(closed_game, raw(close)); CHECK(closed_game.stage() == mtnet::INetSession::Stage::LoggingIn, "game PHASE_CLOSE returns to LoginPhase"); CHECK(closed_game.world().size() == 0, "game PHASE_CLOSE clears the old world before login retry"); CHECK(close_owner_notified_after_reset, "game PHASE_CLOSE notifies the host after the old world is cleared"); } // ClientVS22 also leaves LoadingPhase before SetLoginPhase. Its // __LeaveLoadingPhase clears the map-local player/effect owner just like the // GamePhase leave path; a close during map loading must not retain the old // main actor for the replacement login phase. { ClassicSession closed_loading; bool loading_leave_notified_after_reset = false; closed_loading.on_phase_leave = [&]() { loading_leave_notified_after_reset = closed_loading.world().size() == 0; }; closed_loading.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(closed_loading, raw(login)); drain(closed_loading); Phase_ loading{HDR_GC_PHASE, PHASE_LOADING}; feed(closed_loading, raw(loading)); GCMainCharacter old_loading_actor{}; old_loading_actor.header = HDR_GC_MAIN_CHARACTER; old_loading_actor.vid = 9251; old_loading_actor.race = 4; std::strcpy(old_loading_actor.name, "LoadingActor"); old_loading_actor.x = 1000; old_loading_actor.y = 2000; feed(closed_loading, raw(old_loading_actor)); CHECK(closed_loading.stage() == mtnet::INetSession::Stage::Loading && closed_loading.world().get(9251) != nullptr, "PHASE_CLOSE fixture owns an old LoadingPhase actor"); Phase_ close{HDR_GC_PHASE, PHASE_CLOSE}; feed(closed_loading, raw(close)); CHECK(closed_loading.stage() == mtnet::INetSession::Stage::LoggingIn, "Loading PHASE_CLOSE returns to LoginPhase"); CHECK(closed_loading.world().size() == 0, "Loading PHASE_CLOSE clears the old world before login retry"); CHECK(loading_leave_notified_after_reset, "Loading PHASE_CLOSE notifies the host after the old world is cleared"); } // SetLoginPhase is also reachable from a non-CLOSE phase transition. The // 40250 setter runs the previous phase-leave owner before sending the new // login packet and clears the old select-character data on the ordinary // (non-DirectEnter) branch. A retry that arrives as PHASE_LOGIN must not // retain the previous map world or the previous character-list snapshot. { ClassicSession cross_phase_select; cross_phase_select.start_offline("admin", "123456789"); Phase_ login{HDR_GC_PHASE, PHASE_LOGIN}; feed(cross_phase_select, raw(login)); GCLoginSuccess old_list{}; old_list.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; old_list.players[0].id = 9270; std::strcpy(old_list.players[0].name, "OldRole"); feed(cross_phase_select, raw(old_list)); Phase_ select{HDR_GC_PHASE, PHASE_SELECT}; feed(cross_phase_select, raw(select)); CHECK(cross_phase_select.parser().char_list_ready() && !cross_phase_select.char_slots()[0].empty(), "cross-phase retry fixture owns the previous character list"); feed(cross_phase_select, raw(login)); CHECK(cross_phase_select.stage() == mtnet::INetSession::Stage::LoggingIn, "direct PHASE_LOGIN enters LoginPhase from SelectPhase"); CHECK(!cross_phase_select.parser().char_list_ready() && cross_phase_select.char_slots()[0].empty(), "PHASE_LOGIN clears the previous character-list owner before retry"); } { ClassicSession cross_phase_game; bool phase_leave_after_reset = false; cross_phase_game.on_phase_leave = [&]() { phase_leave_after_reset = cross_phase_game.world().size() == 0; }; cross_phase_game.start_offline("admin", "123456789"); Phase_ game_phase{HDR_GC_PHASE, PHASE_GAME}; feed(cross_phase_game, raw(game_phase)); cross_phase_game.world().mut_spawn(9271, 101, CHRTYPE_MONSTER, "old-map-actor", 1.0f, 2.0f, 0.0f, 0.0f); CHECK(cross_phase_game.world().get(9271) != nullptr, "cross-phase retry fixture owns the previous game world"); Phase_ login_again{HDR_GC_PHASE, PHASE_LOGIN}; feed(cross_phase_game, raw(login_again)); CHECK(cross_phase_game.world().size() == 0, "PHASE_LOGIN runs the previous GamePhase leave cleanup before retry"); CHECK(phase_leave_after_reset, "PHASE_LOGIN notifies the host after GamePhase leave cleanup"); } // SetSelectPhase also runs the previous phase-leave owner. In ClientVS22 // this is what invokes __LeaveGamePhase when the server returns from Game // to Select; the old map world must not survive under the Select owner. { ClassicSession select_reentry; bool phase_leave_after_reset = false; select_reentry.on_phase_leave = [&]() { phase_leave_after_reset = select_reentry.world().size() == 0; }; select_reentry.start_offline("admin", "123456789"); Phase_ game_phase{HDR_GC_PHASE, PHASE_GAME}; feed(select_reentry, raw(game_phase)); select_reentry.world().mut_spawn(9272, 101, CHRTYPE_MONSTER, "select-reentry-old-map", 1.0f, 2.0f, 0.0f, 0.0f); CHECK(select_reentry.world().get(9272) != nullptr, "Select re-entry fixture owns the previous game world"); Phase_ select_again{HDR_GC_PHASE, PHASE_SELECT}; feed(select_reentry, raw(select_again)); CHECK(select_reentry.stage() == mtnet::INetSession::Stage::CharSelect, "PHASE_SELECT installs the SelectPhase owner after GamePhase"); CHECK(select_reentry.world().size() == 0, "PHASE_SELECT runs the previous GamePhase leave cleanup"); CHECK(phase_leave_after_reset, "PHASE_SELECT notifies the host after GamePhase leave cleanup"); } // --- DirectEnter opens the character slot's TCP endpoint --- { uint16_t initial_port = 0; uint16_t slot_port = 0; uint16_t warp_port = 0; const int initial_listener = listen_loopback(initial_port); const int slot_listener = listen_loopback(slot_port); const int warp_listener = listen_loopback(warp_port); if (initial_listener >= 0 && slot_listener >= 0 && warp_listener >= 0) { ClassicSession direct; CHECK(direct.connect_with_login_key("127.0.0.1", initial_port, "admin", 0x12345678), "direct enter initial game connection"); int initial_connection = -1; CHECK(accept_loopback(initial_listener, initial_connection), "direct enter first TCP connection accepted"); GCLoginSuccess login{}; login.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; login.players[0].id = 2001; std::strcpy(login.players[0].name, "Direct"); login.players[0].addr = static_cast(0x0100007f); // 127.0.0.1 login.players[0].port = slot_port; feed(direct, raw(login)); CHECK(direct.char_slots()[0].addr == static_cast(0x0100007f) && direct.char_slots()[0].port == slot_port, "direct enter slot endpoint parsed"); CHECK(direct.connect_direct_enter(0), "direct enter reconnect accepted"); int slot_connection = -1; CHECK(accept_loopback(slot_listener, slot_connection), "direct enter second TCP connection targets slot endpoint"); Phase_ direct_login{HDR_GC_PHASE, PHASE_LOGIN}; feed(direct, raw(direct_login)); auto direct_login_wire = drain(direct); CHECK(direct_login_wire.size() == sizeof(CGLogin2) + 1, "DirectEnter sends one sequenced CG_LOGIN2 after PHASE_LOGIN"); if (direct_login_wire.size() >= sizeof(CGLogin2)) { CGLogin2 direct_login_packet{}; std::memcpy(&direct_login_packet, direct_login_wire.data(), sizeof(direct_login_packet)); CHECK(direct_login_packet.header == HDR_CG_LOGIN2 && direct_login_packet.login_key == 0x12345678, "DirectEnter CG_LOGIN2 keeps the authentication ticket"); } Phase_ direct_select{HDR_GC_PHASE, PHASE_SELECT}; feed(direct, raw(direct_select)); auto direct_select_wire = drain(direct); CHECK(direct.stage() == mtnet::INetSession::Stage::Loading && direct_select_wire.size() == sizeof(CGPlayerSelect) + 1, "DirectEnter SetLoadingPhase sends the reference CG_PLAYER_SELECT"); if (direct_select_wire.size() == sizeof(CGPlayerSelect) + 1) { CGPlayerSelect direct_select_packet{}; std::memcpy(&direct_select_packet, direct_select_wire.data(), sizeof(direct_select_packet)); CHECK(direct_select_packet.header == HDR_CG_CHARACTER_SELECT && direct_select_packet.player_index == 0, "DirectEnter loading select packet retains the selected slot"); } Phase_ direct_loading{HDR_GC_PHASE, PHASE_LOADING}; feed(direct, raw(direct_loading)); Phase_ direct_game{HDR_GC_PHASE, PHASE_GAME}; feed(direct, raw(direct_game)); CHECK(direct.stage() == mtnet::INetSession::Stage::InGame && direct.was_online_lost(), "DirectEnter reaches an established GamePhase before GC_WARP"); CHECK(direct.connect_warp("127.0.0.1", warp_port), "GC_WARP reconnect keeps selected slot and login key"); CHECK(direct.selected_slot() == 0 && direct.login_key() == 0x12345678, "GC_WARP keeps the same selected slot and login ticket in-session"); CHECK(!direct.was_online_lost(), "GC_WARP clears the old GamePhase marker until the replacement reaches PHASE_GAME"); int warp_connection = -1; CHECK(accept_loopback(warp_listener, warp_connection), "GC_WARP reconnect targets packet endpoint"); feed(direct, raw(direct_login)); auto warp_login_wire = drain(direct); CHECK(warp_login_wire.size() == sizeof(CGLogin2) + 1, "GC_WARP sends one sequenced CG_LOGIN2 after PHASE_LOGIN"); if (warp_login_wire.size() >= sizeof(CGLogin2)) { CGLogin2 warp_login_packet{}; std::memcpy(&warp_login_packet, warp_login_wire.data(), sizeof(warp_login_packet)); CHECK(warp_login_packet.header == HDR_CG_LOGIN2 && warp_login_packet.login_key == 0x12345678, "GC_WARP CG_LOGIN2 keeps the same authentication ticket"); } feed(direct, raw(direct_select)); feed(direct, raw(direct_loading)); feed(direct, raw(direct_game)); CHECK(direct.stage() == mtnet::INetSession::Stage::InGame && direct.was_online_lost(), "GC_WARP marks the replacement connection online only after PHASE_GAME"); if (initial_connection >= 0) { ::close(initial_connection); } if (slot_connection >= 0) { ::close(slot_connection); } if (warp_connection >= 0) { ::close(warp_connection); } } else { std::puts("SKIP: DirectEnter TCP target test (localhost bind unavailable)"); } if (initial_listener >= 0) { ::close(initial_listener); } if (slot_listener >= 0) { ::close(slot_listener); } if (warp_listener >= 0) { ::close(warp_listener); } } // --- TCP peer close follows 40250 OnRemoteDisconnect -> SetLoginPhase --- { uint16_t port = 0; const int listener = listen_loopback(port); if (listener >= 0) { ClassicSession pre_game_close; bool peer_close_seen = false; std::string peer_close_reason; pre_game_close.on_remote_disconnect = [&](const std::string &reason) { peer_close_seen = true; peer_close_reason = reason; }; CHECK(pre_game_close.connect("127.0.0.1", port, "admin", "123456789"), "pre-game failure probe connects TCP socket"); int accepted = -1; CHECK(accept_loopback(listener, accepted), "pre-game failure probe accepts socket"); if (accepted >= 0) { ::close(accepted); } for (int i = 0; i < 10 && pre_game_close.stage() != mtnet::INetSession::Stage::LoggingIn; ++i) { pre_game_close.pump(); ::usleep(1000); } CHECK(pre_game_close.stage() == mtnet::INetSession::Stage::LoggingIn, "peer close before PHASE_GAME returns to LoginPhase"); CHECK(!pre_game_close.phase_closed(), "peer close is distinct from an explicit PHASE_CLOSE packet"); CHECK(!pre_game_close.login_secret_cleared(), "peer-close SetLoginPhase keeps the transport credential until the next LoginWindow.Connect"); CHECK(peer_close_seen && peer_close_reason == "peer closed", "peer close is reported before the old stream is cleared"); // Once PHASE_GAME has been entered, the same reference callback still // returns to LoginPhase and must not leave an InGame owner behind. ClassicSession in_game_close; bool in_game_peer_close_seen = false; in_game_close.on_remote_disconnect = [&](const std::string &) { in_game_peer_close_seen = true; }; CHECK(in_game_close.connect("127.0.0.1", port, "admin", "123456789"), "in-game peer close probe connects TCP socket"); int in_game_accepted = -1; CHECK(accept_loopback(listener, in_game_accepted), "in-game peer close probe accepts socket"); Phase_ game_phase{HDR_GC_PHASE, PHASE_GAME}; feed(in_game_close, raw(game_phase)); CHECK(in_game_close.stage() == mtnet::INetSession::Stage::InGame, "peer close probe enters GamePhase before disconnect"); in_game_close.world().mut_spawn(4001, 101, 2, "old-actor", 1.0f, 2.0f, 0.0f, 0.0f); CHECK(in_game_close.world().size() == 1, "in-game peer close probe owns old world state before disconnect"); if (in_game_accepted >= 0) { ::close(in_game_accepted); } for (int i = 0; i < 10 && in_game_close.stage() != mtnet::INetSession::Stage::LoggingIn; ++i) { in_game_close.pump(); ::usleep(1000); } CHECK(in_game_close.stage() == mtnet::INetSession::Stage::LoggingIn, "in-game peer close returns to LoginPhase"); CHECK(in_game_close.login_retry_pending(), "in-game peer close keeps the LoginWindow retry owner on the same session"); CHECK(in_game_peer_close_seen, "in-game peer close reaches the observer before stream cleanup"); CHECK(in_game_close.world().size() == 0, "peer-close LoginPhase clears the old game world before retry"); // LoginWindow.Connect must be able to reuse this same session after a // game peer close. The replacement AUTH phase must send the newly // entered credentials on the new auth transport, not fall back to a // global teardown or reuse the old game owner. uint16_t retry_auth_port = 0; const int retry_auth_listener = listen_loopback(retry_auth_port); if (retry_auth_listener >= 0) { CHECK(in_game_close.retry_login("127.0.0.1", retry_auth_port, "127.0.0.1", 1, "admin", "new-secret"), "game peer-close retry replaces only the auth transport"); int retry_auth_connection = -1; CHECK(accept_loopback(retry_auth_listener, retry_auth_connection), "game peer-close retry accepts a replacement auth transport"); Phase_ retry_auth_phase{HDR_GC_PHASE, PHASE_AUTH}; in_game_close.auth_stream().feed(&retry_auth_phase, sizeof(retry_auth_phase)); auto retry_auth_packet = drain_stream(in_game_close.auth_stream()); CHECK(retry_auth_packet.size() == sizeof(CGLogin3) + 1, "game peer-close retry sends one CG_LOGIN3 after replacement AUTH phase"); if (retry_auth_packet.size() == sizeof(CGLogin3) + 1) { CGLogin3 packet{}; std::memcpy(&packet, retry_auth_packet.data(), sizeof(packet)); CHECK(std::strcmp(packet.passwd, "new-secret") == 0, "game peer-close retry uses the newly entered password"); } if (retry_auth_connection >= 0) { ::close(retry_auth_connection); } ::close(retry_auth_listener); } else { std::puts("SKIP: game peer-close retry transport probe (localhost bind unavailable)"); } ::close(listener); } else { std::puts("SKIP: pre-game disconnect classification probe (localhost bind unavailable)"); } } // --- DirectEnter connect failure follows OnConnectFailure -> ClosePhase --- { uint16_t live_port = 0; uint16_t dead_port = 0; const int live_listener = listen_loopback(live_port); const int dead_listener = listen_loopback(dead_port); if (live_listener >= 0 && dead_listener >= 0) { ClassicSession direct_failure; bool direct_phase_close = false; int direct_login_failures = 0; direct_failure.on_phase_close = [&]() { direct_phase_close = true; }; direct_failure.on_login_failure = [&](const std::string &) { ++direct_login_failures; }; CHECK(direct_failure.connect_with_login_key("127.0.0.1", live_port, "admin", 0x12345678), "DirectEnter failure probe opens the original game connection"); int live_accepted = -1; CHECK(accept_loopback(live_listener, live_accepted), "DirectEnter failure probe accepts the original connection"); GCLoginSuccess login{}; login.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT; login.players[0].id = 3001; login.players[0].addr = static_cast(0x0100007f); login.players[0].port = dead_port; feed(direct_failure, raw(login)); ::close(dead_listener); // The connection may fail synchronously (ECONNREFUSED) or on the // first non-blocking pump; both paths must reach LoginPhase. direct_failure.connect_direct_enter(0); for (int i = 0; i < 10 && direct_failure.stage() != mtnet::INetSession::Stage::LoggingIn; ++i) { direct_failure.pump(); ::usleep(1000); } CHECK(direct_failure.stage() == mtnet::INetSession::Stage::LoggingIn, "DirectEnter connect failure returns to LoginPhase"); CHECK(direct_phase_close, "DirectEnter connect failure follows OnConnectFailure -> ClosePhase"); CHECK(direct_login_failures == 0, "DirectEnter connect failure is not reported as credential login failure"); CHECK(drain(direct_failure).empty(), "connect failure clears the old stream send buffer before retry"); // 40250 OnConnectFailure -> ClosePhase -> networkmodule.SetLoginPhase // calls net.Disconnect(), whose SetOffLinePhase clears DirectEnter. // The next replacement PHASE_LOGIN must therefore take the ordinary // LoginPhase branch and clear the stale character-list owner. Phase_ retry_login_phase{HDR_GC_PHASE, PHASE_LOGIN}; feed(direct_failure, raw(retry_login_phase)); CHECK(direct_failure.char_slots()[0].id == 0, "DirectEnter connect failure clears the stale slot before replacement LoginPhase"); if (live_accepted >= 0) { ::close(live_accepted); } } else { std::puts("SKIP: DirectEnter connect failure probe (localhost bind unavailable)"); } if (live_listener >= 0) { ::close(live_listener); } if (dead_listener >= 0) { ::close(dead_listener); } } // Ordinary game-stream OnConnectFailure is distinct from a credential // rejection. The 40250 login window receives OnConnectFailure and remains // the retry owner; it must not receive OnLoginFailure for a refused game // endpoint after authentication has already succeeded. { uint16_t auth_port = 0; const int auth_listener = listen_loopback(auth_port); if (auth_listener >= 0) { ClassicSession ordinary_failure; int credential_failures = 0; std::string connect_failure_reason; ordinary_failure.on_login_failure = [&](const std::string &) { ++credential_failures; }; ordinary_failure.on_connect_failure = [&](const std::string &reason) { connect_failure_reason = reason; }; CHECK(ordinary_failure.connect("127.0.0.1", auth_port, "this-game-host-must-not-resolve.invalid", 1, "admin", "secret"), "ordinary game-connect failure fixture opens the auth transport"); int accepted = -1; CHECK(accept_loopback(auth_listener, accepted), "ordinary game-connect failure fixture accepts auth transport"); Phase_ auth_phase{HDR_GC_PHASE, PHASE_AUTH}; ordinary_failure.auth_stream().feed(&auth_phase, sizeof(auth_phase)); CHECK(drain_stream(ordinary_failure.auth_stream()).size() == sizeof(CGLogin3) + 1, "ordinary game-connect failure fixture sends one CG_LOGIN3"); GCAuthSuccess success{}; success.header = HDR_GC_AUTH_SUCCESS; success.result = 1; success.login_key = 0x10203040; ordinary_failure.auth_stream().feed(&success, sizeof(success)); ordinary_failure.pump(); CHECK(ordinary_failure.stage() == mtnet::INetSession::Stage::LoggingIn, "ordinary game-connect failure returns to the LoginPhase owner"); CHECK(ordinary_failure.login_retry_pending(), "ordinary game-connect failure keeps the LoginPhase retry owner"); CHECK(credential_failures == 0, "ordinary game-connect failure is not reported as credential login failure"); CHECK(!connect_failure_reason.empty(), "ordinary game-connect failure reaches the distinct connect-failure callback"); if (accepted >= 0) { ::close(accepted); } ordinary_failure.disconnect(); ::close(auth_listener); } else { std::puts("SKIP: ordinary game-connect failure probe (localhost bind unavailable)"); } } // --- GC_MOVE(3): NPC starts moving, interpolation advances on tick --- { GCMove mv{}; mv.header = HDR_GC_MOVE; mv.func = mtnet::FUNC_MOVE; mv.rot = 64; mv.vid = 8888; mv.x = 110000; mv.y = 200500; mv.duration = 1000; s.set_now(1500); // the move starts at the reference server time feed(s, raw(mv)); const auto *npc = s.world().get(8888); CHECK(npc && npc->moving, "npc moving after GC_MOVE"); s.set_now(2000); // halfway through the 1000ms move s.world().tick(); npc = s.world().get(8888); CHECK(npc && npc->x > 100500.f && npc->x < 110000.f, "npc interpolated partway"); } // --- GC_WARP(65): packed classic static packet must remain frameable --- { GCWarpClassic warp{}; warp.header = HDR_GC_WARP; warp.x = 432100; warp.y = 876500; warp.addr = 0; warp.port = 0; feed(s, raw(warp)); auto warps = s.world().drain_warps(); CHECK(warps.size() == 1 && warps[0].x == 432100 && warps[0].y == 876500 && !warps[0].same_server(), "classic GC_WARP always requests reconnect"); } // --- GC_WARP is an old-socket boundary: pipelined tail packets are dropped --- { ClassicSession warp_boundary; warp_boundary.start_offline("admin", "123456789"); Phase_ game_phase{HDR_GC_PHASE, PHASE_GAME}; feed(warp_boundary, raw(game_phase)); GCWarpClassic warp{}; warp.header = HDR_GC_WARP; warp.x = 100; warp.y = 200; warp.addr = static_cast(0x0100007f); warp.port = 30003; GCCharacterAdd stale_tail{}; stale_tail.header = HDR_GC_CHARACTER_ADD; stale_tail.vid = 9901; stale_tail.race = 101; stale_tail.type = CHRTYPE_MONSTER; auto burst = raw(warp); auto tail = raw(stale_tail); burst.insert(burst.end(), tail.begin(), tail.end()); feed(warp_boundary, burst); CHECK(warp_boundary.world().get(9901) == nullptr, "GC_WARP clears pipelined old-socket tail before parsing it"); mtnet::WarpCue cue; CHECK(warp_boundary.world().take_next_warp(cue) && cue.x == 100 && cue.y == 200, "GC_WARP remains queued for the host reconnect owner"); } // --- GC_CHARACTER_POINTS(16): stat block --- { GCPoints pts{}; pts.header = HDR_GC_CHARACTER_POINTS; pts.points[mtnet::POINT_LEVEL] = 42; pts.points[mtnet::POINT_MAX_HP] = 3500; pts.points[mtnet::POINT_HP] = 3500; feed(s, raw(pts)); CHECK(s.world().points().level() == 42, "points level"); CHECK(s.world().points().max_hp() == 3500, "points max_hp"); const auto *me = s.world().get(7777); CHECK(me && me->max_hp == 3500 && me->level == 42, "vitals mirrored onto main entity"); } // --- GC_CHARACTER_POINT_CHANGE(17): single stat delta (int32_t header quirk) --- { GCPointChange pc{}; pc.header = HDR_GC_CHARACTER_POINT_CHANGE; pc.vid = 7777; pc.type = mtnet::POINT_HP; pc.value = 1200; feed(s, raw(pc)); CHECK(s.world().points().hp() == 1200, "point change -> hp 1200"); CHECK(s.world().get(7777)->hp == 1200, "point change mirrored"); } // --- in-game intents: CG_MOVE / ATTACK / CHAT / TARGET, each + a seq byte --- { // sequence index so far: [0]=CG_LOGIN, [1]=CG_CHARACTER_DELETE, // [2]=CG_CHARACTER_SELECT, [3]=CG_CLIENT_VERSION2, [4]=CG_ENTERGAME. CHECK(s.stream().sequence_index() == 5, "seq index at 5 before intents"); CHECK(s.send_move(mtnet::FUNC_MOVE, 0, 90.0f, 111111, 222222, 4242), "send_move"); auto out = drain(s); CHECK(out.size() == sizeof(CGMove) + 1, "CG_MOVE + seq"); CGMove mv{}; std::memcpy(&mv, out.data(), sizeof(mv)); CHECK(mv.header == HDR_CG_MOVE && mv.func == mtnet::FUNC_MOVE, "move header/func"); CHECK(mv.rot == 18, "rot deg 90 -> wire 18 (deg/5)"); CHECK(mv.x == 111111 && mv.y == 222222 && mv.time == 4242, "move x/y/time"); CHECK(out[sizeof(CGMove)] == SEQUENCE_TABLE[5], "move seq == table[5]"); CHECK(s.send_attack(1, 8888), "send_attack"); out = drain(s); CHECK(out.size() == sizeof(CGAttack) + 1 && out[0] == HDR_CG_ATTACK, "CG_ATTACK + seq"); CGAttack at{}; std::memcpy(&at, out.data(), sizeof(at)); CHECK(at.type == 1 && at.victim_vid == 8888, "attack fields"); CHECK(out[sizeof(CGAttack)] == SEQUENCE_TABLE[6], "attack seq == table[6]"); CHECK(s.send_target(8888), "send_target"); out = drain(s); CGTarget tg{}; std::memcpy(&tg, out.data(), sizeof(tg)); CHECK(out.size() == sizeof(CGTarget) + 1 && tg.vid == 8888, "CG_TARGET + seq"); CHECK(out[sizeof(CGTarget)] == SEQUENCE_TABLE[7], "target seq == table[7]"); // dynamic CG_CHAT: [hdr][u16 len][type]"hello"\0 + seq CHECK(s.send_chat(0, "hello"), "send_chat"); out = drain(s); const size_t chat_len = sizeof(CGChatHead) + 5 + 1; // "hello" + NUL CHECK(out.size() == chat_len + 1, "CG_CHAT wire size + seq"); CGChatHead ch{}; std::memcpy(&ch, out.data(), sizeof(ch)); CHECK(ch.header == HDR_CG_CHAT && ch.length == chat_len && ch.type == 0, "chat head"); CHECK(std::memcmp(out.data() + sizeof(CGChatHead), "hello\0", 6) == 0, "chat text + NUL"); CHECK(out[chat_len] == SEQUENCE_TABLE[8], "chat seq == table[8]"); CHECK(s.send_item_move(1, 5, 1, 9, 1), "send_item_move"); out = drain(s); CGItemMove im{}; std::memcpy(&im, out.data(), sizeof(im)); CHECK(out.size() == sizeof(CGItemMove) + 1, "CG_ITEM_MOVE + seq"); CHECK(im.pos.window_type == 1 && im.pos.cell == 5 && im.change_pos.cell == 9 && im.num == 1, "item_move fields"); } // --- GC_ITEM_SET(21) / UPDATE(25) / DEL(20) — inventory --- { GCItemSet is{}; is.header = HDR_GC_ITEM_SET; is.cell = {1, 4}; // inventory, cell 4 is.vnum = 27100; // a sword vnum is.count = 1; is.flags = 0x2; is.anti_flags = 0x10; is.highlight = 1; is.sockets[0] = 28030; is.attrs[0] = {5, 30}; is.attrs[1] = {1, 250}; feed(s, raw(is)); const mtnet::Item &it = s.world().item_slot(1, 4); CHECK(it.vnum == 27100 && it.count == 1 && it.flags == 0x2 && it.anti_flags == 0x10, "item_set landed"); CHECK(it.sockets[0] == 28030 && it.attrs[0].type == 5 && it.attrs[0].value == 30 && it.attrs[1].value == 250, "item_set sockets/attrs decoded from 3-byte wire attr"); auto item_highlights = s.world().drain_item_highlights(); CHECK(item_highlights.size() == 1 && item_highlights[0].window == 1 && item_highlights[0].cell == 4, "classic item_set preserves 40250 highlight side effect"); GCItemUpdate iu{}; iu.header = HDR_GC_ITEM_UPDATE; iu.cell = {1, 4}; iu.count = 3; feed(s, raw(iu)); CHECK(s.world().item_slot(1, 4).count == 3, "item_update count -> 3"); // GC 20 is TPacketGCItemDelDeprecated on the 40250 server (char_item.cpp:424, // packet.h:1124): header, TItemPos, vnum, count, alSockets[3], aAttr[7] = 42 // bytes; the 40250 client reads it as ITEM_SET. Sent on inventory load, so feed // it glued to the next packet: a short frame desyncs the loading stream. std::vector del(42, 0); del[0] = HDR_GC_ITEM_DEL; del[1] = 1; // WINDOW_INVENTORY del[2] = 4; // cell 4 (u16 LE) GCItemSet next{}; next.header = HDR_GC_ITEM_SET; next.cell = {1, 7}; next.vnum = 11200; next.count = 2; std::vector glued = del; std::vector nb = raw(next); glued.insert(glued.end(), nb.begin(), nb.end()); feed(s, glued); CHECK(s.world().item_slot(1, 4).vnum == 0, "item_del cleared inventory cell 4"); CHECK(s.world().item_slot(1, 7).vnum == 11200 && s.world().item_slot(1, 7).count == 2, "packet after 42-byte GC 20 framed correctly"); CHECK(s.last_error().empty(), "no desync after GC 20"); // Same wire layout with a vnum: the 40250 client applies it as an item set. std::vector set20(42, 0); set20[0] = HDR_GC_ITEM_DEL; set20[1] = 1; set20[2] = 9; const uint32_t vnum20 = 27001; std::memcpy(&set20[4], &vnum20, 4); set20[8] = 5; // count const int32_t sock0 = 28030; std::memcpy(&set20[9], &sock0, 4); set20[21] = 7; // aAttr[0].bType const int16_t av = 15; std::memcpy(&set20[22], &av, 2); feed(s, set20); const mtnet::Item &it20 = s.world().item_slot(1, 9); CHECK(it20.vnum == 27001 && it20.count == 5 && it20.sockets[0] == 28030 && it20.attrs[0].type == 7 && it20.attrs[0].value == 15, "GC 20 with vnum sets the cell like the 40250 client"); s.world().mut_item_del(1, 9); } // --- GC 95 HEADER_GC_REFINE_INFORMATION: the 40250 client registers it with // TPacketGCRefineInformation = [hdr][pos][TRefineTable] = 59 bytes (no type byte; // only 119 _NEW carries type). Glue a packet after it to prove the frame length. --- { s.world().drain_refine_cues(); std::vector rf(59, 0); rf[0] = 95; rf[1] = 6; // pos const uint32_t src = 11209, dst = 11210, mat_vnum = 30053; const int32_t cost = 5000, prob = 90, mat_count = 2; std::memcpy(&rf[2], &src, 4); std::memcpy(&rf[6], &dst, 4); rf[10] = 1; // material_count std::memcpy(&rf[11], &cost, 4); std::memcpy(&rf[15], &prob, 4); std::memcpy(&rf[19], &mat_vnum, 4); std::memcpy(&rf[23], &mat_count, 4); GCItemSet after{}; after.header = HDR_GC_ITEM_SET; after.cell = {1, 11}; after.vnum = 11210; after.count = 1; std::vector ab = raw(after); rf.insert(rf.end(), ab.begin(), ab.end()); feed(s, rf); auto cues = s.world().drain_refine_cues(); CHECK(cues.size() == 1 && cues[0].type == 0 && cues[0].pos == 6 && cues[0].src_vnum == 11209 && cues[0].result_vnum == 11210 && cues[0].material_count == 1 && cues[0].cost == 5000 && cues[0].prob == 90 && cues[0].materials[0].vnum == 30053 && cues[0].materials[0].count == 2, "GC 95 refine information uses the 59-byte 40250 layout"); CHECK(s.world().item_slot(1, 11).vnum == 11210, "packet after GC 95 framed correctly"); CHECK(s.last_error().empty(), "no desync after GC 95"); s.world().mut_item_del(1, 11); } // --- GC entity state: UPDATE(19) / CHANGE_SPEED(18) / POSITION(43) / MOTION(36) / // STUN(13) / DEAD(14) / TARGET(63) --- { // re-add the NPC (it was despawned above? no — despawn is later). vid 8888 present. GCCharacterUpdate cu{}; cu.header = HDR_GC_CHARACTER_UPDATE; cu.vid = 8888; cu.parts[0] = 1101; cu.parts[1] = 2205; cu.moving_speed = 180; cu.attack_speed = 140; cu.state_flag = 0x44; cu.affect_flag[0] = 0x0a0b0c0d; cu.affect_flag[1] = 0x10203040; cu.guild_id = 77; cu.alignment = -50; cu.pk_mode = 1; cu.mount_vnum = 20101; feed(s, raw(cu)); const auto *npc = s.world().get(8888); CHECK(npc && npc->parts[0] == 1101 && npc->moving_speed == 180 && npc->guild == 77 && npc->alignment == -50 && npc->pk_mode == 1 && npc->mount_vnum == 20101 && npc->state_flags == 0x44 && npc->affect_flags == 0x102030400a0b0c0dULL, "char_update applied"); GCChangeSpeed cs{HDR_GC_CHANGE_SPEED, 8888, 250}; feed(s, raw(cs)); CHECK(s.world().get(8888)->moving_speed == 250, "change_speed -> 250"); GCCharacterPosition cp{HDR_GC_CHARACTER_POSITION, 8888, 4}; feed(s, raw(cp)); CHECK(s.world().get(8888)->position == 4, "position -> 4"); GCMotion gm{HDR_GC_MOTION, 8888, 7777, 42}; feed(s, raw(gm)); auto ms = s.world().drain_motions(); CHECK(ms.size() == 1 && ms[0].vid == 8888 && ms[0].victim_vid == 7777 && ms[0].motion == 42, "motion queued"); GCTarget gt{HDR_GC_TARGET, 8888, 65}; feed(s, raw(gt)); CHECK(s.world().target_vid() == 8888 && s.world().target_hp_pct() == 65, "target hp 65%"); GCStun st{HDR_GC_STUN, 8888}; feed(s, raw(st)); CHECK(s.world().get(8888)->stunned, "stun"); GCDead gd2{HDR_GC_DEAD, 8888}; feed(s, raw(gd2)); CHECK(s.world().get(8888)->dead && s.world().get(8888)->hp == 0, "dead + hp 0"); } // --- GC_SKILL_LEVEL(76) / QUICKSLOT(28/29/30) / AFFECT(126/127) / DAMAGE(135) / WHISPER(34) --- { GCSkillLevel sk{}; sk.header = HDR_GC_SKILL_LEVEL; sk.skills[1] = {1, 17}; // skill 1: master_type 1, level 17 sk.skills[50] = {0, 40}; // skill 50: normal, level 40 feed(s, raw(sk)); CHECK(s.world().skill_level(1) == 17 && s.world().skill_master(1) == 1, "skill 1"); CHECK(s.world().skill_level(50) == 40, "skill 50"); GCQuickSlotAdd qa{}; qa.header = HDR_GC_QUICKSLOT_ADD; qa.pos = 3; qa.slot = {2, 7}; // type=skill, ref=7 feed(s, raw(qa)); CHECK(s.world().quickslot(3).type == 2 && s.world().quickslot(3).position == 7, "quickslot 3"); GCQuickSlotSwap qs2{HDR_GC_QUICKSLOT_SWAP, 3, 8}; feed(s, raw(qs2)); CHECK(s.world().quickslot(8).type == 2 && s.world().quickslot(3).type == 0, "quickslot swap"); GCQuickSlotDel qd{HDR_GC_QUICKSLOT_DEL, 8}; feed(s, raw(qd)); CHECK(s.world().quickslot(8).type == 0, "quickslot del"); GCAffectAdd aa{}; aa.header = HDR_GC_AFFECT_ADD; aa.elem = {90001, 16 /*ATT_GRADE*/, 50, 0, 300}; feed(s, raw(aa)); bool found = false; for (const auto &a : s.world().affects()) { if (a.type == 90001 && a.value == 50 && a.duration == 300) { found = true; } } CHECK(found, "affect_add landed"); GCAffectRemove ar{HDR_GC_AFFECT_REMOVE, 90001, 0}; feed(s, raw(ar)); CHECK(s.world().affects().empty(), "affect_remove cleared"); GCDamageInfo di{HDR_GC_DAMAGE_INFO, 7777, 0x1, 321}; feed(s, raw(di)); auto ds = s.world().drain_damage(); CHECK(ds.size() == 1 && ds[0].vid == 7777 && ds[0].amount == 321, "damage queued"); // 40250 RecvChatPacket ignores invalid types and non-system messages // whose chatter VID is not in the character manager. auto feed_chat = [&](uint8_t type, uint32_t vid, const char *text) { GCChatHead h{}; h.header = HDR_GC_CHAT; h.type = type; h.vid = vid; h.empire = 1; h.size = static_cast(sizeof(h) + std::strlen(text)); std::vector pkt = raw(h); pkt.insert(pkt.end(), text, text + std::strlen(text)); feed(s, pkt); }; feed_chat(mtnet::CHAT_TYPE_TALKING, 0xDEADBEEF, "unknown chatter"); feed_chat(static_cast(mtnet::CHAT_TYPE_BIG_NOTICE + 1), 7777, "invalid type"); CHECK(s.world().drain_chat().empty(), "unknown/invalid GC_CHAT has no event"); feed_chat(mtnet::CHAT_TYPE_TALKING, 7777, "known chatter"); auto known_chat = s.world().drain_chat(); CHECK(known_chat.size() == 1 && known_chat[0].vid == 7777 && known_chat[0].text == "known chatter", "known GC_CHAT remains visible"); // GC_WHISPER: [hdr][u16 size][type][name_from[25]][text] const char *msg = "hey there"; GCWhisperHead wh{}; wh.header = HDR_GC_WHISPER; wh.type = 0; std::strcpy(wh.name_from, "Gm"); wh.size = (uint16_t)(sizeof(GCWhisperHead) + std::strlen(msg)); std::vector wp = raw(wh); wp.insert(wp.end(), msg, msg + std::strlen(msg)); feed(s, wp); auto cm = s.world().drain_chat(); CHECK(cm.size() == 1 && cm[0].type == mtnet::CHAT_TYPE_WHISPER && cm[0].from == "Gm" && cm[0].text == "hey there", "whisper -> chat queue"); // RecvWhisperPacket cannot accept a body shorter than its fixed type + // sender-name fields. The parser must reject it instead of silently // claiming success and dropping a malformed server frame. const uint8_t short_whisper[] = {0}; CHECK(!s.parser().on_gc(HDR_GC_WHISPER, short_whisper, sizeof(short_whisper)), "truncated whisper fixed fields are rejected"); } // --- GC party (77..92) --- { GCPartyParameter pp{HDR_GC_PARTY_PARAMETER, 1}; feed(s, raw(pp)); CHECK(s.world().party_distribute_mode() == 1, "party distribute mode 1"); GCPartyAdd pa{}; pa.header = HDR_GC_PARTY_ADD; pa.pid = 5001; std::strcpy(pa.name, "Mate"); feed(s, raw(pa)); const auto *pm = s.world().party_member(5001); CHECK(pm && pm->name == "Mate", "party_add"); GCPartyUpdate pu{}; pu.header = HDR_GC_PARTY_UPDATE; pu.pid = 5001; pu.role = 1; // leader bit pu.percent_hp = 80; pu.affects[0] = 3; feed(s, raw(pu)); pm = s.world().party_member(5001); CHECK(pm && pm->leader() && pm->hp_pct == 80 && pm->affects[0] == 3, "party_update"); GCPartyLink pl{HDR_GC_PARTY_LINK, 5001, 8888}; feed(s, raw(pl)); CHECK(s.world().party_member(5001)->vid == 8888, "party_link -> vid"); GCPartyUnlink pul{HDR_GC_PARTY_UNLINK, 5001, 8888}; feed(s, raw(pul)); CHECK(s.world().party_member(5001)->vid == 0, "party_unlink"); GCPartyInvite pi{HDR_GC_PARTY_INVITE, 9999}; feed(s, raw(pi)); // (drained by pump_classic in the real client; here just verify no crash + member intact) CHECK(s.world().party_member(5001) != nullptr, "party still intact after invite"); GCPartyRemove pr{HDR_GC_PARTY_REMOVE, 5001}; feed(s, raw(pr)); CHECK(s.world().party_member(5001) == nullptr, "party_remove"); } // --- GC_SHOP(38, dynamic): START -> items, END -> closed, error sub --- { // [subheader=START][u32 vid][ShopItem43 x 40], only first 2 non-empty std::vector sp; sp.push_back(SHOP_SUB_START); uint32_t svid = 4242; sp.insert(sp.end(), (uint8_t *)&svid, (uint8_t *)&svid + 4); ShopItem43 items[SHOP_HOST_ITEM_MAX_NUM]{}; items[0].vnum = 27001; items[0].price = 5000; items[0].count = 1; items[0].display_pos = 0; items[2].vnum = 10; items[2].price = 20; items[2].count = 200; items[2].display_pos = 2; sp.insert(sp.end(), (uint8_t *)items, (uint8_t *)items + sizeof(items)); // wrap as GC_SHOP dynamic packet: [hdr 38][u16 size][body] uint16_t sz = (uint16_t)(3 + sp.size()); std::vector pkt; pkt.push_back(HDR_GC_SHOP); pkt.insert(pkt.end(), (uint8_t *)&sz, (uint8_t *)&sz + 2); pkt.insert(pkt.end(), sp.begin(), sp.end()); feed(s, pkt); CHECK(s.world().shop_open() && s.world().shop_vid() == 4242, "shop opened"); const auto &si = s.world().shop_items(); CHECK(si.size() == 2 && si[0].vnum == 27001 && si[0].price == 5000 && si[1].pos == 2, "shop items (empties skipped, display_pos kept)"); // error sub-header std::vector ep = {HDR_GC_SHOP, 4, 0, SHOP_SUB_SOLDOUT}; feed(s, ep); auto errs = s.world().drain_shop_errors(); CHECK(errs.size() == 1 && errs[0] == "SOLDOUT", "shop error SOLDOUT"); std::vector ce = {HDR_GC_SHOP, 4, 0, SHOP_SUB_END}; feed(s, ce); CHECK(!s.world().shop_open() && s.world().shop_items().empty(), "shop closed"); } // --- GC_SCRIPT(45) / GC_QUEST_CONFIRM(46) / GC_QUEST_INFO(81) --- { // GC_SCRIPT dynamic: [hdr 45][u16 size][skin][u16 src_size][text] const char *dlg = "Hello adventurer!"; std::vector body; body.push_back(2); // skin body.push_back(0); body.push_back(0); // src_size body.insert(body.end(), dlg, dlg + std::strlen(dlg)); uint16_t sz = (uint16_t)(3 + body.size()); std::vector pkt = {HDR_GC_SCRIPT}; pkt.insert(pkt.end(), (uint8_t *)&sz, (uint8_t *)&sz + 2); pkt.insert(pkt.end(), body.begin(), body.end()); feed(s, pkt); auto sc = s.world().drain_scripts(); CHECK(sc.size() == 1 && sc[0].skin == 2 && sc[0].text == "Hello adventurer!", "script cue"); GCQuestConfirm qc{}; qc.header = HDR_GC_QUEST_CONFIRM; std::strcpy(qc.msg, "Accept quest?"); qc.timeout = 30; qc.request_pid = 555; feed(s, raw(qc)); auto cf = s.world().drain_confirms(); CHECK(cf.size() == 1 && cf[0].msg == "Accept quest?" && cf[0].timeout == 30 && cf[0].request_pid == 555, "quest confirm cue"); // GC_QUEST_INFO (questpc.cpp PC::SendQuestInfoPakcet): [hdr 81][u16 size][u16 index] // [u8 flag] then fixed-width optional fields (title 31, counter name 17, i32). The // head is written before size is bumped, so the wire size stays 6. std::vector qpkt(QUEST_INFO_HEAD_SIZE, 0); qpkt[0] = HDR_GC_QUEST_INFO; uint16_t qsz = QUEST_INFO_HEAD_SIZE; std::memcpy(&qpkt[1], &qsz, 2); uint16_t qidx = 3; std::memcpy(&qpkt[3], &qidx, 2); qpkt[5] = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE; std::vector qt(QUEST_INFO_TITLE_SIZE, 0); std::memcpy(qt.data(), "Kill 10 wolves", 14); qpkt.insert(qpkt.end(), qt.begin(), qt.end()); std::vector cn(QUEST_INFO_COUNTER_NAME_SIZE, 0); std::memcpy(cn.data(), "Wolves", 6); qpkt.insert(qpkt.end(), cn.begin(), cn.end()); int32_t cv = 4; qpkt.insert(qpkt.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4); CHECK(qpkt.size() == static_cast(quest_info_packet_size(qpkt[5])), "quest info test packet matches 40250 flag-driven length"); feed(s, qpkt); const mtnet::QuestInfo *q = s.world().quest(3); CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" && q->counter_value == 4, "quest info parsed (flag-driven)"); // 40250 RecvQuestInfoPacket interprets the byte after IS_BEGIN as a // boolean. false is QUEST_PACKET_TYPE_END and removes the instance; // it is not an update that leaves a begin=false row in the quest log. std::vector qend(QUEST_INFO_HEAD_SIZE + QUEST_INFO_IS_BEGIN_SIZE, 0); qend[0] = HDR_GC_QUEST_INFO; std::memcpy(&qend[1], &qsz, 2); // reference wire head keeps size == 6 std::memcpy(&qend[3], &qidx, 2); qend[5] = QUEST_SEND_IS_BEGIN; qend[6] = 0; feed(s, qend); CHECK(s.world().quest(3) == nullptr, "classic quest end deletes quest instance"); auto end_changes = s.world().drain_quest_changes(); CHECK(end_changes.size() == 2 && end_changes.back() == 3, "classic quest end emits removal refresh after update"); } // --- GC_DUEL_START dynamic: [hdr 40][u16 whole_size][opponent VIDs] --- { uint32_t opponent_vids[] = {41001, 41002}; uint16_t duel_size = (uint16_t)(sizeof(GCDuelStart) + sizeof(opponent_vids)); std::vector duel_pkt = {HDR_GC_DUEL_START}; duel_pkt.insert(duel_pkt.end(), (uint8_t *)&duel_size, (uint8_t *)&duel_size + 2); duel_pkt.insert(duel_pkt.end(), (uint8_t *)opponent_vids, (uint8_t *)opponent_vids + sizeof(opponent_vids)); feed(s, duel_pkt); const auto &opponents = s.world().duel_opponents(); CHECK(opponents.size() == 2 && opponents[0] == 41001 && opponents[1] == 41002 && !s.world().duel_cannot_attack(), "duel start opponent list"); CHECK(s.world().take_duel_started(), "duel start event"); uint16_t empty_duel_size = sizeof(GCDuelStart); std::vector empty_duel = {HDR_GC_DUEL_START}; empty_duel.insert(empty_duel.end(), (uint8_t *)&empty_duel_size, (uint8_t *)&empty_duel_size + 2); feed(s, empty_duel); CHECK(s.world().duel_opponents().empty() && s.world().duel_cannot_attack(), "empty duel start cannot-attack state"); } // --- P8 messenger (74, dynamic): complete list + login/logout deltas --- { std::vector body = {MESSENGER_GC_LIST, 1, 5}; body.insert(body.end(), {'A', 'l', 'i', 'c', 'e'}); body.push_back(0); body.push_back(3); body.insert(body.end(), {'B', 'o', 'b'}); GCMessengerHead h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + body.size() - 1), body[0]}; std::vector pkt = raw(h); pkt.insert(pkt.end(), body.begin() + 1, body.end()); feed(s, pkt); auto fs = s.world().friends(); CHECK(fs.size() == 2, "messenger list friend count"); bool alice = false, bob = false; for (const auto &f : fs) { alice = alice || (f.name == "Alice" && f.online); bob = bob || (f.name == "Bob" && !f.online); } CHECK(alice && bob, "messenger list online state"); std::vector login_body = {MESSENGER_GC_LOGIN, 3, 'B', 'o', 'b'}; GCMessengerHead login_h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + login_body.size() - 1), login_body[0]}; std::vector login_pkt = raw(login_h); login_pkt.insert(login_pkt.end(), login_body.begin() + 1, login_body.end()); feed(s, login_pkt); fs = s.world().friends(); bool bob_online = false; for (const auto &f : fs) { bob_online = bob_online || (f.name == "Bob" && f.online); } CHECK(fs.size() == 2 && bob_online, "messenger login update"); std::vector logout_body = {MESSENGER_GC_LOGOUT, 5, 'A', 'l', 'i', 'c', 'e'}; GCMessengerHead logout_h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + logout_body.size() - 1), logout_body[0]}; std::vector logout_pkt = raw(logout_h); logout_pkt.insert(logout_pkt.end(), logout_body.begin() + 1, logout_body.end()); feed(s, logout_pkt); fs = s.world().friends(); bool alice_offline = false; for (const auto &f : fs) { alice_offline = alice_offline || (f.name == "Alice" && !f.online); } CHECK(fs.size() == 2 && alice_offline, "messenger logout update"); std::vector mobile_body = {MESSENGER_GC_MOBILE, 1, 5, 'A', 'l', 'i', 'c', 'e'}; GCMessengerHead mobile_h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + mobile_body.size() - 1), mobile_body[0]}; std::vector mobile_pkt = raw(mobile_h); mobile_pkt.insert(mobile_pkt.end(), mobile_body.begin() + 1, mobile_body.end()); feed(s, mobile_pkt); bool alice_mobile = false; for (const auto &f : s.world().friends()) { alice_mobile = alice_mobile || (f.name == "Alice" && f.mobile); } CHECK(alice_mobile, "messenger mobile state"); } // --- P8 exchange (42, static) ------------------------------------------- { GCExchange ex{}; ex.header = HDR_GC_EXCHANGE; ex.subheader = EXCHANGE_GC_START; ex.arg1 = 31337; feed(s, raw(ex)); CHECK(s.world().exchange().active && s.world().exchange().partner_vid == 31337, "exchange start"); ex = GCExchange{}; ex.header = HDR_GC_EXCHANGE; ex.subheader = EXCHANGE_GC_ITEM_ADD; ex.is_me = 1; ex.arg1 = 27001; ex.arg2 = {0, 4}; ex.arg3 = 7; ex.sockets[1] = 9002; ex.attrs[2] = {17, -33}; feed(s, raw(ex)); CHECK(s.world().exchange().self_items[4].vnum == 27001 && s.world().exchange().self_items[4].count == 7 && s.world().exchange().self_items[4].sockets[1] == 9002 && s.world().exchange().self_items[4].attrs[2].type == 17 && s.world().exchange().self_items[4].attrs[2].value == -33, "exchange item add keeps sockets/attrs"); ex.subheader = EXCHANGE_GC_GOLD_ADD; ex.arg1 = 123456; feed(s, raw(ex)); CHECK(s.world().exchange().self_gold == 123456, "exchange gold"); ex.subheader = EXCHANGE_GC_ACCEPT; ex.arg1 = 1; feed(s, raw(ex)); CHECK(s.world().exchange().self_accept, "exchange accept"); ex.subheader = EXCHANGE_GC_END; feed(s, raw(ex)); CHECK(!s.world().exchange().active, "exchange end"); } // --- Guild dynamic packets: members, presence, comments, invite, score -- { GuildMember38 member{}; member.pid = 7001; member.grade = 1; member.is_general = 1; member.job = 2; member.level = 55; member.offer = 1234; member.name_flag = 1; std::strcpy(member.name, "Guildmate"); std::vector body = {mtnet::GUILD_GC_LIST}; auto member_bytes = raw(member); body.insert(body.end(), member_bytes.begin(), member_bytes.end()); GCMessengerHead gh{HDR_GC_GUILD, static_cast(sizeof(GCMessengerHead) + body.size() - 1), body[0]}; std::vector guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), body.begin() + 1, body.end()); feed(s, guild_pkt); auto members = s.world().guild_members(); CHECK(members.size() == 1 && members[0].pid == 7001 && members[0].name == "Guildmate" && members[0].is_general, "guild member list"); std::vector login_body = {mtnet::GUILD_GC_LOGIN, 0, 0, 0, 0}; std::memcpy(login_body.data() + 1, &member.pid, sizeof(member.pid)); gh.size = static_cast(sizeof(GCMessengerHead) + login_body.size() - 1); gh.subheader = login_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), login_body.begin() + 1, login_body.end()); feed(s, guild_pkt); members = s.world().guild_members(); CHECK(members.size() == 1 && members[0].online, "guild member login"); GuildComment80 comment{}; comment.id = 17; std::strcpy(comment.name, "Guildmate"); std::strcpy(comment.content, "Welcome to the guild"); std::vector comments_body = {mtnet::GUILD_GC_COMMENTS, 1}; auto comment_bytes = raw(comment); comments_body.insert(comments_body.end(), comment_bytes.begin(), comment_bytes.end()); gh.size = static_cast(sizeof(GCMessengerHead) + comments_body.size() - 1); gh.subheader = comments_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), comments_body.begin() + 1, comments_body.end()); feed(s, guild_pkt); const auto &comments = s.world().guild_comments(); CHECK(comments.size() == 1 && comments[0].id == 17 && comments[0].content == "Welcome to the guild", "guild comments"); GuildInvite17 invite{9001, "Wolves"}; std::vector invite_body = {mtnet::GUILD_GC_GUILD_INVITE}; auto invite_bytes = raw(invite); invite_body.insert(invite_body.end(), invite_bytes.begin(), invite_bytes.end()); gh.size = static_cast(sizeof(GCMessengerHead) + invite_body.size() - 1); gh.subheader = invite_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), invite_body.begin() + 1, invite_body.end()); feed(s, guild_pkt); auto invites = s.world().drain_guild_invites(); CHECK(invites.size() == 1 && invites[0].guild_id == 9001 && invites[0].guild_name == "Wolves", "guild invite"); GuildWarPoint12 score{77, 88, 12}; std::vector score_body = {mtnet::GUILD_GC_WAR_POINT}; auto score_bytes = raw(score); score_body.insert(score_body.end(), score_bytes.begin(), score_bytes.end()); gh.size = static_cast(sizeof(GCMessengerHead) + score_body.size() - 1); gh.subheader = score_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), score_body.begin() + 1, score_body.end()); feed(s, guild_pkt); auto scores = s.world().drain_guild_war_scores(); CHECK(scores.size() == 1 && scores[0].gain_guild_id == 77 && scores[0].point == 12, "guild war score"); } // --- P8 safebox / mall fixed packets ------------------------------------ { GCSafeboxSize sb_size{HDR_GC_SAFEBOX_SIZE, 2}; feed(s, raw(sb_size)); GCSafeboxMoneyChange sb_money{HDR_GC_SAFEBOX_MONEY_CHANGE, 987654}; feed(s, raw(sb_money)); GCItemSet sb_item{}; sb_item.header = HDR_GC_SAFEBOX_SET; sb_item.cell = {3, 8}; sb_item.vnum = 50001; sb_item.count = 9; sb_item.sockets[0] = 60001; sb_item.attrs[0] = {5, 30}; feed(s, raw(sb_item)); CHECK(s.world().safebox_open() && s.world().safebox_size() == 2 && s.world().safebox_gold() == 987654, "safebox open/money"); CHECK(s.world().safebox_slot(8).vnum == 50001 && s.world().safebox_slot(8).count == 9 && s.world().safebox_slot(8).sockets[0] == 60001 && s.world().safebox_slot(8).attrs[0].value == 30, "safebox item set"); GCItemDel sb_del{HDR_GC_SAFEBOX_DEL, 8}; feed(s, raw(sb_del)); CHECK(s.world().safebox_slot(8).empty(), "safebox item del"); GCMallOpen mall_open{HDR_GC_MALL_OPEN, 1}; feed(s, raw(mall_open)); GCItemSet mall_item{}; mall_item.header = HDR_GC_MALL_SET; mall_item.cell = {4, 3}; mall_item.vnum = 70001; mall_item.count = 2; feed(s, raw(mall_item)); CHECK(s.world().mall_open() && s.world().mall_size() == 1 && s.world().mall_slot(3).vnum == 70001, "mall open/item set"); GCItemDel mall_del{HDR_GC_MALL_DEL, 3}; feed(s, raw(mall_del)); CHECK(s.world().mall_slot(3).empty(), "mall item del"); } // --- P8 outbound intents: fixed packets carry one trailing sequence byte --- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_friend_add("Alice"), "send_friend_add"); auto out = drain(s); CHECK(out.size() == sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN + 1 && out[0] == HDR_CG_MESSENGER && out[1] == MESSENGER_CG_ADD_BY_NAME && std::memcmp(out.data() + sizeof(CGMessenger), "Alice", 5) == 0 && out.back() == SEQUENCE_TABLE[seq], "friend add wire + sequence"); ++seq; CHECK(s.send_friend_remove("Alice"), "send_friend_remove"); out = drain(s); CHECK(out.size() == sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN + 1 && out[1] == MESSENGER_CG_REMOVE && out.back() == SEQUENCE_TABLE[seq], "friend remove wire + sequence"); ++seq; CHECK(s.send_exchange_start(31337), "send_exchange_start"); out = drain(s); CGExchange cg_ex{}; std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(out.size() == sizeof(CGExchange) + 1 && cg_ex.subheader == EXCHANGE_CG_START && cg_ex.arg1 == 31337 && out.back() == SEQUENCE_TABLE[seq], "exchange start wire + sequence"); ++seq; CHECK(s.send_exchange_item_add(1, 12, 4), "send_exchange_item_add"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_ITEM_ADD && cg_ex.arg2 == 4 && cg_ex.pos.window_type == 1 && cg_ex.pos.cell == 12 && out.back() == SEQUENCE_TABLE[seq], "exchange item wire"); ++seq; CHECK(s.send_exchange_gold(900), "send_exchange_gold"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_GOLD_ADD && cg_ex.arg1 == 900 && out.back() == SEQUENCE_TABLE[seq], "exchange gold wire"); ++seq; CHECK(s.send_exchange_accept(), "send_exchange_accept"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_ACCEPT && out.back() == SEQUENCE_TABLE[seq], "exchange accept wire"); ++seq; CHECK(s.send_exchange_cancel(), "send_exchange_cancel"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_CANCEL && out.back() == SEQUENCE_TABLE[seq], "exchange cancel wire"); ++seq; CHECK(s.send_safebox_checkin(8, 1, 12), "send_safebox_checkin"); out = drain(s); CGSafeboxCheckin checkin{}; std::memcpy(&checkin, out.data(), sizeof(checkin)); CHECK(checkin.header == HDR_CG_SAFEBOX_CHECKIN && checkin.safe_pos == 8 && checkin.item_pos.window_type == 1 && checkin.item_pos.cell == 12 && out.back() == SEQUENCE_TABLE[seq], "safebox checkin wire"); ++seq; CHECK(s.send_safebox_checkout(8, 1, 12), "send_safebox_checkout"); out = drain(s); CHECK(out.size() == sizeof(CGSafeboxCheckout) + 1 && out[0] == HDR_CG_SAFEBOX_CHECKOUT && out.back() == SEQUENCE_TABLE[seq], "safebox checkout wire"); ++seq; CHECK(s.send_safebox_move(8, 9, 2), "send_safebox_move"); out = drain(s); CGItemMove move{}; std::memcpy(&move, out.data(), sizeof(move)); CHECK(move.header == HDR_CG_SAFEBOX_ITEM_MOVE && move.pos.window_type == 1 && move.pos.cell == 8 && move.change_pos.cell == 9 && move.num == 2 && out.back() == SEQUENCE_TABLE[seq], "safebox move wire"); ++seq; CHECK(s.send_mall_checkout(3, 1, 22), "send_mall_checkout"); out = drain(s); CGMallCheckout mall{}; std::memcpy(&mall, out.data(), sizeof(mall)); CHECK(mall.header == HDR_CG_MALL_CHECKOUT && mall.mall_pos == 3 && mall.item_pos.window_type == 1 && mall.item_pos.cell == 22 && out.back() == SEQUENCE_TABLE[seq], "mall checkout wire"); CHECK(s.stream().sequence_index() == seq + 1, "P8 sequence index advanced"); } // --- 40250 command-backed cube intents ------------------------------- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_cube_open(), "cube open"); auto out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 10 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube open", 10) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube open command wire"); ++seq; CHECK(s.send_cube_add_item(2, 17), "cube add"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 14 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube add 2 17", 14) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube add command wire"); ++seq; CHECK(s.send_cube_delete_item(2), "cube delete"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 14 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube delete 2", 14) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube delete command wire"); ++seq; CHECK(s.send_cube_list(), "cube list"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 10 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube list", 10) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube list command wire"); ++seq; CHECK(s.send_cube_close(), "cube close"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 11 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube close", 11) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube close command wire"); CHECK(!s.send_cube_add_item(24, 17), "cube index bounds"); } // --- Guild comment/invite answer intents ------------------------------- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_guild_comment("hello"), "send guild comment"); auto out = drain(s); CHECK(out.size() == sizeof(CGGuild) + 1 + 5 + 1 + 1 && out[0] == HDR_CG_GUILD && out[1] == GUILD_CG_POST_COMMENT && out[2] == 6 && std::memcmp(out.data() + sizeof(CGGuild) + 1, "hello", 5) == 0 && out[sizeof(CGGuild) + 1 + 5] == 0 && out.back() == SEQUENCE_TABLE[seq], "guild comment wire"); ++seq; CHECK(s.send_guild_grade_name(2, "Officer"), "send guild grade name"); out = drain(s); CHECK(out.size() == sizeof(CGGuild) + 1 + 9 + 1 && out[1] == GUILD_CG_CHANGE_GRADE_NAME && out[2] == 2 && std::memcmp(out.data() + sizeof(CGGuild) + 1, "Officer", 7) == 0 && out[sizeof(CGGuild) + 1 + 7] == 0 && out.back() == SEQUENCE_TABLE[seq], "guild grade name wire"); ++seq; CHECK(s.send_guild_grade_authority(2, 7), "send guild grade authority"); out = drain(s); CHECK(out.size() == sizeof(CGGuild) + 2 + 1 && out[1] == GUILD_CG_CHANGE_GRADE_AUTHORITY && out[2] == 2 && out[3] == 7 && out.back() == SEQUENCE_TABLE[seq], "guild grade authority wire"); ++seq; CHECK(s.send_guild_member_grade(1234, 3), "send guild member grade"); out = drain(s); uint32_t pid = 0; std::memcpy(&pid, out.data() + sizeof(CGGuild), sizeof(pid)); CHECK(out.size() == sizeof(CGGuild) + 4 + 1 + 1 && out[1] == GUILD_CG_CHANGE_MEMBER_GRADE && pid == 1234 && out[sizeof(CGGuild) + 4] == 3 && out.back() == SEQUENCE_TABLE[seq], "guild member grade wire"); ++seq; CHECK(s.send_guild_member_general(1234, true), "send guild member general"); out = drain(s); std::memcpy(&pid, out.data() + sizeof(CGGuild), sizeof(pid)); CHECK(out.size() == sizeof(CGGuild) + 4 + 1 + 1 && out[1] == GUILD_CG_CHANGE_MEMBER_GENERAL && pid == 1234 && out[sizeof(CGGuild) + 4] == 1 && out.back() == SEQUENCE_TABLE[seq], "guild member general wire"); ++seq; CHECK(s.send_guild_invite_answer(9001, true), "send guild invite answer"); out = drain(s); uint32_t gid = 0; std::memcpy(&gid, out.data() + sizeof(CGGuild), sizeof(gid)); CHECK(out.size() == sizeof(CGGuild) + 4 + 1 + 1 && out[1] == GUILD_CG_GUILD_INVITE_ANSWER && gid == 9001 && out[sizeof(CGGuild) + 4] == 1 && out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire"); } // --- CG_USE_SKILL: PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket sends // only the use-skill packet; fly-targeting is a separate caller decision --- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_fly_targeting(777, 321, 654), "primary fly targeting send"); auto fly_out = drain(s); CGFlyTargeting fly_packet{}; std::memcpy(&fly_packet, fly_out.data(), sizeof(fly_packet)); CHECK(fly_out.size() == sizeof(CGFlyTargeting) + 1 && fly_packet.header == HDR_CG_FLY_TARGETING && fly_packet.target_vid == 777 && fly_packet.x == 321 && fly_packet.y == 654 && fly_out.back() == SEQUENCE_TABLE[seq], "primary fly targeting wire + sequence"); ++seq; CHECK(s.send_add_fly_targeting(778, 322, 655), "extra fly targeting send"); auto add_fly_out = drain(s); CGFlyTargeting add_fly_packet{}; std::memcpy(&add_fly_packet, add_fly_out.data(), sizeof(add_fly_packet)); CHECK(add_fly_out.size() == sizeof(CGFlyTargeting) + 1 && add_fly_packet.header == HDR_CG_ADD_FLY_TARGETING && add_fly_packet.target_vid == 778 && add_fly_out.back() == SEQUENCE_TABLE[seq], "extra fly targeting keeps separate header + sequence"); ++seq; CHECK(s.send_use_skill(3, 8888), "use skill send"); auto out = drain(s); CHECK(out.size() == sizeof(CGUseSkill) + 1 && out[0] == HDR_CG_USE_SKILL && out.back() == SEQUENCE_TABLE[seq], "use skill wire: no extra fly targeting"); } // --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD // is dropped and leaves the live entity untouched (§2.2) --- { const auto *before = s.world().get(8888); CHECK(before != nullptr, "8888 present before the drop test"); const std::string name0 = before->name; const int32_t level0 = before->level; const uint16_t part0 = before->parts[0]; GCCharAddInfo ci{}; ci.header = HDR_GC_CHAR_ADDITIONAL_INFO; ci.vid = 8888; std::strcpy(ci.name, "Goblin"); ci.parts[0] = 3; ci.level = 12; feed(s, raw(ci)); const auto *e = s.world().get(8888); CHECK(e && e->name == name0 && e->level == level0 && e->parts[0] == part0, "additional-info without a pending add is a no-op"); CHECK(s.parser().pending_actor_count() == 0, "no stray pending record created"); } // --- GC_SYNC_POSITION(5, dynamic): snap entities --- { struct { uint8_t hdr; uint16_t size; } __attribute__((packed)) sph{HDR_GC_SYNC_POSITION, 0}; SyncPosElement el{8888, 999000, 888000}; sph.size = (uint16_t)(sizeof(sph) + sizeof(el)); std::vector sp(sizeof(sph)); std::memcpy(sp.data(), &sph, sizeof(sph)); auto eb = raw(el); sp.insert(sp.end(), eb.begin(), eb.end()); feed(s, sp); const auto *e = s.world().get(8888); CHECK(e && e->x == 999000.f && e->y == 888000.f && !e->moving, "sync_position snapped"); } // --- GC_ITEM_GROUND_ADD(26) / DEL(27) --- { GCItemGroundAdd ga{}; ga.header = HDR_GC_ITEM_GROUND_ADD; ga.vid = 55555; ga.vnum = 1; ga.x = 100200; ga.y = 200300; ga.z = 4; feed(s, raw(ga)); CHECK(s.world().ground(55555) != nullptr, "ground item added"); GCItemGroundDel gd{HDR_GC_ITEM_GROUND_DEL, 55555}; feed(s, raw(gd)); CHECK(s.world().ground(55555) == nullptr, "ground item removed"); } // --- GC_CHARACTER_DEL(2) --- { GCCharacterDel del{HDR_GC_CHARACTER_DEL, 8888}; feed(s, raw(del)); CHECK(s.world().get(8888) == nullptr, "npc despawned"); CHECK(s.world().size() == 1, "1 entity left"); } // --- intents are rejected outside PHASE_GAME --- { ClassicSession s2; CHECK(!s2.send_move(1, 0, 0, 0, 0), "send_move rejected before InGame"); CHECK(!s2.send_chat(0, "hi"), "send_chat rejected before InGame"); } CHECK(s.last_error().empty(), "no error over the whole flow"); if (g_fail) { std::fprintf(stderr, "%d check(s) failed\n", g_fail); return 1; } std::puts("net_classic_session_test OK"); return 0; }