Files
mtgodot-poc/extension/tests/net_classic_session_test.cpp
T
shenleiandClaude Opus 5 e926759ff5 test(net): 去掉 net 测试的 flaky,顺带把整套 ctest 从 78.7s 降到 8.7s
全量 ctest 连续两次各挂一个 net 测试,且挂的不是同一个,压测后定位到
四个互相独立的原因,全部在测试自身,产品代码未改动。

1. accept_loopback(): poll() 的 `<= 0` 把 EINTR 当成对端从未连接,且单片
   500ms 超时在有负载时不够。50 轮压测里 4 次失败全部源于此。改为 EINTR
   重试 + 5s 截止时间(listener 本就非阻塞,超时后 accept 不会挂住)。
2. :570 的 pump 循环条件抄自上面的死端口用例,但此处 connect 会成功,
   stage 先经过 LoggingIn,循环提前一步退出。
3. 同文件 5 处手写 pump 循环只给 10-20ms 等真实 loopback socket。新增
   pump_until(session, want, max_ms=2000) 统一替换,等的是断言真正关心
   的那个状态。
4. net_loopback_test 的 10 处 run(..., N, []{return false;}) 是固定空转
   而非等待条件。服务端批量收包改用该批最后一个包的标志作条件(TCP 有序),
   客户端事件用本地累加器在谓词里 drain。

提速:两处 this-*-host-must-not-resolve.invalid 换成带空标签的名字。本机
解析器不按 RFC 6761 本地拒绝 .invalid,每次发往上游等满 30s 超时;空标签
让 getaddrinfo 在语法层返回同一个 EAI_NONAME,实测 <10ms。
net_classic_session_test 61.2s -> 3.5s;loopback 改条件退出后 0.94s。

验证:net_classic_session_test 100/100(修复前 50 轮挂 4 轮);
net_loopback_test 100/100(修复前单独跑第 7 轮即挂);全量 ctest 27/27;
port_map.py check 0 error;8 个 pack key 值均未入库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 10:26:51 +09:00

3194 lines
131 KiB
C++

