// net_classic_stream_test — 40250 "classic" framing layer, socket-free. // Drives ClassicStream via feed()/take_outgoing() with synthetic packets. #include "../src/net/classic/classic_stream.h" #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 std::vector drain(ClassicStream &s) { std::vector out; uint8_t buf[4096]; for (;;) { size_t n = s.take_outgoing(buf, sizeof(buf)); if (!n) { break; } out.insert(out.end(), buf, buf + n); } return out; } int main() { // ---------------------------------------------------------- handshake echo { ClassicStream s; Handshake hs{}; hs.header = HDR_HANDSHAKE; hs.handshake = 0xDEADBEEF; hs.time = 1000; hs.delta = 50; auto b = raw(hs); s.feed(b.data(), b.size()); auto out = drain(s); CHECK(out.size() == sizeof(Handshake), "handshake echo is 13 bytes (no seq)"); Handshake echo{}; std::memcpy(&echo, out.data(), sizeof(echo)); CHECK(echo.header == HDR_HANDSHAKE, "echo keeps header 0xFF"); CHECK(echo.handshake == 0xDEADBEEF, "echo keeps handshake id"); CHECK(echo.time == 1000u + 2u * 50u, "echo time = time + 2*delta"); CHECK(echo.delta == 0, "echo delta = 0"); Handshake resync{}; resync.header = HDR_HANDSHAKE; resync.handshake = 0xDEADBEEF; resync.time = 2000; resync.delta = 10; s.set_sequence_mode(true); s.set_time_sync_mode(true); b = raw(resync); s.feed(b.data(), b.size()); out = drain(s); CHECK(out.size() == sizeof(Handshake) + 1, "later handshake uses time-sync + seq"); Handshake sync_echo{}; std::memcpy(&sync_echo, out.data(), sizeof(sync_echo)); CHECK(sync_echo.header == HDR_CG_TIME_SYNC, "later handshake header is CG_TIME_SYNC"); CHECK(sync_echo.time == 2000u + 2u * 10u && sync_echo.delta == 0, "later handshake time fields are converged"); CHECK(out[sizeof(Handshake)] == SEQUENCE_TABLE[0], "time-sync uses next sequence byte"); // The reference keeps the old active clock until the blank // HANDSHAKE_OK arrives; the later handshake is only a pending proposal. const uint32_t before_ok = s.server_frame_ms(); CHECK(before_ok < 100000u, "resync does not activate the new server clock early"); uint8_t ok = HDR_GC_TIME_SYNC; s.feed(&ok, 1); const uint32_t after_ok = s.server_frame_ms(); CHECK(after_ok >= 2000u && after_ok < 10000u, "HANDSHAKE_OK applies the staged server clock correction"); } // -------------------------------------------- server frame clock (§3.2) { ClassicStream s; CHECK(s.server_frame_ms() == 0, "server_frame_ms is 0 before any handshake"); CHECK(!s.has_server_clock(), "has_server_clock false before handshake"); Handshake hs{}; hs.header = HDR_HANDSHAKE; hs.handshake = 0x1234; hs.time = 500000; hs.delta = 20; auto b = raw(hs); s.feed(b.data(), b.size()); (void)drain(s); CHECK(s.has_server_clock(), "has_server_clock true after handshake"); uint32_t base = 500000u + 20u; // hs.time + lDelta uint32_t f1 = s.server_frame_ms(); CHECK(f1 >= base && f1 < base + 5000u, "server_frame_ms ~ hs.time + lDelta right after handshake"); uint32_t f2 = s.server_frame_ms(); CHECK(f2 >= f1, "server frame clock is monotonic non-decreasing"); } // ---------------------------------------------------------- GC_PHASE { ClassicStream s; int seen = -1; s.on_phase = [&](uint8_t p) { seen = p; }; Phase_ p{HDR_GC_PHASE, PHASE_LOGIN}; auto b = raw(p); s.feed(b.data(), b.size()); CHECK(seen == PHASE_LOGIN, "GC_PHASE delivers phase 2"); } // ---------------------------------------------------------- GC_PING -> CG_PONG { ClassicStream s; uint8_t ping = HDR_GC_PING; // 44 s.feed(&ping, 1); auto out = drain(s); CHECK(out.size() == 1 && out[0] == HDR_CG_PONG, "PING -> bare CG_PONG (seq off)"); s.set_sequence_mode(true); s.feed(&ping, 1); out = drain(s); CHECK(out.size() == 2 && out[0] == HDR_CG_PONG && out[1] == SEQUENCE_TABLE[0], "PING -> CG_PONG + seq[0] (seq on)"); } // ---------------------------------------------------------- static GC packet { ClassicStream s; uint8_t got_hdr = 0; uint32_t got_len = 0; std::vector got_body; s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) { got_hdr = h; got_len = len; got_body.assign(body, body + len); return true; }; GCMainCharacter mc{}; mc.header = HDR_GC_MAIN_CHARACTER; mc.vid = 0x0A0B0C0D; mc.race = 4; std::strcpy(mc.name, "Hero"); mc.x = 123456; mc.y = 654321; mc.z = 7; mc.empire = 2; mc.skill_group = 1; auto b = raw(mc); s.feed(b.data(), b.size()); CHECK(got_hdr == HDR_GC_MAIN_CHARACTER, "static: header 113 delivered"); CHECK(got_len == sizeof(GCMainCharacter) - 1, "static: body len = sizeof-1"); // body starts right after the header byte -> first 4 bytes are the vid uint32_t vid; std::memcpy(&vid, got_body.data(), 4); CHECK(vid == 0x0A0B0C0D, "static: body begins at vid"); } // ---------------------------------------------------------- dynamic GC packet { ClassicStream s; uint8_t got_hdr = 0; uint32_t got_len = 0; std::string got_tail; s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) { got_hdr = h; got_len = len; got_tail.assign(reinterpret_cast(body), len); return true; }; // GC_CHAT: [header][u16 size][type][u32 vid][empire] + "hi" const char *msg = "hi"; GCChatHead ch{}; ch.header = HDR_GC_CHAT; ch.type = 1; ch.vid = 42; ch.empire = 3; ch.size = static_cast(sizeof(GCChatHead) + std::strlen(msg)); std::vector pkt = raw(ch); pkt.insert(pkt.end(), msg, msg + std::strlen(msg)); s.feed(pkt.data(), pkt.size()); CHECK(got_hdr == HDR_GC_CHAT, "dynamic: GC_CHAT delivered"); // body = everything after [header][u16 size] => (type,vid,empire,"hi") CHECK(got_len == ch.size - sizeof(DynHead), "dynamic: body len = size - 3"); CHECK(got_tail.size() >= 2 && got_tail.substr(got_tail.size() - 2) == "hi", "dynamic: text tail present"); } // ---------------------------------------------------------- static whisper head + text tail { ClassicStream s; std::vector headers; s.on_packet = [&](uint8_t h, const uint8_t *, uint32_t) { headers.push_back(h); return true; }; GCWhisperHead whisper{}; whisper.header = HDR_GC_WHISPER; whisper.type = 1; std::strcpy(whisper.name_from, "Sender"); const char *text = "hi"; whisper.size = static_cast(sizeof(whisper) + std::strlen(text)); std::vector wire = raw(whisper); wire.insert(wire.end(), text, text + std::strlen(text)); GCCharacterDel next{HDR_GC_CHARACTER_DEL, 77}; auto next_wire = raw(next); wire.insert(wire.end(), next_wire.begin(), next_wire.end()); s.feed(wire.data(), sizeof(GCWhisperHead) / 2); CHECK(headers.empty(), "whisper partial fixed head waits"); s.feed(wire.data() + sizeof(GCWhisperHead) / 2, wire.size() - sizeof(GCWhisperHead) / 2); CHECK(headers.size() == 2 && headers[0] == HDR_GC_WHISPER && headers[1] == HDR_GC_CHARACTER_DEL, "whisper text tail does not swallow the following static packet"); } // The reference consumes HYBRIDCRYPT payloads in every phase and returns // without sending them through the normal GC parser. They must not become a // false unsupported-packet disconnect in the adapted client. { ClassicStream s; int callbacks = 0; int errors = 0; s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { ++callbacks; return true; }; s.on_error = [&](const std::string &) { ++errors; }; DynHead key{HDR_GC_HYBRIDCRYPT_KEYS, static_cast(sizeof(DynHead))}; auto b = raw(key); s.feed(b.data(), b.size()); CHECK(callbacks == 0 && errors == 0, "HYBRIDCRYPT_KEYS is consumed without normal parser callback"); } // A phase packet returns from the current reference phase function. Bytes // already buffered behind it stay for the next phase tick. { ClassicStream s; int phases = 0; int packets = 0; s.on_phase = [&](uint8_t) { ++phases; }; s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { ++packets; return true; }; Phase_ phase{HDR_GC_PHASE, PHASE_GAME}; GCCharacterDel next{HDR_GC_CHARACTER_DEL, 42}; std::vector burst = raw(phase); auto next_bytes = raw(next); burst.insert(burst.end(), next_bytes.begin(), next_bytes.end()); s.feed(burst.data(), burst.size()); CHECK(phases == 1 && packets == 0, "phase boundary stops dispatch before the pipelined next packet"); s.feed(nullptr, 0); CHECK(packets == 1, "pipelined packet is dispatched on the next phase tick"); } // GamePhase's bounded pump stops after the third packet while the receive // buffer is below 8192 bytes. The fourth packet remains for the next tick. { ClassicStream s; int packets = 0; s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { ++packets; return true; }; Phase_ phase{HDR_GC_PHASE, PHASE_GAME}; s.feed(&phase, sizeof(phase)); GCCharacterDel del{HDR_GC_CHARACTER_DEL, 100}; std::vector burst; for (int i = 0; i < 4; ++i) { auto bytes = raw(del); bytes[1] = static_cast(100 + i); burst.insert(burst.end(), bytes.begin(), bytes.end()); } s.feed(burst.data(), burst.size()); CHECK(packets == 3, "GamePhase bounded pump consumes three small packets"); s.feed(nullptr, 0); CHECK(packets == 4, "GamePhase bounded pump resumes on the next tick"); } // ---------------------------------------------------------- send seq sequencing { ClassicStream s; s.set_sequence_mode(true); CGMove mv{}; mv.header = HDR_CG_MOVE; mv.func = 1; mv.x = 10; mv.y = 20; s.send_fixed(raw(mv).data(), sizeof(mv)); s.send_fixed(raw(mv).data(), sizeof(mv)); auto out = drain(s); CHECK(out.size() == 2 * (sizeof(CGMove) + 1), "two CG_MOVE + 2 seq bytes"); CHECK(out[sizeof(CGMove)] == SEQUENCE_TABLE[0], "first seq = table[0]"); CHECK(out[2 * sizeof(CGMove) + 1] == SEQUENCE_TABLE[1], "second seq = table[1]"); CHECK(s.sequence_index() == 2, "seq index advanced to 2"); } // seq off => no trailing byte { ClassicStream s; CGMove mv{}; mv.header = HDR_CG_MOVE; s.send_fixed(raw(mv).data(), sizeof(mv)); CHECK(drain(s).size() == sizeof(CGMove), "seq off: no trailing byte"); } // ---------------------------------------------------------- partial feed { ClassicStream s; int hits = 0; s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { ++hits; return true; }; GCCharacterDel del{HDR_GC_CHARACTER_DEL, 99}; auto b = raw(del); s.feed(b.data(), 2); // header + 1 byte of vid CHECK(hits == 0, "partial: nothing delivered yet"); s.feed(b.data() + 2, b.size() - 2); CHECK(hits == 1, "partial: delivered once the rest arrives"); } // CheckPacket strips only a leading run of zero padding. A zero-only read // is incomplete and must not be reported as an unknown packet. { ClassicStream s; int hits = 0; std::string err; s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { ++hits; return true; }; s.on_error = [&](const std::string &e) { err = e; }; const uint8_t padding[] = {0, 0}; s.feed(padding, sizeof(padding)); CHECK(hits == 0 && err.empty(), "zero-only leading padding waits without error"); GCCharacterDel del{HDR_GC_CHARACTER_DEL, 100}; auto b = raw(del); std::vector padded = {0, 0}; padded.insert(padded.end(), b.begin(), b.end()); s.feed(padded.data(), padded.size()); CHECK(hits == 1 && err.empty(), "leading zero run is removed before valid packet"); } // ---------------------------------------------------------- unknown header { ClassicStream s; std::string err; s.on_error = [&](const std::string &e) { err = e; }; uint8_t bad = 200; // ROULETTE — not in the size table yet s.feed(&bad, 1); CHECK(!err.empty(), "unknown header -> on_error"); CHECK(s.state() == ClassicStream::State::Offline, "unknown header -> disconnect"); GCCharacterDel del{HDR_GC_CHARACTER_DEL, 99}; auto trailing = raw(del); s.feed(trailing.data(), trailing.size()); CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes"); } // ------------------------------------------- GC_GUILD SKILL_INFO short write { // guild.cpp CGuild::SendSkillInfoPacket: size = 4 + 6 + GUILD_SKILL_COUNT (22) // but only skill_point + abySkill[12] + power(2) + max_power(2) follow the // head (21 bytes). LoginMember sends it right before SendEnemyGuild's // TPacketGCGuildName (size 20 = 0x14), which is how a real login desynced // into "unknown GC header 200 (last: 75,20)". ClassicStream s; std::string err; s.on_error = [&](const std::string &e) { err = e; }; std::vector>> got; s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) { got.push_back({h, std::vector(body, body + len)}); return true; }; std::vector skill = {HDR_GC_GUILD, 22, 0, 12, 3}; for (uint8_t i = 0; i < 12; ++i) { skill.push_back(static_cast(i + 1)); } skill.insert(skill.end(), {0x10, 0x00, 0x20, 0x00}); // power 16, max_power 32 CHECK(skill.size() == 21, "server SKILL_INFO writes 21 bytes"); std::vector name = {HDR_GC_GUILD, 20, 0, 16, 0x2A, 0, 0, 0}; const char gname[12] = "Wolves"; name.insert(name.end(), gname, gname + sizeof(gname)); CHECK(name.size() == 20, "TPacketGCGuildName is 20 bytes"); std::vector wire = skill; wire.insert(wire.end(), name.begin(), name.end()); s.feed(wire.data(), wire.size()); CHECK(err.empty(), "guild skill info + guild name: no framing error"); CHECK(got.size() == 2, "guild skill info + guild name: two packets delivered"); if (got.size() == 2) { CHECK(got[0].second.size() == 1 + 17 && got[0].second[0] == 12 && got[0].second[1] == 3 && got[0].second[16] == 0x20, "skill info body = subheader + 17 bytes"); CHECK(got[1].first == HDR_GC_GUILD && got[1].second.size() == 17 && got[1].second[0] == 16 && got[1].second[1] == 0x2A, "guild name packet framed intact after skill info"); } } // ---------------------------------------------------------- rejected handler { ClassicStream s; std::string err; s.on_error = [&](const std::string &e) { err = e; }; int calls = 0; s.on_packet = [&](uint8_t, const uint8_t *, uint32_t) { ++calls; return calls > 1; }; GCCharacterDel del{HDR_GC_CHARACTER_DEL, 99}; auto b = raw(del); std::vector glued = b; glued.insert(glued.end(), b.begin(), b.end()); s.feed(glued.data(), glued.size()); CHECK(err.find("packet handler rejected") != std::string::npos, "rejected packet -> explicit on_error"); CHECK(calls == 1, "rejected packet clears pipelined receive bytes"); s.feed(b.data(), b.size()); CHECK(calls == 2, "stream owner survives known handler failure"); } // ---- GC_KEY_AGREEMENT: a partial packet just waits (no error, no crash) ---- // (the full DH2 exchange lives in net_classic_encstream_test.) { ClassicStream s; std::string err; s.on_error = [&](const std::string &e) { err = e; }; uint8_t ka[100] = {HDR_KEY_AGREEMENT}; // < sizeof(KeyAgreement) == 261 s.feed(ka, sizeof(ka)); s.feed(ka, sizeof(ka)); // still short (200 < 261) CHECK(err.empty(), "partial GC_KEY_AGREEMENT -> buffered, no error"); } // ------------------------------------------------ non-blocking connect gate { // TEST-NET-1 gives us a deterministic unroutable destination on normal // CI hosts. If the environment rejects it synchronously, keep the rest of // the socket-free suite usable; when it is pending, limit_ms=0 proves that // Connecting is not promoted to Online before writability/timeout. ClassicStream s; if (s.connect("192.0.2.1", 9, 0) && s.state() == ClassicStream::State::Connecting) { s.process(); CHECK(s.state() == ClassicStream::State::Offline, "pending classic connect times out before Online"); CHECK(s.last_error() == "connect timeout", "pending classic connect reports timeout"); } else { std::puts("classic connect timeout probe skipped (connect was synchronous)"); } } if (g_fail) { std::fprintf(stderr, "%d check(s) failed\n", g_fail); return 1; } std::puts("net_classic_stream_test OK"); return 0; }