// net_classic_session_test — 40250 classic HANDSHAKE -> LOGIN -> SELECT -> // LOADING -> GAME driven socket-free: scripted server bytes into // session.stream().feed(), outgoing checked via take_outgoing(). #include "../src/net/classic/classic_session.h" #include #include #include #include #include #include #include #include #include using namespace mtnet::classic; static int g_fail = 0; #define CHECK(c, msg) \ do { \ if (!(c)) { \ std::fprintf(stderr, "FAIL: %s\n", msg); \ ++g_fail; \ } \ } while (0) template static std::vector raw(const T &s) { std::vector b(sizeof(T)); std::memcpy(b.data(), &s, sizeof(T)); return b; } static void feed(ClassicSession &s, const std::vector &b) { s.stream().feed(b.data(), b.size()); } static std::vector drain(ClassicSession &s) { std::vector 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(&addr), sizeof(addr)) < 0 || ::listen(fd, 4) < 0) { ::close(fd); return -1; } socklen_t len = sizeof(addr); if (::getsockname(fd, reinterpret_cast(&addr), &len) < 0) { ::close(fd); return -1; } int flags = ::fcntl(fd, F_GETFL, 0); ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); port = ntohs(addr.sin_port); return fd; } static bool accept_loopback(int listener, int &accepted) { pollfd pfd{listener, POLLIN, 0}; if (::poll(&pfd, 1, 500) <= 0) { return false; } accepted = ::accept(listener, nullptr, nullptr); return accepted >= 0; } int main() { ClassicSession s; s.set_now(0); std::vector got_list; bool entered = false; s.on_char_list = [&](const std::vector &l) { got_list = l; }; s.on_entered_game = [&]() { entered = true; }; s.start_offline("admin", "123456789"); 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(0x0100007f); // 127.0.0.1, little-endian lAddr ls.players[0].port = 13002; ls.players[1].id = 1002; std::strcpy(ls.players[1].name, "Sura"); ls.players[1].job = 4; ls.players[1].level = 7; feed(s, raw(ls)); CHECK(s.parser().char_list_ready(), "login success parsed"); CHECK(s.parser().slot_count() == 4, "4 slots"); CHECK(s.parser().handle() == 0xAABBCCDD, "handle carried"); CHECK(s.parser().random_key() == 0x99887766, "random_key carried"); const auto &sl = s.char_slots(); CHECK(sl.size() == 4 && sl[0].name == "Warrior" && sl[0].level == 42 && sl[0].change_name, "slot 0 + forced rename flag"); CHECK(sl[0].addr == static_cast(0x0100007f) && sl[0].port == 13002, "slot 0 game address carried"); CHECK(sl[1].name == "Sura" && sl[1].job == 4, "slot 1"); CHECK(sl[2].empty() && sl[3].empty(), "slots 2,3 empty"); } // --- 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"); } // --- GC 9 create failure: input_login.cpp:442-465 sends a zeroed 10-byte // TPacketGCLoginFailure under header 9 (blocked creation / bad name). The 40250 // client reads TPacketGCCreateFailure (2 bytes) and PythonNetworkStream.cpp:509 // skips the trailing zero bytes as blank headers. input_db.cpp:196 sends 2 bytes. --- { s.parser().drain_char_events(); std::vector cf(10, 0); cf[0] = HDR_GC_CREATE_FAILURE; GCPlayerCreateFailure dup{HDR_GC_CREATE_FAILURE, 1}; std::vector db = raw(dup); cf.insert(cf.end(), db.begin(), db.end()); feed(s, cf); auto ev = s.parser().drain_char_events(); 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[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 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(0x0100007f); // 127.0.0.1 login.players[0].port = slot_port; feed(direct, raw(login)); CHECK(direct.char_slots()[0].addr == static_cast(0x0100007f) && direct.char_slots()[0].port == slot_port, "direct enter slot endpoint parsed"); CHECK(direct.connect_direct_enter(0), "direct enter reconnect accepted"); int slot_connection = -1; CHECK(accept_loopback(slot_listener, slot_connection), "direct enter second TCP connection targets slot endpoint"); 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"); // GC 20 is TPacketGCItemDelDeprecated on the 40250 server (char_item.cpp:424, // packet.h:1124): header, TItemPos, vnum, count, alSockets[3], aAttr[7] = 42 // bytes; the 40250 client reads it as ITEM_SET. Sent on inventory load, so feed // it glued to the next packet: a short frame desyncs the loading stream. std::vector del(42, 0); del[0] = HDR_GC_ITEM_DEL; del[1] = 1; // WINDOW_INVENTORY del[2] = 4; // cell 4 (u16 LE) GCItemSet next{}; next.header = HDR_GC_ITEM_SET; next.cell = {1, 7}; next.vnum = 11200; next.count = 2; std::vector glued = del; std::vector nb = raw(next); glued.insert(glued.end(), nb.begin(), nb.end()); feed(s, glued); CHECK(s.world().item_slot(1, 4).vnum == 0, "item_del cleared inventory cell 4"); CHECK(s.world().item_slot(1, 7).vnum == 11200 && s.world().item_slot(1, 7).count == 2, "packet after 42-byte GC 20 framed correctly"); CHECK(s.last_error().empty(), "no desync after GC 20"); // Same wire layout with a vnum: the 40250 client applies it as an item set. std::vector set20(42, 0); set20[0] = HDR_GC_ITEM_DEL; set20[1] = 1; set20[2] = 9; const uint32_t vnum20 = 27001; std::memcpy(&set20[4], &vnum20, 4); set20[8] = 5; // count const int32_t sock0 = 28030; std::memcpy(&set20[9], &sock0, 4); set20[21] = 7; // aAttr[0].bType const int16_t av = 15; std::memcpy(&set20[22], &av, 2); feed(s, set20); const mtnet::Item &it20 = s.world().item_slot(1, 9); CHECK(it20.vnum == 27001 && it20.count == 5 && it20.sockets[0] == 28030 && it20.attrs[0].type == 7 && it20.attrs[0].value == 15, "GC 20 with vnum sets the cell like the 40250 client"); s.world().mut_item_del(1, 9); } // --- GC 95 HEADER_GC_REFINE_INFORMATION: the 40250 client registers it with // TPacketGCRefineInformation = [hdr][pos][TRefineTable] = 59 bytes (no type byte; // only 119 _NEW carries type). Glue a packet after it to prove the frame length. --- { s.world().drain_refine_cues(); std::vector rf(59, 0); rf[0] = 95; rf[1] = 6; // pos const uint32_t src = 11209, dst = 11210, mat_vnum = 30053; const int32_t cost = 5000, prob = 90, mat_count = 2; std::memcpy(&rf[2], &src, 4); std::memcpy(&rf[6], &dst, 4); rf[10] = 1; // material_count std::memcpy(&rf[11], &cost, 4); std::memcpy(&rf[15], &prob, 4); std::memcpy(&rf[19], &mat_vnum, 4); std::memcpy(&rf[23], &mat_count, 4); GCItemSet after{}; after.header = HDR_GC_ITEM_SET; after.cell = {1, 11}; after.vnum = 11210; after.count = 1; std::vector ab = raw(after); rf.insert(rf.end(), ab.begin(), ab.end()); feed(s, rf); auto cues = s.world().drain_refine_cues(); CHECK(cues.size() == 1 && cues[0].type == 0 && cues[0].pos == 6 && cues[0].src_vnum == 11209 && cues[0].result_vnum == 11210 && cues[0].material_count == 1 && cues[0].cost == 5000 && cues[0].prob == 90 && cues[0].materials[0].vnum == 30053 && cues[0].materials[0].count == 2, "GC 95 refine information uses the 59-byte 40250 layout"); CHECK(s.world().item_slot(1, 11).vnum == 11210, "packet after GC 95 framed correctly"); CHECK(s.last_error().empty(), "no desync after GC 95"); s.world().mut_item_del(1, 11); } // --- GC entity state: UPDATE(19) / CHANGE_SPEED(18) / POSITION(43) / MOTION(36) / // STUN(13) / DEAD(14) / TARGET(63) --- { // re-add the NPC (it was despawned above? no — despawn is later). vid 8888 present. GCCharacterUpdate cu{}; cu.header = HDR_GC_CHARACTER_UPDATE; cu.vid = 8888; cu.parts[0] = 1101; cu.parts[1] = 2205; cu.moving_speed = 180; cu.attack_speed = 140; cu.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 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 sp; sp.push_back(SHOP_SUB_START); uint32_t svid = 4242; sp.insert(sp.end(), (uint8_t *)&svid, (uint8_t *)&svid + 4); ShopItem43 items[SHOP_HOST_ITEM_MAX_NUM]{}; items[0].vnum = 27001; items[0].price = 5000; items[0].count = 1; items[0].display_pos = 0; items[2].vnum = 10; items[2].price = 20; items[2].count = 200; items[2].display_pos = 2; sp.insert(sp.end(), (uint8_t *)items, (uint8_t *)items + sizeof(items)); // wrap as GC_SHOP dynamic packet: [hdr 38][u16 size][body] uint16_t sz = (uint16_t)(3 + sp.size()); std::vector pkt; pkt.push_back(HDR_GC_SHOP); pkt.insert(pkt.end(), (uint8_t *)&sz, (uint8_t *)&sz + 2); pkt.insert(pkt.end(), sp.begin(), sp.end()); feed(s, pkt); CHECK(s.world().shop_open() && s.world().shop_vid() == 4242, "shop opened"); const auto &si = s.world().shop_items(); CHECK(si.size() == 2 && si[0].vnum == 27001 && si[0].price == 5000 && si[1].pos == 2, "shop items (empties skipped, display_pos kept)"); // error sub-header std::vector ep = {HDR_GC_SHOP, 4, 0, SHOP_SUB_SOLDOUT}; feed(s, ep); auto errs = s.world().drain_shop_errors(); CHECK(errs.size() == 1 && errs[0] == "SOLDOUT", "shop error SOLDOUT"); std::vector ce = {HDR_GC_SHOP, 4, 0, SHOP_SUB_END}; feed(s, ce); CHECK(!s.world().shop_open() && s.world().shop_items().empty(), "shop closed"); } // --- GC_SCRIPT(45) / GC_QUEST_CONFIRM(46) / GC_QUEST_INFO(81) --- { // GC_SCRIPT dynamic: [hdr 45][u16 size][skin][u16 src_size][text] const char *dlg = "Hello adventurer!"; std::vector body; body.push_back(2); // skin body.push_back(0); body.push_back(0); // src_size body.insert(body.end(), dlg, dlg + std::strlen(dlg)); uint16_t sz = (uint16_t)(3 + body.size()); std::vector pkt = {HDR_GC_SCRIPT}; pkt.insert(pkt.end(), (uint8_t *)&sz, (uint8_t *)&sz + 2); pkt.insert(pkt.end(), body.begin(), body.end()); feed(s, pkt); auto sc = s.world().drain_scripts(); CHECK(sc.size() == 1 && sc[0].skin == 2 && sc[0].text == "Hello adventurer!", "script cue"); GCQuestConfirm qc{}; qc.header = HDR_GC_QUEST_CONFIRM; std::strcpy(qc.msg, "Accept quest?"); qc.timeout = 30; qc.request_pid = 555; feed(s, raw(qc)); auto cf = s.world().drain_confirms(); CHECK(cf.size() == 1 && cf[0].msg == "Accept quest?" && cf[0].timeout == 30 && cf[0].request_pid == 555, "quest confirm cue"); // GC_QUEST_INFO (questpc.cpp PC::SendQuestInfoPakcet): [hdr 81][u16 size][u16 index] // [u8 flag] then fixed-width optional fields (title 31, counter name 17, i32). The // head is written before size is bumped, so the wire size stays 6. std::vector qpkt(QUEST_INFO_HEAD_SIZE, 0); qpkt[0] = HDR_GC_QUEST_INFO; uint16_t qsz = QUEST_INFO_HEAD_SIZE; std::memcpy(&qpkt[1], &qsz, 2); uint16_t qidx = 3; std::memcpy(&qpkt[3], &qidx, 2); qpkt[5] = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE; std::vector qt(QUEST_INFO_TITLE_SIZE, 0); std::memcpy(qt.data(), "Kill 10 wolves", 14); qpkt.insert(qpkt.end(), qt.begin(), qt.end()); std::vector cn(QUEST_INFO_COUNTER_NAME_SIZE, 0); std::memcpy(cn.data(), "Wolves", 6); qpkt.insert(qpkt.end(), cn.begin(), cn.end()); int32_t cv = 4; qpkt.insert(qpkt.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4); CHECK(qpkt.size() == static_cast(quest_info_packet_size(qpkt[5])), "quest info test packet matches 40250 flag-driven length"); feed(s, qpkt); const mtnet::QuestInfo *q = s.world().quest(3); CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" && q->counter_value == 4, "quest info parsed (flag-driven)"); } // --- GC_DUEL_START dynamic: [hdr 40][u16 whole_size][opponent VIDs] --- { uint32_t opponent_vids[] = {41001, 41002}; uint16_t duel_size = (uint16_t)(sizeof(GCDuelStart) + sizeof(opponent_vids)); std::vector duel_pkt = {HDR_GC_DUEL_START}; duel_pkt.insert(duel_pkt.end(), (uint8_t *)&duel_size, (uint8_t *)&duel_size + 2); duel_pkt.insert(duel_pkt.end(), (uint8_t *)opponent_vids, (uint8_t *)opponent_vids + sizeof(opponent_vids)); feed(s, duel_pkt); const auto &opponents = s.world().duel_opponents(); CHECK(opponents.size() == 2 && opponents[0] == 41001 && opponents[1] == 41002 && !s.world().duel_cannot_attack(), "duel start opponent list"); CHECK(s.world().take_duel_started(), "duel start event"); uint16_t empty_duel_size = sizeof(GCDuelStart); std::vector empty_duel = {HDR_GC_DUEL_START}; empty_duel.insert(empty_duel.end(), (uint8_t *)&empty_duel_size, (uint8_t *)&empty_duel_size + 2); feed(s, empty_duel); CHECK(s.world().duel_opponents().empty() && s.world().duel_cannot_attack(), "empty duel start cannot-attack state"); } // --- P8 messenger (74, dynamic): complete list + login/logout deltas --- { std::vector body = {MESSENGER_GC_LIST, 1, 5}; body.insert(body.end(), {'A', 'l', 'i', 'c', 'e'}); body.push_back(0); body.push_back(3); body.insert(body.end(), {'B', 'o', 'b'}); GCMessengerHead h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + body.size() - 1), body[0]}; std::vector pkt = raw(h); pkt.insert(pkt.end(), body.begin() + 1, body.end()); feed(s, pkt); auto fs = s.world().friends(); CHECK(fs.size() == 2, "messenger list friend count"); bool alice = false, bob = false; for (const auto &f : fs) { alice = alice || (f.name == "Alice" && f.online); bob = bob || (f.name == "Bob" && !f.online); } CHECK(alice && bob, "messenger list online state"); std::vector login_body = {MESSENGER_GC_LOGIN, 3, 'B', 'o', 'b'}; GCMessengerHead login_h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + login_body.size() - 1), login_body[0]}; std::vector login_pkt = raw(login_h); login_pkt.insert(login_pkt.end(), login_body.begin() + 1, login_body.end()); feed(s, login_pkt); fs = s.world().friends(); bool bob_online = false; for (const auto &f : fs) { bob_online = bob_online || (f.name == "Bob" && f.online); } CHECK(fs.size() == 2 && bob_online, "messenger login update"); std::vector logout_body = {MESSENGER_GC_LOGOUT, 5, 'A', 'l', 'i', 'c', 'e'}; GCMessengerHead logout_h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + logout_body.size() - 1), logout_body[0]}; std::vector logout_pkt = raw(logout_h); logout_pkt.insert(logout_pkt.end(), logout_body.begin() + 1, logout_body.end()); feed(s, logout_pkt); fs = s.world().friends(); bool alice_offline = false; for (const auto &f : fs) { alice_offline = alice_offline || (f.name == "Alice" && !f.online); } CHECK(fs.size() == 2 && alice_offline, "messenger logout update"); std::vector mobile_body = {MESSENGER_GC_MOBILE, 1, 5, 'A', 'l', 'i', 'c', 'e'}; GCMessengerHead mobile_h{HDR_GC_MESSENGER, static_cast(sizeof(GCMessengerHead) + mobile_body.size() - 1), mobile_body[0]}; std::vector mobile_pkt = raw(mobile_h); mobile_pkt.insert(mobile_pkt.end(), mobile_body.begin() + 1, mobile_body.end()); feed(s, mobile_pkt); bool alice_mobile = false; for (const auto &f : s.world().friends()) { alice_mobile = alice_mobile || (f.name == "Alice" && f.mobile); } CHECK(alice_mobile, "messenger mobile state"); } // --- P8 exchange (42, static) ------------------------------------------- { GCExchange ex{}; ex.header = HDR_GC_EXCHANGE; ex.subheader = EXCHANGE_GC_START; ex.arg1 = 31337; feed(s, raw(ex)); CHECK(s.world().exchange().active && s.world().exchange().partner_vid == 31337, "exchange start"); ex = GCExchange{}; ex.header = HDR_GC_EXCHANGE; ex.subheader = EXCHANGE_GC_ITEM_ADD; ex.is_me = 1; ex.arg1 = 27001; ex.arg2 = {0, 4}; ex.arg3 = 7; ex.sockets[1] = 9002; ex.attrs[2] = {17, -33}; feed(s, raw(ex)); CHECK(s.world().exchange().self_items[4].vnum == 27001 && s.world().exchange().self_items[4].count == 7 && s.world().exchange().self_items[4].sockets[1] == 9002 && s.world().exchange().self_items[4].attrs[2].type == 17 && s.world().exchange().self_items[4].attrs[2].value == -33, "exchange item add keeps sockets/attrs"); ex.subheader = EXCHANGE_GC_GOLD_ADD; ex.arg1 = 123456; feed(s, raw(ex)); CHECK(s.world().exchange().self_gold == 123456, "exchange gold"); ex.subheader = EXCHANGE_GC_ACCEPT; ex.arg1 = 1; feed(s, raw(ex)); CHECK(s.world().exchange().self_accept, "exchange accept"); ex.subheader = EXCHANGE_GC_END; feed(s, raw(ex)); CHECK(!s.world().exchange().active, "exchange end"); } // --- Guild dynamic packets: members, presence, comments, invite, score -- { GuildMember38 member{}; member.pid = 7001; member.grade = 1; member.is_general = 1; member.job = 2; member.level = 55; member.offer = 1234; member.name_flag = 1; std::strcpy(member.name, "Guildmate"); std::vector body = {mtnet::GUILD_GC_LIST}; auto member_bytes = raw(member); body.insert(body.end(), member_bytes.begin(), member_bytes.end()); GCMessengerHead gh{HDR_GC_GUILD, static_cast(sizeof(GCMessengerHead) + body.size() - 1), body[0]}; std::vector guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), body.begin() + 1, body.end()); feed(s, guild_pkt); auto members = s.world().guild_members(); CHECK(members.size() == 1 && members[0].pid == 7001 && members[0].name == "Guildmate" && members[0].is_general, "guild member list"); std::vector login_body = {mtnet::GUILD_GC_LOGIN, 0, 0, 0, 0}; std::memcpy(login_body.data() + 1, &member.pid, sizeof(member.pid)); gh.size = static_cast(sizeof(GCMessengerHead) + login_body.size() - 1); gh.subheader = login_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), login_body.begin() + 1, login_body.end()); feed(s, guild_pkt); members = s.world().guild_members(); CHECK(members.size() == 1 && members[0].online, "guild member login"); GuildComment80 comment{}; comment.id = 17; std::strcpy(comment.name, "Guildmate"); std::strcpy(comment.content, "Welcome to the guild"); std::vector comments_body = {mtnet::GUILD_GC_COMMENTS, 1}; auto comment_bytes = raw(comment); comments_body.insert(comments_body.end(), comment_bytes.begin(), comment_bytes.end()); gh.size = static_cast(sizeof(GCMessengerHead) + comments_body.size() - 1); gh.subheader = comments_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), comments_body.begin() + 1, comments_body.end()); feed(s, guild_pkt); const auto &comments = s.world().guild_comments(); CHECK(comments.size() == 1 && comments[0].id == 17 && comments[0].content == "Welcome to the guild", "guild comments"); GuildInvite17 invite{9001, "Wolves"}; std::vector invite_body = {mtnet::GUILD_GC_GUILD_INVITE}; auto invite_bytes = raw(invite); invite_body.insert(invite_body.end(), invite_bytes.begin(), invite_bytes.end()); gh.size = static_cast(sizeof(GCMessengerHead) + invite_body.size() - 1); gh.subheader = invite_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), invite_body.begin() + 1, invite_body.end()); feed(s, guild_pkt); auto invites = s.world().drain_guild_invites(); CHECK(invites.size() == 1 && invites[0].guild_id == 9001 && invites[0].guild_name == "Wolves", "guild invite"); GuildWarPoint12 score{77, 88, 12}; std::vector score_body = {mtnet::GUILD_GC_WAR_POINT}; auto score_bytes = raw(score); score_body.insert(score_body.end(), score_bytes.begin(), score_bytes.end()); gh.size = static_cast(sizeof(GCMessengerHead) + score_body.size() - 1); gh.subheader = score_body[0]; guild_pkt = raw(gh); guild_pkt.insert(guild_pkt.end(), score_body.begin() + 1, score_body.end()); feed(s, guild_pkt); auto scores = s.world().drain_guild_war_scores(); CHECK(scores.size() == 1 && scores[0].gain_guild_id == 77 && scores[0].point == 12, "guild war score"); } // --- P8 safebox / mall fixed packets ------------------------------------ { GCSafeboxSize sb_size{HDR_GC_SAFEBOX_SIZE, 2}; feed(s, raw(sb_size)); GCSafeboxMoneyChange sb_money{HDR_GC_SAFEBOX_MONEY_CHANGE, 987654}; feed(s, raw(sb_money)); GCItemSet sb_item{}; sb_item.header = HDR_GC_SAFEBOX_SET; sb_item.cell = {3, 8}; sb_item.vnum = 50001; sb_item.count = 9; sb_item.sockets[0] = 60001; sb_item.attrs[0] = {5, 30}; feed(s, raw(sb_item)); CHECK(s.world().safebox_open() && s.world().safebox_size() == 2 && s.world().safebox_gold() == 987654, "safebox open/money"); CHECK(s.world().safebox_slot(8).vnum == 50001 && s.world().safebox_slot(8).count == 9 && s.world().safebox_slot(8).sockets[0] == 60001 && s.world().safebox_slot(8).attrs[0].value == 30, "safebox item set"); GCItemDel sb_del{HDR_GC_SAFEBOX_DEL, 8}; feed(s, raw(sb_del)); CHECK(s.world().safebox_slot(8).empty(), "safebox item del"); GCMallOpen mall_open{HDR_GC_MALL_OPEN, 1}; feed(s, raw(mall_open)); GCItemSet mall_item{}; mall_item.header = HDR_GC_MALL_SET; mall_item.cell = {4, 3}; mall_item.vnum = 70001; mall_item.count = 2; feed(s, raw(mall_item)); CHECK(s.world().mall_open() && s.world().mall_size() == 1 && s.world().mall_slot(3).vnum == 70001, "mall open/item set"); GCItemDel mall_del{HDR_GC_MALL_DEL, 3}; feed(s, raw(mall_del)); CHECK(s.world().mall_slot(3).empty(), "mall item del"); } // --- P8 outbound intents: fixed packets carry one trailing sequence byte --- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_friend_add("Alice"), "send_friend_add"); auto out = drain(s); CHECK(out.size() == sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN + 1 && out[0] == HDR_CG_MESSENGER && out[1] == MESSENGER_CG_ADD_BY_NAME && std::memcmp(out.data() + sizeof(CGMessenger), "Alice", 5) == 0 && out.back() == SEQUENCE_TABLE[seq], "friend add wire + sequence"); ++seq; CHECK(s.send_friend_remove("Alice"), "send_friend_remove"); out = drain(s); CHECK(out.size() == sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN + 1 && out[1] == MESSENGER_CG_REMOVE && out.back() == SEQUENCE_TABLE[seq], "friend remove wire + sequence"); ++seq; CHECK(s.send_exchange_start(31337), "send_exchange_start"); out = drain(s); CGExchange cg_ex{}; std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(out.size() == sizeof(CGExchange) + 1 && cg_ex.subheader == EXCHANGE_CG_START && cg_ex.arg1 == 31337 && out.back() == SEQUENCE_TABLE[seq], "exchange start wire + sequence"); ++seq; CHECK(s.send_exchange_item_add(1, 12, 4), "send_exchange_item_add"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_ITEM_ADD && cg_ex.arg2 == 4 && cg_ex.pos.window_type == 1 && cg_ex.pos.cell == 12 && out.back() == SEQUENCE_TABLE[seq], "exchange item wire"); ++seq; CHECK(s.send_exchange_gold(900), "send_exchange_gold"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_GOLD_ADD && cg_ex.arg1 == 900 && out.back() == SEQUENCE_TABLE[seq], "exchange gold wire"); ++seq; CHECK(s.send_exchange_accept(), "send_exchange_accept"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_ACCEPT && out.back() == SEQUENCE_TABLE[seq], "exchange accept wire"); ++seq; CHECK(s.send_exchange_cancel(), "send_exchange_cancel"); out = drain(s); std::memcpy(&cg_ex, out.data(), sizeof(cg_ex)); CHECK(cg_ex.subheader == EXCHANGE_CG_CANCEL && out.back() == SEQUENCE_TABLE[seq], "exchange cancel wire"); ++seq; CHECK(s.send_safebox_checkin(8, 1, 12), "send_safebox_checkin"); out = drain(s); CGSafeboxCheckin checkin{}; std::memcpy(&checkin, out.data(), sizeof(checkin)); CHECK(checkin.header == HDR_CG_SAFEBOX_CHECKIN && checkin.safe_pos == 8 && checkin.item_pos.window_type == 1 && checkin.item_pos.cell == 12 && out.back() == SEQUENCE_TABLE[seq], "safebox checkin wire"); ++seq; CHECK(s.send_safebox_checkout(8, 1, 12), "send_safebox_checkout"); out = drain(s); CHECK(out.size() == sizeof(CGSafeboxCheckout) + 1 && out[0] == HDR_CG_SAFEBOX_CHECKOUT && out.back() == SEQUENCE_TABLE[seq], "safebox checkout wire"); ++seq; CHECK(s.send_safebox_move(8, 9, 2), "send_safebox_move"); out = drain(s); CGItemMove move{}; std::memcpy(&move, out.data(), sizeof(move)); CHECK(move.header == HDR_CG_SAFEBOX_ITEM_MOVE && move.pos.window_type == 1 && move.pos.cell == 8 && move.change_pos.cell == 9 && move.num == 2 && out.back() == SEQUENCE_TABLE[seq], "safebox move wire"); ++seq; CHECK(s.send_mall_checkout(3, 1, 22), "send_mall_checkout"); out = drain(s); CGMallCheckout mall{}; std::memcpy(&mall, out.data(), sizeof(mall)); CHECK(mall.header == HDR_CG_MALL_CHECKOUT && mall.mall_pos == 3 && mall.item_pos.window_type == 1 && mall.item_pos.cell == 22 && out.back() == SEQUENCE_TABLE[seq], "mall checkout wire"); CHECK(s.stream().sequence_index() == seq + 1, "P8 sequence index advanced"); } // --- 40250 command-backed cube intents ------------------------------- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_cube_open(), "cube open"); auto out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 10 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube open", 10) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube open command wire"); ++seq; CHECK(s.send_cube_add_item(2, 17), "cube add"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 14 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube add 2 17", 14) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube add command wire"); ++seq; CHECK(s.send_cube_delete_item(2), "cube delete"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 14 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube delete 2", 14) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube delete command wire"); ++seq; CHECK(s.send_cube_list(), "cube list"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 10 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube list", 10) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube list command wire"); ++seq; CHECK(s.send_cube_close(), "cube close"); out = drain(s); CHECK(out.size() == sizeof(CGChatHead) + 11 + 1 + 1 && std::memcmp(out.data() + sizeof(CGChatHead), "/cube close", 11) == 0 && out.back() == SEQUENCE_TABLE[seq], "cube close command wire"); CHECK(!s.send_cube_add_item(24, 17), "cube index bounds"); } // --- Guild comment/invite answer intents ------------------------------- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_guild_comment("hello"), "send guild comment"); auto out = drain(s); CHECK(out.size() == sizeof(CGGuild) + 1 + 5 + 1 + 1 && out[0] == HDR_CG_GUILD && out[1] == GUILD_CG_POST_COMMENT && out[2] == 6 && std::memcmp(out.data() + sizeof(CGGuild) + 1, "hello", 5) == 0 && out[sizeof(CGGuild) + 1 + 5] == 0 && out.back() == SEQUENCE_TABLE[seq], "guild comment wire"); ++seq; CHECK(s.send_guild_grade_name(2, "Officer"), "send guild grade name"); out = drain(s); CHECK(out.size() == sizeof(CGGuild) + 1 + 9 + 1 && out[1] == GUILD_CG_CHANGE_GRADE_NAME && out[2] == 2 && std::memcmp(out.data() + sizeof(CGGuild) + 1, "Officer", 7) == 0 && out[sizeof(CGGuild) + 1 + 7] == 0 && out.back() == SEQUENCE_TABLE[seq], "guild grade name wire"); ++seq; CHECK(s.send_guild_grade_authority(2, 7), "send guild grade authority"); out = drain(s); CHECK(out.size() == sizeof(CGGuild) + 2 + 1 && out[1] == GUILD_CG_CHANGE_GRADE_AUTHORITY && out[2] == 2 && out[3] == 7 && out.back() == SEQUENCE_TABLE[seq], "guild grade authority wire"); ++seq; CHECK(s.send_guild_member_grade(1234, 3), "send guild member grade"); out = drain(s); uint32_t pid = 0; std::memcpy(&pid, out.data() + sizeof(CGGuild), sizeof(pid)); CHECK(out.size() == sizeof(CGGuild) + 4 + 1 + 1 && out[1] == GUILD_CG_CHANGE_MEMBER_GRADE && pid == 1234 && out[sizeof(CGGuild) + 4] == 3 && out.back() == SEQUENCE_TABLE[seq], "guild member grade wire"); ++seq; CHECK(s.send_guild_member_general(1234, true), "send guild member general"); out = drain(s); std::memcpy(&pid, out.data() + sizeof(CGGuild), sizeof(pid)); CHECK(out.size() == sizeof(CGGuild) + 4 + 1 + 1 && out[1] == GUILD_CG_CHANGE_MEMBER_GENERAL && pid == 1234 && out[sizeof(CGGuild) + 4] == 1 && out.back() == SEQUENCE_TABLE[seq], "guild member general wire"); ++seq; CHECK(s.send_guild_invite_answer(9001, true), "send guild invite answer"); out = drain(s); uint32_t gid = 0; std::memcpy(&gid, out.data() + sizeof(CGGuild), sizeof(gid)); CHECK(out.size() == sizeof(CGGuild) + 4 + 1 + 1 && out[1] == GUILD_CG_GUILD_INVITE_ANSWER && gid == 9001 && out[sizeof(CGGuild) + 4] == 1 && out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire"); } // --- CG_USE_SKILL: PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket sends // only the use-skill packet; fly-targeting is a separate caller decision --- { uint32_t seq = s.stream().sequence_index(); CHECK(s.send_use_skill(3, 8888), "use skill send"); auto out = drain(s); CHECK(out.size() == sizeof(CGUseSkill) + 1 && out[0] == HDR_CG_USE_SKILL && out.back() == SEQUENCE_TABLE[seq], "use skill wire: no extra fly targeting"); } // --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD // is dropped and leaves the live entity untouched (§2.2) --- { const auto *before = s.world().get(8888); CHECK(before != nullptr, "8888 present before the drop test"); const std::string name0 = before->name; const int32_t level0 = before->level; const uint16_t part0 = before->parts[0]; GCCharAddInfo ci{}; ci.header = HDR_GC_CHAR_ADDITIONAL_INFO; ci.vid = 8888; std::strcpy(ci.name, "Goblin"); ci.parts[0] = 3; ci.level = 12; feed(s, raw(ci)); const auto *e = s.world().get(8888); CHECK(e && e->name == name0 && e->level == level0 && e->parts[0] == part0, "additional-info without a pending add is a no-op"); CHECK(s.parser().pending_actor_count() == 0, "no stray pending record created"); } // --- GC_SYNC_POSITION(5, dynamic): snap entities --- { struct { uint8_t hdr; uint16_t size; } __attribute__((packed)) sph{HDR_GC_SYNC_POSITION, 0}; SyncPosElement el{8888, 999000, 888000}; sph.size = (uint16_t)(sizeof(sph) + sizeof(el)); std::vector sp(sizeof(sph)); std::memcpy(sp.data(), &sph, sizeof(sph)); auto eb = raw(el); sp.insert(sp.end(), eb.begin(), eb.end()); feed(s, sp); const auto *e = s.world().get(8888); CHECK(e && e->x == 999000.f && e->y == 888000.f && !e->moving, "sync_position snapped"); } // --- GC_ITEM_GROUND_ADD(26) / DEL(27) --- { GCItemGroundAdd ga{}; ga.header = HDR_GC_ITEM_GROUND_ADD; ga.vid = 55555; ga.vnum = 1; ga.x = 100200; ga.y = 200300; ga.z = 4; feed(s, raw(ga)); CHECK(s.world().ground(55555) != nullptr, "ground item added"); GCItemGroundDel gd{HDR_GC_ITEM_GROUND_DEL, 55555}; feed(s, raw(gd)); CHECK(s.world().ground(55555) == nullptr, "ground item removed"); } // --- GC_CHARACTER_DEL(2) --- { GCCharacterDel del{HDR_GC_CHARACTER_DEL, 8888}; feed(s, raw(del)); CHECK(s.world().get(8888) == nullptr, "npc despawned"); CHECK(s.world().size() == 1, "1 entity left"); } // --- intents are rejected outside PHASE_GAME --- { ClassicSession s2; CHECK(!s2.send_move(1, 0, 0, 0, 0), "send_move rejected before InGame"); CHECK(!s2.send_chat(0, "hi"), "send_chat rejected before InGame"); } CHECK(s.last_error().empty(), "no error over the whole flow"); if (g_fail) { std::fprintf(stderr, "%d check(s) failed\n", g_fail); return 1; } std::puts("net_classic_session_test OK"); return 0; }