1229 lines
36 KiB
C++
1229 lines
36 KiB
C++
#include <array>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <iostream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
#include <arpa/inet.h>
|
|
#include <netinet/in.h>
|
|
#include <poll.h>
|
|
#include <sys/socket.h>
|
|
#include <unistd.h>
|
|
|
|
#include "common/packet_headers.h"
|
|
#include "game/SecureCipher.h"
|
|
|
|
namespace
|
|
{
|
|
constexpr size_t LOGIN_MAX_LEN_LOCAL = 30;
|
|
constexpr size_t PASSWD_MAX_LEN_LOCAL = 16;
|
|
constexpr size_t ACCOUNT_STATUS_MAX_LEN_LOCAL = 8;
|
|
constexpr size_t PLAYER_PER_ACCOUNT_LOCAL = 4;
|
|
constexpr size_t CHARACTER_NAME_MAX_LEN_LOCAL = 64;
|
|
constexpr size_t GUILD_NAME_MAX_LEN_LOCAL = 12;
|
|
constexpr size_t SAFEBOX_PASSWORD_MAX_LEN_LOCAL = 6;
|
|
constexpr uint8_t CHAT_TYPE_TALKING_LOCAL = 0;
|
|
|
|
#pragma pack(push, 1)
|
|
struct PacketGCPhase
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t phase;
|
|
};
|
|
|
|
struct PacketGCKeyChallenge
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t server_pk[SecureCipher::PK_SIZE];
|
|
uint8_t challenge[SecureCipher::CHALLENGE_SIZE];
|
|
uint32_t server_time;
|
|
};
|
|
|
|
struct PacketCGKeyResponse
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t client_pk[SecureCipher::PK_SIZE];
|
|
uint8_t challenge_response[SecureCipher::HMAC_SIZE];
|
|
};
|
|
|
|
struct PacketGCKeyComplete
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t encrypted_token[SecureCipher::SESSION_TOKEN_SIZE + SecureCipher::TAG_SIZE];
|
|
uint8_t nonce[SecureCipher::NONCE_SIZE];
|
|
};
|
|
|
|
struct PacketCGLogin3
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
char login[LOGIN_MAX_LEN_LOCAL + 1];
|
|
char passwd[PASSWD_MAX_LEN_LOCAL + 1];
|
|
};
|
|
|
|
struct PacketCGLogin2
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
char login[LOGIN_MAX_LEN_LOCAL + 1];
|
|
uint32_t login_key;
|
|
};
|
|
|
|
struct PacketCGChat
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t type;
|
|
};
|
|
|
|
struct SimplePlayerLocal
|
|
{
|
|
uint32_t id;
|
|
char name[CHARACTER_NAME_MAX_LEN_LOCAL + 1];
|
|
uint8_t job;
|
|
uint8_t level;
|
|
uint32_t play_minutes;
|
|
uint8_t st;
|
|
uint8_t ht;
|
|
uint8_t dx;
|
|
uint8_t iq;
|
|
uint16_t main_part;
|
|
uint8_t change_name;
|
|
uint16_t hair_part;
|
|
uint8_t dummy[4];
|
|
int32_t x;
|
|
int32_t y;
|
|
uint32_t addr;
|
|
uint16_t port;
|
|
uint8_t skill_group;
|
|
};
|
|
|
|
struct PacketGCLoginSuccess4
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
SimplePlayerLocal players[PLAYER_PER_ACCOUNT_LOCAL];
|
|
uint32_t guild_id[PLAYER_PER_ACCOUNT_LOCAL];
|
|
char guild_name[PLAYER_PER_ACCOUNT_LOCAL][GUILD_NAME_MAX_LEN_LOCAL + 1];
|
|
uint32_t handle;
|
|
uint32_t random_key;
|
|
};
|
|
|
|
struct PacketGCAuthSuccess
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint32_t login_key;
|
|
uint8_t result;
|
|
};
|
|
|
|
struct PacketGCLoginFailure
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
char status[ACCOUNT_STATUS_MAX_LEN_LOCAL + 1];
|
|
};
|
|
|
|
struct PacketGCEmpire
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t empire;
|
|
};
|
|
|
|
struct PacketCGPlayerCreate
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t index;
|
|
char name[CHARACTER_NAME_MAX_LEN_LOCAL + 1];
|
|
uint16_t job;
|
|
uint8_t shape;
|
|
uint8_t con;
|
|
uint8_t intel;
|
|
uint8_t str;
|
|
uint8_t dex;
|
|
};
|
|
|
|
struct PacketCGPlayerDelete
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t index;
|
|
char private_code[8];
|
|
};
|
|
|
|
struct PacketGCPlayerCreateSuccess
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t account_character_index;
|
|
SimplePlayerLocal player;
|
|
};
|
|
|
|
struct PacketGCCreateFailure
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t type;
|
|
};
|
|
|
|
struct PacketGCPlayerDeleteSuccess
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t account_index;
|
|
};
|
|
|
|
struct PacketCGPlayerSelect
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t index;
|
|
};
|
|
|
|
struct PacketCGEnterGame
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
};
|
|
|
|
struct PacketCGClientVersion
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
char filename[32 + 1];
|
|
char timestamp[32 + 1];
|
|
};
|
|
|
|
struct PacketGCMainCharacter
|
|
{
|
|
enum
|
|
{
|
|
MUSIC_NAME_LEN = 24
|
|
};
|
|
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint32_t vid;
|
|
uint16_t race_num;
|
|
char name[CHARACTER_NAME_MAX_LEN_LOCAL + 1];
|
|
char bgm_name[MUSIC_NAME_LEN + 1];
|
|
float bgm_vol;
|
|
int32_t x;
|
|
int32_t y;
|
|
int32_t z;
|
|
uint8_t empire;
|
|
uint8_t skill_group;
|
|
};
|
|
|
|
struct PacketGCTime
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
time_t time;
|
|
};
|
|
|
|
struct PacketGCChannel
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t channel;
|
|
};
|
|
|
|
struct PacketGCSafeboxSize
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
uint8_t size;
|
|
};
|
|
|
|
struct PacketGCSafeboxWrongPassword
|
|
{
|
|
uint16_t header;
|
|
uint16_t length;
|
|
};
|
|
#pragma pack(pop)
|
|
|
|
struct SmokeOptions
|
|
{
|
|
bool json = false;
|
|
std::string create_character_name;
|
|
std::string client_version = "1215955205";
|
|
std::string expect_auth_failure;
|
|
std::string expect_channel_failure;
|
|
std::string delete_private_code;
|
|
std::string mall_password;
|
|
};
|
|
|
|
struct SmokeResult
|
|
{
|
|
bool ok = false;
|
|
std::string result = "unexpected_failure";
|
|
std::string stage = "startup";
|
|
std::string error;
|
|
std::string failure_status;
|
|
uint32_t login_key = 0;
|
|
int empire = -1;
|
|
int character_index = -1;
|
|
std::string character_name;
|
|
bool character_created = false;
|
|
bool character_deleted = false;
|
|
int game_channel = -1;
|
|
bool mall_opened = false;
|
|
int mall_size = -1;
|
|
int deleted_character_index = -1;
|
|
int64_t auth_handshake_ms = -1;
|
|
int64_t auth_login_ms = -1;
|
|
int64_t channel_handshake_ms = -1;
|
|
int64_t channel_login_ms = -1;
|
|
int64_t character_create_ms = -1;
|
|
int64_t character_delete_ms = -1;
|
|
int64_t character_select_ms = -1;
|
|
int64_t entergame_ms = -1;
|
|
int64_t mall_open_ms = -1;
|
|
std::vector<std::string> events;
|
|
};
|
|
|
|
struct AuthResponse
|
|
{
|
|
bool success = false;
|
|
uint32_t login_key = 0;
|
|
std::string failure_status;
|
|
};
|
|
|
|
struct ChannelLoginResponse
|
|
{
|
|
bool success = false;
|
|
int empire = -1;
|
|
std::string failure_status;
|
|
PacketGCLoginSuccess4 login_success {};
|
|
};
|
|
|
|
void Expect(bool condition, std::string_view message)
|
|
{
|
|
if (!condition)
|
|
throw std::runtime_error(std::string(message));
|
|
}
|
|
|
|
std::string JsonEscape(std::string_view input)
|
|
{
|
|
std::string escaped;
|
|
escaped.reserve(input.size() + 8);
|
|
|
|
for (const char c : input)
|
|
{
|
|
switch (c)
|
|
{
|
|
case '\\':
|
|
escaped += "\\\\";
|
|
break;
|
|
case '"':
|
|
escaped += "\\\"";
|
|
break;
|
|
case '\n':
|
|
escaped += "\\n";
|
|
break;
|
|
case '\r':
|
|
escaped += "\\r";
|
|
break;
|
|
case '\t':
|
|
escaped += "\\t";
|
|
break;
|
|
default:
|
|
escaped.push_back(c);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return escaped;
|
|
}
|
|
|
|
void EmitEvent(SmokeResult& result, bool json, const std::string& line)
|
|
{
|
|
result.events.push_back(line);
|
|
if (!json)
|
|
std::cout << line << "\n";
|
|
}
|
|
|
|
int64_t ElapsedMs(std::chrono::steady_clock::time_point started_at)
|
|
{
|
|
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - started_at)
|
|
.count();
|
|
}
|
|
|
|
void PrintJson(const SmokeResult& result)
|
|
{
|
|
std::cout
|
|
<< "{"
|
|
<< "\"ok\":" << (result.ok ? "true" : "false")
|
|
<< ",\"result\":\"" << JsonEscape(result.result) << "\""
|
|
<< ",\"stage\":\"" << JsonEscape(result.stage) << "\""
|
|
<< ",\"error\":\"" << JsonEscape(result.error) << "\""
|
|
<< ",\"failure_status\":\"" << JsonEscape(result.failure_status) << "\""
|
|
<< ",\"login_key\":" << result.login_key
|
|
<< ",\"empire\":" << result.empire
|
|
<< ",\"character_index\":" << result.character_index
|
|
<< ",\"character_name\":\"" << JsonEscape(result.character_name) << "\""
|
|
<< ",\"character_created\":" << (result.character_created ? "true" : "false")
|
|
<< ",\"character_deleted\":" << (result.character_deleted ? "true" : "false")
|
|
<< ",\"deleted_character_index\":" << result.deleted_character_index
|
|
<< ",\"game_channel\":" << result.game_channel
|
|
<< ",\"mall_opened\":" << (result.mall_opened ? "true" : "false")
|
|
<< ",\"mall_size\":" << result.mall_size
|
|
<< ",\"timings_ms\":{"
|
|
<< "\"auth_handshake\":" << result.auth_handshake_ms
|
|
<< ",\"auth_login\":" << result.auth_login_ms
|
|
<< ",\"channel_handshake\":" << result.channel_handshake_ms
|
|
<< ",\"channel_login\":" << result.channel_login_ms
|
|
<< ",\"character_create\":" << result.character_create_ms
|
|
<< ",\"character_delete\":" << result.character_delete_ms
|
|
<< ",\"character_select\":" << result.character_select_ms
|
|
<< ",\"entergame\":" << result.entergame_ms
|
|
<< ",\"mall_open\":" << result.mall_open_ms
|
|
<< "},\"events\":[";
|
|
|
|
for (size_t i = 0; i < result.events.size(); ++i)
|
|
{
|
|
if (i != 0)
|
|
std::cout << ",";
|
|
std::cout << "\"" << JsonEscape(result.events[i]) << "\"";
|
|
}
|
|
|
|
std::cout << "]}\n";
|
|
}
|
|
|
|
void WriteExact(int fd, const void* data, size_t length, std::string_view context)
|
|
{
|
|
const uint8_t* cursor = static_cast<const uint8_t*>(data);
|
|
size_t remaining = length;
|
|
|
|
while (remaining > 0)
|
|
{
|
|
const ssize_t written = send(fd, cursor, remaining, 0);
|
|
if (written <= 0)
|
|
throw std::runtime_error(std::string(context) + ": send failed: " + std::strerror(errno));
|
|
|
|
cursor += written;
|
|
remaining -= static_cast<size_t>(written);
|
|
}
|
|
}
|
|
|
|
void ReadExact(int fd, void* data, size_t length, std::string_view context)
|
|
{
|
|
uint8_t* cursor = static_cast<uint8_t*>(data);
|
|
size_t remaining = length;
|
|
|
|
while (remaining > 0)
|
|
{
|
|
const ssize_t bytes_read = recv(fd, cursor, remaining, 0);
|
|
if (bytes_read <= 0)
|
|
throw std::runtime_error(std::string(context) + ": recv failed: " + std::strerror(errno));
|
|
|
|
cursor += bytes_read;
|
|
remaining -= static_cast<size_t>(bytes_read);
|
|
}
|
|
}
|
|
|
|
bool WaitForReadable(int fd, int timeout_ms)
|
|
{
|
|
pollfd descriptor {};
|
|
descriptor.fd = fd;
|
|
descriptor.events = POLLIN;
|
|
|
|
const int rc = poll(&descriptor, 1, timeout_ms);
|
|
if (rc < 0)
|
|
throw std::runtime_error("poll failed: " + std::string(std::strerror(errno)));
|
|
|
|
return rc > 0 && (descriptor.revents & POLLIN);
|
|
}
|
|
|
|
int ConnectTcp(const std::string& host, uint16_t port)
|
|
{
|
|
const int fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
Expect(fd >= 0, "socket() failed");
|
|
|
|
timeval timeout {};
|
|
timeout.tv_sec = 5;
|
|
timeout.tv_usec = 0;
|
|
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
|
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
|
|
|
sockaddr_in addr {};
|
|
addr.sin_family = AF_INET;
|
|
addr.sin_port = htons(port);
|
|
Expect(inet_pton(AF_INET, host.c_str(), &addr.sin_addr) == 1, "inet_pton() failed");
|
|
Expect(connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0,
|
|
"connect() failed: " + std::string(std::strerror(errno)));
|
|
return fd;
|
|
}
|
|
|
|
class EncryptedClient
|
|
{
|
|
public:
|
|
EncryptedClient(const std::string& host, uint16_t port)
|
|
: m_fd(ConnectTcp(host, port))
|
|
{
|
|
Expect(m_cipher.Initialize(), "SecureCipher init failed");
|
|
}
|
|
|
|
~EncryptedClient()
|
|
{
|
|
if (m_fd >= 0)
|
|
close(m_fd);
|
|
}
|
|
|
|
void Handshake()
|
|
{
|
|
PacketGCPhase phase {};
|
|
ReadExact(m_fd, &phase, sizeof(phase), "read initial phase");
|
|
Expect(phase.header == GC::PHASE, "unexpected initial phase header");
|
|
|
|
PacketGCKeyChallenge challenge {};
|
|
ReadExact(m_fd, &challenge, sizeof(challenge), "read key challenge");
|
|
Expect(challenge.header == GC::KEY_CHALLENGE, "unexpected key challenge header");
|
|
Expect(m_cipher.ComputeClientKeys(challenge.server_pk), "client key derivation failed");
|
|
|
|
PacketCGKeyResponse response {};
|
|
response.header = CG::KEY_RESPONSE;
|
|
response.length = sizeof(response);
|
|
m_cipher.GetPublicKey(response.client_pk);
|
|
m_cipher.ComputeChallengeResponse(challenge.challenge, response.challenge_response);
|
|
WriteExact(m_fd, &response, sizeof(response), "write key response");
|
|
|
|
PacketGCKeyComplete complete {};
|
|
ReadExact(m_fd, &complete, sizeof(complete), "read key complete");
|
|
Expect(complete.header == GC::KEY_COMPLETE, "unexpected key complete header");
|
|
|
|
std::array<uint8_t, SecureCipher::SESSION_TOKEN_SIZE> session_token {};
|
|
Expect(m_cipher.DecryptToken(complete.encrypted_token, sizeof(complete.encrypted_token), complete.nonce, session_token.data()),
|
|
"decrypt token failed");
|
|
m_cipher.SetSessionToken(session_token.data());
|
|
m_cipher.SetActivated(true);
|
|
}
|
|
|
|
template <typename TPacket>
|
|
void SendEncryptedPacket(const TPacket& packet)
|
|
{
|
|
TPacket encrypted = packet;
|
|
m_cipher.EncryptInPlace(&encrypted, sizeof(encrypted));
|
|
WriteExact(m_fd, &encrypted, sizeof(encrypted), "write encrypted packet");
|
|
}
|
|
|
|
void SendEncryptedFrame(const void* data, size_t length)
|
|
{
|
|
std::vector<uint8_t> encrypted(length);
|
|
std::memcpy(encrypted.data(), data, length);
|
|
m_cipher.EncryptInPlace(encrypted.data(), encrypted.size());
|
|
WriteExact(m_fd, encrypted.data(), encrypted.size(), "write encrypted frame");
|
|
}
|
|
|
|
bool WaitForFrame(std::vector<uint8_t>& frame, int timeout_ms)
|
|
{
|
|
while (true)
|
|
{
|
|
if (TryPopFrame(frame))
|
|
return true;
|
|
|
|
if (!WaitForReadable(m_fd, timeout_ms))
|
|
return false;
|
|
|
|
ReadEncryptedChunk();
|
|
}
|
|
}
|
|
|
|
private:
|
|
bool TryPopFrame(std::vector<uint8_t>& frame)
|
|
{
|
|
if (m_stream.size() < PACKET_HEADER_SIZE)
|
|
return false;
|
|
|
|
const auto header = *reinterpret_cast<const uint16_t*>(m_stream.data());
|
|
const auto length = *reinterpret_cast<const uint16_t*>(m_stream.data() + sizeof(uint16_t));
|
|
Expect(length >= PACKET_HEADER_SIZE && length <= 8192,
|
|
"invalid encrypted packet length header=" + std::to_string(header) +
|
|
" length=" + std::to_string(length));
|
|
|
|
if (m_stream.size() < length)
|
|
return false;
|
|
|
|
frame.assign(m_stream.begin(), m_stream.begin() + length);
|
|
m_stream.erase(m_stream.begin(), m_stream.begin() + length);
|
|
return true;
|
|
}
|
|
|
|
void ReadEncryptedChunk()
|
|
{
|
|
std::array<uint8_t, 4096> buffer {};
|
|
const ssize_t bytes_read = recv(m_fd, buffer.data(), buffer.size(), 0);
|
|
if (bytes_read <= 0)
|
|
throw std::runtime_error("recv failed: " + std::string(std::strerror(errno)));
|
|
|
|
const size_t old_size = m_stream.size();
|
|
m_stream.resize(old_size + static_cast<size_t>(bytes_read));
|
|
std::memcpy(m_stream.data() + old_size, buffer.data(), static_cast<size_t>(bytes_read));
|
|
m_cipher.DecryptInPlace(m_stream.data() + old_size, static_cast<size_t>(bytes_read));
|
|
}
|
|
|
|
int m_fd = -1;
|
|
SecureCipher m_cipher;
|
|
std::vector<uint8_t> m_stream;
|
|
};
|
|
|
|
uint16_t FrameHeader(const std::vector<uint8_t>& frame)
|
|
{
|
|
return *reinterpret_cast<const uint16_t*>(frame.data());
|
|
}
|
|
|
|
uint16_t FrameLength(const std::vector<uint8_t>& frame)
|
|
{
|
|
return *reinterpret_cast<const uint16_t*>(frame.data() + sizeof(uint16_t));
|
|
}
|
|
|
|
int RemainingTimeoutMs(std::chrono::steady_clock::time_point deadline)
|
|
{
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (now >= deadline)
|
|
return 0;
|
|
|
|
return static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count());
|
|
}
|
|
|
|
uint8_t FindFirstPlayableCharacterIndex(const PacketGCLoginSuccess4& success)
|
|
{
|
|
for (uint8_t index = 0; index < PLAYER_PER_ACCOUNT_LOCAL; ++index)
|
|
{
|
|
if (success.players[index].id != 0 && success.players[index].change_name == 0)
|
|
return index;
|
|
}
|
|
|
|
throw std::runtime_error("account has no playable characters");
|
|
}
|
|
|
|
AuthResponse Authenticate(EncryptedClient& auth_client, const std::string& login, const std::string& password, SmokeResult& result, bool json)
|
|
{
|
|
PacketCGLogin3 login_packet {};
|
|
login_packet.header = CG::LOGIN3;
|
|
login_packet.length = sizeof(login_packet);
|
|
std::strncpy(login_packet.login, login.c_str(), sizeof(login_packet.login) - 1);
|
|
std::strncpy(login_packet.passwd, password.c_str(), sizeof(login_packet.passwd) - 1);
|
|
auth_client.SendEncryptedPacket(login_packet);
|
|
|
|
std::vector<uint8_t> frame;
|
|
for (int i = 0; i < 8; ++i)
|
|
{
|
|
Expect(auth_client.WaitForFrame(frame, 5000), "timed out waiting for auth response");
|
|
const auto header = FrameHeader(frame);
|
|
|
|
if (header == GC::PHASE)
|
|
continue;
|
|
|
|
if (header == GC::LOGIN_FAILURE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected login failure size");
|
|
const auto* failure = reinterpret_cast<const PacketGCLoginFailure*>(frame.data());
|
|
EmitEvent(result, json, "auth_failure status=" + std::string(failure->status));
|
|
return AuthResponse {false, 0, failure->status};
|
|
}
|
|
|
|
if (header == GC::AUTH_SUCCESS)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCAuthSuccess), "unexpected auth success size");
|
|
const auto* success = reinterpret_cast<const PacketGCAuthSuccess*>(frame.data());
|
|
Expect(success->result != 0, "auth result returned failure");
|
|
EmitEvent(result, json, "auth_success login_key=" + std::to_string(success->login_key));
|
|
return AuthResponse {true, success->login_key, {}};
|
|
}
|
|
}
|
|
|
|
throw std::runtime_error("did not receive AUTH_SUCCESS");
|
|
}
|
|
|
|
ChannelLoginResponse LoginToChannel(EncryptedClient& channel_client, const std::string& login, uint32_t login_key, SmokeResult& result, bool json)
|
|
{
|
|
PacketCGLogin2 login_packet {};
|
|
login_packet.header = CG::LOGIN2;
|
|
login_packet.length = sizeof(login_packet);
|
|
login_packet.login_key = login_key;
|
|
std::strncpy(login_packet.login, login.c_str(), sizeof(login_packet.login) - 1);
|
|
channel_client.SendEncryptedPacket(login_packet);
|
|
|
|
bool saw_empire = false;
|
|
bool saw_login_success = false;
|
|
uint8_t empire = 0;
|
|
PacketGCLoginSuccess4 login_success {};
|
|
std::vector<uint8_t> frame;
|
|
|
|
for (int i = 0; i < 16; ++i)
|
|
{
|
|
Expect(channel_client.WaitForFrame(frame, 5000), "timed out waiting for channel response");
|
|
const auto header = FrameHeader(frame);
|
|
const auto length = FrameLength(frame);
|
|
|
|
if (header == GC::PHASE)
|
|
continue;
|
|
|
|
if (header == GC::LOGIN_FAILURE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected channel login failure size");
|
|
const auto* failure = reinterpret_cast<const PacketGCLoginFailure*>(frame.data());
|
|
EmitEvent(result, json, "channel_failure status=" + std::string(failure->status));
|
|
return ChannelLoginResponse {false, -1, failure->status, {}};
|
|
}
|
|
|
|
if (header == GC::EMPIRE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCEmpire), "unexpected empire packet size");
|
|
const auto* empire_packet = reinterpret_cast<const PacketGCEmpire*>(frame.data());
|
|
saw_empire = true;
|
|
empire = empire_packet->empire;
|
|
EmitEvent(result, json, "channel_empire empire=" + std::to_string(static_cast<int>(empire)));
|
|
continue;
|
|
}
|
|
|
|
if (header == GC::LOGIN_SUCCESS4)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCLoginSuccess4), "unexpected channel login success size");
|
|
std::memcpy(&login_success, frame.data(), sizeof(login_success));
|
|
saw_login_success = true;
|
|
EmitEvent(result, json, "channel_login_success length=" + std::to_string(length));
|
|
break;
|
|
}
|
|
}
|
|
|
|
Expect(saw_empire, "did not receive EMPIRE");
|
|
Expect(saw_login_success, "did not receive LOGIN_SUCCESS4");
|
|
return ChannelLoginResponse {true, empire, {}, login_success};
|
|
}
|
|
|
|
uint8_t CreateCharacter(EncryptedClient& channel_client, std::string_view create_name, SmokeResult& result, bool json)
|
|
{
|
|
PacketCGPlayerCreate create_packet {};
|
|
create_packet.header = CG::CHARACTER_CREATE;
|
|
create_packet.length = sizeof(create_packet);
|
|
create_packet.index = 0;
|
|
create_packet.job = 0;
|
|
create_packet.shape = 0;
|
|
create_packet.con = 4;
|
|
create_packet.intel = 4;
|
|
create_packet.str = 4;
|
|
create_packet.dex = 4;
|
|
std::strncpy(create_packet.name, create_name.data(), sizeof(create_packet.name) - 1);
|
|
channel_client.SendEncryptedPacket(create_packet);
|
|
|
|
std::vector<uint8_t> frame;
|
|
for (int i = 0; i < 16; ++i)
|
|
{
|
|
Expect(channel_client.WaitForFrame(frame, 5000), "timed out waiting for character create response");
|
|
const auto header = FrameHeader(frame);
|
|
|
|
if (header == GC::PHASE)
|
|
continue;
|
|
|
|
if (header == GC::PLAYER_CREATE_FAILURE)
|
|
{
|
|
if (frame.size() == sizeof(PacketGCCreateFailure))
|
|
{
|
|
const auto* failure = reinterpret_cast<const PacketGCCreateFailure*>(frame.data());
|
|
throw std::runtime_error("character create failed type=" + std::to_string(failure->type));
|
|
}
|
|
|
|
throw std::runtime_error("character create failed");
|
|
}
|
|
|
|
if (header == GC::PLAYER_CREATE_SUCCESS)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCPlayerCreateSuccess), "unexpected player create success size");
|
|
const auto* success = reinterpret_cast<const PacketGCPlayerCreateSuccess*>(frame.data());
|
|
EmitEvent(result, json, "character_created index=" + std::to_string(static_cast<int>(success->account_character_index)) +
|
|
" name=" + success->player.name);
|
|
result.character_created = true;
|
|
result.character_name = success->player.name;
|
|
return success->account_character_index;
|
|
}
|
|
}
|
|
|
|
throw std::runtime_error("did not receive PLAYER_CREATE_SUCCESS");
|
|
}
|
|
|
|
void DeleteCharacter(EncryptedClient& channel_client, uint8_t character_index, std::string_view private_code, SmokeResult& result, bool json)
|
|
{
|
|
Expect(private_code.size() == 7, "delete private code must be exactly 7 characters");
|
|
|
|
PacketCGPlayerDelete delete_packet {};
|
|
delete_packet.header = CG::CHARACTER_DELETE;
|
|
delete_packet.length = sizeof(delete_packet);
|
|
delete_packet.index = character_index;
|
|
std::strncpy(delete_packet.private_code, private_code.data(), sizeof(delete_packet.private_code) - 1);
|
|
channel_client.SendEncryptedPacket(delete_packet);
|
|
|
|
std::vector<uint8_t> frame;
|
|
for (int i = 0; i < 16; ++i)
|
|
{
|
|
Expect(channel_client.WaitForFrame(frame, 5000), "timed out waiting for character delete response");
|
|
const auto header = FrameHeader(frame);
|
|
|
|
if (header == GC::PHASE)
|
|
continue;
|
|
|
|
if (header == GC::LOGIN_FAILURE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected character delete failure size");
|
|
const auto* failure = reinterpret_cast<const PacketGCLoginFailure*>(frame.data());
|
|
throw std::runtime_error(std::string("character delete failed: ") + failure->status);
|
|
}
|
|
|
|
if (header == GC::PLAYER_DELETE_WRONG_SOCIAL_ID)
|
|
{
|
|
throw std::runtime_error("character delete failed: wrong private code");
|
|
}
|
|
|
|
if (header == GC::PLAYER_DELETE_SUCCESS)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCPlayerDeleteSuccess), "unexpected player delete success size");
|
|
const auto* success = reinterpret_cast<const PacketGCPlayerDeleteSuccess*>(frame.data());
|
|
Expect(success->account_index == character_index, "character delete returned unexpected index");
|
|
result.character_deleted = true;
|
|
result.deleted_character_index = success->account_index;
|
|
EmitEvent(result, json, "character_deleted index=" + std::to_string(static_cast<int>(success->account_index)));
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw std::runtime_error("did not receive PLAYER_DELETE_SUCCESS");
|
|
}
|
|
|
|
void SelectCharacter(EncryptedClient& channel_client, uint8_t character_index, SmokeResult& result, bool json)
|
|
{
|
|
PacketCGPlayerSelect select_packet {};
|
|
select_packet.header = CG::CHARACTER_SELECT;
|
|
select_packet.length = sizeof(select_packet);
|
|
select_packet.index = character_index;
|
|
channel_client.SendEncryptedPacket(select_packet);
|
|
|
|
bool saw_phase_loading = false;
|
|
bool saw_main_character = false;
|
|
std::vector<uint8_t> frame;
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
|
|
|
while (true)
|
|
{
|
|
const int timeout_ms = RemainingTimeoutMs(deadline);
|
|
Expect(timeout_ms > 0, "timed out waiting for character select response");
|
|
Expect(channel_client.WaitForFrame(frame, timeout_ms), "timed out waiting for character select response");
|
|
const auto header = FrameHeader(frame);
|
|
|
|
if (header == GC::LOGIN_FAILURE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected character select failure size");
|
|
const auto* failure = reinterpret_cast<const PacketGCLoginFailure*>(frame.data());
|
|
throw std::runtime_error(std::string("character select failed: ") + failure->status);
|
|
}
|
|
|
|
if (header == GC::PHASE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCPhase), "unexpected phase packet size");
|
|
const auto* phase = reinterpret_cast<const PacketGCPhase*>(frame.data());
|
|
if (phase->phase == PHASE_LOADING)
|
|
{
|
|
saw_phase_loading = true;
|
|
EmitEvent(result, json, "phase_loading");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (header == GC::MAIN_CHARACTER)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCMainCharacter), "unexpected main character packet size");
|
|
const auto* main_character = reinterpret_cast<const PacketGCMainCharacter*>(frame.data());
|
|
Expect(main_character->name[0] != '\0', "main character name is empty");
|
|
saw_main_character = true;
|
|
result.character_name = main_character->name;
|
|
EmitEvent(result, json, "main_character name=" + std::string(main_character->name));
|
|
}
|
|
|
|
if (saw_phase_loading && saw_main_character)
|
|
return;
|
|
}
|
|
}
|
|
|
|
void SendClientVersion(EncryptedClient& channel_client, std::string_view client_version, SmokeResult& result, bool json)
|
|
{
|
|
PacketCGClientVersion version_packet {};
|
|
version_packet.header = CG::CLIENT_VERSION;
|
|
version_packet.length = sizeof(version_packet);
|
|
std::strncpy(version_packet.filename, "Metin2_Debug.exe", sizeof(version_packet.filename) - 1);
|
|
std::strncpy(version_packet.timestamp, client_version.data(), sizeof(version_packet.timestamp) - 1);
|
|
channel_client.SendEncryptedPacket(version_packet);
|
|
EmitEvent(result, json, "client_version " + std::string(version_packet.timestamp));
|
|
}
|
|
|
|
void SendChatCommand(EncryptedClient& channel_client, std::string_view command)
|
|
{
|
|
std::vector<uint8_t> frame(sizeof(PacketCGChat) + command.size() + 1, 0);
|
|
auto* chat = reinterpret_cast<PacketCGChat*>(frame.data());
|
|
chat->header = CG::CHAT;
|
|
chat->length = static_cast<uint16_t>(frame.size());
|
|
chat->type = CHAT_TYPE_TALKING_LOCAL;
|
|
std::memcpy(frame.data() + sizeof(PacketCGChat), command.data(), command.size());
|
|
channel_client.SendEncryptedFrame(frame.data(), frame.size());
|
|
}
|
|
|
|
void EnterGame(EncryptedClient& channel_client, SmokeResult& result, bool json)
|
|
{
|
|
PacketCGEnterGame enter_game {};
|
|
enter_game.header = CG::ENTERGAME;
|
|
enter_game.length = sizeof(enter_game);
|
|
channel_client.SendEncryptedPacket(enter_game);
|
|
|
|
bool saw_phase_game = false;
|
|
bool saw_time = false;
|
|
bool saw_channel = false;
|
|
std::vector<uint8_t> frame;
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
|
|
|
while (true)
|
|
{
|
|
const int timeout_ms = RemainingTimeoutMs(deadline);
|
|
Expect(timeout_ms > 0, "timed out waiting for entergame response");
|
|
Expect(channel_client.WaitForFrame(frame, timeout_ms), "timed out waiting for entergame response");
|
|
const auto header = FrameHeader(frame);
|
|
|
|
if (header == GC::LOGIN_FAILURE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected entergame failure size");
|
|
const auto* failure = reinterpret_cast<const PacketGCLoginFailure*>(frame.data());
|
|
throw std::runtime_error(std::string("entergame failed: ") + failure->status);
|
|
}
|
|
|
|
if (header == GC::PHASE)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCPhase), "unexpected phase packet size");
|
|
const auto* phase = reinterpret_cast<const PacketGCPhase*>(frame.data());
|
|
if (phase->phase == PHASE_GAME)
|
|
{
|
|
saw_phase_game = true;
|
|
EmitEvent(result, json, "phase_game");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (header == GC::TIME)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCTime), "unexpected time packet size");
|
|
saw_time = true;
|
|
continue;
|
|
}
|
|
|
|
if (header == GC::CHANNEL)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCChannel), "unexpected channel packet size");
|
|
const auto* channel = reinterpret_cast<const PacketGCChannel*>(frame.data());
|
|
saw_channel = true;
|
|
result.game_channel = channel->channel;
|
|
EmitEvent(result, json, "game_channel channel=" + std::to_string(static_cast<int>(channel->channel)));
|
|
}
|
|
|
|
if (saw_phase_game && saw_time && saw_channel)
|
|
return;
|
|
}
|
|
}
|
|
|
|
void OpenMall(EncryptedClient& channel_client, const std::string& password, SmokeResult& result, bool json)
|
|
{
|
|
Expect(!password.empty(), "mall password is empty");
|
|
Expect(password.size() <= SAFEBOX_PASSWORD_MAX_LEN_LOCAL, "mall password too long");
|
|
|
|
SendChatCommand(channel_client, "/mall_password " + password);
|
|
|
|
std::vector<uint8_t> frame;
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
|
|
|
while (true)
|
|
{
|
|
const int timeout_ms = RemainingTimeoutMs(deadline);
|
|
Expect(timeout_ms > 0, "timed out waiting for mall open response");
|
|
Expect(channel_client.WaitForFrame(frame, timeout_ms), "timed out waiting for mall open response");
|
|
const auto header = FrameHeader(frame);
|
|
|
|
if (header == GC::SAFEBOX_WRONG_PASSWORD)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCSafeboxWrongPassword), "unexpected safebox wrong password packet size");
|
|
throw std::runtime_error("mall password rejected");
|
|
}
|
|
|
|
if (header == GC::MALL_OPEN)
|
|
{
|
|
Expect(frame.size() == sizeof(PacketGCSafeboxSize), "unexpected mall open packet size");
|
|
const auto* mall_open = reinterpret_cast<const PacketGCSafeboxSize*>(frame.data());
|
|
result.mall_opened = true;
|
|
result.mall_size = mall_open->size;
|
|
EmitEvent(result, json, "mall_open size=" + std::to_string(static_cast<int>(mall_open->size)));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
SmokeOptions options;
|
|
SmokeResult result;
|
|
|
|
try
|
|
{
|
|
Expect(argc >= 6,
|
|
"usage: metin_login_smoke <host> <auth_port> <channel_port> <login> <password>\n"
|
|
" or: metin_login_smoke <host> <auth_port> <channel_port> <login> --password-env=ENV_NAME\n"
|
|
" optional: --create-character-name=NAME [--client-version=VERSION] [--json] "
|
|
"[--expect-auth-failure=STATUS] [--expect-channel-failure=STATUS] [--delete-private-code=CODE] "
|
|
"[--mall-password=PASSWORD]");
|
|
|
|
const std::string host = argv[1];
|
|
const uint16_t auth_port = static_cast<uint16_t>(std::stoi(argv[2]));
|
|
const uint16_t channel_port = static_cast<uint16_t>(std::stoi(argv[3]));
|
|
const std::string login = argv[4];
|
|
std::string password;
|
|
|
|
const std::string password_arg = argv[5];
|
|
const std::string prefix = "--password-env=";
|
|
if (password_arg.rfind(prefix, 0) == 0)
|
|
{
|
|
const char* value = std::getenv(password_arg.substr(prefix.size()).c_str());
|
|
Expect(value && *value, "password environment variable is empty");
|
|
password = value;
|
|
}
|
|
else
|
|
{
|
|
password = password_arg;
|
|
}
|
|
|
|
for (int i = 6; i < argc; ++i)
|
|
{
|
|
const std::string create_arg = argv[i];
|
|
const std::string create_prefix = "--create-character-name=";
|
|
const std::string version_prefix = "--client-version=";
|
|
const std::string auth_failure_prefix = "--expect-auth-failure=";
|
|
const std::string channel_failure_prefix = "--expect-channel-failure=";
|
|
const std::string delete_private_code_prefix = "--delete-private-code=";
|
|
const std::string mall_password_prefix = "--mall-password=";
|
|
if (create_arg.rfind(create_prefix, 0) == 0)
|
|
{
|
|
options.create_character_name = create_arg.substr(create_prefix.size());
|
|
Expect(!options.create_character_name.empty(), "create character name is empty");
|
|
Expect(options.create_character_name.size() <= CHARACTER_NAME_MAX_LEN_LOCAL, "create character name too long");
|
|
continue;
|
|
}
|
|
|
|
if (create_arg.rfind(version_prefix, 0) == 0)
|
|
{
|
|
options.client_version = create_arg.substr(version_prefix.size());
|
|
Expect(!options.client_version.empty(), "client version is empty");
|
|
continue;
|
|
}
|
|
|
|
if (create_arg.rfind(auth_failure_prefix, 0) == 0)
|
|
{
|
|
options.expect_auth_failure = create_arg.substr(auth_failure_prefix.size());
|
|
Expect(!options.expect_auth_failure.empty(), "expected auth failure status is empty");
|
|
continue;
|
|
}
|
|
|
|
if (create_arg.rfind(channel_failure_prefix, 0) == 0)
|
|
{
|
|
options.expect_channel_failure = create_arg.substr(channel_failure_prefix.size());
|
|
Expect(!options.expect_channel_failure.empty(), "expected channel failure status is empty");
|
|
continue;
|
|
}
|
|
|
|
if (create_arg.rfind(delete_private_code_prefix, 0) == 0)
|
|
{
|
|
options.delete_private_code = create_arg.substr(delete_private_code_prefix.size());
|
|
Expect(!options.delete_private_code.empty(), "delete private code is empty");
|
|
Expect(options.delete_private_code.size() == 7, "delete private code must be exactly 7 characters");
|
|
continue;
|
|
}
|
|
|
|
if (create_arg.rfind(mall_password_prefix, 0) == 0)
|
|
{
|
|
options.mall_password = create_arg.substr(mall_password_prefix.size());
|
|
Expect(!options.mall_password.empty(), "mall password is empty");
|
|
Expect(options.mall_password.size() <= SAFEBOX_PASSWORD_MAX_LEN_LOCAL, "mall password too long");
|
|
continue;
|
|
}
|
|
|
|
if (create_arg == "--json")
|
|
{
|
|
options.json = true;
|
|
continue;
|
|
}
|
|
|
|
throw std::runtime_error("unknown optional argument: " + create_arg);
|
|
}
|
|
|
|
Expect(login.size() <= LOGIN_MAX_LEN_LOCAL, "login too long");
|
|
Expect(password.size() <= PASSWD_MAX_LEN_LOCAL, "password too long");
|
|
|
|
result.stage = "auth_handshake";
|
|
auto started_at = std::chrono::steady_clock::now();
|
|
EncryptedClient auth_client(host, auth_port);
|
|
auth_client.Handshake();
|
|
result.auth_handshake_ms = ElapsedMs(started_at);
|
|
|
|
result.stage = "auth_login";
|
|
started_at = std::chrono::steady_clock::now();
|
|
const AuthResponse auth = Authenticate(auth_client, login, password, result, options.json);
|
|
result.auth_login_ms = ElapsedMs(started_at);
|
|
result.failure_status.clear();
|
|
|
|
if (!auth.success)
|
|
{
|
|
result.failure_status = auth.failure_status;
|
|
if (!options.expect_auth_failure.empty())
|
|
{
|
|
Expect(auth.failure_status == options.expect_auth_failure,
|
|
"expected auth failure '" + options.expect_auth_failure + "' but got '" + auth.failure_status + "'");
|
|
result.ok = true;
|
|
result.result = "expected_auth_failure";
|
|
result.stage = "auth_login";
|
|
EmitEvent(result, options.json, "expected_auth_failure status=" + auth.failure_status);
|
|
if (options.json)
|
|
PrintJson(result);
|
|
return 0;
|
|
}
|
|
|
|
throw std::runtime_error("auth login failed: " + auth.failure_status);
|
|
}
|
|
|
|
Expect(options.expect_auth_failure.empty(),
|
|
"auth login succeeded but expected failure '" + options.expect_auth_failure + "'");
|
|
|
|
const uint32_t login_key = auth.login_key;
|
|
result.login_key = login_key;
|
|
|
|
result.stage = "channel_handshake";
|
|
started_at = std::chrono::steady_clock::now();
|
|
EncryptedClient channel_client(host, channel_port);
|
|
channel_client.Handshake();
|
|
result.channel_handshake_ms = ElapsedMs(started_at);
|
|
|
|
result.stage = "channel_login";
|
|
started_at = std::chrono::steady_clock::now();
|
|
const ChannelLoginResponse channel = LoginToChannel(channel_client, login, login_key, result, options.json);
|
|
result.channel_login_ms = ElapsedMs(started_at);
|
|
|
|
if (!channel.success)
|
|
{
|
|
result.failure_status = channel.failure_status;
|
|
if (!options.expect_channel_failure.empty())
|
|
{
|
|
Expect(channel.failure_status == options.expect_channel_failure,
|
|
"expected channel failure '" + options.expect_channel_failure + "' but got '" + channel.failure_status + "'");
|
|
result.ok = true;
|
|
result.result = "expected_channel_failure";
|
|
result.stage = "channel_login";
|
|
EmitEvent(result, options.json, "expected_channel_failure status=" + channel.failure_status);
|
|
if (options.json)
|
|
PrintJson(result);
|
|
return 0;
|
|
}
|
|
|
|
throw std::runtime_error("channel login failed: " + channel.failure_status);
|
|
}
|
|
|
|
Expect(options.expect_channel_failure.empty(),
|
|
"channel login succeeded but expected failure '" + options.expect_channel_failure + "'");
|
|
|
|
const PacketGCLoginSuccess4& login_success = channel.login_success;
|
|
result.empire = channel.empire;
|
|
|
|
uint8_t character_index = 0;
|
|
try
|
|
{
|
|
character_index = FindFirstPlayableCharacterIndex(login_success);
|
|
result.character_index = character_index;
|
|
result.character_name = login_success.players[character_index].name;
|
|
EmitEvent(result, options.json, "selected_character index=" + std::to_string(static_cast<int>(character_index)));
|
|
}
|
|
catch (const std::exception&)
|
|
{
|
|
Expect(!options.create_character_name.empty(), "account has no playable characters and no create-character name was provided");
|
|
result.stage = "character_create";
|
|
started_at = std::chrono::steady_clock::now();
|
|
character_index = CreateCharacter(channel_client, options.create_character_name, result, options.json);
|
|
result.character_create_ms = ElapsedMs(started_at);
|
|
result.character_index = character_index;
|
|
}
|
|
|
|
if (!options.delete_private_code.empty())
|
|
{
|
|
result.stage = "character_delete";
|
|
started_at = std::chrono::steady_clock::now();
|
|
DeleteCharacter(channel_client, character_index, options.delete_private_code, result, options.json);
|
|
result.character_delete_ms = ElapsedMs(started_at);
|
|
result.ok = true;
|
|
result.result = "delete_success";
|
|
result.stage = "complete";
|
|
EmitEvent(result, options.json, "delete_success");
|
|
if (options.json)
|
|
PrintJson(result);
|
|
return 0;
|
|
}
|
|
|
|
result.stage = "character_select";
|
|
started_at = std::chrono::steady_clock::now();
|
|
SelectCharacter(channel_client, character_index, result, options.json);
|
|
result.character_select_ms = ElapsedMs(started_at);
|
|
|
|
result.stage = "client_version";
|
|
SendClientVersion(channel_client, options.client_version, result, options.json);
|
|
|
|
result.stage = "entergame";
|
|
started_at = std::chrono::steady_clock::now();
|
|
EnterGame(channel_client, result, options.json);
|
|
result.entergame_ms = ElapsedMs(started_at);
|
|
|
|
if (!options.mall_password.empty())
|
|
{
|
|
result.stage = "mall_open";
|
|
started_at = std::chrono::steady_clock::now();
|
|
OpenMall(channel_client, options.mall_password, result, options.json);
|
|
result.mall_open_ms = ElapsedMs(started_at);
|
|
}
|
|
|
|
result.ok = true;
|
|
result.result = "success";
|
|
result.stage = "complete";
|
|
EmitEvent(result, options.json, "full_login_success");
|
|
if (options.json)
|
|
PrintJson(result);
|
|
return 0;
|
|
}
|
|
catch (const std::exception& e)
|
|
{
|
|
result.ok = false;
|
|
result.result = "unexpected_failure";
|
|
result.error = e.what();
|
|
if (options.json)
|
|
{
|
|
PrintJson(result);
|
|
}
|
|
else
|
|
{
|
|
std::cerr << "metin_login_smoke failed: " << e.what() << "\n";
|
|
}
|
|
return 1;
|
|
}
|
|
}
|