Implement 40250 classic client port

This commit is contained in:
shenlei
2026-09-01 18:49:28 +09:00
parent 2277b1dd60
commit 8f61990a83
481 changed files with 169826 additions and 293 deletions
@@ -0,0 +1,323 @@
// Ported from ClientVS22/source/EterBase/cipher.cpp (Crypto++ 8.4.0).
#include "classic_cipher.h"
#include <cryptopp/cryptlib.h>
#include <cryptopp/modes.h>
#include <cryptopp/nbtheory.h>
#include <cryptopp/osrng.h>
#include <cryptopp/secblock.h>
#include <cryptopp/dh.h>
#include <cryptopp/dh2.h>
#include <cryptopp/aes.h>
#include <cryptopp/blowfish.h>
#include <cryptopp/camellia.h>
#include <cryptopp/cast.h>
#include <cryptopp/des.h>
#include <cryptopp/idea.h>
#include <cryptopp/mars.h>
#include <cryptopp/rc5.h>
#include <cryptopp/rc6.h>
#include <cryptopp/seed.h>
#include <cryptopp/serpent.h>
#include <cryptopp/shacal2.h>
#include <cryptopp/tea.h>
#include <cryptopp/twofish.h>
#include <algorithm>
#include <cstring>
#include <memory>
using namespace CryptoPP;
namespace mtnet::classic {
namespace {
// Block cipher algorithm selector abstract base class.
struct BlockCipherAlgorithm {
enum {
kDefault, // to give more chances to default algorithm
kRC6,
kMARS,
kTwofish,
kSerpent,
kCAST256,
kIDEA,
k3DES, // DES-EDE2
kCamellia,
kSEED,
kRC5,
kBlowfish,
kTEA,
kSHACAL2,
kMaxAlgorithms
};
BlockCipherAlgorithm() = default;
virtual ~BlockCipherAlgorithm() = default;
static BlockCipherAlgorithm *Pick(int hint);
virtual int GetBlockSize() const = 0;
virtual int GetDefaultKeyLength() const = 0;
virtual SymmetricCipher *CreateEncoder(const CryptoPP::byte *key, size_t keylen,
const CryptoPP::byte *iv) const = 0;
virtual SymmetricCipher *CreateDecoder(const CryptoPP::byte *key, size_t keylen,
const CryptoPP::byte *iv) const = 0;
};
template <class T>
struct BlockCipherDetail : public BlockCipherAlgorithm {
int GetBlockSize() const override { return T::BLOCKSIZE; }
int GetDefaultKeyLength() const override { return T::DEFAULT_KEYLENGTH; }
SymmetricCipher *CreateEncoder(const CryptoPP::byte *key, size_t keylen,
const CryptoPP::byte *iv) const override {
return new typename CTR_Mode<T>::Encryption(key, keylen, iv);
}
SymmetricCipher *CreateDecoder(const CryptoPP::byte *key, size_t keylen,
const CryptoPP::byte *iv) const override {
return new typename CTR_Mode<T>::Decryption(key, keylen, iv);
}
};
BlockCipherAlgorithm *BlockCipherAlgorithm::Pick(int hint) {
BlockCipherAlgorithm *detail;
int selector = hint % kMaxAlgorithms;
switch (selector) {
case kRC6: detail = new BlockCipherDetail<RC6>(); break;
case kMARS: detail = new BlockCipherDetail<MARS>(); break;
case kTwofish: detail = new BlockCipherDetail<Twofish>(); break;
case kSerpent: detail = new BlockCipherDetail<Serpent>(); break;
case kCAST256: detail = new BlockCipherDetail<CAST256>(); break;
case kIDEA: detail = new BlockCipherDetail<IDEA>(); break;
case k3DES: detail = new BlockCipherDetail<DES_EDE2>(); break;
case kCamellia: detail = new BlockCipherDetail<Camellia>(); break;
case kSEED: detail = new BlockCipherDetail<SEED>(); break;
case kRC5: detail = new BlockCipherDetail<RC5>(); break;
case kBlowfish: detail = new BlockCipherDetail<Blowfish>(); break;
case kTEA: detail = new BlockCipherDetail<TEA>(); break;
case kSHACAL2: detail = new BlockCipherDetail<SHACAL2>(); break;
case kDefault:
default: detail = new BlockCipherDetail<Twofish>(); break; // default algorithm
}
return detail;
}
} // namespace
// Key agreement scheme abstract class.
class KeyAgreement {
public:
KeyAgreement() = default;
virtual ~KeyAgreement() = default;
virtual size_t Prepare(void *buffer, size_t *length) = 0;
virtual bool Agree(size_t agreed_length, const void *buffer, size_t length) = 0;
const SecByteBlock &shared() const { return shared_; }
protected:
SecByteBlock shared_;
};
namespace {
// Crypto++ Unified Diffie-Hellman key agreement scheme.
class DH2KeyAgreement : public KeyAgreement {
public:
DH2KeyAgreement() : dh_(), dh2_(dh_) {}
size_t Prepare(void *buffer, size_t *length) override;
bool Agree(size_t agreed_length, const void *buffer, size_t length) override;
private:
DH dh_;
DH2 dh2_;
SecByteBlock spriv_key_;
SecByteBlock epriv_key_;
};
size_t DH2KeyAgreement::Prepare(void *buffer, size_t *length) {
// RFC 5114, 1024-bit MODP Group with 160-bit Prime Order Subgroup.
Integer p("0xB10B8F96A080E01DDE92DE5EAE5D54EC52C99FBCFB06A3C6"
"9A6A9DCA52D23B616073E28675A23D189838EF1E2EE652C0"
"13ECB4AEA906112324975C3CD49B83BFACCBDD7D90C4BD70"
"98488E9C219A73724EFFD6FAE5644738FAA31A4FF55BCCC0"
"A151AF5F0DC8B4BD45BF37DF365C1A65E68CFDA76D4DA708"
"DF1FB2BC2E4A4371");
Integer g("0xA4D1CBD5C3FD34126765A442EFB99905F8104DD258AC507F"
"D6406CFF14266D31266FEA1E5C41564B777E690F5504F213"
"160217B4B01B886A5E91547F9E2749F4D7FBD7D3B9A92EE1"
"909D0D2263F80A76A6A24C087A091F531DBF0A0169B6A28A"
"D662A4D18E73AFA32D779D5918D08BC8858F4DCEF97C2A24"
"855E6EEB22B3B2E5");
Integer q("0xF518AA8781A8DF278ABA4E7D64B7CB9D49462353");
AutoSeededRandomPool rnd;
dh_.AccessGroupParameters().Initialize(p, q, g);
if (!dh_.GetGroupParameters().ValidateGroup(rnd, 3)) {
return 0;
}
p = dh_.GetGroupParameters().GetModulus();
q = dh_.GetGroupParameters().GetSubgroupOrder();
g = dh_.GetGroupParameters().GetGenerator();
Integer v = ModularExponentiation(g, q, p);
if (v != Integer::One()) {
return 0;
}
spriv_key_.New(dh2_.StaticPrivateKeyLength());
epriv_key_.New(dh2_.EphemeralPrivateKeyLength());
SecByteBlock spub_key(dh2_.StaticPublicKeyLength());
SecByteBlock epub_key(dh2_.EphemeralPublicKeyLength());
dh2_.GenerateStaticKeyPair(rnd, spriv_key_, spub_key);
dh2_.GenerateEphemeralKeyPair(rnd, epriv_key_, epub_key);
const size_t spub_key_length = spub_key.size();
const size_t epub_key_length = epub_key.size();
const size_t data_length = spub_key_length + epub_key_length;
if (*length < data_length) {
return 0;
}
*length = data_length;
CryptoPP::byte *buf = (CryptoPP::byte *)buffer;
std::memcpy(buf, spub_key.BytePtr(), spub_key_length);
std::memcpy(buf + spub_key_length, epub_key.BytePtr(), epub_key_length);
return dh2_.AgreedValueLength();
}
bool DH2KeyAgreement::Agree(size_t agreed_length, const void *buffer, size_t length) {
if (agreed_length != dh2_.AgreedValueLength()) {
return false;
}
const size_t spub_key_length = dh2_.StaticPublicKeyLength();
const size_t epub_key_length = dh2_.EphemeralPublicKeyLength();
if (length != (spub_key_length + epub_key_length)) {
return false;
}
shared_.New(dh2_.AgreedValueLength());
const CryptoPP::byte *buf = (const CryptoPP::byte *)buffer;
if (!dh2_.Agree(shared_, spriv_key_, epriv_key_, buf, buf + spub_key_length)) {
return false;
}
return true;
}
} // namespace
// --------------------------------------------------------------------- ClassicCipher
ClassicCipher::ClassicCipher() = default;
ClassicCipher::~ClassicCipher() {
clean_up();
}
void ClassicCipher::clean_up() {
delete encoder_;
encoder_ = nullptr;
delete decoder_;
decoder_ = nullptr;
delete key_agreement_;
key_agreement_ = nullptr;
activated_ = false;
}
void ClassicCipher::encrypt(void *buffer, size_t length) {
if (!activated_ || !encoder_) {
return;
}
encoder_->ProcessData((CryptoPP::byte *)buffer, (const CryptoPP::byte *)buffer, length);
}
void ClassicCipher::decrypt(void *buffer, size_t length) {
if (!activated_ || !decoder_) {
return;
}
decoder_->ProcessData((CryptoPP::byte *)buffer, (const CryptoPP::byte *)buffer, length);
}
size_t ClassicCipher::prepare(void *buffer, size_t *length) {
assert(key_agreement_ == nullptr);
key_agreement_ = new DH2KeyAgreement();
size_t agreed_length = key_agreement_->Prepare(buffer, length);
if (agreed_length == 0) {
delete key_agreement_;
key_agreement_ = nullptr;
}
return agreed_length;
}
bool ClassicCipher::activate(bool polarity, size_t agreed_length, const void *buffer,
size_t length) {
assert(!activated_);
if (!key_agreement_) {
return false;
}
bool result = false;
if (key_agreement_->Agree(agreed_length, buffer, length)) {
result = set_up(polarity);
}
// NOTE: unlike the upstream Cipher, we keep key_agreement_ alive until here
// only; set_up() reads shared() before we free it below.
delete key_agreement_;
key_agreement_ = nullptr;
return result;
}
bool ClassicCipher::set_up(bool polarity) {
const SecByteBlock &shared = key_agreement_->shared();
if (shared.size() < 2) {
return false;
}
int hint_0 = shared.BytePtr()[*(shared.BytePtr()) % shared.size()];
int hint_1 = shared.BytePtr()[*(shared.BytePtr() + 1) % shared.size()];
std::unique_ptr<BlockCipherAlgorithm> algorithm_0(BlockCipherAlgorithm::Pick(hint_0));
std::unique_ptr<BlockCipherAlgorithm> algorithm_1(BlockCipherAlgorithm::Pick(hint_1));
const size_t key_length_0 = algorithm_0->GetDefaultKeyLength();
const size_t iv_length_0 = algorithm_0->GetBlockSize();
if (shared.size() < key_length_0 || shared.size() < iv_length_0) {
return false;
}
const size_t key_length_1 = algorithm_1->GetDefaultKeyLength();
const size_t iv_length_1 = algorithm_1->GetBlockSize();
if (shared.size() < key_length_1 || shared.size() < iv_length_1) {
return false;
}
SecByteBlock key_0(key_length_0), iv_0(iv_length_0);
SecByteBlock key_1(key_length_1), iv_1(iv_length_1);
size_t offset;
key_0.Assign(shared, key_length_0);
offset = std::min(key_length_0, shared.size() - key_length_1);
key_1.Assign(shared.BytePtr() + offset, key_length_1);
offset = shared.size() - iv_length_0;
iv_0.Assign(shared.BytePtr() + offset, iv_length_0);
offset = (offset < iv_length_1 ? 0 : offset - iv_length_1);
iv_1.Assign(shared.BytePtr() + offset, iv_length_1);
if (polarity) {
encoder_ = algorithm_1->CreateEncoder(key_1, key_1.size(), iv_1);
decoder_ = algorithm_0->CreateDecoder(key_0, key_0.size(), iv_0);
} else {
encoder_ = algorithm_0->CreateEncoder(key_0, key_0.size(), iv_0);
decoder_ = algorithm_1->CreateDecoder(key_1, key_1.size(), iv_1);
}
return encoder_ != nullptr && decoder_ != nullptr;
}
} // namespace mtnet::classic
@@ -0,0 +1,52 @@
#pragma once
// ClassicCipher — 40250 _IMPROVED_PACKET_ENCRYPTION_ (docs/CLIENT-40250-PORT.md §3).
// Ported near-verbatim from ClientVS22/source/EterBase/cipher.{h,cpp}
// (Themida / stdafx / logging stripped). Needs Crypto++ 8.4.0 (mt3p::cryptopp).
//
// Prepare(buf,&len) -> generate this end's DH2 public blob (spub||epub),
// returns the agreed-value length (0 = failure).
// Activate(polarity, agreed_len, peer_blob, peer_len)
// -> DH2 Agree() -> derive 2 hint-selected block ciphers in
// CTR mode, wire encoder_/decoder_ per polarity.
// **client polarity = true**, server = false.
// Encrypt/Decrypt(buf,len) in place, no padding (CTR stream).
// activated() flips true only after set_activated(true) (on GC_KEY_AGREEMENT_COMPLETED).
#include <cassert>
#include <cstddef>
namespace CryptoPP {
class SymmetricCipher;
}
namespace mtnet::classic {
class KeyAgreement;
class ClassicCipher {
public:
ClassicCipher();
~ClassicCipher();
void clean_up();
size_t prepare(void *buffer, size_t *length);
bool activate(bool polarity, size_t agreed_length, const void *buffer, size_t length);
void encrypt(void *buffer, size_t length);
void decrypt(void *buffer, size_t length);
bool activated() const { return activated_; }
void set_activated(bool value) { activated_ = value; }
bool key_ready() const { return encoder_ != nullptr && decoder_ != nullptr; }
private:
bool set_up(bool polarity);
bool activated_ = false;
CryptoPP::SymmetricCipher *encoder_ = nullptr;
CryptoPP::SymmetricCipher *decoder_ = nullptr;
KeyAgreement *key_agreement_ = nullptr;
};
} // namespace mtnet::classic
@@ -0,0 +1,297 @@
#pragma once
// ClassicMarkClient — the 40250 guild-mark side connection.
//
// The side socket shares the classic handshake framing with the game socket.
// The stock mark client sends CG_MARK_LOGIN immediately after the first
// GC_HANDSHAKE; depending on the server build, the mark side can then stay
// plaintext or continue through the optional DH2/CTR exchange. Its mark
// protocol has its own frames:
// CG_MARK_* are raw fixed packets (one-byte header),
// GC_MARK_IDXLIST/GC_MARK_BLOCK are [header][u32 whole-size] packets, and
// GC_SYMBOL_DATA is a normal [header][u16 whole-size] dynamic packet.
//
// Keeping this separate from MarkClient is intentional: MarkClient implements
// the m2dev fork's four-byte frame protocol and those bytes are not accepted by
// a stock 40250 mark server.
#include "classic_stream.h"
#include "mark_image.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
namespace mtnet::classic {
class ClassicMarkClient {
public:
enum class Mode { Download, DownloadSymbol, UploadMark, UploadSymbol };
ClassicMarkClient(uint32_t handle, uint32_t random_key) : m_handle(handle), m_random_key(random_key) {
// GuildMarkDownloader does not use the normal game-side time-sync
// response; every handshake is answered by its mark login packet.
m_stream.set_time_sync_mode(false);
m_stream.on_server_handshake = [this]() { on_server_handshake(); };
m_stream.on_cipher_active = [this]() { on_cipher_active(); };
m_stream.on_phase = [this](uint8_t phase) { on_phase(phase); };
m_stream.on_packet = [this](uint8_t header, const uint8_t *body, uint32_t len) {
return on_packet(header, body, len);
};
m_stream.on_raw_packet = [this](uint8_t header, const uint8_t *packet, size_t available) {
return on_raw_packet(header, packet, available);
};
}
bool connect(const std::string &host, uint16_t port) {
if (!m_valid) {
return false;
}
return m_stream.connect(host, port);
}
void disconnect() { m_stream.disconnect(); }
void process() { m_stream.process(); }
ClassicStream::State state() const { return m_stream.state(); }
const std::string &last_error() const { return m_stream.last_error(); }
void set_wire_trace(bool on) { m_stream.set_wire_trace(on); }
void set_upload_mark(uint32_t guild_id,
const uint32_t px[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT]) {
m_mode = Mode::UploadMark;
m_up_gid = guild_id;
std::memcpy(m_up_mark, px, sizeof(m_up_mark));
}
void set_upload_symbol(uint32_t guild_id, std::vector<uint8_t> bytes) {
m_mode = Mode::UploadSymbol;
m_up_gid = guild_id;
m_up_symbol = std::move(bytes);
m_valid = m_up_symbol.size() + sizeof(CGGuildSymbolUpload) <= 0xFFFFu;
}
void set_download_symbol(uint32_t guild_id, uint32_t crc = 0, uint32_t size = 0) {
m_mode = Mode::DownloadSymbol;
m_symbol_gid = guild_id;
m_symbol_crc = crc;
m_symbol_size = size;
}
Mode mode() const { return m_mode; }
bool complete() const { return m_complete; }
bool upload_sent() const { return m_upload_sent; }
bool upload_done() const { return m_upload_sent && m_stream.outgoing_pending() == 0; }
const MarkImageSet &marks() const { return m_marks; }
uint32_t symbol_guild_id() const { return m_symbol_gid; }
const std::vector<uint8_t> &symbol_data() const { return m_symbol_data; }
// Socket-free access is useful for protocol tests and alternative transports.
ClassicStream &stream() { return m_stream; }
MarkImageSet &marks() { return m_marks; }
private:
static constexpr uint32_t kMaxPacket = 8u * 1024 * 1024;
static uint16_t read_u16(const uint8_t *p) {
return static_cast<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8));
}
static uint32_t read_u32(const uint8_t *p) {
return static_cast<uint32_t>(p[0]) |
(static_cast<uint32_t>(p[1]) << 8) |
(static_cast<uint32_t>(p[2]) << 16) |
(static_cast<uint32_t>(p[3]) << 24);
}
static ClassicStream::RawPacketResult raw(ClassicStream::RawPacketStatus status,
size_t bytes = 0) {
return {status, bytes};
}
void on_server_handshake() {
CGMarkLogin p{};
p.header = HDR_CG_MARK_LOGIN;
p.handle = m_handle;
p.random_key = m_random_key;
m_login_sent = m_stream.send_fixed(&p, sizeof(p));
}
void on_cipher_active() {
// The stock 40250 mark client logs in immediately after the first
// GC_HANDSHAKE (while still in PHASE_HANDSHAKE). Do not send a second
// login after key agreement: the server has already switched to its
// login input processor and would reject the duplicate header.
if (!m_login_sent) {
on_server_handshake();
}
}
void on_phase(uint8_t phase) {
if (phase == PHASE_CLOSE) {
m_complete = true;
return;
}
if (phase != PHASE_LOGIN) {
return;
}
if (m_mode == Mode::UploadMark) {
m_upload_sent = send_upload_mark();
m_complete = m_upload_sent;
return;
}
if (m_mode == Mode::UploadSymbol) {
m_upload_sent = send_upload_symbol();
m_complete = m_upload_sent;
return;
}
if (m_mode == Mode::DownloadSymbol) {
CGSymbolCRC p{};
p.header = HDR_CG_SYMBOL_CRC;
p.guild_id = m_symbol_gid;
p.crc = m_symbol_crc;
p.size = m_symbol_size;
m_symbol_crc_sent = m_stream.send_fixed(&p, sizeof(p));
return;
}
if (!m_idx_requested) {
CGMarkIDXList p{HDR_CG_MARK_IDXLIST};
m_idx_requested = m_stream.send_fixed(&p, sizeof(p));
}
}
ClassicStream::RawPacketResult on_raw_packet(uint8_t header, const uint8_t *packet,
size_t available) {
if (header == HDR_GC_MARK_IDXLIST) {
return receive_idxlist(packet, available);
}
if (header == HDR_GC_MARK_BLOCK) {
return receive_block(packet, available);
}
return raw(ClassicStream::RawPacketStatus::NotHandled);
}
bool on_packet(uint8_t header, const uint8_t *body, uint32_t len) {
// ClassicStream has already consumed [header][u16 size] here. The
// remaining body starts with guild_id and then the raw symbol bytes.
if (header == HDR_GC_SYMBOL_DATA && m_mode == Mode::DownloadSymbol) {
if (len < sizeof(uint32_t)) {
return false;
}
m_symbol_gid = read_u32(body);
m_symbol_data.assign(body + sizeof(uint32_t), body + len);
m_complete = true;
}
return true;
}
ClassicStream::RawPacketResult receive_idxlist(const uint8_t *packet, size_t available) {
if (available < sizeof(GCMarkIDXList)) {
return raw(ClassicStream::RawPacketStatus::NeedMore);
}
const uint32_t total = read_u32(packet + 1);
const uint16_t count = read_u16(packet + 5);
const size_t expected = sizeof(GCMarkIDXList) + static_cast<size_t>(count) * 4;
if (total < expected || total > kMaxPacket) {
return raw(ClassicStream::RawPacketStatus::Error);
}
if (available < total) {
return raw(ClassicStream::RawPacketStatus::NeedMore);
}
m_marks.clear();
const size_t done = parse_mark_idxlist(packet + sizeof(GCMarkIDXList),
total - sizeof(GCMarkIDXList), count, m_marks);
if (done != count) {
return raw(ClassicStream::RawPacketStatus::Error);
}
m_needed = m_marks.needed_images();
m_next = 0;
if (m_needed.empty()) {
m_complete = true;
} else {
send_crclist(m_needed[0]);
}
return raw(ClassicStream::RawPacketStatus::Consumed, total);
}
ClassicStream::RawPacketResult receive_block(const uint8_t *packet, size_t available) {
if (available < sizeof(GCMarkBlock)) {
return raw(ClassicStream::RawPacketStatus::NeedMore);
}
const uint32_t total = read_u32(packet + 1);
const uint8_t img_idx = packet[5];
const uint32_t count = read_u32(packet + 6);
if (total < sizeof(GCMarkBlock) || total > kMaxPacket) {
return raw(ClassicStream::RawPacketStatus::Error);
}
if (available < total) {
return raw(ClassicStream::RawPacketStatus::NeedMore);
}
parse_mark_block(packet + sizeof(GCMarkBlock), total - sizeof(GCMarkBlock), img_idx,
count, m_marks);
++m_next;
if (m_next < m_needed.size()) {
send_crclist(m_needed[m_next]);
} else {
m_complete = true;
}
return raw(ClassicStream::RawPacketStatus::Consumed, total);
}
void send_crclist(int img_idx) {
CGMarkCRCList p{};
p.header = HDR_CG_MARK_CRCLIST;
p.img_idx = static_cast<uint8_t>(img_idx);
// All-zero CRCs ask the stock server for every block in the image.
m_stream.send_fixed(&p, sizeof(p));
}
bool send_upload_mark() {
CGMarkUpload p{};
p.header = HDR_CG_MARK_UPLOAD;
p.gid = m_up_gid;
std::memcpy(p.image, m_up_mark, sizeof(p.image));
return m_stream.send_fixed(&p, sizeof(p));
}
bool send_upload_symbol() {
if (m_up_symbol.empty() || !m_valid) {
return false;
}
std::vector<uint8_t> packet(sizeof(CGGuildSymbolUpload) + m_up_symbol.size());
CGGuildSymbolUpload head{};
head.header = HDR_CG_GUILD_SYMBOL_UPLOAD;
head.size = static_cast<uint16_t>(packet.size());
head.guild_id = m_up_gid;
std::memcpy(packet.data(), &head, sizeof(head));
std::memcpy(packet.data() + sizeof(head), m_up_symbol.data(), m_up_symbol.size());
return m_stream.send_dynamic(packet.data(), packet.size());
}
ClassicStream m_stream;
uint32_t m_handle = 0;
uint32_t m_random_key = 0;
bool m_valid = true;
bool m_login_sent = false;
bool m_idx_requested = false;
bool m_complete = false;
bool m_upload_sent = false;
bool m_symbol_crc_sent = false;
Mode m_mode = Mode::Download;
uint32_t m_up_gid = 0;
uint32_t m_up_mark[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT] = {};
std::vector<uint8_t> m_up_symbol;
uint32_t m_symbol_gid = 0;
uint32_t m_symbol_crc = 0;
uint32_t m_symbol_size = 0;
std::vector<uint8_t> m_symbol_data;
MarkImageSet m_marks;
std::vector<int> m_needed;
size_t m_next = 0;
};
} // namespace mtnet::classic
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
#pragma once
// ClassicParser — 40250 GC packet bodies -> shared world model (EntityStore via
// its protocol-neutral mut_* API) + login/char-list state.
// Counterpart of EntityStore::apply() for MT_PROTOCOL=classic.
// See docs/CLIENT-40250-PORT.md §4 / §5.
//
// `body` is the bytes AFTER the 1-byte header (for dynamic packets: after the
// [header][uint16 size] prefix); `len` its length — i.e. exactly what
// ClassicStream::on_packet delivers.
#include "wire_classic.h"
#include "../entity_store.h"
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
namespace mtnet::classic {
struct CharSlot {
uint32_t id = 0;
std::string name;
uint8_t job = 0;
uint8_t level = 0;
uint8_t st = 0, ht = 0, dx = 0, iq = 0;
uint32_t play_minutes = 0;
int32_t x = 0, y = 0;
uint16_t main_part = 0, hair_part = 0;
uint8_t skill_group = 0;
// Server requires this character to be renamed before entering the game.
bool change_name = false;
uint32_t guild_id = 0;
std::string guild_name;
bool empty() const { return id == 0 && name.empty(); }
};
class ClassicParser {
public:
struct CharEvent {
enum Kind { CreateOk, CreateFail, DeleteOk, DeleteFail } kind;
int slot = -1;
int fail_type = 0;
};
struct NameEvent {
uint32_t pid = 0;
std::string name;
};
explicit ClassicParser(EntityStore &world) : m_world(world) {}
// one GC packet. returns false to abort the connection.
bool on_gc(uint8_t header, const uint8_t *body, uint32_t len);
// --- login / select state ---
const std::vector<CharSlot> &char_slots() const { return m_slots; }
int slot_count() const { return m_slot_count; } // 3 or 4
uint32_t handle() const { return m_handle; }
uint32_t random_key() const { return m_random_key; }
bool char_list_ready() const { return m_char_list_ready; }
const std::string &login_failure() const { return m_login_failure; }
uint32_t login_key() const { return m_login_key; } // GC_LOGIN_KEY (auth path)
uint8_t empire() const { return m_empire; }
std::vector<CharEvent> drain_char_events() {
auto v = std::move(m_char_events);
m_char_events.clear();
return v;
}
std::vector<NameEvent> drain_name_events() {
auto v = std::move(m_name_events);
m_name_events.clear();
return v;
}
int drain_guild_make_requests() {
int count = m_guild_make_requests;
m_guild_make_requests = 0;
return count;
}
private:
template <class T>
static bool fill(T &t, uint8_t header, const uint8_t *body, uint32_t len) {
if (len + 1u < sizeof(T)) {
return false;
}
t.header = header;
std::memcpy(reinterpret_cast<uint8_t *>(&t) + 1, body, sizeof(T) - 1);
return true;
}
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);
EntityStore &m_world;
std::vector<CharSlot> m_slots;
int m_slot_count = 0;
uint32_t m_handle = 0;
uint32_t m_random_key = 0;
uint32_t m_login_key = 0;
uint8_t m_empire = 0;
std::vector<CharEvent> m_char_events;
std::vector<NameEvent> m_name_events;
int m_guild_make_requests = 0;
bool m_char_list_ready = false;
std::string m_login_failure;
};
} // namespace mtnet::classic
@@ -0,0 +1,947 @@
#include "classic_session.h"
#include <algorithm>
#include <cstring>
#include <vector>
namespace mtnet::classic {
ClassicSession::ClassicSession() : m_parser(m_world) {
m_stream.on_phase = [this](uint8_t p) { on_phase(p); };
m_stream.on_packet = [this](uint8_t h, const uint8_t *b, uint32_t l) {
return on_packet(h, b, l);
};
m_stream.on_error = [this](const std::string &e) {
m_last_error = e;
set_stage(Stage::Failed);
if (on_error) {
on_error(e);
}
};
m_stream.on_state_change = [this](ClassicStream::State s) {
if (s == ClassicStream::State::Offline && m_stage != Stage::Failed &&
m_stage != Stage::Offline) {
m_last_error = m_stream.last_error().empty() ? "disconnected" : m_stream.last_error();
set_stage(Stage::Failed);
} else if (s == ClassicStream::State::Online && m_stage == Stage::Connecting) {
// handshake packets flow next; stay in Connecting until GC_PHASE(LOGIN)
}
};
}
void ClassicSession::set_stage(Stage s) {
if (m_stage == s) {
return;
}
m_stage = s;
if (on_stage_change) {
on_stage_change(s);
}
}
void ClassicSession::start_offline(const std::string &id, const std::string &pw) {
m_id = id;
m_pw = pw;
m_login_sent = false;
m_entergame_sent = false;
m_version_sent = false;
m_last_error.clear();
// During PHASE_HANDSHAKE even retry handshakes must be answered with
// 0xFF. The normal game client switches to CG_TIME_SYNC only after the
// server has advanced into the login/select side of the connection.
m_stream.set_time_sync_mode(false);
set_stage(Stage::Connecting);
}
bool ClassicSession::connect(const std::string &game_host, uint16_t game_port,
const std::string &id, const std::string &pw) {
start_offline(id, pw);
if (!m_stream.connect(game_host, game_port)) {
m_last_error = m_stream.last_error();
set_stage(Stage::Failed);
return false;
}
return true;
}
void ClassicSession::disconnect() {
m_stream.disconnect();
set_stage(Stage::Offline);
}
void ClassicSession::pump() {
m_stream.process();
// auto CG_ENTERGAME a beat into PHASE_LOADING (the m2dev client waits ~1.5s;
// sending it too early makes the server drop us mid spawn-burst).
if (m_stage == Stage::Loading && !m_entergame_sent && m_entergame_delay > 0 &&
m_now - m_loading_since >= m_entergame_delay) {
enter_game();
}
m_world.tick();
}
void ClassicSession::send_login() {
if (m_login_sent) {
return;
}
// docs §2.2: the client turns sequence mode on around here; CG_LOGIN is a
// bSeq packet and the server expects the trailing byte from packet #1.
m_stream.set_sequence_mode(true);
CGLogin p{};
p.header = HDR_CG_LOGIN;
std::strncpy(p.login, m_id.c_str(), sizeof(p.login) - 1);
std::strncpy(p.passwd, m_pw.c_str(), sizeof(p.passwd) - 1);
m_stream.send_fixed(&p, sizeof(p));
m_login_sent = true;
}
bool ClassicSession::create_character(int slot, const std::string &name, int job, int shape,
int con, int intel, int str, int dex) {
if (m_stage != Stage::CharSelect || slot < 0 || slot >= m_parser.slot_count() ||
name.empty() || name.size() > CHARACTER_NAME_MAX_LEN) {
return false;
}
CGPlayerCreate p{};
p.header = HDR_CG_CHARACTER_CREATE;
p.index = static_cast<uint8_t>(slot);
std::memcpy(p.name, name.data(), name.size());
p.job = static_cast<uint16_t>(job);
p.shape = static_cast<uint8_t>(shape);
p.con = static_cast<uint8_t>(con);
p.intel = static_cast<uint8_t>(intel);
p.str = static_cast<uint8_t>(str);
p.dex = static_cast<uint8_t>(dex);
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::delete_character(int slot, const std::string &private_code) {
if (m_stage != Stage::CharSelect || slot < 0 || slot >= m_parser.slot_count() ||
private_code.size() > PRIVATE_CODE_LENGTH) {
return false;
}
CGPlayerDelete p{};
p.header = HDR_CG_CHARACTER_DELETE;
p.index = static_cast<uint8_t>(slot);
std::memcpy(p.private_code, private_code.data(), private_code.size());
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::change_name(int slot, const std::string &name) {
if (m_stage != Stage::CharSelect || slot < 0 || slot >= m_parser.slot_count() ||
name.empty() || name.size() > CHARACTER_NAME_MAX_LEN) {
return false;
}
CGChangeName p{};
p.header = HDR_CG_CHANGE_NAME;
p.index = static_cast<uint8_t>(slot);
std::memcpy(p.name, name.data(), name.size());
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_empire(uint8_t empire) {
if (m_stage != Stage::CharSelect) {
return false;
}
CGEmpire p{HDR_CG_EMPIRE, empire};
return m_stream.send_fixed(&p, sizeof(p));
}
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))) {
return false;
}
m_version_sent = true;
return true;
}
void ClassicSession::on_phase(uint8_t phase) {
if (phase == PHASE_LOGIN || phase == PHASE_SELECT || phase == PHASE_LOADING ||
phase == PHASE_GAME || phase == PHASE_DEAD) {
m_stream.set_time_sync_mode(true);
}
switch (phase) {
case PHASE_LOGIN:
set_stage(Stage::LoggingIn);
send_login();
break;
case PHASE_SELECT:
set_stage(Stage::CharSelect);
if (m_parser.char_list_ready() && on_char_list) {
on_char_list(m_parser.char_slots());
}
break;
case PHASE_LOADING:
set_stage(Stage::Loading);
m_loading_since = m_now;
m_entergame_sent = false;
break;
case PHASE_GAME:
set_stage(Stage::InGame);
if (on_entered_game) {
on_entered_game();
}
break;
case PHASE_CLOSE:
m_last_error = m_last_error.empty() ? "server closed phase" : m_last_error;
set_stage(Stage::Failed);
break;
default:
break;
}
}
bool ClassicSession::on_packet(uint8_t header, const uint8_t *body, uint32_t len) {
if (!m_parser.on_gc(header, body, len)) {
m_last_error = "parse error on GC header " + std::to_string(header);
return false;
}
if ((header == HDR_GC_MAIN_CHARACTER || header == HDR_GC_MAIN_CHARACTER3_BGM ||
header == HDR_GC_MAIN_CHARACTER4_BGM_VOL) && !send_client_version()) {
m_last_error = "failed to send client version";
return false;
}
if (!m_parser.login_failure().empty() && m_stage == Stage::LoggingIn) {
m_last_error = "login failed: " + m_parser.login_failure();
set_stage(Stage::Failed);
return false;
}
return true;
}
bool ClassicSession::select_char(int slot) {
// Caller-side misuse: return false but don't poison m_last_error, which is
// reserved for transport/protocol failures ("is the connection healthy").
if (m_stage != Stage::CharSelect) {
return false;
}
if (slot < 0 || slot >= m_parser.slot_count()) {
return false;
}
CGPlayerSelect p{};
p.header = HDR_CG_CHARACTER_SELECT;
p.player_index = static_cast<uint8_t>(slot);
m_stream.send_fixed(&p, sizeof(p));
return true;
}
bool ClassicSession::enter_game() {
if (m_entergame_sent) {
return true;
}
CGEnterGame p{};
p.header = HDR_CG_ENTERGAME;
m_stream.send_fixed(&p, sizeof(p));
m_entergame_sent = true;
return true;
}
// --- in-game intents ---------------------------------------------------------
bool ClassicSession::send_move(uint8_t func, uint8_t arg, float rot_deg, int32_t x, int32_t y,
uint32_t time_ms) {
if (m_stage != Stage::InGame) {
return false;
}
CGMove p{};
p.header = HDR_CG_MOVE;
p.func = func;
p.arg = arg;
p.rot = static_cast<uint8_t>(rot_deg / 5.0f); // classic wire: bRot = deg/5
p.x = x;
p.y = y;
p.time = time_ms ? time_ms : m_now;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_attack(uint8_t type, uint32_t victim_vid) {
if (m_stage != Stage::InGame) {
return false;
}
CGAttack p{};
p.header = HDR_CG_ATTACK;
p.type = type;
p.victim_vid = victim_vid;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_chat(uint8_t type, const std::string &text) {
if (m_stage != Stage::InGame || text.empty() || text.size() >= 512) {
return false;
}
// wire: [header][WORD length][BYTE type][text\0]; length = 4 + strlen + 1
const uint16_t len = static_cast<uint16_t>(sizeof(CGChatHead) + text.size() + 1);
std::vector<uint8_t> buf(len, 0);
CGChatHead h{};
h.header = HDR_CG_CHAT;
h.length = len;
h.type = type;
std::memcpy(buf.data(), &h, sizeof(h));
std::memcpy(buf.data() + sizeof(h), text.data(), text.size());
// buf[len-1] stays 0 (the trailing NUL)
return m_stream.send_dynamic(buf.data(), buf.size());
}
bool ClassicSession::send_target(uint32_t vid) {
if (m_stage != Stage::InGame) {
return false;
}
CGTarget p{HDR_CG_TARGET, vid};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_character_position(uint8_t position) {
if (m_stage != Stage::InGame) {
return false;
}
CGPosition p{HDR_CG_CHARACTER_POSITION, position};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_click_npc(uint32_t vid) {
if (m_stage != Stage::InGame) {
return false;
}
CGOnClick p{HDR_CG_ON_CLICK, vid};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_script_answer(uint8_t answer) {
if (m_stage != Stage::InGame) {
return false;
}
CGScriptAnswer p{HDR_CG_SCRIPT_ANSWER, answer};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_item_use(uint8_t window, uint16_t cell) {
if (m_stage != Stage::InGame) {
return false;
}
CGItemUse p{};
p.header = HDR_CG_ITEM_USE;
p.pos = {window, cell};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_item_move(uint8_t window, uint16_t cell, uint8_t to_window,
uint16_t to_cell, uint8_t count) {
if (m_stage != Stage::InGame) {
return false;
}
CGItemMove p{};
p.header = HDR_CG_ITEM_MOVE;
p.pos = {window, cell};
p.change_pos = {to_window, to_cell};
p.num = count;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_item_drop(uint8_t window, uint16_t cell, uint32_t gold, uint8_t count) {
if (m_stage != Stage::InGame) {
return false;
}
if (count > 0) {
CGItemDrop2 p{};
p.header = HDR_CG_ITEM_DROP2;
p.pos = {window, cell};
p.gold = gold;
p.count = count;
return m_stream.send_fixed(&p, sizeof(p));
}
CGItemDrop p{};
p.header = HDR_CG_ITEM_DROP;
p.pos = {window, cell};
p.gold = gold;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_item_pickup(uint32_t ground_vid) {
if (m_stage != Stage::InGame) {
return false;
}
CGItemPickup p{HDR_CG_ITEM_PICKUP, ground_vid};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_use_skill(uint32_t skill_vnum, uint32_t target_vid) {
if (m_stage != Stage::InGame) {
return false;
}
if (target_vid) {
CGFlyTargeting ft{};
ft.header = HDR_CG_FLY_TARGETING;
ft.target_vid = target_vid;
m_stream.send_fixed(&ft, sizeof(ft));
}
CGUseSkill p{};
p.header = HDR_CG_USE_SKILL;
p.vnum = skill_vnum;
p.target_vid = target_vid;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_item_use_to_item(uint8_t window, uint16_t cell, uint8_t target_window,
uint16_t target_cell) {
if (m_stage != Stage::InGame) {
return false;
}
CGItemUseToItem p{};
p.header = HDR_CG_ITEM_USE_TO_ITEM;
p.pos = {window, cell};
p.target_pos = {target_window, target_cell};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_item_give(uint32_t target_vid, uint8_t window, uint16_t cell,
uint8_t count) {
if (m_stage != Stage::InGame || target_vid == 0 || count == 0) {
return false;
}
CGGiveItem p{};
p.header = HDR_CG_ITEM_GIVE;
p.target_vid = target_vid;
p.pos = {window, cell};
p.count = count;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_request_warp() {
if (m_stage != Stage::InGame) {
return false;
}
CGWarp p{HDR_CG_WARP};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_fishing(uint8_t rot) {
if (m_stage != Stage::InGame) {
return false;
}
CGFishing p{HDR_CG_FISHING, rot};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_dungeon() {
if (m_stage != Stage::InGame) {
return false;
}
CGWarp p{HDR_CG_DUNGEON};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_sync_positions(const std::vector<SyncPosElement> &positions) {
if (m_stage != Stage::InGame || positions.empty() || positions.size() > 16) {
return false;
}
const size_t total = sizeof(DynHead) + positions.size() * sizeof(SyncPosElement);
if (total > 0xffffu) {
return false;
}
std::vector<uint8_t> buf(total, 0);
DynHead head{HDR_CG_SYNC_POSITION, static_cast<uint16_t>(total)};
std::memcpy(buf.data(), &head, sizeof(head));
std::memcpy(buf.data() + sizeof(head), positions.data(),
positions.size() * sizeof(SyncPosElement));
return m_stream.send_fixed(buf.data(), buf.size());
}
bool ClassicSession::send_whisper(const std::string &to, const std::string &text) {
if (m_stage != Stage::InGame || to.empty() || to.size() > CHARACTER_NAME_MAX_LEN ||
text.empty() || text.size() >= 512) {
return false;
}
const size_t total = sizeof(CGWhisper) + text.size() + 1;
if (total > 0xffffu) {
return false;
}
std::vector<uint8_t> buf(total, 0);
CGWhisper p{};
p.header = HDR_CG_WHISPER;
p.size = static_cast<uint16_t>(total);
std::memcpy(p.name_to, to.data(), 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());
}
bool ClassicSession::send_shoot(uint8_t type) {
if (m_stage != Stage::InGame) {
return false;
}
CGShoot p{HDR_CG_SHOOT, type};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_add_fly_targeting(uint32_t target_vid, int32_t x, int32_t y) {
if (m_stage != Stage::InGame) {
return false;
}
CGFlyTargeting p{HDR_CG_ADD_FLY_TARGETING, target_vid, x, y};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_quickslot_add(uint8_t pos, uint8_t type, uint8_t ref) {
if (m_stage != Stage::InGame || pos >= QUICKSLOT_MAX_NUM) {
return false;
}
CGQuickSlotAdd p{HDR_CG_QUICKSLOT_ADD, pos, type, ref};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_quickslot_del(uint8_t pos) {
if (m_stage != Stage::InGame || pos >= QUICKSLOT_MAX_NUM) {
return false;
}
CGQuickSlotDel p{HDR_CG_QUICKSLOT_DEL, pos};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_quickslot_swap(uint8_t pos, uint8_t pos_to) {
if (m_stage != Stage::InGame || pos >= QUICKSLOT_MAX_NUM || pos_to >= QUICKSLOT_MAX_NUM) {
return false;
}
CGQuickSlotSwap p{HDR_CG_QUICKSLOT_SWAP, pos, pos_to};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_script_button(uint32_t index) {
if (m_stage != Stage::InGame) {
return false;
}
CGScriptButton p{HDR_CG_SCRIPT_BUTTON, index};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_script_select_item(uint32_t selection) {
if (m_stage != Stage::InGame) {
return false;
}
CGScriptSelectItem p{HDR_CG_SCRIPT_SELECT_ITEM, selection};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_quest_input(const std::string &text) {
if (m_stage != Stage::InGame || text.size() > 64) {
return false;
}
CGQuestInput p{};
p.header = HDR_CG_QUEST_INPUT_STRING;
std::memcpy(p.msg, text.data(), text.size());
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_quest_confirm(bool yes, uint32_t request_pid) {
if (m_stage != Stage::InGame) {
return false;
}
CGQuestConfirm p{HDR_CG_QUEST_CONFIRM, static_cast<uint8_t>(yes ? 1 : 0), request_pid};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_quest_cancel() {
// The classic protocol has no separate CG_QUEST_CANCEL header. The stock
// client closes a script by answering 255 (the same sentinel exposed by
// script_answer()) and the server routes it through ScriptAnswer.
return send_script_answer(255);
}
bool ClassicSession::send_party_invite(uint32_t vid) {
if (m_stage != Stage::InGame || vid == 0) return false;
CGPartyInvite p{HDR_CG_PARTY_INVITE, vid};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_party_answer(uint32_t leader_vid, bool accept) {
if (m_stage != Stage::InGame || leader_vid == 0) return false;
CGPartyInviteAnswer p{HDR_CG_PARTY_INVITE_ANSWER, leader_vid,
static_cast<uint8_t>(accept ? 1 : 0)};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_party_remove(uint32_t pid) {
if (m_stage != Stage::InGame || pid == 0) return false;
CGPartyRemove p{HDR_CG_PARTY_REMOVE, pid};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_party_set_state(uint32_t pid, uint8_t role, bool on) {
if (m_stage != Stage::InGame || pid == 0) return false;
CGPartySetState p{HDR_CG_PARTY_SET_STATE, pid, role, static_cast<uint8_t>(on ? 1 : 0)};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_party_use_skill(uint8_t skill_index, uint32_t vid) {
if (m_stage != Stage::InGame) return false;
CGPartyUseSkill p{HDR_CG_PARTY_USE_SKILL, skill_index, vid};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_party_parameter(uint8_t mode) {
if (m_stage != Stage::InGame) return false;
CGPartyParameter p{HDR_CG_PARTY_PARAMETER, mode};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_shop_buy(uint8_t pos, uint8_t count) {
if (m_stage != Stage::InGame) return false;
uint8_t buf[sizeof(CGShop) + sizeof(CGShopBuy)] = {};
CGShop head{HDR_CG_SHOP, SHOP_CG_BUY};
CGShopBuy body{count, pos};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &body, sizeof(body));
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_shop_sell(uint8_t pos, uint8_t count) {
if (m_stage != Stage::InGame) return false;
if (count == 0) {
uint8_t buf[sizeof(CGShop) + sizeof(CGShopSell)] = {};
CGShop head{HDR_CG_SHOP, SHOP_CG_SELL};
CGShopSell body{pos};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &body, sizeof(body));
return m_stream.send_fixed(buf, sizeof(buf));
}
uint8_t buf[sizeof(CGShop) + sizeof(CGShopSell2)] = {};
CGShop head{HDR_CG_SHOP, SHOP_CG_SELL2};
CGShopSell2 body{pos, count};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &body, sizeof(body));
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_shop_close() {
if (m_stage != Stage::InGame) return false;
CGShop p{HDR_CG_SHOP, SHOP_CG_END};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_private_shop(const std::string &sign,
const std::vector<CGMyShopItem> &items) {
if (m_stage != Stage::InGame || sign.size() > SHOP_TAB_NAME_MAX) return false;
const size_t n = std::min(items.size(), static_cast<size_t>(39));
const size_t total = sizeof(CGMyShopHead) + n * sizeof(CGMyShopItem);
std::vector<uint8_t> buf(total, 0);
CGMyShopHead head{};
head.header = HDR_CG_MYSHOP;
std::memcpy(head.sign, sign.data(), sign.size());
head.count = static_cast<uint8_t>(n);
std::memcpy(buf.data(), &head, sizeof(head));
if (n > 0) {
std::memcpy(buf.data() + sizeof(head), items.data(), n * sizeof(CGMyShopItem));
}
return m_stream.send_fixed(buf.data(), buf.size());
}
bool ClassicSession::send_cube_make(int result_index) {
// 40250's command handler ignores a recipe index: an argument means
// "make all" while no argument makes one recipe from the currently loaded
// cube slots. Never send the index through as it would unexpectedly craft
// repeatedly and consume every matching material.
(void)result_index;
return send_chat(0, "/cube make");
}
bool ClassicSession::send_cube_result_list(int npc_vnum) {
(void)npc_vnum; // 40250 derives the active NPC from the selected cube context.
return send_chat(0, "/cube r_info");
}
bool ClassicSession::send_cube_materials(int start_index, int count) {
if (start_index < 0 || count < 1 || start_index > 255 || count > 255) return false;
return send_chat(0, "/cube r_info " + std::to_string(start_index) + " " +
std::to_string(count));
}
bool ClassicSession::send_cube_open() {
return send_chat(0, "/cube open");
}
bool ClassicSession::send_cube_close() {
return send_chat(0, "/cube close");
}
bool ClassicSession::send_cube_list() {
return send_chat(0, "/cube list");
}
bool ClassicSession::send_cube_add_item(int cube_index, int inventory_index) {
if (cube_index < 0 || cube_index >= 24 || inventory_index < 0 || inventory_index > 255) {
return false;
}
return send_chat(0, "/cube add " + std::to_string(cube_index) + " " +
std::to_string(inventory_index));
}
bool ClassicSession::send_cube_delete_item(int cube_index) {
if (cube_index < 0 || cube_index >= 24) return false;
return send_chat(0, "/cube delete " + std::to_string(cube_index));
}
bool ClassicSession::send_guild_sub(uint8_t subheader) {
if (m_stage != Stage::InGame) return false;
CGGuild p{HDR_CG_GUILD, subheader};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_guild_u32(uint8_t subheader, uint32_t value) {
if (m_stage != Stage::InGame) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(value)] = {};
CGGuild head{HDR_CG_GUILD, subheader};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &value, sizeof(value));
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_guild_i32(uint8_t subheader, int32_t value) {
if (m_stage != Stage::InGame) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(value)] = {};
CGGuild head{HDR_CG_GUILD, subheader};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &value, sizeof(value));
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_guild_grade_name(uint8_t grade, const std::string &name) {
if (m_stage != Stage::InGame || grade == 0 || grade >= 16 || name.empty() ||
name.size() > GUILD_GRADE_NAME_MAX_LEN) return false;
uint8_t buf[sizeof(CGGuild) + 1 + GUILD_GRADE_NAME_MAX_LEN + 1] = {};
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());
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_guild_grade_authority(uint8_t grade, uint8_t authority) {
if (m_stage != Stage::InGame || grade == 0 || grade >= 16) return false;
uint8_t buf[sizeof(CGGuild) + 2] = {};
CGGuild head{HDR_CG_GUILD, GUILD_CG_CHANGE_GRADE_AUTHORITY};
std::memcpy(buf, &head, sizeof(head));
buf[sizeof(head)] = grade;
buf[sizeof(head) + 1] = authority;
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_guild_member_grade(uint32_t pid, uint8_t grade) {
if (m_stage != Stage::InGame || pid == 0 || grade == 0 || grade >= 16) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(pid) + 1] = {};
CGGuild head{HDR_CG_GUILD, GUILD_CG_CHANGE_MEMBER_GRADE};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &pid, sizeof(pid));
buf[sizeof(head) + sizeof(pid)] = grade;
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_guild_member_general(uint32_t pid, bool enabled) {
if (m_stage != Stage::InGame || pid == 0) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(pid) + 1] = {};
CGGuild head{HDR_CG_GUILD, GUILD_CG_CHANGE_MEMBER_GENERAL};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &pid, sizeof(pid));
buf[sizeof(head) + sizeof(pid)] = enabled ? 1 : 0;
return m_stream.send_fixed(buf, sizeof(buf));
}
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);
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());
}
return m_stream.send_fixed(buf.data(), buf.size());
}
bool ClassicSession::send_guild_invite_answer(uint32_t guild_id, bool accept) {
if (m_stage != Stage::InGame) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(guild_id) + 1] = {};
CGGuild head{HDR_CG_GUILD, GUILD_CG_GUILD_INVITE_ANSWER};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &guild_id, sizeof(guild_id));
buf[sizeof(head) + sizeof(guild_id)] = accept ? 1 : 0;
return m_stream.send_fixed(buf, sizeof(buf));
}
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());
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_guild_skill(uint32_t skill_vnum, uint32_t target_pid) {
if (m_stage != Stage::InGame) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(CGGuildUseSkill)] = {};
CGGuild head{HDR_CG_GUILD, GUILD_CG_USE_SKILL};
CGGuildUseSkill body{skill_vnum, target_pid};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &body, sizeof(body));
return m_stream.send_fixed(buf, sizeof(buf));
}
bool ClassicSession::send_refine(uint8_t pos, uint8_t type) {
if (m_stage != Stage::InGame) return false;
CGRefine p{HDR_CG_REFINE, pos, type};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_dragon_soul_refine(uint8_t subheader, const ItemPos *grid, size_t count) {
if (m_stage != Stage::InGame || !grid || count > DS_REFINE_GRID_MAX_NUM) return false;
CGDragonSoulRefine p{};
p.header = HDR_CG_DRAGON_SOUL_REFINE;
p.subheader = subheader;
std::memcpy(p.grid, grid, count * sizeof(ItemPos));
return m_stream.send_fixed(&p, sizeof(p));
}
namespace {
bool valid_character_name(const std::string &name) {
return !name.empty() && name.size() < CHARACTER_NAME_MAX_LEN;
}
}
bool ClassicSession::send_friend_add(const std::string &name) {
if (m_stage != Stage::InGame || !valid_character_name(name)) {
return false;
}
// Messenger's extra payload is not length-prefixed: the server consumes a
// fixed CHARACTER_NAME_MAX_LEN byte buffer after the two-byte base header.
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());
return m_stream.send_fixed(buf.data(), buf.size());
}
bool ClassicSession::send_friend_remove(const std::string &name) {
if (m_stage != Stage::InGame || !valid_character_name(name)) {
return false;
}
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());
return m_stream.send_fixed(buf.data(), buf.size());
}
bool ClassicSession::send_exchange_start(uint32_t partner_vid) {
if (m_stage != Stage::InGame) {
return false;
}
CGExchange p{};
p.header = HDR_CG_EXCHANGE;
p.subheader = EXCHANGE_CG_START;
p.arg1 = partner_vid;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_exchange_item_add(uint8_t inv_window, uint16_t inv_cell,
uint8_t display_pos) {
if (m_stage != Stage::InGame) {
return false;
}
CGExchange p{};
p.header = HDR_CG_EXCHANGE;
p.subheader = EXCHANGE_CG_ITEM_ADD;
p.arg2 = display_pos;
p.pos = {inv_window, inv_cell};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_exchange_gold(uint32_t gold) {
if (m_stage != Stage::InGame) {
return false;
}
CGExchange p{};
p.header = HDR_CG_EXCHANGE;
p.subheader = EXCHANGE_CG_GOLD_ADD;
p.arg1 = gold;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_exchange_accept() {
if (m_stage != Stage::InGame) {
return false;
}
CGExchange p{};
p.header = HDR_CG_EXCHANGE;
p.subheader = EXCHANGE_CG_ACCEPT;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_exchange_cancel() {
if (m_stage != Stage::InGame) {
return false;
}
CGExchange p{};
p.header = HDR_CG_EXCHANGE;
p.subheader = EXCHANGE_CG_CANCEL;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_safebox_checkin(uint8_t safe_pos, uint8_t inv_window,
uint16_t inv_cell) {
if (m_stage != Stage::InGame) {
return false;
}
CGSafeboxCheckin p{};
p.header = HDR_CG_SAFEBOX_CHECKIN;
p.safe_pos = safe_pos;
p.item_pos = {inv_window, inv_cell};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_safebox_checkout(uint8_t safe_pos, uint8_t inv_window,
uint16_t inv_cell) {
if (m_stage != Stage::InGame) {
return false;
}
CGSafeboxCheckout p{};
p.header = HDR_CG_SAFEBOX_CHECKOUT;
p.safe_pos = safe_pos;
p.item_pos = {inv_window, inv_cell};
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_safebox_move(uint16_t from_cell, uint16_t to_cell, uint8_t count) {
if (m_stage != Stage::InGame) {
return false;
}
CGItemMove p{};
p.header = HDR_CG_SAFEBOX_ITEM_MOVE;
// The stock 40250 client sends inventory-window positions; the active
// safebox context tells the server these positions refer to the safebox.
p.pos = {WINDOW_INVENTORY, from_cell};
p.change_pos = {WINDOW_INVENTORY, to_cell};
p.num = count;
return m_stream.send_fixed(&p, sizeof(p));
}
bool ClassicSession::send_mall_checkout(uint8_t mall_pos, uint8_t inv_window,
uint16_t inv_cell) {
if (m_stage != Stage::InGame) {
return false;
}
CGMallCheckout p{};
p.header = HDR_CG_MALL_CHECKOUT;
p.mall_pos = mall_pos;
p.item_pos = {inv_window, inv_cell};
return m_stream.send_fixed(&p, sizeof(p));
}
} // namespace mtnet::classic
+169
View File
@@ -0,0 +1,169 @@
#pragma once
// ClassicSession — MT_PROTOCOL=classic: drives ClassicStream through
// HANDSHAKE -> LOGIN -> SELECT -> LOADING -> GAME and parses GC packets into a
// shared EntityStore. See docs/CLIENT-40250-PORT.md §4 / §8.
//
// The stock 40250 server uses _IMPROVED_PACKET_ENCRYPTION_; ClassicStream
// negotiates that DH2 + CTR session before the first login packet.
#include "classic_parser.h"
#include "classic_stream.h"
#include "wire_classic.h"
#include "../entity_store.h"
#include "../i_net_session.h"
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
namespace mtnet::classic {
class ClassicSession : public mtnet::INetSession {
public:
ClassicSession();
// --- INetSession ---
bool connect(const std::string &game_host, uint16_t game_port, const std::string &id,
const std::string &pw) override;
void disconnect() override;
bool select_char(int slot) override;
bool enter_game() override;
bool create_character(int slot, const std::string &name, int job, int shape,
int con, int intel, int str, int dex);
bool delete_character(int slot, const std::string &private_code);
bool change_name(int slot, const std::string &name);
bool send_empire(uint8_t empire);
void pump() override;
Stage stage() const override { return m_stage; }
const std::string &last_error() const override { return m_last_error; }
EntityStore &world() override { return m_world; }
bool send_move(uint8_t func, uint8_t arg, float rot_deg, int32_t x, int32_t y,
uint32_t time_ms = 0) override;
bool send_attack(uint8_t type, uint32_t victim_vid) override;
bool send_chat(uint8_t type, const std::string &text) override;
bool send_target(uint32_t vid) override;
bool send_character_position(uint8_t position) override;
bool send_click_npc(uint32_t vid) override;
bool send_script_answer(uint8_t answer) override;
bool send_item_use(uint8_t window, uint16_t cell) override;
bool send_item_move(uint8_t window, uint16_t cell, uint8_t to_window, uint16_t to_cell,
uint8_t count) override;
bool send_item_drop(uint8_t window, uint16_t cell, uint32_t gold, uint8_t count) override;
bool send_item_pickup(uint32_t ground_vid) override;
bool send_use_skill(uint32_t skill_vnum, uint32_t target_vid) override;
bool send_item_use_to_item(uint8_t window, uint16_t cell, uint8_t target_window,
uint16_t target_cell);
bool send_item_give(uint32_t target_vid, uint8_t window, uint16_t cell, uint8_t count);
bool send_request_warp();
bool send_fishing(uint8_t rot);
bool send_dungeon();
bool send_sync_positions(const std::vector<SyncPosElement> &positions);
bool send_whisper(const std::string &to, const std::string &text);
bool send_shoot(uint8_t type);
bool send_add_fly_targeting(uint32_t target_vid, int32_t x, int32_t y);
bool send_quickslot_add(uint8_t pos, uint8_t type, uint8_t ref);
bool send_quickslot_del(uint8_t pos);
bool send_quickslot_swap(uint8_t pos, uint8_t pos_to);
bool send_script_button(uint32_t index);
bool send_script_select_item(uint32_t selection);
bool send_quest_input(const std::string &text);
bool send_quest_confirm(bool yes, uint32_t request_pid);
bool send_quest_cancel();
bool send_party_invite(uint32_t vid);
bool send_party_answer(uint32_t leader_vid, bool accept);
bool send_party_remove(uint32_t pid);
bool send_party_set_state(uint32_t pid, uint8_t role, bool on);
bool send_party_use_skill(uint8_t skill_index, uint32_t vid);
bool send_party_parameter(uint8_t mode);
bool send_shop_buy(uint8_t pos, uint8_t count);
bool send_shop_sell(uint8_t pos, uint8_t count);
bool send_shop_close();
bool send_private_shop(const std::string &sign, const std::vector<CGMyShopItem> &items);
bool send_cube_make(int result_index);
bool send_cube_result_list(int npc_vnum);
bool send_cube_materials(int start_index, int count);
bool send_cube_open();
bool send_cube_close();
bool send_cube_list();
bool send_cube_add_item(int cube_index, int inventory_index);
bool send_cube_delete_item(int cube_index);
bool send_guild_sub(uint8_t subheader);
bool send_guild_u32(uint8_t subheader, uint32_t value);
bool send_guild_i32(uint8_t subheader, int32_t value);
bool send_guild_grade_name(uint8_t grade, const std::string &name);
bool send_guild_grade_authority(uint8_t grade, uint8_t authority);
bool send_guild_member_grade(uint32_t pid, uint8_t grade);
bool send_guild_member_general(uint32_t pid, bool enabled);
bool send_guild_comment(const std::string &text);
bool send_guild_invite_answer(uint32_t guild_id, bool accept);
bool send_guild_answer_make(const std::string &name);
bool send_guild_skill(uint32_t skill_vnum, uint32_t target_pid);
bool send_refine(uint8_t pos, uint8_t type);
bool send_dragon_soul_refine(uint8_t subheader, const ItemPos *grid, size_t count);
bool send_friend_add(const std::string &name) override;
bool send_friend_remove(const std::string &name) override;
bool send_exchange_start(uint32_t partner_vid) override;
bool send_exchange_item_add(uint8_t inv_window, uint16_t inv_cell,
uint8_t display_pos) override;
bool send_exchange_gold(uint32_t gold) override;
bool send_exchange_accept() override;
bool send_exchange_cancel() override;
bool send_safebox_checkin(uint8_t safe_pos, uint8_t inv_window,
uint16_t inv_cell) override;
bool send_safebox_checkout(uint8_t safe_pos, uint8_t inv_window,
uint16_t inv_cell) override;
bool send_safebox_move(uint16_t from_cell, uint16_t to_cell, uint8_t count) override;
bool send_mall_checkout(uint8_t mall_pos, uint8_t inv_window,
uint16_t inv_cell) override;
// --- callbacks ---
std::function<void(const std::vector<CharSlot> &)> on_char_list;
std::function<void()> on_entered_game;
std::function<void(Stage)> on_stage_change;
std::function<void(const std::string &)> on_error;
// Set up creds + state WITHOUT opening a socket — feed the stream by hand via
// stream().feed()/take_outgoing(). Used by the offline flow test and any
// non-TCP transport. connect() == start_offline() + m_stream.connect().
void start_offline(const std::string &id, const std::string &pw);
// --- accessors ---
const std::vector<CharSlot> &char_slots() const { return m_parser.char_slots(); }
ClassicStream &stream() { return m_stream; } // for tests / trace
ClassicParser &parser() { return m_parser; }
uint32_t now_ms() const { return m_now; }
void set_now(uint32_t ms) {
m_now = ms;
m_world.set_now(ms);
}
// auto-send CG_ENTERGAME this many ms after entering PHASE_LOADING (0 = never,
// caller drives enter_game()). Default mirrors the m2dev client's ~1.5s.
void set_auto_entergame_delay(uint32_t ms) { m_entergame_delay = ms; }
void set_wire_trace(bool on) { m_stream.set_wire_trace(on); }
private:
void set_stage(Stage s);
void on_phase(uint8_t phase);
bool on_packet(uint8_t header, const uint8_t *body, uint32_t len);
void send_login();
bool send_client_version();
EntityStore m_world;
ClassicStream m_stream;
ClassicParser m_parser;
Stage m_stage = Stage::Offline;
std::string m_last_error;
std::string m_id, m_pw;
uint32_t m_now = 0;
uint32_t m_loading_since = 0;
uint32_t m_entergame_delay = 1500;
bool m_entergame_sent = false;
bool m_login_sent = false;
bool m_version_sent = false;
};
} // namespace mtnet::classic
@@ -0,0 +1,499 @@
#include "classic_stream.h"
#include <arpa/inet.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
namespace mtnet::classic {
namespace {
constexpr size_t RECV_CHUNK = 32 * 1024;
uint32_t now_ms() {
using namespace std::chrono;
return static_cast<uint32_t>(
duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count());
}
void set_nonblocking(int fd) {
int fl = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
} // namespace
ClassicStream::~ClassicStream() {
disconnect();
}
void ClassicStream::set_state(State s) {
if (m_state == s) {
return;
}
m_state = s;
if (on_state_change) {
on_state_change(s);
}
}
// --------------------------------------------------------------------- socket
bool ClassicStream::connect(const std::string &host, uint16_t port) {
disconnect();
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo *res = nullptr;
char portbuf[8];
std::snprintf(portbuf, sizeof(portbuf), "%u", port);
if (getaddrinfo(host.c_str(), portbuf, &hints, &res) != 0 || !res) {
m_last_error = "getaddrinfo(" + host + ") failed";
return false;
}
m_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (m_sock < 0) {
m_last_error = std::string("socket: ") + std::strerror(errno);
freeaddrinfo(res);
return false;
}
set_nonblocking(m_sock);
int one = 1;
setsockopt(m_sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
int rc = ::connect(m_sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
if (rc == 0) {
set_state(State::Online);
} else if (errno == EINPROGRESS || errno == EWOULDBLOCK) {
set_state(State::Connecting);
} else {
m_last_error = std::string("connect: ") + std::strerror(errno);
::close(m_sock);
m_sock = -1;
return false;
}
return true;
}
void ClassicStream::disconnect() {
if (m_sock >= 0) {
::close(m_sock);
m_sock = -1;
}
m_recv.clear();
m_send.clear();
m_seq_idx = 0;
m_seq_on = false;
m_handshake_seen = false;
m_cipher.clean_up();
set_state(State::Offline);
}
void ClassicStream::decrypt_appended(size_t n) {
if (n == 0 || !m_cipher.activated()) {
return;
}
// the n bytes just written sit at the tail of the unread region
m_cipher.decrypt(m_recv.mutable_unread() + (m_recv.readable() - n), n);
}
bool ClassicStream::recv_into_buffer() {
uint8_t *dst = m_recv.reserve_write(RECV_CHUNK);
ssize_t n = ::recv(m_sock, dst, RECV_CHUNK, 0);
if (n > 0) {
m_recv.commit_write(static_cast<size_t>(n), RECV_CHUNK);
decrypt_appended(static_cast<size_t>(n));
return true;
}
m_recv.commit_write(0, RECV_CHUNK);
if (n == 0) {
m_last_error = "peer closed";
return false;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
return true;
}
m_last_error = std::string("recv: ") + std::strerror(errno);
return false;
}
bool ClassicStream::flush_send() {
while (m_send.readable() > 0) {
ssize_t n = ::send(m_sock, m_send.read_ptr(), m_send.readable(), 0);
if (n > 0) {
m_send.discard(static_cast<size_t>(n));
continue;
}
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
break;
}
m_last_error = std::string("send: ") + std::strerror(errno);
return false;
}
return true;
}
void ClassicStream::process() {
if (m_sock < 0) {
return;
}
if (m_state == State::Connecting) {
int err = 0;
socklen_t len = sizeof(err);
if (getsockopt(m_sock, SOL_SOCKET, SO_ERROR, &err, &len) < 0 || err != 0) {
m_last_error = "connect failed";
disconnect();
return;
}
set_state(State::Online);
}
if (!recv_into_buffer()) {
disconnect();
return;
}
dispatch();
if (m_sock >= 0 && !flush_send()) {
disconnect();
}
}
// ----------------------------------------------------------------- framing core
void ClassicStream::feed(const void *data, size_t n) {
m_recv.write(data, n);
decrypt_appended(n);
dispatch();
}
void ClassicStream::emit_bytes(const void *p, size_t n) {
if (!m_cipher.activated()) {
m_send.write(p, n);
return;
}
// CTR stream: encrypt in call order; the encoder keeps its counter across calls.
std::string tmp(static_cast<const char *>(p), n);
m_cipher.encrypt(tmp.data(), n);
m_send.write(tmp.data(), n);
}
size_t ClassicStream::take_outgoing(void *dst, size_t cap) {
size_t n = m_send.readable() < cap ? m_send.readable() : cap;
if (n) {
std::memcpy(dst, m_send.read_ptr(), n);
m_send.discard(n);
}
return n;
}
void ClassicStream::append_sequence_if_needed(uint8_t header) {
if (!m_seq_on || !is_sequence_cg(header)) {
return;
}
uint8_t seq = SEQUENCE_TABLE[m_seq_idx];
m_seq_idx = (m_seq_idx + 1) % SEQUENCE_TABLE_SIZE;
emit_bytes(&seq, 1);
}
bool ClassicStream::send_fixed(const void *struct_bytes, size_t n) {
if (n == 0) {
return false;
}
uint8_t header = *static_cast<const uint8_t *>(struct_bytes);
emit_bytes(struct_bytes, n);
append_sequence_if_needed(header);
if (m_trace) {
std::fprintf(stderr, "[classic] send hdr=%u n=%zu seq=%d\n", header, n,
(m_seq_on && is_sequence_cg(header)));
}
return true;
}
bool ClassicStream::send_dynamic(const void *bytes, size_t n) {
if (n < sizeof(DynHead)) {
return false;
}
uint8_t header = *static_cast<const uint8_t *>(bytes);
emit_bytes(bytes, n);
append_sequence_if_needed(header);
return true;
}
// ----------------------------------------------------------------- dispatch
bool ClassicStream::handle_control(uint8_t header, bool &consumed) {
consumed = false;
switch (header) {
case HDR_HANDSHAKE: {
Handshake hs{};
if (!m_recv.peek(&hs, sizeof(hs))) {
return true; // need more
}
m_recv.discard(sizeof(hs));
consumed = true;
// mirror EterLib/PythonNetworkStreamPhaseHandshake.cpp + desc.cpp:
m_server_time_base = hs.time + static_cast<uint32_t>(hs.delta);
m_client_time_base = now_ms();
hs.time = hs.time + 2u * static_cast<uint32_t>(hs.delta);
hs.delta = 0;
const bool initial = !m_handshake_seen;
m_handshake_seen = true;
if (!initial && m_time_sync_mode) {
// PythonNetworkStreamPhaseHandShake.cpp uses 0xFF only for the
// first handshake. Select/Game phase resyncs are sent as the
// same struct with header 0xFC and the next sequence byte.
hs.header = HDR_CG_TIME_SYNC;
send_fixed(&hs, sizeof(hs));
} else {
// Initial handshake (and the mark side's repeated login
// handshake) stays 0xFF and has no sequence byte.
emit_bytes(&hs, sizeof(hs));
}
if (on_server_handshake) {
on_server_handshake();
}
if (m_trace) {
std::fprintf(stderr, "[classic] handshake echo time=%u\n", hs.time);
}
return true;
}
case HDR_GC_TIME_SYNC: { // 0xFC — 1-byte "handshake ok" blank (re-sync path)
Blank b{};
if (!m_recv.peek(&b, sizeof(b))) {
return true;
}
m_recv.discard(sizeof(b));
consumed = true;
if (on_handshake_ok) {
on_handshake_ok();
}
return true;
}
case HDR_GC_PHASE: {
Phase_ p{};
if (!m_recv.peek(&p, sizeof(p))) {
return true;
}
m_recv.discard(sizeof(p));
consumed = true;
if (m_trace) {
std::fprintf(stderr, "[classic] phase -> %u\n", p.phase);
}
if (on_phase) {
on_phase(p.phase);
}
return true;
}
case HDR_GC_PING: {
Blank b{};
if (!m_recv.peek(&b, sizeof(b))) {
return true;
}
m_recv.discard(sizeof(b));
consumed = true;
uint8_t pong = HDR_CG_PONG; // 0xFE
send_fixed(&pong, 1); // CG_PONG is bSeq=true (sizeof(BYTE)+seq)
return true;
}
case HDR_GC_BINDUDP: {
BindUDP u{};
if (!m_recv.peek(&u, sizeof(u))) {
return true;
}
m_recv.discard(sizeof(u));
consumed = true;
return true; // UDP not used
}
case HDR_KEY_AGREEMENT: { // 0xFB — GC_KEY_AGREEMENT (server's DH2 blob)
if (m_recv.readable() < sizeof(KeyAgreement)) {
return true; // need the whole 261-byte packet (consumed stays false)
}
consumed = true;
return handle_key_agreement();
}
case HDR_GC_KEY_AGREEMENT_COMPLETED: { // 0xFA
KeyAgreementCompleted kac{};
if (!m_recv.peek(&kac, sizeof(kac))) {
return true;
}
m_recv.discard(sizeof(kac));
consumed = true;
m_cipher.set_activated(true);
// the server may have pipelined encrypted bytes right behind this;
// decrypt whatever's already buffered before we dispatch it.
if (m_recv.readable() > 0) {
m_cipher.decrypt(m_recv.mutable_unread(), m_recv.readable());
}
if (m_trace) {
std::fprintf(stderr, "[classic] cipher activated\n");
}
if (on_cipher_active) {
on_cipher_active();
}
return true;
}
default:
return true; // not a control header
}
}
bool ClassicStream::handle_key_agreement() {
KeyAgreement pkt{};
if (!m_recv.peek(&pkt, sizeof(pkt))) {
return true; // need the whole 261-byte packet
}
m_recv.discard(sizeof(pkt));
// generate our own DH2 public blob
KeyAgreement out{};
out.header = HDR_KEY_AGREEMENT; // CG_KEY_AGREEMENT (same 0xFB)
size_t data_len = sizeof(out.data);
size_t agreed = m_cipher.prepare(out.data, &data_len);
if (agreed == 0) {
m_last_error = "classic cipher: DH2 Prepare failed";
if (on_error) {
on_error(m_last_error);
}
return false;
}
out.agreed_length = static_cast<uint16_t>(agreed);
out.data_length = static_cast<uint16_t>(data_len);
// derive keys from the peer's blob (does NOT flip activated_ yet)
if (!m_cipher.activate(m_polarity, pkt.agreed_length, pkt.data, pkt.data_length)) {
m_last_error = "classic cipher: DH2 Agree/SetUp failed";
if (on_error) {
on_error(m_last_error);
}
return false;
}
// reply with our blob — still plaintext (peer activates on its own COMPLETED)
emit_bytes(&out, sizeof(out));
if (m_trace) {
std::fprintf(stderr, "[classic] key agreement: replied, keys ready\n");
}
return true;
}
void ClassicStream::dispatch() {
for (;;) {
if (m_recv.readable() == 0) {
return;
}
uint8_t header = *m_recv.read_ptr();
// zero padding between packets (cipher block alignment on the m2dev side;
// harmless to skip here too).
if (header == 0) {
m_recv.discard(1);
continue;
}
bool consumed = false;
if (!handle_control(header, consumed)) {
if (on_error && m_last_error.empty()) {
on_error("dispatch aborted");
}
disconnect();
return;
}
if (consumed) {
continue;
}
// control handler said "need more bytes" for a control header it owns?
if (header == HDR_HANDSHAKE || header == HDR_GC_TIME_SYNC || header == HDR_GC_PHASE ||
header == HDR_GC_PING || header == HDR_GC_BINDUDP || header == HDR_KEY_AGREEMENT ||
header == HDR_GC_KEY_AGREEMENT_COMPLETED) {
return; // wait for the rest of this control packet
}
// Some side connections share the classic cipher/control handshake but
// have their own packet framing. Give that connection a chance before
// the normal GC static/dynamic table is consulted.
if (on_raw_packet) {
RawPacketResult raw = on_raw_packet(header, m_recv.read_ptr(), m_recv.readable());
switch (raw.status) {
case RawPacketStatus::NeedMore:
return;
case RawPacketStatus::Consumed:
if (raw.bytes == 0 || raw.bytes > m_recv.readable()) {
m_last_error = "raw packet consumed invalid byte count";
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
}
m_recv.discard(raw.bytes);
continue;
case RawPacketStatus::Error:
m_last_error = "raw packet framing error";
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
case RawPacketStatus::NotHandled:
break;
}
}
// --- dynamic-size GC packet ---
if (is_dynamic_gc(header)) {
DynHead dh{};
if (!m_recv.peek(&dh, sizeof(dh))) {
return;
}
if (dh.size < sizeof(DynHead)) {
m_last_error = "dynamic packet size underflow";
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
}
if (m_recv.readable() < dh.size) {
return; // whole packet not here yet
}
m_recv.discard(sizeof(DynHead));
uint32_t body_len = dh.size - sizeof(DynHead);
const uint8_t *body = m_recv.read_ptr();
bool ok = !on_packet || on_packet(header, body, body_len);
m_recv.discard(body_len);
if (!ok) {
disconnect();
return;
}
continue;
}
// --- static-size GC packet --- (packet_size_gc = FULL size incl. header)
int total = packet_size_gc(header);
if (total <= 0) {
m_last_error = "unknown/unsupported GC header " + std::to_string(header);
if (on_error) {
on_error(m_last_error);
}
disconnect();
return;
}
if (m_recv.readable() < static_cast<size_t>(total)) {
return;
}
const uint8_t *p = m_recv.read_ptr();
bool ok = !on_packet || on_packet(header, p + 1, static_cast<uint32_t>(total - 1));
m_recv.discard(static_cast<size_t>(total));
if (!ok) {
disconnect();
return;
}
}
}
} // namespace mtnet::classic
+132
View File
@@ -0,0 +1,132 @@
#pragma once
// ClassicStream — 40250 "classic" Metin2 framing over non-blocking TCP.
// The `MT_PROTOCOL=classic` counterpart of mtnet::NetStream (m2dev fork).
// See docs/CLIENT-40250-PORT.md §2 (framing) / §4 (handshake / phase).
//
// 1-byte header -> static size table (wire_classic.h) OR dynamic `uint16 size`.
// CG game packets append a trailing sequence byte once sequence mode is on.
// Control packets (HANDSHAKE / TIME_SYNC / PHASE / PING / BINDUDP) are handled
// here; everything else goes to on_packet(header, body, len).
//
// Encryption (Crypto++ DH2 + CTR, docs §3) is negotiated in the stream before
// phase/login packets are dispatched.
//
// Testability: feed()/take_outgoing() drive the framing synchronously with no
// socket; connect()/process() add the real POSIX I/O on top.
#include "classic_cipher.h"
#include "sequence_table.h"
#include "wire_classic.h"
#include "../byte_buffer.h"
#include <cstdint>
#include <functional>
#include <string>
namespace mtnet::classic {
class ClassicStream {
public:
enum class State { Offline, Connecting, Online };
enum class RawPacketStatus { NotHandled, NeedMore, Consumed, Error };
struct RawPacketResult {
RawPacketStatus status = RawPacketStatus::NotHandled;
size_t bytes = 0;
};
ClassicStream() = default;
~ClassicStream();
// --- callbacks (set before connect/feed) ---
std::function<void(State)> on_state_change;
// Fired after the initial GC_HANDSHAKE has been consumed and echoed. The
// 40250 guild-mark side sends CG_MARK_LOGIN at this point, before the
// normal game-side key agreement/phase transition.
std::function<void()> on_server_handshake;
std::function<void(uint8_t phase)> on_phase; // GC_PHASE
std::function<void()> on_handshake_ok; // time sync converged
std::function<void()> on_cipher_active; // GC_KEY_AGREEMENT_COMPLETED
// A non-control packet is fully buffered. `body` covers the bytes AFTER the
// header byte (for dynamic packets: after the header+size prefix), `len` its
// length. Return false to abort the connection.
std::function<bool(uint8_t header, const uint8_t *body, uint32_t len)> on_packet;
// Optional protocol-specific framing hook. The 40250 mark side uses a
// one-byte header followed by a u32 whole-packet size for GC_MARK_* packets,
// instead of the normal static/dynamic game packet map.
// Return the exact number of bytes consumed when status == Consumed.
std::function<RawPacketResult(uint8_t header, const uint8_t *packet, size_t available)>
on_raw_packet;
std::function<void(const std::string &)> on_error;
// --- real socket lifecycle ---
bool connect(const std::string &host, uint16_t port);
void disconnect();
void process(); // one recv() + dispatch + one send() flush
State state() const { return m_state; }
bool is_online() const { return m_state == State::Online; }
const std::string &last_error() const { return m_last_error; }
// --- socket-free framing (tests / alternative transports) ---
void feed(const void *data, size_t n); // append inbound bytes, run dispatch
size_t take_outgoing(void *dst, size_t cap);
size_t outgoing_pending() const { return m_send.readable(); }
// --- sending ---
// `struct_bytes[0]` must already be the header byte; frames the body and
// appends the sequence byte when sequence mode is on and the header is bSeq.
bool send_fixed(const void *struct_bytes, size_t n);
// Caller supplies the full dynamic packet incl. [header][uint16 size][...].
bool send_dynamic(const void *bytes, size_t n);
// docs §2.2: turn on once the client would call net.SetPacketSequenceMode()
// (LOGIN phase onward). Off during pure HANDSHAKE.
void set_sequence_mode(bool on) { m_seq_on = on; }
bool sequence_mode() const { return m_seq_on; }
uint32_t sequence_index() const { return m_seq_idx; }
// The normal game connection changes later GC_HANDSHAKE packets into
// CG_TIME_SYNC. The guild-mark connection is a special case: the stock
// downloader keeps answering with CG_MARK_LOGIN, so it leaves this off.
void set_time_sync_mode(bool on) { m_time_sync_mode = on; }
void set_wire_trace(bool on) { m_trace = on; }
// _IMPROVED_PACKET_ENCRYPTION_ (docs §3). Client polarity = true.
bool cipher_active() const { return m_cipher.activated(); }
void set_polarity(bool client) { m_polarity = client; }
private:
void set_state(State s);
bool recv_into_buffer();
bool flush_send();
void dispatch(); // consume complete packets from m_recv
bool handle_control(uint8_t header, bool &consumed);
// append + (if the cipher is live) encrypt bytes into the send buffer.
void emit_bytes(const void *p, size_t n);
void append_sequence_if_needed(uint8_t header);
bool handle_key_agreement();
void decrypt_appended(size_t n); // decrypt the last n bytes just written to m_recv
int m_sock = -1;
State m_state = State::Offline;
std::string m_last_error;
ByteBuffer m_recv;
ByteBuffer m_send;
ClassicCipher m_cipher;
bool m_polarity = true; // client
bool m_seq_on = false;
uint32_t m_seq_idx = 0;
bool m_handshake_seen = false;
bool m_time_sync_mode = false;
bool m_trace = false;
// server clock offset from the last handshake exchange (informational).
uint32_t m_server_time_base = 0;
uint32_t m_client_time_base = 0;
};
} // namespace mtnet::classic
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+270
View File
@@ -32,6 +32,7 @@ struct Entity {
uint16_t race = 0;
uint8_t ch_type = 0; // CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, 4 WARP, ...
std::string name;
std::string shop_sign; // GC_SHOP_SIGN; empty when the private shop is closed
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
bool is_main = false;
@@ -230,6 +231,7 @@ struct PartyMember {
struct Friend {
std::string name;
bool online = false;
bool mobile = false; // 40250 GC_MESSENGER_MOBILE / connected-state bit
};
// --- P8 NPC shop (GC_SHOP) ----------------------------------------------------
@@ -269,11 +271,21 @@ struct GuildMember {
uint32_t pid = 0;
uint8_t grade = 0;
bool is_general = false;
bool online = false;
uint8_t job = 0;
uint8_t level = 0;
uint32_t offer = 0;
std::string name;
};
struct GuildComment {
uint32_t id = 0;
std::string name;
std::string content;
};
struct GuildInvite {
uint32_t guild_id = 0;
std::string guild_name;
};
struct GuildGrade {
std::string name;
uint8_t auth = 0;
@@ -333,6 +345,37 @@ struct RefineCue {
uint8_t material_count = 0;
};
// One stateful line from the 40250 GC_CHAT/COMMAND bus. The original client
// routes these through CPythonNetworkStream::ServerCommand(); keeping them as
// typed events lets the Godot bridge expose the same behaviour without making
// the parser depend on UI code.
struct ServerCommandEvent {
enum Kind {
PartyRequestDenied,
SafeboxPasswordRequired,
SafeboxWrongPassword,
MallPasswordRequired,
RefineSucceeded,
RefineFailed,
PrivateShopOpenRequested,
MyShopPrice,
BlockModeChanged,
ObserverModeChanged,
ObserverCountChanged,
StoneDetected,
StaminaStarted,
StaminaStopped,
MobileFlagChanged,
MobileAuthRequired,
ComboChanged,
GiftAvailable,
};
Kind kind = PartyRequestDenied;
uint32_t value = 0; // item vnum / price, depending on kind
uint32_t value2 = 0;
float value3 = 0.0f; // StoneDetect angle, in degrees after legacy conversion
};
// An item lying in the world (GC_ITEM_GROUND_ADD).
struct GroundItem {
uint32_t vid = 0;
@@ -381,6 +424,8 @@ struct PlayerPoints {
int32_t max_hp() const { return v[POINT_MAX_HP]; }
int32_t sp() const { return v[POINT_SP]; }
int32_t max_sp() const { return v[POINT_MAX_SP]; }
int32_t stamina() const { return v[POINT_STAMINA]; }
int32_t max_stamina() const { return v[POINT_MAX_STAMINA]; }
int32_t level() const { return v[POINT_LEVEL]; }
int32_t exp() const { return v[POINT_EXP]; }
int32_t next_exp() const { return v[POINT_NEXT_EXP]; }
@@ -408,8 +453,158 @@ public:
// Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored.
// This is the m2dev-fork parser; the 40250 "classic" backend calls the
// protocol-neutral mutation API below instead (docs/CLIENT-40250-PORT.md §6).
void apply(uint16_t header, const void *body, uint16_t len);
// --- protocol-neutral mutation API ---------------------------------------
// Both the m2dev apply() path and mtnet::classic::ClassicParser drive the
// same world model through these. Positions are server cm; `rot256` is the
// legacy 0..255 heading; `func` mirrors FUNC_*.
void mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const std::string &name,
float x, float y, float z, float angle, uint16_t moving_speed = 0,
uint8_t attack_speed = 0);
void mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x, float y,
float z);
void mut_despawn(uint32_t vid);
// angle_deg is the already-decoded compass heading (m2dev: rot*360/256;
// classic: rot*5 — the two wire encodings differ, so decode at the parser).
void mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
uint32_t dur_ms);
// Full stat block (index = POINT_*). `n` <= 256.
void mut_set_points(const int32_t *pts, int n);
// One stat delta (GC_CHARACTER_POINT_CHANGE equivalent).
void mut_set_point(uint8_t type, int32_t value, uint32_t vid);
// --- items --- window: 1=inventory, 2=equipment, 5=dragon-soul, 7=belt.
// `sockets` is ITEM_SOCKET_SLOT_MAX_NUM ints; `attrs` is
// ITEM_ATTRIBUTE_SLOT_MAX_NUM ItemAttr (pass nullptr to leave them zero).
void mut_item_set(uint8_t window, uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags,
uint32_t anti_flags, const int32_t *sockets, const ItemAttr *attrs);
void mut_item_del(uint8_t window, uint16_t cell);
void mut_item_update(uint8_t window, uint16_t cell, uint8_t count, const int32_t *sockets,
const ItemAttr *attrs);
void mut_ground_add(uint32_t vid, uint32_t vnum, float x, float y, float z);
void mut_ground_del(uint32_t vid);
void mut_ground_owner(uint32_t vid, const std::string &owner);
void mut_item_used(uint32_t vnum);
void mut_item_picked(uint32_t vnum, uint8_t count, const std::string &from);
// --- entity state (all no-op if vid unknown) ---
// `parts` is CHR_EQUIPPART_NUM u16 (nullptr = leave unchanged).
void mut_char_update(uint32_t vid, const uint16_t *parts, uint8_t moving_speed,
uint8_t attack_speed, int32_t guild_id, int16_t alignment, uint8_t pk_mode,
uint32_t mount_vnum);
void mut_set_position(uint32_t vid, uint8_t position);
// snap an entity to (x,y) with no interpolation (GC_SYNC_POSITION).
void mut_snap_position(uint32_t vid, float x, float y);
void mut_change_speed(uint32_t vid, uint16_t moving_speed);
void mut_walk_mode(uint32_t vid, uint8_t mode);
void mut_stun(uint32_t vid);
void mut_dead(uint32_t vid);
void mut_motion(uint32_t vid, uint32_t victim_vid, uint16_t motion);
void mut_target(uint32_t vid, uint8_t hp_pct);
void mut_damage(uint32_t vid, uint8_t flag, int32_t amount);
void mut_mount(uint32_t vid, uint32_t mount_vnum);
void mut_fly(uint8_t type, uint32_t start_vid, uint32_t end_vid);
void mut_fly_target(uint32_t shooter_vid, uint32_t target_vid, int32_t x, int32_t y,
bool append);
void mut_dig(uint32_t vid, uint32_t target_vid, uint8_t count);
void mut_special_effect(uint32_t vid, int32_t special);
void mut_specific_effect(uint32_t vid, const std::string &file);
void mut_pvp(uint32_t src_vid, uint32_t dst_vid, uint8_t mode);
void mut_duel_start();
void mut_duel_start(const uint32_t *opponents, int count);
void mut_view_equipment(uint32_t vid, const Item *items, int n);
// GC_CHAR_ADDITIONAL_INFO — fills a spawned entity's name/parts/level/guild.
void mut_char_info(uint32_t vid, const std::string &name, const uint16_t *parts,
uint8_t empire, int32_t guild_id, int32_t level, int16_t alignment, uint8_t pk_mode,
uint32_t mount_vnum);
// GC_SHOP_SIGN — the sign belongs to the entity that owns the private shop.
void mut_shop_sign(uint32_t vid, const std::string &sign);
// --- local-player skills / quickslots / affects ---
// `level` / `master` are SKILL_MAX_NUM bytes (master may be nullptr).
void mut_skill_levels(const uint8_t *level, const uint8_t *master, int n);
void mut_skill_group(uint8_t group);
void mut_skill_cooldown_end(uint8_t skill);
void mut_quickslot_set(uint8_t pos, uint8_t type, uint8_t position);
void mut_quickslot_del(uint8_t pos);
void mut_quickslot_swap(uint8_t pos, uint8_t pos_to);
void mut_affect_add(uint32_t type, uint8_t point_idx, int32_t value, uint32_t flag,
int32_t duration);
void mut_affect_remove(uint32_t type);
// --- chat --- (GC_CHAT with type==CHAT_TYPE_COMMAND routes to apply_server_command)
void mut_chat(uint8_t type, uint32_t vid, const std::string &text);
void mut_whisper(uint8_t sub, const std::string &from, const std::string &text);
// --- NPC shop (GC_SHOP) --- a single-shelf shop is one unnamed tab.
void mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs);
void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count);
void mut_shop_update_price(int32_t price);
void mut_shop_close();
void mut_shop_error(const std::string &code);
// --- quest / NPC dialog ---
void mut_script(uint8_t skin, const std::string &text); // GC_SCRIPT
void mut_quest_confirm(const std::string &msg, int32_t timeout, uint32_t request_pid);
void mut_quest_info(uint16_t index, uint8_t flag, bool begin, const std::string &title,
const std::string &clock_name, int32_t clock_value, const std::string &counter_name,
int32_t counter_value, const std::string &icon);
// --- P9 world systems ---
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port);
void mut_server_time(int64_t time);
void mut_channel(uint8_t channel);
void mut_npc_marks(const std::vector<NPCMark> &marks);
void mut_land_areas(const std::vector<LandArea> &areas);
void mut_marker_create(int32_t id, const std::string &name, uint32_t vid, uint8_t type);
void mut_marker_update(int32_t id, int32_t x, int32_t y);
void mut_marker_delete(int32_t id);
void mut_observer(ObserverEvent::Kind kind, uint32_t vid, int32_t x, int32_t y);
void mut_lover(const std::string &name, uint8_t love_point);
void mut_love_point(uint8_t love_point);
void mut_fishing(uint8_t subheader, uint32_t info, uint8_t dir);
void mut_dungeon(uint8_t subheader, int32_t x, int32_t y, bool has_destination);
// --- party (GC_PARTY_*) --- `affects` is PARTY_AFFECT_SLOT_MAX_NUM int16 (may be nullptr).
void mut_party_invite(uint32_t leader);
void mut_party_add(uint32_t pid, const std::string &name);
void mut_party_update(uint32_t pid, uint8_t role, uint8_t hp_pct, const int16_t *affects);
void mut_party_remove(uint32_t pid);
void mut_party_link(uint32_t pid, uint32_t vid);
void mut_party_unlink(uint32_t pid);
void mut_party_parameter(uint8_t distribute_mode);
// --- messenger / exchange / storage -------------------------------------
void mut_friend_set(const std::string &name, bool online);
void mut_friend_mobile(const std::string &name, bool mobile);
void mut_friend_remove(const std::string &name);
void mut_friend_clear();
// Server command `messenger_auth <name>` asks the local player to approve
// or deny a pending friend request.
void mut_friend_invite(const std::string &name);
void mut_exchange_start(uint32_t partner_vid);
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count);
void mut_exchange_item_del(bool self, uint8_t slot);
void mut_exchange_gold(bool self, int64_t gold);
void mut_exchange_accept(bool self, bool accepted);
void mut_exchange_end();
void mut_exchange_notice(); // server-side ALREADY / LESS_GOLD notice
void mut_safebox_open(int size);
void mut_safebox_money(int64_t money);
void mut_safebox_set(uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags,
uint32_t anti_flags, const int32_t *sockets, const ItemAttr *attrs);
void mut_safebox_del(uint16_t cell);
void mut_safebox_wrong_password();
void mut_safebox_close();
void mut_mall_open(int size);
void mut_mall_set(uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags,
uint32_t anti_flags, const int32_t *sockets, const ItemAttr *attrs);
void mut_mall_del(uint16_t cell);
void mut_mall_close();
// Advance interpolation of moving entities to m_now.
void tick();
@@ -522,10 +717,16 @@ public:
return v;
}
bool friends_dirty() { bool d = m_friends_dirty; m_friends_dirty = false; return d; }
std::vector<std::string> drain_friend_invites() {
auto v = std::move(m_friend_invites);
m_friend_invites.clear();
return v;
}
// --- P8 NPC shop ---
bool shop_open() const { return m_shop_open; }
uint32_t shop_vid() const { return m_shop_vid; }
int32_t shop_selling_price() const { return m_shop_selling_price; }
const std::vector<ShopEntry> &shop_items() const { return m_shop_items; }
// SHOP_GC_START_EX shelves; empty for a plain SHOP_GC_START shop (use shop_items()).
const std::vector<ShopTab> &shop_tabs() const { return m_shop_tabs; }
@@ -588,6 +789,29 @@ public:
CubeResultEntry cube_last_success() const { return m_cube_last_success; }
// --- guild ---
void mut_guild_info(uint16_t member_count, uint16_t max_member_count, uint32_t id,
uint32_t master_pid, uint32_t exp, uint8_t level, const std::string &name,
uint32_t gold, bool has_land);
void mut_guild_clear_members();
void mut_guild_member(uint32_t pid, uint8_t grade, bool is_general, uint8_t job,
uint8_t level, uint32_t offer, const std::string &name);
void mut_guild_member_online(uint32_t pid, bool online);
void mut_guild_remove_member(uint32_t pid);
void mut_guild_grade(uint8_t grade, const std::string &name, uint8_t auth);
void mut_guild_grade_name(uint8_t grade, const std::string &name);
void mut_guild_grade_auth(uint8_t grade, uint8_t auth);
void mut_guild_member_grade(uint32_t pid, uint8_t grade);
void mut_guild_member_general(uint32_t pid, bool is_general);
void mut_guild_exp(uint8_t level, uint32_t exp);
void mut_guild_money(uint32_t gold);
void mut_guild_skill(uint8_t skill_point, const uint8_t *levels, uint16_t guild_point,
uint16_t max_guild_point);
void mut_guild_war(uint32_t self_id, uint32_t opponent_id, uint8_t type, uint8_t state);
void mut_guild_war_pairs(const std::vector<GuildWarPair> &pairs, bool remove);
void mut_guild_war_score(uint32_t gain_guild_id, uint32_t opponent_guild_id, int32_t point);
void mut_guild_name(uint32_t id, const std::string &name);
void mut_guild_comments(const std::vector<GuildComment> &comments);
void mut_guild_invite(uint32_t guild_id, const std::string &guild_name);
const GuildState &guild() const { return m_guild; }
std::vector<GuildMember> guild_members() const {
std::vector<GuildMember> v;
@@ -602,6 +826,17 @@ public:
return (i >= 0 && i < 16) ? m_guild_grades[i] : kEmpty;
}
bool guild_dirty() { bool d = m_guild_dirty; m_guild_dirty = false; return d; }
const std::vector<GuildComment> &guild_comments() const { return m_guild_comments; }
bool guild_comments_dirty() {
bool d = m_guild_comments_dirty;
m_guild_comments_dirty = false;
return d;
}
std::vector<GuildInvite> drain_guild_invites() {
auto v = std::move(m_guild_invites);
m_guild_invites.clear();
return v;
}
// --- guild war / guild skill ---
const GuildSkillState &guild_skill() const { return m_guild_skill; }
@@ -637,6 +872,10 @@ public:
m_ds_cues.clear();
return v;
}
void mut_refine(uint8_t type, uint8_t pos, uint32_t src_vnum, uint32_t result_vnum,
uint8_t material_count, int32_t cost, int32_t prob,
const RefineCue::Mat *materials);
void mut_dragon_soul_refine(uint8_t sub_type, uint8_t window, uint16_t cell);
const Item &dragon_soul_slot(int cell) const;
// --- P9 world systems ---
@@ -703,12 +942,21 @@ public:
return v;
}
std::vector<PvpRelation> pvp_relations() const;
const std::vector<uint32_t> &duel_opponents() const { return m_duel_opponents; }
bool duel_cannot_attack() const { return m_duel_cannot_attack; }
bool take_duel_started() {
bool started = m_duel_started;
m_duel_started = false;
return started;
}
const LoverInfo &lover() const { return m_lover; }
bool observer_mode() const { return m_observer_mode; }
int observer_count() const { return m_observer_count; }
bool mobile_flag() const { return m_mobile_flag; }
bool combo_skill_flag() const { return m_combo_skill_flag; }
bool stamina_consuming() const { return m_stamina_consuming; }
uint32_t stamina_per_sec() const { return m_stamina_per_sec; }
uint32_t current_stamina() const { return m_current_stamina; }
bool take_lover_dirty() {
bool dirty = m_lover_dirty;
m_lover_dirty = false;
@@ -725,6 +973,11 @@ public:
m_chat.clear();
return v;
}
std::vector<ServerCommandEvent> drain_server_commands() {
auto v = std::move(m_server_commands);
m_server_commands.clear();
return v;
}
// vids whose hp/sp/level/dead/stunned changed since last drain (deduped).
std::vector<uint32_t> drain_vitals() {
auto v = std::move(m_vitals);
@@ -855,9 +1108,20 @@ private:
std::vector<ItemEvent> m_item_events;
std::unordered_map<uint64_t, PvpRelation> m_pvp;
std::vector<PvpRelation> m_pvp_changes;
std::vector<uint32_t> m_duel_opponents;
bool m_duel_started = false;
bool m_duel_cannot_attack = false;
LoverInfo m_lover;
bool m_lover_dirty = false;
bool m_observer_mode = false;
int m_observer_count = 0;
bool m_mobile_flag = false;
bool m_combo_skill_flag = false;
bool m_stamina_consuming = false;
uint32_t m_stamina_per_sec = 0;
uint32_t m_current_stamina = 0;
float m_stamina_current_f = 0.0f;
uint32_t m_stamina_last_ms = 0;
// P8 social / shop / storage
std::unordered_map<uint32_t, PartyMember> m_party;
@@ -866,11 +1130,13 @@ private:
std::vector<uint32_t> m_party_invites;
std::unordered_map<std::string, Friend> m_friends;
bool m_friends_dirty = false;
std::vector<std::string> m_friend_invites;
bool m_shop_open = false;
uint32_t m_shop_vid = 0;
std::vector<ShopEntry> m_shop_items; // == m_shop_tabs[0].items when tabs present
std::vector<ShopTab> m_shop_tabs;
bool m_shop_dirty = false;
int32_t m_shop_selling_price = 0;
std::vector<std::string> m_shop_errors;
ExchangeState m_exchange;
bool m_exchange_dirty = false;
@@ -888,6 +1154,7 @@ private:
CubeState m_cube;
std::vector<CubeEvent> m_cube_events;
CubeResultEntry m_cube_last_success;
std::vector<ServerCommandEvent> m_server_commands;
void apply_server_command(const std::string &line);
// P9 world systems
@@ -905,8 +1172,11 @@ private:
// guild / refine
GuildState m_guild;
std::unordered_map<uint32_t, GuildMember> m_guild_members;
std::vector<GuildComment> m_guild_comments;
std::vector<GuildInvite> m_guild_invites;
GuildGrade m_guild_grades[16];
bool m_guild_dirty = false;
bool m_guild_comments_dirty = false;
GuildSkillState m_guild_skill;
bool m_guild_skill_dirty = false;
GuildWarStatus m_guild_war;
+67
View File
@@ -37,6 +37,7 @@ public:
uint32_t play_minutes = 0;
int st = 0, ht = 0, dx = 0, iq = 0;
int main_part = 0, hair_part = 0;
bool change_name = false;
};
static void fill_slot_stats(CharSlot &c, const SimplePlayerInfo &s) {
@@ -47,6 +48,7 @@ public:
c.iq = s.iq;
c.main_part = s.main_part;
c.hair_part = s.hair_part;
c.change_name = s.change_name != 0;
}
// guild-mark side connection auth (from GC_LOGIN_SUCCESS3/4).
@@ -582,6 +584,70 @@ public:
std::memcpy(buf + sizeof(head), &arg, 4);
return send_packet(buf, sizeof(buf));
}
bool send_guild_sub_i32(uint8_t subheader, int32_t arg) {
uint8_t buf[sizeof(CGGuild) + sizeof(arg)];
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), subheader};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &arg, sizeof(arg));
return send_packet(buf, sizeof(buf));
}
bool send_guild_grade_name(uint8_t grade, const std::string &name) {
if (grade == 0 || grade >= 16 || name.empty() || name.size() > GUILD_GRADE_NAME_MAX_LEN) {
return false;
}
uint8_t buf[sizeof(CGGuild) + 1 + GUILD_GRADE_NAME_MAX_LEN + 1] = {};
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), 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());
return send_packet(buf, sizeof(buf));
}
bool send_guild_grade_authority(uint8_t grade, uint8_t authority) {
if (grade == 0 || grade >= 16) return false;
uint8_t buf[sizeof(CGGuild) + 2];
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_CHANGE_GRADE_AUTHORITY};
std::memcpy(buf, &head, sizeof(head));
buf[sizeof(head)] = grade;
buf[sizeof(head) + 1] = authority;
return send_packet(buf, sizeof(buf));
}
bool send_guild_member_grade(uint32_t pid, uint8_t grade) {
if (pid == 0 || grade == 0 || grade >= 16) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(pid) + 1];
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_CHANGE_MEMBER_GRADE};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &pid, sizeof(pid));
buf[sizeof(head) + sizeof(pid)] = grade;
return send_packet(buf, sizeof(buf));
}
bool send_guild_member_general(uint32_t pid, bool enabled) {
if (pid == 0) return false;
uint8_t buf[sizeof(CGGuild) + sizeof(pid) + 1];
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_CHANGE_MEMBER_GENERAL};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &pid, sizeof(pid));
buf[sizeof(head) + sizeof(pid)] = enabled ? 1 : 0;
return send_packet(buf, sizeof(buf));
}
bool send_guild_comment(const std::string &text) {
if (text.empty() || text.size() + 1 > 50) return false;
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + text.size() + 1, 0);
CGGuild head{CG_GUILD, (uint16_t)buf.size(), 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());
}
return send_packet(buf.data(), buf.size());
}
bool send_guild_invite_answer(uint32_t guild_id, bool accept) {
uint8_t buf[sizeof(CGGuild) + 4 + 1];
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_INVITE_ANSWER};
std::memcpy(buf, &head, sizeof(head));
std::memcpy(buf + sizeof(head), &guild_id, sizeof(guild_id));
buf[sizeof(head) + sizeof(guild_id)] = accept ? 1 : 0;
return send_packet(buf, sizeof(buf));
}
// GUILD_CG_USE_SKILL: sub-header + {u32 skill_vnum, u32 target_vid}.
bool send_guild_use_skill(uint32_t skill_vnum, uint32_t target_vid) {
uint8_t buf[sizeof(CGGuild) + 8];
@@ -744,6 +810,7 @@ protected:
for (auto &slot : m_chars) {
if (slot.id == p.pid) {
slot.name = name;
slot.change_name = false;
}
}
m_name_events.push_back({p.pid, std::move(name)});
+78
View File
@@ -0,0 +1,78 @@
#pragma once
// INetSession — the seam M2Client will talk to so the m2dev-fork stack and the
// 40250 "classic" stack are swappable via MT_PROTOCOL (docs/CLIENT-40250-PORT.md
// §6). Lifecycle + pump only for now; the per-intent send_* surface is added as
// the classic backend grows (both backends already share EntityStore).
#include <cstdint>
#include <string>
namespace mtnet {
class EntityStore;
class INetSession {
public:
virtual ~INetSession() = default;
// Coarse lifecycle state for the host node's state machine.
enum class Stage { Offline, Connecting, LoggingIn, CharSelect, Loading, InGame, Failed };
// auth_host/port may equal game_host/port when there is no separate auth server.
virtual bool connect(const std::string &game_host, uint16_t game_port,
const std::string &id, const std::string &pw) = 0;
virtual void disconnect() = 0;
// pick a character-list slot (0-based); sends the select packet.
virtual bool select_char(int slot) = 0;
// explicit CG_ENTERGAME once loading is done (backends may also send it themselves).
virtual bool enter_game() = 0;
// pump the socket once per frame: I/O + parse into the shared EntityStore.
virtual void pump() = 0;
virtual Stage stage() const = 0;
virtual bool in_game() const { return stage() == Stage::InGame; }
virtual const std::string &last_error() const = 0;
// The world model both backends write into.
virtual EntityStore &world() = 0;
// --- in-game intents (only valid once in_game()) -----------------------
// x/y are server cm; rot_deg is a compass heading; time_ms from the frame
// clock (0 = let the backend fill it). window: 1=inventory, 2=equipment.
virtual bool send_move(uint8_t func, uint8_t arg, float rot_deg, int32_t x, int32_t y,
uint32_t time_ms = 0) = 0;
virtual bool send_attack(uint8_t type, uint32_t victim_vid) = 0;
virtual bool send_chat(uint8_t type, const std::string &text) = 0;
virtual bool send_target(uint32_t vid) = 0;
virtual bool send_character_position(uint8_t position) = 0;
virtual bool send_click_npc(uint32_t vid) = 0;
virtual bool send_script_answer(uint8_t answer) = 0;
virtual bool send_item_use(uint8_t window, uint16_t cell) = 0;
virtual bool send_item_move(uint8_t window, uint16_t cell, uint8_t to_window, uint16_t to_cell,
uint8_t count) = 0;
virtual bool send_item_drop(uint8_t window, uint16_t cell, uint32_t gold, uint8_t count) = 0;
virtual bool send_item_pickup(uint32_t ground_vid) = 0;
// sends CG_FLY_TARGETING first when target_vid != 0, matching the legacy order.
virtual bool send_use_skill(uint32_t skill_vnum, uint32_t target_vid) = 0;
// --- P8 social / exchange / storage intents -----------------------------
virtual bool send_friend_add(const std::string &name) = 0;
virtual bool send_friend_remove(const std::string &name) = 0;
virtual bool send_exchange_start(uint32_t partner_vid) = 0;
virtual bool send_exchange_item_add(uint8_t inv_window, uint16_t inv_cell,
uint8_t display_pos) = 0;
virtual bool send_exchange_gold(uint32_t gold) = 0;
virtual bool send_exchange_accept() = 0;
virtual bool send_exchange_cancel() = 0;
virtual bool send_safebox_checkin(uint8_t safe_pos, uint8_t inv_window,
uint16_t inv_cell) = 0;
virtual bool send_safebox_checkout(uint8_t safe_pos, uint8_t inv_window,
uint16_t inv_cell) = 0;
virtual bool send_safebox_move(uint16_t from_cell, uint16_t to_cell, uint8_t count) = 0;
virtual bool send_mall_checkout(uint8_t mall_pos, uint8_t inv_window,
uint16_t inv_cell) = 0;
};
} // namespace mtnet
File diff suppressed because it is too large Load Diff
+46
View File
@@ -31,6 +31,11 @@ class AuthClient;
class GameClient;
class MarkClient;
class MarkImageSet;
class EntityStore;
namespace classic {
class ClassicSession;
class ClassicMarkClient;
}
} // namespace mtnet
namespace mtgodot {
@@ -61,6 +66,9 @@ public:
bool change_name(int slot, const godot::String &name);
// GC_EMPIRE (0 = server still wants an empire pick before select).
int get_empire() const;
// CG_EMPIRE: choose one of the three empires when the account has no empire.
// Both network backends expose the same intent for the character-select UI.
bool select_empire(int empire);
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
int get_slot_count() const;
@@ -120,6 +128,7 @@ public:
// --- P8 messenger / friends ---
bool add_friend(const godot::String &name);
bool remove_friend(const godot::String &name);
bool friend_answer(const godot::String &name, bool accept);
godot::Array get_friends() const; // [{name, online}]
godot::Dictionary get_lover() const; // {valid, name, love_point}
@@ -147,12 +156,14 @@ public:
int get_safebox_size() const;
int get_safebox_gold() const;
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
bool safebox_password(const godot::String &password);
// --- item-mall (창고몰) ---
bool is_mall_open() const;
int get_mall_size() const;
godot::Array get_mall_items() const; // [{cell, vnum, count}]
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
bool mall_password(const godot::String &password);
// --- private (PC) shop ---
// items: Array of {vnum, count, inv_cell, price, display_pos}
@@ -164,6 +175,11 @@ public:
bool cube_make(int result_index);
bool cube_request_result_list(int npc_vnum);
bool cube_request_materials(int start_index, int count);
bool cube_open();
bool cube_close();
bool cube_list();
bool cube_add_item(int cube_index, int inventory_index);
bool cube_delete_item(int cube_index);
// --- guild ---
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
@@ -172,7 +188,17 @@ public:
bool guild_add_member(int vid);
bool guild_remove_member(int pid);
bool guild_offer(int amount);
bool guild_charge_gsp(int amount);
bool guild_change_grade_name(int grade, const godot::String &name);
bool guild_change_grade_authority(int grade, int authority);
bool guild_change_member_grade(int pid, int grade);
bool guild_change_member_general(int pid, bool enabled);
bool guild_post_comment(const godot::String &text);
bool guild_delete_comment(int comment_id);
bool guild_refresh_comments();
bool guild_answer_invite(int guild_id, bool accept);
bool guild_answer_make(const godot::String &name);
godot::Array get_guild_comments() const; // [{id, name, content}]
// --- guild war / guild skill ---
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
@@ -223,6 +249,11 @@ public:
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
bool is_observer_mode() const;
int get_observer_count() const;
bool has_mobile_flag() const;
bool combo_skill_enabled() const;
godot::Dictionary get_stamina_state() const; // {consuming, per_sec, current}
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
@@ -239,11 +270,13 @@ public:
godot::Dictionary get_item(int window, int cell) const;
godot::Array get_ground_items() const; // {vid, vnum, pos}
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
godot::Dictionary get_duel() const; // {active, cannot_attack, opponents}
// --- networked world snapshot (positions already Godot-space, metres) ---
godot::Dictionary get_entity(int vid) const;
godot::Array get_entities() const;
int get_main_vid() const;
int get_main_pid() const;
godot::Dictionary get_points() const; // local player stat block
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
godot::Array get_affects() const; // active buffs/debuffs on the local player
@@ -287,7 +320,17 @@ private:
std::unique_ptr<mtnet::AuthClient> auth;
std::unique_ptr<mtnet::GameClient> game;
// MT_PROTOCOL=classic (40250): replaces auth+game for the flows it supports
// (connect/login/select/enter-game/world/intents). Null otherwise.
std::unique_ptr<mtnet::classic::ClassicSession> classic_sess;
bool classic_list_emitted = false;
int classic_last_stage = -1;
int classic_last_empire = -1;
void pump_classic();
// the EntityStore of whichever backend is active (classic_sess or game).
const mtnet::EntityStore *active_world() const;
std::unique_ptr<mtnet::MarkClient> mark;
std::unique_ptr<mtnet::classic::ClassicMarkClient> classic_mark;
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
godot::String mark_host;
int mark_port = 0;
@@ -295,11 +338,14 @@ private:
uint32_t symbol_guild_id = 0;
std::vector<uint8_t> symbol_data;
void pump_mark();
void pump_classic_mark();
int last_auth_state = -1;
int last_game_state = -1;
int last_game_phase = -1;
int last_game_empire = -1;
bool char_list_emitted = false;
uint32_t selected_pid = 0;
godot::Array build_char_list() const;
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
// it before the server finishes the spawn burst makes it drop us ~10s later.
+6
View File
@@ -326,6 +326,8 @@ enum : uint8_t {
POINT_MAX_HP = 6,
POINT_SP = 7,
POINT_MAX_SP = 8,
POINT_STAMINA = 9,
POINT_MAX_STAMINA = 10,
POINT_GOLD = 11,
POINT_ATT_SPEED = 17,
POINT_ENERGY = 128,
@@ -457,9 +459,13 @@ enum : uint8_t { // GuildSub::CG
GUILD_CG_CHANGE_GRADE_AUTHORITY = 3,
GUILD_CG_OFFER = 4,
GUILD_CG_POST_COMMENT = 5,
GUILD_CG_DELETE_COMMENT = 6,
GUILD_CG_REFRESH_COMMENT = 7,
GUILD_CG_CHANGE_MEMBER_GRADE = 8,
GUILD_CG_USE_SKILL = 9,
GUILD_CG_CHANGE_MEMBER_GENERAL = 10,
GUILD_CG_INVITE_ANSWER = 11,
GUILD_CG_CHARGE_GSP = 12,
GUILD_CG_DEPOSIT_MONEY = 13,
GUILD_CG_WITHDRAW_MONEY = 14,
};