diff --git a/tests/login_smoke.cpp b/tests/login_smoke.cpp index a10523c..c0fc2f2 100644 --- a/tests/login_smoke.cpp +++ b/tests/login_smoke.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -23,6 +24,9 @@ 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; #pragma pack(push, 1) struct PacketGCPhase @@ -73,6 +77,39 @@ struct PacketCGLogin2 uint32_t login_key; }; +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; @@ -94,6 +131,91 @@ struct PacketGCEmpire 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 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 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; +}; #pragma pack(pop) void Expect(bool condition, std::string_view message) @@ -281,6 +403,26 @@ uint16_t FrameLength(const std::vector& frame) return *reinterpret_cast(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(std::chrono::duration_cast(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"); +} + uint32_t Authenticate(const std::string& host, uint16_t auth_port, const std::string& login, const std::string& password) { EncryptedClient auth_client(host, auth_port); @@ -322,11 +464,8 @@ uint32_t Authenticate(const std::string& host, uint16_t auth_port, const std::st throw std::runtime_error("did not receive AUTH_SUCCESS"); } -void LoginToChannel(const std::string& host, uint16_t channel_port, const std::string& login, uint32_t login_key) +PacketGCLoginSuccess4 LoginToChannel(EncryptedClient& channel_client, const std::string& login, uint32_t login_key) { - EncryptedClient channel_client(host, channel_port); - channel_client.Handshake(); - PacketCGLogin2 login_packet {}; login_packet.header = CG::LOGIN2; login_packet.length = sizeof(login_packet); @@ -337,6 +476,7 @@ void LoginToChannel(const std::string& host, uint16_t channel_port, const std::s bool saw_empire = false; bool saw_login_success = false; uint8_t empire = 0; + PacketGCLoginSuccess4 login_success {}; std::vector frame; for (int i = 0; i < 16; ++i) @@ -367,6 +507,8 @@ void LoginToChannel(const std::string& host, uint16_t channel_port, const std::s 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; std::cout << "channel_login_success length=" << length << "\n"; break; @@ -375,6 +517,178 @@ void LoginToChannel(const std::string& host, uint16_t channel_port, const std::s Expect(saw_empire, "did not receive EMPIRE"); Expect(saw_login_success, "did not receive LOGIN_SUCCESS4"); + return login_success; +} + +uint8_t CreateCharacter(EncryptedClient& channel_client, std::string_view create_name) +{ + 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 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(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(frame.data()); + std::cout << "character_created index=" << static_cast(success->account_character_index) + << " name=" << success->player.name << "\n"; + return success->account_character_index; + } + } + + throw std::runtime_error("did not receive PLAYER_CREATE_SUCCESS"); +} + +void SelectCharacter(EncryptedClient& channel_client, uint8_t character_index) +{ + 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 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(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(frame.data()); + if (phase->phase == PHASE_LOADING) + { + saw_phase_loading = true; + std::cout << "phase_loading\n"; + } + continue; + } + + if (header == GC::MAIN_CHARACTER) + { + Expect(frame.size() == sizeof(PacketGCMainCharacter), "unexpected main character packet size"); + const auto* main_character = reinterpret_cast(frame.data()); + Expect(main_character->name[0] != '\0', "main character name is empty"); + saw_main_character = true; + std::cout << "main_character name=" << main_character->name << "\n"; + } + + if (saw_phase_loading && saw_main_character) + return; + } +} + +void SendClientVersion(EncryptedClient& channel_client, std::string_view client_version) +{ + 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); + std::cout << "client_version " << version_packet.timestamp << "\n"; +} + +void EnterGame(EncryptedClient& channel_client) +{ + 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 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(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(frame.data()); + if (phase->phase == PHASE_GAME) + { + saw_phase_game = true; + std::cout << "phase_game\n"; + } + 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(frame.data()); + saw_channel = true; + std::cout << "game_channel channel=" << static_cast(channel->channel) << "\n"; + } + + if (saw_phase_game && saw_time && saw_channel) + return; + } } } @@ -382,15 +696,18 @@ int main(int argc, char** argv) { try { - Expect(argc == 6, + Expect(argc >= 6 && argc <= 8, "usage: metin_login_smoke \n" - " or: metin_login_smoke --password-env=ENV_NAME"); + " or: metin_login_smoke --password-env=ENV_NAME\n" + " optional: --create-character-name=NAME [--client-version=VERSION]"); const std::string host = argv[1]; const uint16_t auth_port = static_cast(std::stoi(argv[2])); const uint16_t channel_port = static_cast(std::stoi(argv[3])); const std::string login = argv[4]; std::string password; + std::string create_character_name; + std::string client_version = "1215955205"; const std::string password_arg = argv[5]; const std::string prefix = "--password-env="; @@ -405,11 +722,52 @@ int main(int argc, char** argv) 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="; + if (create_arg.rfind(create_prefix, 0) == 0) + { + create_character_name = create_arg.substr(create_prefix.size()); + Expect(!create_character_name.empty(), "create character name is empty"); + Expect(create_character_name.size() <= CHARACTER_NAME_MAX_LEN_LOCAL, "create character name too long"); + continue; + } + + if (create_arg.rfind(version_prefix, 0) == 0) + { + client_version = create_arg.substr(version_prefix.size()); + Expect(!client_version.empty(), "client version is empty"); + continue; + } + + throw std::runtime_error("unknown optional argument"); + } + Expect(login.size() <= LOGIN_MAX_LEN_LOCAL, "login too long"); Expect(password.size() <= PASSWD_MAX_LEN_LOCAL, "password too long"); const uint32_t login_key = Authenticate(host, auth_port, login, password); - LoginToChannel(host, channel_port, login, login_key); + EncryptedClient channel_client(host, channel_port); + channel_client.Handshake(); + const PacketGCLoginSuccess4 login_success = LoginToChannel(channel_client, login, login_key); + + uint8_t character_index = 0; + try + { + character_index = FindFirstPlayableCharacterIndex(login_success); + std::cout << "selected_character index=" << static_cast(character_index) << "\n"; + } + catch (const std::exception&) + { + Expect(!create_character_name.empty(), "account has no playable characters and no create-character name was provided"); + character_index = CreateCharacter(channel_client, create_character_name); + } + + SelectCharacter(channel_client, character_index); + SendClientVersion(channel_client, client_version); + EnterGame(channel_client); std::cout << "full_login_success\n"; return 0;