Files
mtgodot-poc/extension/tests/net_classic_session_test.cpp
T

1268 lines
47 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 <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(ClassicSession &s) {
std::vector<uint8_t> out;
uint8_t buf[2048];
for (;;) {
size_t n = s.stream().take_outgoing(buf, sizeof(buf));
if (!n) {
break;
}
out.insert(out.end(), buf, buf + n);
}
return out;
}
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) {
pollfd pfd{listener, POLLIN, 0};
if (::poll(&pfd, 1, 500) <= 0) {
return false;
}
accepted = ::accept(listener, nullptr, nullptr);
return accepted >= 0;
}
int main() {
ClassicSession s;
s.set_now(0);
std::vector<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");
s.set_auto_entergame_delay(1500);
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");
}
// --- GC_LOGIN_SUCCESS_NEWSLOT (header 32, 4 slots; 2 filled) ---
{
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");
}
// --- GC_CHANGE_NAME completes the forced rename state ---
{
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");
}
// --- 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 == "Renamed" && !got_list[0].change_name,
"on_char_list fired with renamed slot");
}
// --- select_char(0) -> CG_CHARACTER_SELECT + seq[1] ---
{
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[1], "seq == table[1]");
}
// --- 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;
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");
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, "CG_CLIENT_VERSION2 + seq");
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[2], "version seq == table[2]");
// --- §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 = 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 = 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 = 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");
// 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");
}
// --- auto CG_ENTERGAME after the loading delay ---
{
s.set_now(1499);
s.pump(); // no socket -> just the auto-entergame check + tick
CHECK(drain(s).empty(), "no ENTERGAME before delay");
s.set_now(1500);
s.pump();
auto out = drain(s);
CHECK(out.size() == sizeof(CGEnterGame) + 1, "CG_ENTERGAME + seq");
CHECK(out[0] == HDR_CG_ENTERGAME && out[1] == SEQUENCE_TABLE[3], "ENTERGAME + seq[3]");
}
// --- 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");
std::vector<uint8_t> packet(sizeof(GCMatrixCard), 0);
packet[0] = HDR_GC_MATRIX_CARD;
feed(unsupported, packet);
CHECK(unsupported.stage() == mtnet::INetSession::Stage::Failed,
"unsupported integration packet -> Failed");
CHECK(unsupported.last_error().find("does not handle") != 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");
}
// --- 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");
CHECK(direct.connect_warp("127.0.0.1", warp_port),
"GC_WARP reconnect keeps selected slot and login key");
int warp_connection = -1;
CHECK(accept_loopback(warp_listener, warp_connection),
"GC_WARP reconnect targets packet endpoint");
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);
}
}
// --- 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;
feed(s, raw(mv));
const auto *npc = s.world().get(8888);
CHECK(npc && npc->moving, "npc moving after GC_MOVE");
s.set_now(1500 + 500); // halfway through the 1000ms move (started at now=1500)
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_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_SELECT,
// [2]=CG_CLIENT_VERSION2, [3]=CG_ENTERGAME.
CHECK(s.stream().sequence_index() == 4, "seq index at 4 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[4], "move seq == table[4]");
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[5], "attack seq == table[5]");
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[6], "target seq == table[6]");
// 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[7], "chat seq == table[7]");
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.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");
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");
GCItemDel id{HDR_GC_ITEM_DEL, 4};
feed(s, raw(id));
CHECK(s.world().item_slot(1, 4).vnum == 0, "item_del cleared inventory cell 4");
}
// --- 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.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,
"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");
// 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");
}
// --- 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 dynamic: [hdr 81][u16 size][u16 index][u8 flag][TITLE\0][COUNTER_NAME\0][i32]
std::vector<uint8_t> qb;
uint16_t qidx = 3;
qb.insert(qb.end(), (uint8_t *)&qidx, (uint8_t *)&qidx + 2);
uint8_t qflag = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE;
qb.push_back(qflag);
const char *qt = "Kill 10 wolves";
qb.insert(qb.end(), qt, qt + std::strlen(qt) + 1);
const char *cn = "Wolves";
qb.insert(qb.end(), cn, cn + std::strlen(cn) + 1);
int32_t cv = 4;
qb.insert(qb.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4);
uint16_t qsz = (uint16_t)(3 + qb.size());
std::vector<uint8_t> qpkt = {HDR_GC_QUEST_INFO};
qpkt.insert(qpkt.end(), (uint8_t *)&qsz, (uint8_t *)&qsz + 2);
qpkt.insert(qpkt.end(), qb.begin(), qb.end());
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)");
}
// --- 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");
}
// --- 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;
}