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
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ struct Entity {
|
||||
uint16_t race = 0;
|
||||
uint8_t ch_type = 0; // CHRTYPE: 0 PC, 1 NPC, 2 MONSTER, 3 STONE, 4 WARP, ...
|
||||
std::string name;
|
||||
std::string shop_sign; // GC_SHOP_SIGN; empty when the private shop is closed
|
||||
uint16_t parts[CHR_EQUIPPART_NUM] = {0, 0, 0, 0};
|
||||
bool is_main = false;
|
||||
|
||||
@@ -230,6 +231,7 @@ struct PartyMember {
|
||||
struct Friend {
|
||||
std::string name;
|
||||
bool online = false;
|
||||
bool mobile = false; // 40250 GC_MESSENGER_MOBILE / connected-state bit
|
||||
};
|
||||
|
||||
// --- P8 NPC shop (GC_SHOP) ----------------------------------------------------
|
||||
@@ -269,11 +271,21 @@ struct GuildMember {
|
||||
uint32_t pid = 0;
|
||||
uint8_t grade = 0;
|
||||
bool is_general = false;
|
||||
bool online = false;
|
||||
uint8_t job = 0;
|
||||
uint8_t level = 0;
|
||||
uint32_t offer = 0;
|
||||
std::string name;
|
||||
};
|
||||
struct GuildComment {
|
||||
uint32_t id = 0;
|
||||
std::string name;
|
||||
std::string content;
|
||||
};
|
||||
struct GuildInvite {
|
||||
uint32_t guild_id = 0;
|
||||
std::string guild_name;
|
||||
};
|
||||
struct GuildGrade {
|
||||
std::string name;
|
||||
uint8_t auth = 0;
|
||||
@@ -333,6 +345,37 @@ struct RefineCue {
|
||||
uint8_t material_count = 0;
|
||||
};
|
||||
|
||||
// One stateful line from the 40250 GC_CHAT/COMMAND bus. The original client
|
||||
// routes these through CPythonNetworkStream::ServerCommand(); keeping them as
|
||||
// typed events lets the Godot bridge expose the same behaviour without making
|
||||
// the parser depend on UI code.
|
||||
struct ServerCommandEvent {
|
||||
enum Kind {
|
||||
PartyRequestDenied,
|
||||
SafeboxPasswordRequired,
|
||||
SafeboxWrongPassword,
|
||||
MallPasswordRequired,
|
||||
RefineSucceeded,
|
||||
RefineFailed,
|
||||
PrivateShopOpenRequested,
|
||||
MyShopPrice,
|
||||
BlockModeChanged,
|
||||
ObserverModeChanged,
|
||||
ObserverCountChanged,
|
||||
StoneDetected,
|
||||
StaminaStarted,
|
||||
StaminaStopped,
|
||||
MobileFlagChanged,
|
||||
MobileAuthRequired,
|
||||
ComboChanged,
|
||||
GiftAvailable,
|
||||
};
|
||||
Kind kind = PartyRequestDenied;
|
||||
uint32_t value = 0; // item vnum / price, depending on kind
|
||||
uint32_t value2 = 0;
|
||||
float value3 = 0.0f; // StoneDetect angle, in degrees after legacy conversion
|
||||
};
|
||||
|
||||
// An item lying in the world (GC_ITEM_GROUND_ADD).
|
||||
struct GroundItem {
|
||||
uint32_t vid = 0;
|
||||
@@ -381,6 +424,8 @@ struct PlayerPoints {
|
||||
int32_t max_hp() const { return v[POINT_MAX_HP]; }
|
||||
int32_t sp() const { return v[POINT_SP]; }
|
||||
int32_t max_sp() const { return v[POINT_MAX_SP]; }
|
||||
int32_t stamina() const { return v[POINT_STAMINA]; }
|
||||
int32_t max_stamina() const { return v[POINT_MAX_STAMINA]; }
|
||||
int32_t level() const { return v[POINT_LEVEL]; }
|
||||
int32_t exp() const { return v[POINT_EXP]; }
|
||||
int32_t next_exp() const { return v[POINT_NEXT_EXP]; }
|
||||
@@ -408,8 +453,158 @@ public:
|
||||
|
||||
// Feed one complete game-phase packet. `body` points at the packet start
|
||||
// (header/length included); `len` == that length. Unknown headers ignored.
|
||||
// This is the m2dev-fork parser; the 40250 "classic" backend calls the
|
||||
// protocol-neutral mutation API below instead (docs/CLIENT-40250-PORT.md §6).
|
||||
void apply(uint16_t header, const void *body, uint16_t len);
|
||||
|
||||
// --- protocol-neutral mutation API ---------------------------------------
|
||||
// Both the m2dev apply() path and mtnet::classic::ClassicParser drive the
|
||||
// same world model through these. Positions are server cm; `rot256` is the
|
||||
// legacy 0..255 heading; `func` mirrors FUNC_*.
|
||||
void mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const std::string &name,
|
||||
float x, float y, float z, float angle, uint16_t moving_speed = 0,
|
||||
uint8_t attack_speed = 0);
|
||||
void mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x, float y,
|
||||
float z);
|
||||
void mut_despawn(uint32_t vid);
|
||||
// angle_deg is the already-decoded compass heading (m2dev: rot*360/256;
|
||||
// classic: rot*5 — the two wire encodings differ, so decode at the parser).
|
||||
void mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx, float ty,
|
||||
uint32_t dur_ms);
|
||||
// Full stat block (index = POINT_*). `n` <= 256.
|
||||
void mut_set_points(const int32_t *pts, int n);
|
||||
// One stat delta (GC_CHARACTER_POINT_CHANGE equivalent).
|
||||
void mut_set_point(uint8_t type, int32_t value, uint32_t vid);
|
||||
|
||||
// --- items --- window: 1=inventory, 2=equipment, 5=dragon-soul, 7=belt.
|
||||
// `sockets` is ITEM_SOCKET_SLOT_MAX_NUM ints; `attrs` is
|
||||
// ITEM_ATTRIBUTE_SLOT_MAX_NUM ItemAttr (pass nullptr to leave them zero).
|
||||
void mut_item_set(uint8_t window, uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags,
|
||||
uint32_t anti_flags, const int32_t *sockets, const ItemAttr *attrs);
|
||||
void mut_item_del(uint8_t window, uint16_t cell);
|
||||
void mut_item_update(uint8_t window, uint16_t cell, uint8_t count, const int32_t *sockets,
|
||||
const ItemAttr *attrs);
|
||||
void mut_ground_add(uint32_t vid, uint32_t vnum, float x, float y, float z);
|
||||
void mut_ground_del(uint32_t vid);
|
||||
void mut_ground_owner(uint32_t vid, const std::string &owner);
|
||||
void mut_item_used(uint32_t vnum);
|
||||
void mut_item_picked(uint32_t vnum, uint8_t count, const std::string &from);
|
||||
|
||||
// --- entity state (all no-op if vid unknown) ---
|
||||
// `parts` is CHR_EQUIPPART_NUM u16 (nullptr = leave unchanged).
|
||||
void mut_char_update(uint32_t vid, const uint16_t *parts, uint8_t moving_speed,
|
||||
uint8_t attack_speed, int32_t guild_id, int16_t alignment, uint8_t pk_mode,
|
||||
uint32_t mount_vnum);
|
||||
void mut_set_position(uint32_t vid, uint8_t position);
|
||||
// snap an entity to (x,y) with no interpolation (GC_SYNC_POSITION).
|
||||
void mut_snap_position(uint32_t vid, float x, float y);
|
||||
void mut_change_speed(uint32_t vid, uint16_t moving_speed);
|
||||
void mut_walk_mode(uint32_t vid, uint8_t mode);
|
||||
void mut_stun(uint32_t vid);
|
||||
void mut_dead(uint32_t vid);
|
||||
void mut_motion(uint32_t vid, uint32_t victim_vid, uint16_t motion);
|
||||
void mut_target(uint32_t vid, uint8_t hp_pct);
|
||||
void mut_damage(uint32_t vid, uint8_t flag, int32_t amount);
|
||||
void mut_mount(uint32_t vid, uint32_t mount_vnum);
|
||||
void mut_fly(uint8_t type, uint32_t start_vid, uint32_t end_vid);
|
||||
void mut_fly_target(uint32_t shooter_vid, uint32_t target_vid, int32_t x, int32_t y,
|
||||
bool append);
|
||||
void mut_dig(uint32_t vid, uint32_t target_vid, uint8_t count);
|
||||
void mut_special_effect(uint32_t vid, int32_t special);
|
||||
void mut_specific_effect(uint32_t vid, const std::string &file);
|
||||
void mut_pvp(uint32_t src_vid, uint32_t dst_vid, uint8_t mode);
|
||||
void mut_duel_start();
|
||||
void mut_duel_start(const uint32_t *opponents, int count);
|
||||
void mut_view_equipment(uint32_t vid, const Item *items, int n);
|
||||
// GC_CHAR_ADDITIONAL_INFO — fills a spawned entity's name/parts/level/guild.
|
||||
void mut_char_info(uint32_t vid, const std::string &name, const uint16_t *parts,
|
||||
uint8_t empire, int32_t guild_id, int32_t level, int16_t alignment, uint8_t pk_mode,
|
||||
uint32_t mount_vnum);
|
||||
// GC_SHOP_SIGN — the sign belongs to the entity that owns the private shop.
|
||||
void mut_shop_sign(uint32_t vid, const std::string &sign);
|
||||
|
||||
// --- local-player skills / quickslots / affects ---
|
||||
// `level` / `master` are SKILL_MAX_NUM bytes (master may be nullptr).
|
||||
void mut_skill_levels(const uint8_t *level, const uint8_t *master, int n);
|
||||
void mut_skill_group(uint8_t group);
|
||||
void mut_skill_cooldown_end(uint8_t skill);
|
||||
void mut_quickslot_set(uint8_t pos, uint8_t type, uint8_t position);
|
||||
void mut_quickslot_del(uint8_t pos);
|
||||
void mut_quickslot_swap(uint8_t pos, uint8_t pos_to);
|
||||
void mut_affect_add(uint32_t type, uint8_t point_idx, int32_t value, uint32_t flag,
|
||||
int32_t duration);
|
||||
void mut_affect_remove(uint32_t type);
|
||||
|
||||
// --- chat --- (GC_CHAT with type==CHAT_TYPE_COMMAND routes to apply_server_command)
|
||||
void mut_chat(uint8_t type, uint32_t vid, const std::string &text);
|
||||
void mut_whisper(uint8_t sub, const std::string &from, const std::string &text);
|
||||
|
||||
// --- NPC shop (GC_SHOP) --- a single-shelf shop is one unnamed tab.
|
||||
void mut_shop_open(uint32_t vid, const std::vector<ShopTab> &tabs);
|
||||
void mut_shop_update_item(uint8_t pos, uint32_t vnum, uint32_t price, uint8_t count);
|
||||
void mut_shop_update_price(int32_t price);
|
||||
void mut_shop_close();
|
||||
void mut_shop_error(const std::string &code);
|
||||
|
||||
// --- quest / NPC dialog ---
|
||||
void mut_script(uint8_t skin, const std::string &text); // GC_SCRIPT
|
||||
void mut_quest_confirm(const std::string &msg, int32_t timeout, uint32_t request_pid);
|
||||
void mut_quest_info(uint16_t index, uint8_t flag, bool begin, const std::string &title,
|
||||
const std::string &clock_name, int32_t clock_value, const std::string &counter_name,
|
||||
int32_t counter_value, const std::string &icon);
|
||||
|
||||
// --- P9 world systems ---
|
||||
void mut_warp(int32_t x, int32_t y, int32_t addr, uint16_t port);
|
||||
void mut_server_time(int64_t time);
|
||||
void mut_channel(uint8_t channel);
|
||||
void mut_npc_marks(const std::vector<NPCMark> &marks);
|
||||
void mut_land_areas(const std::vector<LandArea> &areas);
|
||||
void mut_marker_create(int32_t id, const std::string &name, uint32_t vid, uint8_t type);
|
||||
void mut_marker_update(int32_t id, int32_t x, int32_t y);
|
||||
void mut_marker_delete(int32_t id);
|
||||
void mut_observer(ObserverEvent::Kind kind, uint32_t vid, int32_t x, int32_t y);
|
||||
void mut_lover(const std::string &name, uint8_t love_point);
|
||||
void mut_love_point(uint8_t love_point);
|
||||
void mut_fishing(uint8_t subheader, uint32_t info, uint8_t dir);
|
||||
void mut_dungeon(uint8_t subheader, int32_t x, int32_t y, bool has_destination);
|
||||
|
||||
// --- party (GC_PARTY_*) --- `affects` is PARTY_AFFECT_SLOT_MAX_NUM int16 (may be nullptr).
|
||||
void mut_party_invite(uint32_t leader);
|
||||
void mut_party_add(uint32_t pid, const std::string &name);
|
||||
void mut_party_update(uint32_t pid, uint8_t role, uint8_t hp_pct, const int16_t *affects);
|
||||
void mut_party_remove(uint32_t pid);
|
||||
void mut_party_link(uint32_t pid, uint32_t vid);
|
||||
void mut_party_unlink(uint32_t pid);
|
||||
void mut_party_parameter(uint8_t distribute_mode);
|
||||
|
||||
// --- messenger / exchange / storage -------------------------------------
|
||||
void mut_friend_set(const std::string &name, bool online);
|
||||
void mut_friend_mobile(const std::string &name, bool mobile);
|
||||
void mut_friend_remove(const std::string &name);
|
||||
void mut_friend_clear();
|
||||
// Server command `messenger_auth <name>` asks the local player to approve
|
||||
// or deny a pending friend request.
|
||||
void mut_friend_invite(const std::string &name);
|
||||
void mut_exchange_start(uint32_t partner_vid);
|
||||
void mut_exchange_item(bool self, uint8_t slot, uint32_t vnum, uint8_t count);
|
||||
void mut_exchange_item_del(bool self, uint8_t slot);
|
||||
void mut_exchange_gold(bool self, int64_t gold);
|
||||
void mut_exchange_accept(bool self, bool accepted);
|
||||
void mut_exchange_end();
|
||||
void mut_exchange_notice(); // server-side ALREADY / LESS_GOLD notice
|
||||
void mut_safebox_open(int size);
|
||||
void mut_safebox_money(int64_t money);
|
||||
void mut_safebox_set(uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags,
|
||||
uint32_t anti_flags, const int32_t *sockets, const ItemAttr *attrs);
|
||||
void mut_safebox_del(uint16_t cell);
|
||||
void mut_safebox_wrong_password();
|
||||
void mut_safebox_close();
|
||||
void mut_mall_open(int size);
|
||||
void mut_mall_set(uint16_t cell, uint32_t vnum, uint8_t count, uint32_t flags,
|
||||
uint32_t anti_flags, const int32_t *sockets, const ItemAttr *attrs);
|
||||
void mut_mall_del(uint16_t cell);
|
||||
void mut_mall_close();
|
||||
|
||||
// Advance interpolation of moving entities to m_now.
|
||||
void tick();
|
||||
|
||||
@@ -522,10 +717,16 @@ public:
|
||||
return v;
|
||||
}
|
||||
bool friends_dirty() { bool d = m_friends_dirty; m_friends_dirty = false; return d; }
|
||||
std::vector<std::string> drain_friend_invites() {
|
||||
auto v = std::move(m_friend_invites);
|
||||
m_friend_invites.clear();
|
||||
return v;
|
||||
}
|
||||
|
||||
// --- P8 NPC shop ---
|
||||
bool shop_open() const { return m_shop_open; }
|
||||
uint32_t shop_vid() const { return m_shop_vid; }
|
||||
int32_t shop_selling_price() const { return m_shop_selling_price; }
|
||||
const std::vector<ShopEntry> &shop_items() const { return m_shop_items; }
|
||||
// SHOP_GC_START_EX shelves; empty for a plain SHOP_GC_START shop (use shop_items()).
|
||||
const std::vector<ShopTab> &shop_tabs() const { return m_shop_tabs; }
|
||||
@@ -588,6 +789,29 @@ public:
|
||||
CubeResultEntry cube_last_success() const { return m_cube_last_success; }
|
||||
|
||||
// --- guild ---
|
||||
void mut_guild_info(uint16_t member_count, uint16_t max_member_count, uint32_t id,
|
||||
uint32_t master_pid, uint32_t exp, uint8_t level, const std::string &name,
|
||||
uint32_t gold, bool has_land);
|
||||
void mut_guild_clear_members();
|
||||
void mut_guild_member(uint32_t pid, uint8_t grade, bool is_general, uint8_t job,
|
||||
uint8_t level, uint32_t offer, const std::string &name);
|
||||
void mut_guild_member_online(uint32_t pid, bool online);
|
||||
void mut_guild_remove_member(uint32_t pid);
|
||||
void mut_guild_grade(uint8_t grade, const std::string &name, uint8_t auth);
|
||||
void mut_guild_grade_name(uint8_t grade, const std::string &name);
|
||||
void mut_guild_grade_auth(uint8_t grade, uint8_t auth);
|
||||
void mut_guild_member_grade(uint32_t pid, uint8_t grade);
|
||||
void mut_guild_member_general(uint32_t pid, bool is_general);
|
||||
void mut_guild_exp(uint8_t level, uint32_t exp);
|
||||
void mut_guild_money(uint32_t gold);
|
||||
void mut_guild_skill(uint8_t skill_point, const uint8_t *levels, uint16_t guild_point,
|
||||
uint16_t max_guild_point);
|
||||
void mut_guild_war(uint32_t self_id, uint32_t opponent_id, uint8_t type, uint8_t state);
|
||||
void mut_guild_war_pairs(const std::vector<GuildWarPair> &pairs, bool remove);
|
||||
void mut_guild_war_score(uint32_t gain_guild_id, uint32_t opponent_guild_id, int32_t point);
|
||||
void mut_guild_name(uint32_t id, const std::string &name);
|
||||
void mut_guild_comments(const std::vector<GuildComment> &comments);
|
||||
void mut_guild_invite(uint32_t guild_id, const std::string &guild_name);
|
||||
const GuildState &guild() const { return m_guild; }
|
||||
std::vector<GuildMember> guild_members() const {
|
||||
std::vector<GuildMember> v;
|
||||
@@ -602,6 +826,17 @@ public:
|
||||
return (i >= 0 && i < 16) ? m_guild_grades[i] : kEmpty;
|
||||
}
|
||||
bool guild_dirty() { bool d = m_guild_dirty; m_guild_dirty = false; return d; }
|
||||
const std::vector<GuildComment> &guild_comments() const { return m_guild_comments; }
|
||||
bool guild_comments_dirty() {
|
||||
bool d = m_guild_comments_dirty;
|
||||
m_guild_comments_dirty = false;
|
||||
return d;
|
||||
}
|
||||
std::vector<GuildInvite> drain_guild_invites() {
|
||||
auto v = std::move(m_guild_invites);
|
||||
m_guild_invites.clear();
|
||||
return v;
|
||||
}
|
||||
|
||||
// --- guild war / guild skill ---
|
||||
const GuildSkillState &guild_skill() const { return m_guild_skill; }
|
||||
@@ -637,6 +872,10 @@ public:
|
||||
m_ds_cues.clear();
|
||||
return v;
|
||||
}
|
||||
void mut_refine(uint8_t type, uint8_t pos, uint32_t src_vnum, uint32_t result_vnum,
|
||||
uint8_t material_count, int32_t cost, int32_t prob,
|
||||
const RefineCue::Mat *materials);
|
||||
void mut_dragon_soul_refine(uint8_t sub_type, uint8_t window, uint16_t cell);
|
||||
const Item &dragon_soul_slot(int cell) const;
|
||||
|
||||
// --- P9 world systems ---
|
||||
@@ -703,12 +942,21 @@ public:
|
||||
return v;
|
||||
}
|
||||
std::vector<PvpRelation> pvp_relations() const;
|
||||
const std::vector<uint32_t> &duel_opponents() const { return m_duel_opponents; }
|
||||
bool duel_cannot_attack() const { return m_duel_cannot_attack; }
|
||||
bool take_duel_started() {
|
||||
bool started = m_duel_started;
|
||||
m_duel_started = false;
|
||||
return started;
|
||||
}
|
||||
const LoverInfo &lover() const { return m_lover; }
|
||||
bool observer_mode() const { return m_observer_mode; }
|
||||
int observer_count() const { return m_observer_count; }
|
||||
bool mobile_flag() const { return m_mobile_flag; }
|
||||
bool combo_skill_flag() const { return m_combo_skill_flag; }
|
||||
bool stamina_consuming() const { return m_stamina_consuming; }
|
||||
uint32_t stamina_per_sec() const { return m_stamina_per_sec; }
|
||||
uint32_t current_stamina() const { return m_current_stamina; }
|
||||
bool take_lover_dirty() {
|
||||
bool dirty = m_lover_dirty;
|
||||
m_lover_dirty = false;
|
||||
@@ -725,6 +973,11 @@ public:
|
||||
m_chat.clear();
|
||||
return v;
|
||||
}
|
||||
std::vector<ServerCommandEvent> drain_server_commands() {
|
||||
auto v = std::move(m_server_commands);
|
||||
m_server_commands.clear();
|
||||
return v;
|
||||
}
|
||||
// vids whose hp/sp/level/dead/stunned changed since last drain (deduped).
|
||||
std::vector<uint32_t> drain_vitals() {
|
||||
auto v = std::move(m_vitals);
|
||||
@@ -855,9 +1108,20 @@ private:
|
||||
std::vector<ItemEvent> m_item_events;
|
||||
std::unordered_map<uint64_t, PvpRelation> m_pvp;
|
||||
std::vector<PvpRelation> m_pvp_changes;
|
||||
std::vector<uint32_t> m_duel_opponents;
|
||||
bool m_duel_started = false;
|
||||
bool m_duel_cannot_attack = false;
|
||||
LoverInfo m_lover;
|
||||
bool m_lover_dirty = false;
|
||||
bool m_observer_mode = false;
|
||||
int m_observer_count = 0;
|
||||
bool m_mobile_flag = false;
|
||||
bool m_combo_skill_flag = false;
|
||||
bool m_stamina_consuming = false;
|
||||
uint32_t m_stamina_per_sec = 0;
|
||||
uint32_t m_current_stamina = 0;
|
||||
float m_stamina_current_f = 0.0f;
|
||||
uint32_t m_stamina_last_ms = 0;
|
||||
|
||||
// P8 social / shop / storage
|
||||
std::unordered_map<uint32_t, PartyMember> m_party;
|
||||
@@ -866,11 +1130,13 @@ private:
|
||||
std::vector<uint32_t> m_party_invites;
|
||||
std::unordered_map<std::string, Friend> m_friends;
|
||||
bool m_friends_dirty = false;
|
||||
std::vector<std::string> m_friend_invites;
|
||||
bool m_shop_open = false;
|
||||
uint32_t m_shop_vid = 0;
|
||||
std::vector<ShopEntry> m_shop_items; // == m_shop_tabs[0].items when tabs present
|
||||
std::vector<ShopTab> m_shop_tabs;
|
||||
bool m_shop_dirty = false;
|
||||
int32_t m_shop_selling_price = 0;
|
||||
std::vector<std::string> m_shop_errors;
|
||||
ExchangeState m_exchange;
|
||||
bool m_exchange_dirty = false;
|
||||
@@ -888,6 +1154,7 @@ private:
|
||||
CubeState m_cube;
|
||||
std::vector<CubeEvent> m_cube_events;
|
||||
CubeResultEntry m_cube_last_success;
|
||||
std::vector<ServerCommandEvent> m_server_commands;
|
||||
void apply_server_command(const std::string &line);
|
||||
|
||||
// P9 world systems
|
||||
@@ -905,8 +1172,11 @@ private:
|
||||
// guild / refine
|
||||
GuildState m_guild;
|
||||
std::unordered_map<uint32_t, GuildMember> m_guild_members;
|
||||
std::vector<GuildComment> m_guild_comments;
|
||||
std::vector<GuildInvite> m_guild_invites;
|
||||
GuildGrade m_guild_grades[16];
|
||||
bool m_guild_dirty = false;
|
||||
bool m_guild_comments_dirty = false;
|
||||
GuildSkillState m_guild_skill;
|
||||
bool m_guild_skill_dirty = false;
|
||||
GuildWarStatus m_guild_war;
|
||||
|
||||
@@ -37,6 +37,7 @@ public:
|
||||
uint32_t play_minutes = 0;
|
||||
int st = 0, ht = 0, dx = 0, iq = 0;
|
||||
int main_part = 0, hair_part = 0;
|
||||
bool change_name = false;
|
||||
};
|
||||
|
||||
static void fill_slot_stats(CharSlot &c, const SimplePlayerInfo &s) {
|
||||
@@ -47,6 +48,7 @@ public:
|
||||
c.iq = s.iq;
|
||||
c.main_part = s.main_part;
|
||||
c.hair_part = s.hair_part;
|
||||
c.change_name = s.change_name != 0;
|
||||
}
|
||||
|
||||
// guild-mark side connection auth (from GC_LOGIN_SUCCESS3/4).
|
||||
@@ -582,6 +584,70 @@ public:
|
||||
std::memcpy(buf + sizeof(head), &arg, 4);
|
||||
return send_packet(buf, sizeof(buf));
|
||||
}
|
||||
bool send_guild_sub_i32(uint8_t subheader, int32_t arg) {
|
||||
uint8_t buf[sizeof(CGGuild) + sizeof(arg)];
|
||||
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), subheader};
|
||||
std::memcpy(buf, &head, sizeof(head));
|
||||
std::memcpy(buf + sizeof(head), &arg, sizeof(arg));
|
||||
return send_packet(buf, sizeof(buf));
|
||||
}
|
||||
bool send_guild_grade_name(uint8_t grade, const std::string &name) {
|
||||
if (grade == 0 || grade >= 16 || name.empty() || name.size() > GUILD_GRADE_NAME_MAX_LEN) {
|
||||
return false;
|
||||
}
|
||||
uint8_t buf[sizeof(CGGuild) + 1 + GUILD_GRADE_NAME_MAX_LEN + 1] = {};
|
||||
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_CHANGE_GRADE_NAME};
|
||||
std::memcpy(buf, &head, sizeof(head));
|
||||
buf[sizeof(head)] = grade;
|
||||
std::memcpy(buf + sizeof(head) + 1, name.data(), name.size());
|
||||
return send_packet(buf, sizeof(buf));
|
||||
}
|
||||
bool send_guild_grade_authority(uint8_t grade, uint8_t authority) {
|
||||
if (grade == 0 || grade >= 16) return false;
|
||||
uint8_t buf[sizeof(CGGuild) + 2];
|
||||
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_CHANGE_GRADE_AUTHORITY};
|
||||
std::memcpy(buf, &head, sizeof(head));
|
||||
buf[sizeof(head)] = grade;
|
||||
buf[sizeof(head) + 1] = authority;
|
||||
return send_packet(buf, sizeof(buf));
|
||||
}
|
||||
bool send_guild_member_grade(uint32_t pid, uint8_t grade) {
|
||||
if (pid == 0 || grade == 0 || grade >= 16) return false;
|
||||
uint8_t buf[sizeof(CGGuild) + sizeof(pid) + 1];
|
||||
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_CHANGE_MEMBER_GRADE};
|
||||
std::memcpy(buf, &head, sizeof(head));
|
||||
std::memcpy(buf + sizeof(head), &pid, sizeof(pid));
|
||||
buf[sizeof(head) + sizeof(pid)] = grade;
|
||||
return send_packet(buf, sizeof(buf));
|
||||
}
|
||||
bool send_guild_member_general(uint32_t pid, bool enabled) {
|
||||
if (pid == 0) return false;
|
||||
uint8_t buf[sizeof(CGGuild) + sizeof(pid) + 1];
|
||||
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_CHANGE_MEMBER_GENERAL};
|
||||
std::memcpy(buf, &head, sizeof(head));
|
||||
std::memcpy(buf + sizeof(head), &pid, sizeof(pid));
|
||||
buf[sizeof(head) + sizeof(pid)] = enabled ? 1 : 0;
|
||||
return send_packet(buf, sizeof(buf));
|
||||
}
|
||||
bool send_guild_comment(const std::string &text) {
|
||||
if (text.empty() || text.size() + 1 > 50) return false;
|
||||
std::vector<uint8_t> buf(sizeof(CGGuild) + 1 + text.size() + 1, 0);
|
||||
CGGuild head{CG_GUILD, (uint16_t)buf.size(), GUILD_CG_POST_COMMENT};
|
||||
std::memcpy(buf.data(), &head, sizeof(head));
|
||||
buf[sizeof(head)] = static_cast<uint8_t>(text.size() + 1);
|
||||
if (!text.empty()) {
|
||||
std::memcpy(buf.data() + sizeof(head) + 1, text.data(), text.size());
|
||||
}
|
||||
return send_packet(buf.data(), buf.size());
|
||||
}
|
||||
bool send_guild_invite_answer(uint32_t guild_id, bool accept) {
|
||||
uint8_t buf[sizeof(CGGuild) + 4 + 1];
|
||||
CGGuild head{CG_GUILD, (uint16_t)sizeof(buf), GUILD_CG_INVITE_ANSWER};
|
||||
std::memcpy(buf, &head, sizeof(head));
|
||||
std::memcpy(buf + sizeof(head), &guild_id, sizeof(guild_id));
|
||||
buf[sizeof(head) + sizeof(guild_id)] = accept ? 1 : 0;
|
||||
return send_packet(buf, sizeof(buf));
|
||||
}
|
||||
// GUILD_CG_USE_SKILL: sub-header + {u32 skill_vnum, u32 target_vid}.
|
||||
bool send_guild_use_skill(uint32_t skill_vnum, uint32_t target_vid) {
|
||||
uint8_t buf[sizeof(CGGuild) + 8];
|
||||
@@ -744,6 +810,7 @@ protected:
|
||||
for (auto &slot : m_chars) {
|
||||
if (slot.id == p.pid) {
|
||||
slot.name = name;
|
||||
slot.change_name = false;
|
||||
}
|
||||
}
|
||||
m_name_events.push_back({p.pid, std::move(name)});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
// INetSession — the seam M2Client will talk to so the m2dev-fork stack and the
|
||||
// 40250 "classic" stack are swappable via MT_PROTOCOL (docs/CLIENT-40250-PORT.md
|
||||
// §6). Lifecycle + pump only for now; the per-intent send_* surface is added as
|
||||
// the classic backend grows (both backends already share EntityStore).
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace mtnet {
|
||||
|
||||
class EntityStore;
|
||||
|
||||
class INetSession {
|
||||
public:
|
||||
virtual ~INetSession() = default;
|
||||
|
||||
// Coarse lifecycle state for the host node's state machine.
|
||||
enum class Stage { Offline, Connecting, LoggingIn, CharSelect, Loading, InGame, Failed };
|
||||
|
||||
// auth_host/port may equal game_host/port when there is no separate auth server.
|
||||
virtual bool connect(const std::string &game_host, uint16_t game_port,
|
||||
const std::string &id, const std::string &pw) = 0;
|
||||
virtual void disconnect() = 0;
|
||||
|
||||
// pick a character-list slot (0-based); sends the select packet.
|
||||
virtual bool select_char(int slot) = 0;
|
||||
// explicit CG_ENTERGAME once loading is done (backends may also send it themselves).
|
||||
virtual bool enter_game() = 0;
|
||||
|
||||
// pump the socket once per frame: I/O + parse into the shared EntityStore.
|
||||
virtual void pump() = 0;
|
||||
|
||||
virtual Stage stage() const = 0;
|
||||
virtual bool in_game() const { return stage() == Stage::InGame; }
|
||||
virtual const std::string &last_error() const = 0;
|
||||
|
||||
// The world model both backends write into.
|
||||
virtual EntityStore &world() = 0;
|
||||
|
||||
// --- in-game intents (only valid once in_game()) -----------------------
|
||||
// x/y are server cm; rot_deg is a compass heading; time_ms from the frame
|
||||
// clock (0 = let the backend fill it). window: 1=inventory, 2=equipment.
|
||||
virtual bool send_move(uint8_t func, uint8_t arg, float rot_deg, int32_t x, int32_t y,
|
||||
uint32_t time_ms = 0) = 0;
|
||||
virtual bool send_attack(uint8_t type, uint32_t victim_vid) = 0;
|
||||
virtual bool send_chat(uint8_t type, const std::string &text) = 0;
|
||||
virtual bool send_target(uint32_t vid) = 0;
|
||||
virtual bool send_character_position(uint8_t position) = 0;
|
||||
virtual bool send_click_npc(uint32_t vid) = 0;
|
||||
virtual bool send_script_answer(uint8_t answer) = 0;
|
||||
virtual bool send_item_use(uint8_t window, uint16_t cell) = 0;
|
||||
virtual bool send_item_move(uint8_t window, uint16_t cell, uint8_t to_window, uint16_t to_cell,
|
||||
uint8_t count) = 0;
|
||||
virtual bool send_item_drop(uint8_t window, uint16_t cell, uint32_t gold, uint8_t count) = 0;
|
||||
virtual bool send_item_pickup(uint32_t ground_vid) = 0;
|
||||
// sends CG_FLY_TARGETING first when target_vid != 0, matching the legacy order.
|
||||
virtual bool send_use_skill(uint32_t skill_vnum, uint32_t target_vid) = 0;
|
||||
|
||||
// --- P8 social / exchange / storage intents -----------------------------
|
||||
virtual bool send_friend_add(const std::string &name) = 0;
|
||||
virtual bool send_friend_remove(const std::string &name) = 0;
|
||||
virtual bool send_exchange_start(uint32_t partner_vid) = 0;
|
||||
virtual bool send_exchange_item_add(uint8_t inv_window, uint16_t inv_cell,
|
||||
uint8_t display_pos) = 0;
|
||||
virtual bool send_exchange_gold(uint32_t gold) = 0;
|
||||
virtual bool send_exchange_accept() = 0;
|
||||
virtual bool send_exchange_cancel() = 0;
|
||||
virtual bool send_safebox_checkin(uint8_t safe_pos, uint8_t inv_window,
|
||||
uint16_t inv_cell) = 0;
|
||||
virtual bool send_safebox_checkout(uint8_t safe_pos, uint8_t inv_window,
|
||||
uint16_t inv_cell) = 0;
|
||||
virtual bool send_safebox_move(uint16_t from_cell, uint16_t to_cell, uint8_t count) = 0;
|
||||
virtual bool send_mall_checkout(uint8_t mall_pos, uint8_t inv_window,
|
||||
uint16_t inv_cell) = 0;
|
||||
};
|
||||
|
||||
} // namespace mtnet
|
||||
+1434
-146
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,11 @@ class AuthClient;
|
||||
class GameClient;
|
||||
class MarkClient;
|
||||
class MarkImageSet;
|
||||
class EntityStore;
|
||||
namespace classic {
|
||||
class ClassicSession;
|
||||
class ClassicMarkClient;
|
||||
}
|
||||
} // namespace mtnet
|
||||
|
||||
namespace mtgodot {
|
||||
@@ -61,6 +66,9 @@ public:
|
||||
bool change_name(int slot, const godot::String &name);
|
||||
// GC_EMPIRE (0 = server still wants an empire pick before select).
|
||||
int get_empire() const;
|
||||
// CG_EMPIRE: choose one of the three empires when the account has no empire.
|
||||
// Both network backends expose the same intent for the character-select UI.
|
||||
bool select_empire(int empire);
|
||||
// 3 or 4 depending on which GC_LOGIN_SUCCESS the server sent.
|
||||
int get_slot_count() const;
|
||||
|
||||
@@ -120,6 +128,7 @@ public:
|
||||
// --- P8 messenger / friends ---
|
||||
bool add_friend(const godot::String &name);
|
||||
bool remove_friend(const godot::String &name);
|
||||
bool friend_answer(const godot::String &name, bool accept);
|
||||
godot::Array get_friends() const; // [{name, online}]
|
||||
godot::Dictionary get_lover() const; // {valid, name, love_point}
|
||||
|
||||
@@ -147,12 +156,14 @@ public:
|
||||
int get_safebox_size() const;
|
||||
int get_safebox_gold() const;
|
||||
godot::Array get_safebox_items() const; // [{cell, vnum, count}]
|
||||
bool safebox_password(const godot::String &password);
|
||||
|
||||
// --- item-mall (창고몰) ---
|
||||
bool is_mall_open() const;
|
||||
int get_mall_size() const;
|
||||
godot::Array get_mall_items() const; // [{cell, vnum, count}]
|
||||
bool mall_checkout(int mall_pos, int inv_window, int inv_cell);
|
||||
bool mall_password(const godot::String &password);
|
||||
|
||||
// --- private (PC) shop ---
|
||||
// items: Array of {vnum, count, inv_cell, price, display_pos}
|
||||
@@ -164,6 +175,11 @@ public:
|
||||
bool cube_make(int result_index);
|
||||
bool cube_request_result_list(int npc_vnum);
|
||||
bool cube_request_materials(int start_index, int count);
|
||||
bool cube_open();
|
||||
bool cube_close();
|
||||
bool cube_list();
|
||||
bool cube_add_item(int cube_index, int inventory_index);
|
||||
bool cube_delete_item(int cube_index);
|
||||
|
||||
// --- guild ---
|
||||
godot::Dictionary get_guild() const; // {in_guild, id, name, level, exp, gold, ...}
|
||||
@@ -172,7 +188,17 @@ public:
|
||||
bool guild_add_member(int vid);
|
||||
bool guild_remove_member(int pid);
|
||||
bool guild_offer(int amount);
|
||||
bool guild_charge_gsp(int amount);
|
||||
bool guild_change_grade_name(int grade, const godot::String &name);
|
||||
bool guild_change_grade_authority(int grade, int authority);
|
||||
bool guild_change_member_grade(int pid, int grade);
|
||||
bool guild_change_member_general(int pid, bool enabled);
|
||||
bool guild_post_comment(const godot::String &text);
|
||||
bool guild_delete_comment(int comment_id);
|
||||
bool guild_refresh_comments();
|
||||
bool guild_answer_invite(int guild_id, bool accept);
|
||||
bool guild_answer_make(const godot::String &name);
|
||||
godot::Array get_guild_comments() const; // [{id, name, content}]
|
||||
|
||||
// --- guild war / guild skill ---
|
||||
godot::Dictionary get_guild_skill() const; // {valid, skill_point, guild_point, max_guild_point, levels[12]}
|
||||
@@ -223,6 +249,11 @@ public:
|
||||
godot::Array get_land_areas() const; // [{id, guild_id, rect}] in server cm
|
||||
godot::Array get_observers() const; // [{vid, pos}] in Godot metres
|
||||
godot::Array get_world_markers() const; // [{id, name, vid, type, pos}]
|
||||
bool is_observer_mode() const;
|
||||
int get_observer_count() const;
|
||||
bool has_mobile_flag() const;
|
||||
bool combo_skill_enabled() const;
|
||||
godot::Dictionary get_stamina_state() const; // {consuming, per_sec, current}
|
||||
|
||||
// items. window: 1=inventory, 2=equipment (mtnet::WINDOW_*).
|
||||
bool move_item(int from_window, int from_cell, int to_window, int to_cell, int count);
|
||||
@@ -239,11 +270,13 @@ public:
|
||||
godot::Dictionary get_item(int window, int cell) const;
|
||||
godot::Array get_ground_items() const; // {vid, vnum, pos}
|
||||
godot::Array get_pvp_relations() const; // [{src_vid, dst_vid, mode}]
|
||||
godot::Dictionary get_duel() const; // {active, cannot_attack, opponents}
|
||||
|
||||
// --- networked world snapshot (positions already Godot-space, metres) ---
|
||||
godot::Dictionary get_entity(int vid) const;
|
||||
godot::Array get_entities() const;
|
||||
int get_main_vid() const;
|
||||
int get_main_pid() const;
|
||||
godot::Dictionary get_points() const; // local player stat block
|
||||
godot::Dictionary get_target() const; // {vid, hp_percent} of selected target
|
||||
godot::Array get_affects() const; // active buffs/debuffs on the local player
|
||||
@@ -287,7 +320,17 @@ private:
|
||||
|
||||
std::unique_ptr<mtnet::AuthClient> auth;
|
||||
std::unique_ptr<mtnet::GameClient> game;
|
||||
// MT_PROTOCOL=classic (40250): replaces auth+game for the flows it supports
|
||||
// (connect/login/select/enter-game/world/intents). Null otherwise.
|
||||
std::unique_ptr<mtnet::classic::ClassicSession> classic_sess;
|
||||
bool classic_list_emitted = false;
|
||||
int classic_last_stage = -1;
|
||||
int classic_last_empire = -1;
|
||||
void pump_classic();
|
||||
// the EntityStore of whichever backend is active (classic_sess or game).
|
||||
const mtnet::EntityStore *active_world() const;
|
||||
std::unique_ptr<mtnet::MarkClient> mark;
|
||||
std::unique_ptr<mtnet::classic::ClassicMarkClient> classic_mark;
|
||||
std::unique_ptr<mtnet::MarkImageSet> mark_store; // survives after `mark` is torn down
|
||||
godot::String mark_host;
|
||||
int mark_port = 0;
|
||||
@@ -295,11 +338,14 @@ private:
|
||||
uint32_t symbol_guild_id = 0;
|
||||
std::vector<uint8_t> symbol_data;
|
||||
void pump_mark();
|
||||
void pump_classic_mark();
|
||||
|
||||
int last_auth_state = -1;
|
||||
int last_game_state = -1;
|
||||
int last_game_phase = -1;
|
||||
int last_game_empire = -1;
|
||||
bool char_list_emitted = false;
|
||||
uint32_t selected_pid = 0;
|
||||
godot::Array build_char_list() const;
|
||||
// CG_ENTERGAME is sent ~1.5s into PHASE_LOADING (not immediately) — sending
|
||||
// it before the server finishes the spawn burst makes it drop us ~10s later.
|
||||
|
||||
@@ -326,6 +326,8 @@ enum : uint8_t {
|
||||
POINT_MAX_HP = 6,
|
||||
POINT_SP = 7,
|
||||
POINT_MAX_SP = 8,
|
||||
POINT_STAMINA = 9,
|
||||
POINT_MAX_STAMINA = 10,
|
||||
POINT_GOLD = 11,
|
||||
POINT_ATT_SPEED = 17,
|
||||
POINT_ENERGY = 128,
|
||||
@@ -457,9 +459,13 @@ enum : uint8_t { // GuildSub::CG
|
||||
GUILD_CG_CHANGE_GRADE_AUTHORITY = 3,
|
||||
GUILD_CG_OFFER = 4,
|
||||
GUILD_CG_POST_COMMENT = 5,
|
||||
GUILD_CG_DELETE_COMMENT = 6,
|
||||
GUILD_CG_REFRESH_COMMENT = 7,
|
||||
GUILD_CG_CHANGE_MEMBER_GRADE = 8,
|
||||
GUILD_CG_USE_SKILL = 9,
|
||||
GUILD_CG_CHANGE_MEMBER_GENERAL = 10,
|
||||
GUILD_CG_INVITE_ANSWER = 11,
|
||||
GUILD_CG_CHARGE_GSP = 12,
|
||||
GUILD_CG_DEPOSIT_MONEY = 13,
|
||||
GUILD_CG_WITHDRAW_MONEY = 14,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user