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:
shenlei
2026-09-02 15:51:48 +09:00
co-authored by Claude Sonnet 5
parent f32064c74a
commit b296457e3c
12 changed files with 742 additions and 74 deletions
+106
View File
@@ -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;
}