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
@@ -1,10 +1,31 @@
|
||||
#include "classic_parser.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mtnet::classic {
|
||||
|
||||
void ClassicParser::set_npclist_path(std::string path) {
|
||||
if (path == m_npclist_path) {
|
||||
return;
|
||||
}
|
||||
m_npclist_path = std::move(path);
|
||||
m_npc_names.clear();
|
||||
m_npc_names_loaded = false;
|
||||
}
|
||||
|
||||
const std::string &ClassicParser::npc_name_for(uint16_t race) {
|
||||
static const std::string kEmpty;
|
||||
if (!m_npc_names_loaded) {
|
||||
m_npc_names = load_npclist(m_npclist_path.empty() ? default_npclist_path() : m_npclist_path);
|
||||
m_npc_names_loaded = true;
|
||||
}
|
||||
const auto it = m_npc_names.find(race);
|
||||
return it == m_npc_names.end() ? kEmpty : it->second;
|
||||
}
|
||||
|
||||
void ClassicParser::parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
|
||||
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle, uint32_t rkey) {
|
||||
m_slots.clear();
|
||||
@@ -207,7 +228,36 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!fill(p, header, body, len)) {
|
||||
return false;
|
||||
}
|
||||
m_world.mut_spawn(p.vid, p.race, p.type, /*name*/ std::string(), (float)p.x, (float)p.y,
|
||||
// §0.3 / §2.2: the reference client (IsInvisibleRace) never spawns
|
||||
// these — drop before any stash / spawn.
|
||||
if (is_invisible_race(p.race)) {
|
||||
return true;
|
||||
}
|
||||
if (p.type == CHRTYPE_PC || p.type == CHRTYPE_NPC) {
|
||||
// Two-packet actor: GC_CHARACTER_ADD carries no name / guild /
|
||||
// parts, so stash a bare record and wait for
|
||||
// GC_CHAR_ADDITIONAL_INFO to merge + flush it. The world model
|
||||
// is untouched until then (§0.2 data layer only).
|
||||
Entity &e = m_pending_actor[p.vid];
|
||||
e = Entity{};
|
||||
e.vid = p.vid;
|
||||
e.race = p.race;
|
||||
e.ch_type = p.type;
|
||||
e.x = (float)p.x;
|
||||
e.y = (float)p.y;
|
||||
e.z = (float)p.z;
|
||||
e.angle = p.angle;
|
||||
e.moving_speed = p.moving_speed;
|
||||
e.attack_speed = p.attack_speed;
|
||||
e.state_flags = p.state_flag;
|
||||
e.affect_flags = (uint64_t)p.affect_flag[0] |
|
||||
((uint64_t)p.affect_flag[1] << 32);
|
||||
return true;
|
||||
}
|
||||
// Monster / stone / door / object: appended immediately, name from
|
||||
// npclist.txt (race -> code, like CPythonNonPlayer::GetName); every
|
||||
// PC-specific field stays 0.
|
||||
m_world.mut_spawn(p.vid, p.race, p.type, npc_name_for(p.race), (float)p.x, (float)p.y,
|
||||
(float)p.z, p.angle, p.moving_speed, p.attack_speed);
|
||||
return true;
|
||||
}
|
||||
@@ -396,9 +446,31 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
|
||||
if (!fill(p, header, body, len)) {
|
||||
return false;
|
||||
}
|
||||
m_world.mut_char_info(p.vid, std::string(p.name, strnlen(p.name, sizeof(p.name))),
|
||||
p.parts, p.empire, (int32_t)p.guild_id, (int32_t)p.level, p.alignment,
|
||||
p.pk_mode, p.mount_vnum);
|
||||
// §2.2: this only ever completes a pending GC_CHARACTER_ADD (the
|
||||
// reference merges into s_kNetActorData, populated by that add). No
|
||||
// pending record -> nothing to build a PC/NPC from: record + drop,
|
||||
// never half-create.
|
||||
const auto it = m_pending_actor.find(p.vid);
|
||||
if (it == m_pending_actor.end()) {
|
||||
m_last_error = "GC_CHAR_ADDITIONAL_INFO: no pending actor for vid " +
|
||||
std::to_string(p.vid);
|
||||
m_unhandled_headers.push_back(header);
|
||||
return true;
|
||||
}
|
||||
Entity e = it->second;
|
||||
e.vid = p.vid;
|
||||
e.name = mtnet::from_wire_str(p.name, sizeof(p.name)); // §1.10
|
||||
e.guild = (int32_t)p.guild_id;
|
||||
e.level = (int32_t)p.level;
|
||||
e.alignment = p.alignment;
|
||||
e.pk_mode = p.pk_mode;
|
||||
e.empire = p.empire;
|
||||
for (int i = 0; i < CHR_EQUIPPART_NUM; ++i) {
|
||||
e.parts[i] = p.parts[i];
|
||||
}
|
||||
e.mount_vnum = p.mount_vnum;
|
||||
m_world.mut_spawn_full(e);
|
||||
m_pending_actor.erase(it);
|
||||
return true;
|
||||
}
|
||||
case HDR_GC_SKILL_LEVEL: { // 76
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// [header][uint16 size] prefix); `len` its length — i.e. exactly what
|
||||
// ClassicStream::on_packet delivers.
|
||||
|
||||
#include "npc_names.h"
|
||||
#include "wire_classic.h"
|
||||
|
||||
#include "../entity_store.h"
|
||||
@@ -67,6 +68,16 @@ public:
|
||||
uint8_t empire() const { return m_empire; }
|
||||
void set_phase_name(const std::string &name) { m_phase_name = name; }
|
||||
const std::string &last_error() const { return m_last_error; }
|
||||
// §2.2: path to root/npclist.txt (race -> model-code table). Used to name
|
||||
// monster / stone / object spawns, mirroring CPythonNonPlayer::GetName().
|
||||
// Empty path -> names resolve to "" (permitted). Falls back to
|
||||
// $MT_ASSETS / $M2_ASSETS when never set.
|
||||
// TODO(W0): M2Client should call
|
||||
// parser().set_npclist_path(AssetRoot::path("root/npclist.txt"))
|
||||
void set_npclist_path(std::string path);
|
||||
// §2.2 test hook: number of GC_CHARACTER_ADD records awaiting their
|
||||
// GC_CHAR_ADDITIONAL_INFO merge.
|
||||
size_t pending_actor_count() const { return m_pending_actor.size(); }
|
||||
std::vector<uint8_t> drain_unhandled_headers() {
|
||||
auto v = std::move(m_unhandled_headers);
|
||||
m_unhandled_headers.clear();
|
||||
@@ -101,12 +112,18 @@ private:
|
||||
void parse_login_success(const SimplePlayer *players, const uint32_t *guild_id,
|
||||
const char (*guild_name)[GUILD_NAME_MAX_LEN + 1], int n, uint32_t handle,
|
||||
uint32_t rkey);
|
||||
// Lazily loads npclist.txt on first use; returns "" for an unknown race or
|
||||
// when no list is available.
|
||||
const std::string &npc_name_for(uint16_t race);
|
||||
|
||||
EntityStore &m_world;
|
||||
// §2.2 two-packet PC/NPC merge: GC_CHARACTER_ADD stages a bare Entity here
|
||||
// keyed by vid; GC_CHAR_ADDITIONAL_INFO merges its fields and flushes the
|
||||
// combined record via m_world.mut_spawn_full(). W1 owns the merge logic.
|
||||
std::unordered_map<uint32_t, Entity> m_pending_actor;
|
||||
std::string m_npclist_path;
|
||||
std::unordered_map<uint32_t, std::string> m_npc_names;
|
||||
bool m_npc_names_loaded = false;
|
||||
std::vector<CharSlot> m_slots;
|
||||
int m_slot_count = 0;
|
||||
uint32_t m_handle = 0;
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#ifndef MT_BUILD_TIMESTAMP
|
||||
// Injected by CMake (extension/CMakeLists.txt) in C __TIMESTAMP__ form
|
||||
// ("Www Mmm dd hh:mm:ss yyyy"); §1.5 uses it as the non-EUROPE
|
||||
// CG_CLIENT_VERSION timestamp. This fallback keeps a non-CMake build compiling.
|
||||
#define MT_BUILD_TIMESTAMP __TIMESTAMP__
|
||||
#endif
|
||||
|
||||
namespace mtnet::classic {
|
||||
|
||||
namespace {
|
||||
@@ -419,7 +426,8 @@ bool ClassicSession::create_character(int slot, const std::string &name, int job
|
||||
CGPlayerCreate p{};
|
||||
p.header = HDR_CG_CHARACTER_CREATE;
|
||||
p.index = static_cast<uint8_t>(slot);
|
||||
std::memcpy(p.name, name.data(), name.size());
|
||||
const std::string wire_name = mtnet::to_wire(name, sizeof(p.name) - 1); // §1.10
|
||||
std::memcpy(p.name, wire_name.data(), wire_name.size());
|
||||
p.job = static_cast<uint16_t>(job);
|
||||
p.shape = static_cast<uint8_t>(shape);
|
||||
p.con = static_cast<uint8_t>(con);
|
||||
@@ -449,7 +457,8 @@ bool ClassicSession::change_name(int slot, const std::string &name) {
|
||||
CGChangeName p{};
|
||||
p.header = HDR_CG_CHANGE_NAME;
|
||||
p.index = static_cast<uint8_t>(slot);
|
||||
std::memcpy(p.name, name.data(), name.size());
|
||||
const std::string wire_name = mtnet::to_wire(name, sizeof(p.name) - 1); // §1.10
|
||||
std::memcpy(p.name, wire_name.data(), wire_name.size());
|
||||
return m_stream.send_fixed(&p, sizeof(p));
|
||||
}
|
||||
|
||||
@@ -465,13 +474,31 @@ bool ClassicSession::send_client_version() {
|
||||
if (m_version_sent) {
|
||||
return true;
|
||||
}
|
||||
CGClientVersion p{};
|
||||
// The stock 40250 game client sends the version report immediately after
|
||||
// receiving the main-character packet and advances the normal CG sequence.
|
||||
p.header = HDR_CG_CLIENT_VERSION;
|
||||
std::strncpy(p.filename, "metin2.bin", sizeof(p.filename) - 1);
|
||||
std::strncpy(p.timestamp, "1215955205", sizeof(p.timestamp) - 1);
|
||||
if (!m_stream.send_fixed(&p, sizeof(p))) {
|
||||
// §1.5: EUROPE-family locales send CG_CLIENT_VERSION2 (0xf1) with the fixed
|
||||
// ymir epoch string; every other locale sends CG_CLIENT_VERSION (0xfd) with
|
||||
// the build timestamp. All fixed-length text fills go through
|
||||
// mtnet::to_wire() (§1.10) — a hard byte cap on a code-point boundary.
|
||||
bool ok = false;
|
||||
if (m_locale_is_europe) {
|
||||
CGClientVersion2 p{};
|
||||
p.header = HDR_CG_CLIENT_VERSION2;
|
||||
const std::string fn = mtnet::to_wire(m_executable_name, sizeof(p.filename) - 1);
|
||||
std::memcpy(p.filename, fn.data(), fn.size());
|
||||
const std::string ts = mtnet::to_wire(std::string_view("1215955205"), sizeof(p.timestamp) - 1);
|
||||
std::memcpy(p.timestamp, ts.data(), ts.size());
|
||||
ok = m_stream.send_fixed(&p, sizeof(p));
|
||||
} else {
|
||||
CGClientVersion p{};
|
||||
p.header = HDR_CG_CLIENT_VERSION;
|
||||
const std::string fn = mtnet::to_wire(m_executable_name, sizeof(p.filename) - 1);
|
||||
std::memcpy(p.filename, fn.data(), fn.size());
|
||||
const std::string ts = mtnet::to_wire(std::string_view(MT_BUILD_TIMESTAMP), sizeof(p.timestamp) - 1);
|
||||
std::memcpy(p.timestamp, ts.data(), ts.size());
|
||||
ok = m_stream.send_fixed(&p, sizeof(p));
|
||||
}
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
m_version_sent = true;
|
||||
@@ -808,7 +835,8 @@ bool ClassicSession::send_whisper(const std::string &to, const std::string &text
|
||||
CGWhisper p{};
|
||||
p.header = HDR_CG_WHISPER;
|
||||
p.size = static_cast<uint16_t>(total);
|
||||
std::memcpy(p.name_to, to.data(), to.size());
|
||||
const std::string wire_to = mtnet::to_wire(to, sizeof(p.name_to) - 1); // §1.10
|
||||
std::memcpy(p.name_to, wire_to.data(), wire_to.size());
|
||||
std::memcpy(buf.data(), &p, sizeof(p));
|
||||
std::memcpy(buf.data() + sizeof(p), text.data(), text.size());
|
||||
return m_stream.send_dynamic(buf.data(), buf.size());
|
||||
@@ -974,7 +1002,8 @@ bool ClassicSession::send_private_shop(const std::string &sign,
|
||||
std::vector<uint8_t> buf(total, 0);
|
||||
CGMyShopHead head{};
|
||||
head.header = HDR_CG_MYSHOP;
|
||||
std::memcpy(head.sign, sign.data(), sign.size());
|
||||
const std::string wire_sign = mtnet::to_wire(sign, sizeof(head.sign) - 1); // §1.10
|
||||
std::memcpy(head.sign, wire_sign.data(), wire_sign.size());
|
||||
head.count = static_cast<uint8_t>(n);
|
||||
std::memcpy(buf.data(), &head, sizeof(head));
|
||||
if (n > 0) {
|
||||
@@ -1059,7 +1088,8 @@ bool ClassicSession::send_guild_grade_name(uint8_t grade, const std::string &nam
|
||||
CGGuild head{HDR_CG_GUILD, GUILD_CG_CHANGE_GRADE_NAME};
|
||||
std::memcpy(buf, &head, sizeof(head));
|
||||
buf[sizeof(head)] = grade;
|
||||
std::memcpy(buf + sizeof(head) + 1, name.data(), name.size());
|
||||
const std::string wire_name = mtnet::to_wire(name, GUILD_GRADE_NAME_MAX_LEN); // §1.10
|
||||
std::memcpy(buf + sizeof(head) + 1, wire_name.data(), wire_name.size());
|
||||
return m_stream.send_fixed(buf, sizeof(buf));
|
||||
}
|
||||
|
||||
@@ -1096,12 +1126,13 @@ bool ClassicSession::send_guild_member_general(uint32_t pid, bool enabled) {
|
||||
bool ClassicSession::send_guild_comment(const std::string &text) {
|
||||
if (m_stage != Stage::InGame || text.empty() ||
|
||||
text.size() + 1 > GUILD_COMMENT_MAX_LEN) return false;
|
||||
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + text.size() + 1, 0);
|
||||
const std::string wire_text = mtnet::to_wire(text, GUILD_COMMENT_MAX_LEN - 1); // §1.10
|
||||
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + wire_text.size() + 1, 0);
|
||||
CGGuild head{HDR_CG_GUILD, GUILD_CG_POST_COMMENT};
|
||||
std::memcpy(buf.data(), &head, sizeof(head));
|
||||
buf[sizeof(head)] = static_cast<uint8_t>(text.size() + 1);
|
||||
if (!text.empty()) {
|
||||
std::memcpy(buf.data() + sizeof(head) + 1, text.data(), text.size());
|
||||
buf[sizeof(head)] = static_cast<uint8_t>(wire_text.size() + 1);
|
||||
if (!wire_text.empty()) {
|
||||
std::memcpy(buf.data() + sizeof(head) + 1, wire_text.data(), wire_text.size());
|
||||
}
|
||||
return m_stream.send_fixed(buf.data(), buf.size());
|
||||
}
|
||||
@@ -1120,7 +1151,8 @@ bool ClassicSession::send_guild_answer_make(const std::string &name) {
|
||||
if (m_stage != Stage::InGame || name.empty() || name.size() > GUILD_NAME_MAX_LEN) return false;
|
||||
CGAnswerMakeGuild p{};
|
||||
p.header = HDR_CG_ANSWER_MAKE_GUILD;
|
||||
std::memcpy(p.guild_name, name.data(), name.size());
|
||||
const std::string wire_name = mtnet::to_wire(name, sizeof(p.guild_name) - 1); // §1.10
|
||||
std::memcpy(p.guild_name, wire_name.data(), wire_name.size());
|
||||
return m_stream.send_fixed(&p, sizeof(p));
|
||||
}
|
||||
|
||||
@@ -1164,7 +1196,8 @@ bool ClassicSession::send_friend_add(const std::string &name) {
|
||||
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
|
||||
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_ADD_BY_NAME};
|
||||
std::memcpy(buf.data(), &p, sizeof(p));
|
||||
std::memcpy(buf.data() + sizeof(p), name.data(), name.size());
|
||||
const std::string wire_name = mtnet::to_wire(name, CHARACTER_NAME_MAX_LEN); // §1.10
|
||||
std::memcpy(buf.data() + sizeof(p), wire_name.data(), wire_name.size());
|
||||
return m_stream.send_fixed(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
@@ -1175,7 +1208,8 @@ bool ClassicSession::send_friend_remove(const std::string &name) {
|
||||
std::vector<uint8_t> buf(sizeof(CGMessenger) + CHARACTER_NAME_MAX_LEN, 0);
|
||||
CGMessenger p{HDR_CG_MESSENGER, MESSENGER_CG_REMOVE};
|
||||
std::memcpy(buf.data(), &p, sizeof(p));
|
||||
std::memcpy(buf.data() + sizeof(p), name.data(), name.size());
|
||||
const std::string wire_name = mtnet::to_wire(name, CHARACTER_NAME_MAX_LEN); // §1.10
|
||||
std::memcpy(buf.data() + sizeof(p), wire_name.data(), wire_name.size());
|
||||
return m_stream.send_fixed(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace mtnet::classic {
|
||||
|
||||
@@ -159,6 +160,18 @@ public:
|
||||
// caller drives enter_game()). The 40250 test account sends a large map-load
|
||||
// burst; waiting for that burst to drain avoids racing the server's spawn data.
|
||||
void set_auto_entergame_delay(uint32_t ms) { m_entergame_delay = ms; }
|
||||
// §1.5: the client-version report (CG_CLIENT_VERSION / _VERSION2) carries the
|
||||
// running executable's file name — the stock client passes the launched
|
||||
// .exe/.bin name (CPythonApplication). The net layer has no way to know it,
|
||||
// so it stays "metin2.bin" until a host sets it.
|
||||
// TODO(W0): M2Client should call
|
||||
// classic_sess->set_executable_name(OS::get_executable_path().get_file())
|
||||
void set_executable_name(std::string name) { m_executable_name = std::move(name); }
|
||||
// §1.5: EUROPE-family locales send CG_CLIENT_VERSION2 (0xf1) + the fixed ymir
|
||||
// epoch string "1215955205"; every other locale sends CG_CLIENT_VERSION
|
||||
// (0xfd) + the build timestamp. This project's LOCALE_SERVICE_* resolves to
|
||||
// EUROPE (see docs/CLIENT-GAP-FIX.md §1.5), hence the default.
|
||||
void set_locale_is_europe(bool on) { m_locale_is_europe = on; }
|
||||
void set_wire_trace(bool on) {
|
||||
m_stream.set_wire_trace(on);
|
||||
m_auth_stream.set_wire_trace(on);
|
||||
@@ -200,6 +213,8 @@ private:
|
||||
bool m_authenticated_game = false;
|
||||
bool m_auth_transition = false;
|
||||
bool m_version_sent = false;
|
||||
bool m_locale_is_europe = true;
|
||||
std::string m_executable_name = "metin2.bin";
|
||||
bool m_game_connection_seen = false;
|
||||
bool m_direct_enter = false;
|
||||
int m_direct_enter_slot = -1;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
// npc_names — race -> display name for non-PC/NPC spawns (§2.2 / §0.3).
|
||||
//
|
||||
// Reference: a monster / stone / door / object spawn is appended immediately
|
||||
// with its name taken from CPythonNonPlayer::GetName(race)
|
||||
// (REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp). The localized
|
||||
// mob_proto ships translated szName strings that no longer resolve to a model
|
||||
// folder, so the client keeps root/npclist.txt as the race -> code table
|
||||
// (see project/ui/mob_view.gd, which loads the same file). We reuse that code
|
||||
// as the entity name; PC/NPC names come from GC_CHAR_ADDITIONAL_INFO instead
|
||||
// and never hit this path.
|
||||
//
|
||||
// Line format (tab- or space-separated): "<vnum>\t<code>\t[<folder>]".
|
||||
// Missing / unreadable file -> empty table -> empty name (allowed by §2.2).
|
||||
//
|
||||
// mtnet has no godot-cpp, so this reads with std::ifstream. The asset root is
|
||||
// not plumbed into ClassicParser today: set_npclist_path() lets a future
|
||||
// M2Client pass "<asset_root>/root/npclist.txt"; absent that we fall back to
|
||||
// $MT_ASSETS / $M2_ASSETS. See the cross-file follow-up in the W1 report.
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace mtnet::classic {
|
||||
|
||||
inline std::unordered_map<uint32_t, std::string> load_npclist(const std::string &path) {
|
||||
std::unordered_map<uint32_t, std::string> table;
|
||||
if (path.empty()) {
|
||||
return table;
|
||||
}
|
||||
std::ifstream in(path);
|
||||
if (!in) {
|
||||
return table;
|
||||
}
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
size_t i = 0;
|
||||
const size_t n = line.size();
|
||||
auto skip_ws = [&] {
|
||||
while (i < n && (line[i] == ' ' || line[i] == '\t' || line[i] == '\r')) {
|
||||
++i;
|
||||
}
|
||||
};
|
||||
skip_ws();
|
||||
size_t start = i;
|
||||
while (i < n && std::isdigit(static_cast<unsigned char>(line[i]))) {
|
||||
++i;
|
||||
}
|
||||
if (i == start) {
|
||||
continue; // no leading vnum
|
||||
}
|
||||
const uint32_t vnum = static_cast<uint32_t>(std::strtoul(line.c_str() + start, nullptr, 10));
|
||||
skip_ws();
|
||||
start = i;
|
||||
while (i < n && line[i] != ' ' && line[i] != '\t' && line[i] != '\r') {
|
||||
++i;
|
||||
}
|
||||
const std::string code = line.substr(start, i - start);
|
||||
if (vnum != 0 && !code.empty()) {
|
||||
table.emplace(vnum, code);
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
// Resolve the default npclist.txt location from the environment when no
|
||||
// explicit path was supplied.
|
||||
inline std::string default_npclist_path() {
|
||||
for (const char *var : {"MT_ASSETS", "M2_ASSETS"}) {
|
||||
if (const char *root = std::getenv(var); root && *root) {
|
||||
return std::string(root) + "/root/npclist.txt";
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace mtnet::classic
|
||||
@@ -23,6 +23,11 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// §1.10: every fixed-length `char name[...]` / sign / comment field below is
|
||||
// filled at the call site via mtnet::to_wire(src, cap) (byte cap, no split
|
||||
// multibyte char) and read back via mtnet::from_wire_str().
|
||||
#include "../text_codec.h"
|
||||
|
||||
namespace mtnet::classic {
|
||||
|
||||
// ------------------------------------------------------------------ constants
|
||||
@@ -52,6 +57,28 @@ inline constexpr int GUILD_MARK_WIDTH = 16;
|
||||
inline constexpr int GUILD_MARK_HEIGHT = 12;
|
||||
inline constexpr int MARK_BLOCK_TOTAL_COUNT = 80;
|
||||
|
||||
// CHRTYPE_* — the domain of Entity::ch_type and packet_add_char.bType
|
||||
// (client GameType.h / server char.h). Values line up with the Entity comment.
|
||||
enum : uint8_t {
|
||||
CHRTYPE_PC = 0,
|
||||
CHRTYPE_NPC = 1,
|
||||
CHRTYPE_MONSTER = 2,
|
||||
CHRTYPE_STONE = 3,
|
||||
CHRTYPE_WARP = 4,
|
||||
CHRTYPE_DOOR = 5,
|
||||
CHRTYPE_BUILDING = 6,
|
||||
CHRTYPE_PET = 7,
|
||||
CHRTYPE_HORSE = 8,
|
||||
CHRTYPE_GOTO = 9,
|
||||
};
|
||||
|
||||
// Races the reference client never spawns — REF/UserInterface/
|
||||
// PythonNetworkStreamPhaseGameActor.cpp:66 IsInvisibleRace(). GC_CHARACTER_ADD
|
||||
// for one of these is dropped before any pending/stash step.
|
||||
inline constexpr bool is_invisible_race(uint16_t race) {
|
||||
return race == 20025 || race == 20038 || race == 20039;
|
||||
}
|
||||
|
||||
// EPacketShopSubHeaders (client Packet.h L1864)
|
||||
enum : uint8_t {
|
||||
SHOP_SUB_START = 0,
|
||||
@@ -466,6 +493,17 @@ struct CGClientVersion {
|
||||
};
|
||||
static_assert(sizeof(CGClientVersion) == 67);
|
||||
|
||||
// CG_CLIENT_VERSION2 (header 0xf1) — TPacketCGClientVersion2. Same layout as
|
||||
// CGClientVersion; the EUROPE / non-YMIR locale sends THIS one with a fixed
|
||||
// timestamp "1215955205" (REF/.../PythonNetworkStreamPhaseGame.cpp:4155,
|
||||
// Locale.cpp:186). The 0xfd variant carries the C __TIMESTAMP__ instead.
|
||||
struct CGClientVersion2 {
|
||||
uint8_t header;
|
||||
char filename[33];
|
||||
char timestamp[33];
|
||||
};
|
||||
static_assert(sizeof(CGClientVersion2) == 67);
|
||||
|
||||
struct GCPlayerCreateSuccess { uint8_t header; uint8_t account_slot; SimplePlayer player; };
|
||||
static_assert(sizeof(GCPlayerCreateSuccess) == 2 + 63);
|
||||
struct GCPlayerCreateFailure { uint8_t header; uint8_t type; };
|
||||
@@ -1221,8 +1259,8 @@ constexpr int packet_size_cg(uint8_t h) {
|
||||
case HDR_CG_CHARACTER_DELETE: return sizeof(CGPlayerDelete);
|
||||
case HDR_CG_CHANGE_NAME: return sizeof(CGChangeName);
|
||||
case HDR_CG_EMPIRE: return sizeof(CGEmpire);
|
||||
case HDR_CG_CLIENT_VERSION:
|
||||
case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion);
|
||||
case HDR_CG_CLIENT_VERSION: return sizeof(CGClientVersion);
|
||||
case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion2);
|
||||
case HDR_CG_ENTERGAME: return sizeof(CGEnterGame);
|
||||
case HDR_CG_MOVE: return sizeof(CGMove);
|
||||
case HDR_CG_ATTACK: return sizeof(CGAttack);
|
||||
|
||||
Reference in New Issue
Block a user