Implement 40250 classic client port
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user