全量 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>
3194 lines
131 KiB
C++
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;
|
|
}
|