- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
477 lines
16 KiB
C++
477 lines
16 KiB
C++
// 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 <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <utility>
|
|
#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 std::vector<uint8_t> drain(ClassicStream &s) {
|
|
std::vector<uint8_t> 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<uint8_t> 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<const char *>(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<uint16_t>(sizeof(GCChatHead) + std::strlen(msg));
|
|
std::vector<uint8_t> 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<uint8_t> 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<uint16_t>(sizeof(whisper) + std::strlen(text));
|
|
std::vector<uint8_t> 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<uint16_t>(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<uint8_t> 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<uint8_t> burst;
|
|
for (int i = 0; i < 4; ++i) {
|
|
auto bytes = raw(del);
|
|
bytes[1] = static_cast<uint8_t>(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<uint8_t> 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<std::pair<uint8_t, std::vector<uint8_t>>> got;
|
|
s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) {
|
|
got.push_back({h, std::vector<uint8_t>(body, body + len)});
|
|
return true;
|
|
};
|
|
std::vector<uint8_t> skill = {HDR_GC_GUILD, 22, 0, 12, 3};
|
|
for (uint8_t i = 0; i < 12; ++i) {
|
|
skill.push_back(static_cast<uint8_t>(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<uint8_t> 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<uint8_t> 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<uint8_t> 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;
|
|
}
|