// 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 <arpa/inet.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
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 <class T>
static std::vector<uint8_t> raw(const T &s) {
std::vector<uint8_t> b(sizeof(T));
std::memcpy(b.data(), &s, sizeof(T));
return b;
}
static void feed(ClassicSession &s, const std::vector<uint8_t> &b) {
s.stream().feed(b.data(), b.size());
}
static std::vector<uint8_t> drain_stream(ClassicStream &stream) {
std::vector<uint8_t> 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<uint8_t> drain(ClassicSession &s) {
return drain_stream(s.stream());
}
// Pump until the session reaches `want`, or give up after `max_ms`.
//
// Every caller below is waiting on a real loopback socket: a nonblocking
// connect completing, a connect being refused, or a peer's FIN arriving. None
// of that is instantaneous, and the old hand-rolled loops gave it 10-20ms,
// which made these checks fail intermittently on a loaded machine. Waiting on
// the state the check is about (rather than on "still Connecting") also avoids
// stopping one step early when the stage passes through an intermediate owner.
static void pump_until(ClassicSession &s, mtnet::INetSession::Stage want, int max_ms = 2000) {
for (int i = 0; i < max_ms && s.stage() != want; ++i) {
s.pump();
::usleep(1000);
}
}
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<const sockaddr *>(&addr), sizeof(addr)) < 0 ||
::listen(fd, 4) < 0) {
::close(fd);
return -1;
}
socklen_t len = sizeof(addr);
if (::getsockname(fd, reinterpret_cast<sockaddr *>(&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) {
// Two things made this the single largest source of flakiness in this suite.
// poll() returns -1/EINTR whenever a signal lands, which the old `<= 0` test
// counted as "the peer never connected"; and one flat 500ms slice is not
// always enough for a loopback connect on a loaded machine. Retry through
// EINTR and give the connect a real deadline instead.
constexpr int deadline_ms = 5000;
constexpr int slice_ms = 250;
for (int waited = 0; waited < deadline_ms;) {
pollfd pfd{listener, POLLIN, 0};
const int rc = ::poll(&pfd, 1, slice_ms);
if (rc > 0) {
accepted = ::accept(listener, nullptr, nullptr);
return accepted >= 0;
}
if (rc < 0) {
if (errno == EINTR) {
continue; // interrupted, not expired
}
return false;
}
waited += slice_ms;
}
return false;
}
int main() {
ClassicSession s;
s.set_now(0);
std::vector<CharSlot> got_list;
bool entered = false;
s.on_char_list = [&](const std::vector<CharSlot> &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<const uint8_t *>(&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<uint8_t> 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<CharSlot> empty_slots;
empty_list.on_char_list = [&](const std::vector<CharSlot> &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");
pump_until(auth_connect_failure, mtnet::INetSession::Stage::Offline);
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.
//
// The unresolvable host has an empty label on purpose. getaddrinfo() rejects
// that on syntax and returns EAI_NONAME without asking a resolver; a ".invalid"
// name is supposed to be refused locally too (RFC 6761) but resolvers here hand
// it upstream and wait out the full 30s timeout, which is where this suite used
// to spend most of its wall clock.
{
ClassicSession auth_immediate_failure;
const bool connected = auth_immediate_failure.connect(
"this-auth-host..must-not-resolve", 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);
}
// Unlike the dead-port fixture above, this connect succeeds, so the
// stage goes Connecting -> LoggingIn first and only reaches Offline
// once the peer's FIN is read.
pump_until(auth_close, mtnet::INetSession::Stage::Offline);
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<uint8_t> 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<std::string> 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<ssize_t>(sizeof(replacement_phase)),
"replacement game fixture sends PHASE_LOGIN before the handoff callback returns");
replacement_phase_sent = sent == static_cast<ssize_t>(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");
// The check above already pinned down "not in the same frame". Here we
// only need a later pump to pick it up, and that needs the replacement
// transport's nonblocking connect to have completed and PHASE_LOGIN to
// have crossed the loopback — a single pump is not guaranteed to see
// both.
pump_until(retry_handoff, mtnet::INetSession::Stage::LoggingIn);
CHECK(retry_handoff.stage() == mtnet::INetSession::Stage::LoggingIn,
"replacement PHASE_LOGIN is processed on a 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<uint8_t> 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<char *>(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<int32_t>(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<int32_t>(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<uint8_t> cf(10, 0);
cf[0] = HDR_GC_CREATE_FAILURE;
GCPlayerCreateFailure dup{HDR_GC_CREATE_FAILURE, 1};
std::vector<uint8_t> 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<uint8_t>{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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> bad_script = {HDR_GC_SCRIPT, 3, 0};
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<int32_t>(0x0100007f); // 127.0.0.1
login.players[0].port = slot_port;
feed(direct, raw(login));
CHECK(direct.char_slots()[0].addr == static_cast<int32_t>(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);
}
pump_until(pre_game_close, mtnet::INetSession::Stage::LoggingIn);
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);
}
pump_until(in_game_close, mtnet::INetSession::Stage::LoggingIn);
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<int32_t>(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);
pump_until(direct_failure, mtnet::INetSession::Stage::LoggingIn);
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", 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<int32_t>(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<uint8_t> 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<uint8_t> glued = del;
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint16_t>(sizeof(h) + std::strlen(text));
std::vector<uint8_t> 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<uint8_t>(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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> qt(QUEST_INFO_TITLE_SIZE, 0);
std::memcpy(qt.data(), "Kill 10 wolves", 14);
qpkt.insert(qpkt.end(), qt.begin(), qt.end());
std::vector<uint8_t> 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<size_t>(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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint16_t>(sizeof(GCMessengerHead) + body.size() - 1), body[0]};
std::vector<uint8_t> 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<uint8_t> login_body = {MESSENGER_GC_LOGIN, 3, 'B', 'o', 'b'};
GCMessengerHead login_h{HDR_GC_MESSENGER,
static_cast<uint16_t>(sizeof(GCMessengerHead) + login_body.size() - 1),
login_body[0]};
std::vector<uint8_t> 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<uint8_t> logout_body = {MESSENGER_GC_LOGOUT, 5, 'A', 'l', 'i', 'c', 'e'};
GCMessengerHead logout_h{HDR_GC_MESSENGER,
static_cast<uint16_t>(sizeof(GCMessengerHead) + logout_body.size() - 1),
logout_body[0]};
std::vector<uint8_t> 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<uint8_t> mobile_body = {MESSENGER_GC_MOBILE, 1, 5,
'A', 'l', 'i', 'c', 'e'};
GCMessengerHead mobile_h{HDR_GC_MESSENGER,
static_cast<uint16_t>(sizeof(GCMessengerHead) + mobile_body.size() - 1),
mobile_body[0]};
std::vector<uint8_t> 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<uint8_t> 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<uint16_t>(sizeof(GCMessengerHead) + body.size() - 1), body[0]};
std::vector<uint8_t> 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<uint8_t> 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<uint16_t>(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<uint8_t> 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<uint16_t>(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<uint8_t> 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<uint16_t>(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<uint8_t> 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<uint16_t>(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<uint8_t> 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;
}