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