40250 classic: increment 50 (Phase 1 W1) — §1.5 client version, §1.10 text codec, §2.2 two-packet merge
§1.5 CG_CLIENT_VERSION: send_client_version() now branches on locale. EUROPE (this project's locale) sends CGClientVersion2 (header 0xf1, 67B, timestamp "1215955205"); non-EUROPE sends CGClientVersion (0xfd) + CMake-injected MT_BUILD_TIMESTAMP in C __TIMESTAMP__ form. New ClassicSession::set_executable_name() (default "metin2.bin"; TODO(W0): M2Client passes OS::get_executable_path().get_file()). m_version_sent set only after send_fixed() succeeds. wire_classic.h gains CGClientVersion2 + size-table entry. §1.10 fixed-length text: new extension/src/net/text_codec.h — to_wire()/ from_wire_str() apply a hard byte cap without splitting a UTF-8 multibyte sequence; godot::String overloads are __has_include-guarded so mtnet stays godot-free. classic_session.cpp routes 8 fixed-length name/sign/comment fills through to_wire(); parser reads GC_CHAR_ADDITIONAL_INFO name via from_wire_str(). New net_text_codec_test (ctest net.text_codec). Locale codepage (CP949/CP1252) transcoding remains a follow-up; wire bytes are currently UTF-8 (ASCII round-trips). §2.2 two-packet PC/NPC merge: ClassicParser GC_CHARACTER_ADD drops invisible races (20025/20038/20039); PC/NPC only stash a bare Entity into m_pending_actor; everything else spawns immediately with a name from root/npclist.txt (new npc_names.h + set_npclist_path(); $MT_ASSETS/$M2_ASSETS fallback; TODO(W0): M2Client passes the asset root). GC_CHAR_ADDITIONAL_INFO with no pending entry records m_last_error + m_unhandled_headers and drops (never half-creates); on a hit it merges the pending Entity with the additional-info's 9 fields into one mut_spawn_full() then erases the pending entry. New pending_actor_count() test hook; net_classic_session_test updated (version assertions -> 0xf1; stash -> merge -> spawn, immediate monster spawn, invisible-race drop, no-pending additional-info is a no-op). Depends on W2 §2.2 steps 4-5 (entity_store mut_char_info touch / mut_shop_sign cleanup) — the merge works with current W2 code as-is. Build + ctest 17/17 (16 + net.text_codec); netbridge_test, p10_test green. Docs: CLIENT-GAP.md W1 row + increment-50 change-log entry; CLIENT-GAP-FIX.md §1.5/§1.10/§2.2 status + facts and C.5 batch record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f32064c74a
commit
b296457e3c
@@ -213,25 +213,74 @@ int main() {
|
||||
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);
|
||||
CHECK(version.size() == sizeof(CGClientVersion) + 1, "CG_CLIENT_VERSION + seq");
|
||||
CGClientVersion cv{};
|
||||
// §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_VERSION && std::strcmp(cv.filename, "metin2.bin") == 0 &&
|
||||
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(CGClientVersion)] == SEQUENCE_TABLE[2], "version seq == table[2]");
|
||||
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 = 1; // NPC
|
||||
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 == 1 && npc->race == 101, "npc spawned");
|
||||
CHECK(s.world().size() == 2, "2 entities");
|
||||
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;
|
||||
@@ -399,7 +448,7 @@ int main() {
|
||||
// --- 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_VERSION, [3]=CG_ENTERGAME.
|
||||
// [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");
|
||||
@@ -1130,19 +1179,27 @@ int main() {
|
||||
out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire");
|
||||
}
|
||||
|
||||
// --- GC_CHAR_ADDITIONAL_INFO(136): fills a spawned entity's name/parts/level ---
|
||||
// --- 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.empire = 1;
|
||||
ci.guild_id = 0;
|
||||
ci.level = 12;
|
||||
feed(s, raw(ci));
|
||||
|
||||
const auto *e = s.world().get(8888);
|
||||
CHECK(e && e->name == "Goblin" && e->level == 12 && e->parts[0] == 3, "char_add_info");
|
||||
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 ---
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// net_text_codec_test — §1.10 fixed-length wire text: to_wire() / from_wire_str()
|
||||
// must apply a hard BYTE cap (like the stock client's strncpy) while never
|
||||
// splitting a UTF-8 multi-byte sequence.
|
||||
#include "../src/net/text_codec.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(c, msg) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
std::fprintf(stderr, "FAIL: %s\n", msg); \
|
||||
++g_fail; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// A CJK code point is 3 bytes in UTF-8 (U+6C49 "汉" = e6 b1 89).
|
||||
static std::string cjk(int n) {
|
||||
std::string s;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
s += "\xE6\xB1\x89";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
int main() {
|
||||
using mtnet::to_wire;
|
||||
using mtnet::from_wire_str;
|
||||
|
||||
// --- CHARACTER_NAME_MAX_LEN-equivalent cap = 24 bytes ---
|
||||
// 8 CJK chars == exactly 24 bytes: accepted whole.
|
||||
{
|
||||
const std::string in = cjk(8);
|
||||
CHECK(in.size() == 24, "8 CJK == 24 bytes");
|
||||
const std::string w = to_wire(in, 24);
|
||||
CHECK(w.size() == 24 && w == in, "exactly-24 accepted unchanged");
|
||||
}
|
||||
|
||||
// 9 CJK chars == 27 bytes: truncated to 24 (8 chars), never 25/26.
|
||||
{
|
||||
const std::string in = cjk(9);
|
||||
CHECK(in.size() == 27, "9 CJK == 27 bytes");
|
||||
const std::string w = to_wire(in, 24);
|
||||
CHECK(w.size() == 24, "27 -> 24 bytes");
|
||||
CHECK(w == cjk(8), "truncated on the code-point boundary (8 chars)");
|
||||
}
|
||||
|
||||
// 25-byte input (8 CJK + 1 stray lead byte): a 24-byte cap keeps 8 chars,
|
||||
// and even a 25- or 26-byte cap cannot fit the 9th char -> still 24.
|
||||
{
|
||||
std::string in = cjk(8);
|
||||
in += '\xE6'; // dangling lead byte of a 9th char
|
||||
CHECK(in.size() == 25, "25-byte input");
|
||||
CHECK(to_wire(in, 24).size() == 24, "cap 24 -> 24");
|
||||
CHECK(to_wire(in, 25) == cjk(8), "cap 25 -> 24 (no split)");
|
||||
CHECK(to_wire(in, 26) == cjk(8), "cap 26 -> 24 (9th char needs 3)");
|
||||
}
|
||||
|
||||
// Mid-sequence cap: cutting 2 bytes into a 3-byte char drops the whole char.
|
||||
{
|
||||
const std::string in = cjk(4); // 12 bytes
|
||||
CHECK(to_wire(in, 11).size() == 9, "cap 11 -> 9 (3 whole chars)");
|
||||
CHECK(to_wire(in, 10).size() == 9, "cap 10 -> 9");
|
||||
CHECK(to_wire(in, 12) == in, "cap 12 -> all 12");
|
||||
}
|
||||
|
||||
// ASCII round-trips against the same cap unchanged.
|
||||
{
|
||||
const std::string in = "Warrior";
|
||||
CHECK(to_wire(in, 24) == in, "ascii under cap unchanged");
|
||||
CHECK(to_wire(std::string(40, 'x'), 24).size() == 24, "ascii over cap -> 24");
|
||||
}
|
||||
|
||||
// --- from_wire_str: stop at NUL, then guard a dangling partial sequence ---
|
||||
{
|
||||
char buf[25] = {};
|
||||
const std::string src = cjk(3); // 9 bytes
|
||||
std::memcpy(buf, src.data(), src.size());
|
||||
CHECK(from_wire_str(buf, sizeof(buf) - 1) == src, "reads up to NUL");
|
||||
}
|
||||
{
|
||||
char buf[25];
|
||||
std::memset(buf, 0, sizeof(buf));
|
||||
const std::string src = cjk(8);
|
||||
std::memcpy(buf, src.data(), src.size());
|
||||
buf[24] = '\xE6'; // 25th byte: lead of a truncated char, no NUL room
|
||||
const std::string got = from_wire_str(buf, 25);
|
||||
CHECK(got == cjk(8), "dangling lead byte trimmed on read");
|
||||
}
|
||||
{
|
||||
// Full 24-byte field, no NUL terminator: read all 8 chars.
|
||||
char buf[24];
|
||||
const std::string src = cjk(8);
|
||||
std::memcpy(buf, src.data(), src.size());
|
||||
CHECK(from_wire_str(buf, 24) == src, "unterminated full field read whole");
|
||||
}
|
||||
|
||||
if (g_fail) {
|
||||
std::fprintf(stderr, "%d checks FAILED\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::puts("net_text_codec_test: OK");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user