// net_classic_e2e — minimal live probe for the 40250 classic backend. // // It intentionally stops after PHASE_GAME, unless MT_CLASSIC_WARP_COMMAND is // set. The optional command is sent once after the first PHASE_GAME and the // probe then requires a second PHASE_GAME, allowing a reversible GM /warp // command to exercise the 40250 GC_WARP replacement transport. // // net_classic_e2e [slot] [timeout_seconds] // MT_CLASSIC_AUTH=1 selects the 40250 auth(11000) -> game() flow. // MT_CLASSIC_WARP_COMMAND="/warp " performs one GC_WARP round trip. // MT_CLASSIC_EXPECT_EMPTY=1 verifies an all-empty SelectPhase character list // without trying to select a stale slot or entering the game. // // Exit status 0 means the socket handshake, DH2/CTR negotiation, login, // character list, character selection and PHASE_GAME were all reached. // Exit status 5 means an application-level login/game-connect failure callback // was observed; 7 means an established game peer disconnected. #include "../src/net/classic/classic_session.h" #include #include #include #include #include #include using steady_clock_t = std::chrono::steady_clock; static const char *stage_name(mtnet::INetSession::Stage stage) { using Stage = mtnet::INetSession::Stage; switch (stage) { case Stage::Offline: return "offline"; case Stage::Connecting: return "connecting"; case Stage::LoggingIn: return "login"; case Stage::CharSelect: return "char_select"; case Stage::Loading: return "loading"; case Stage::InGame: return "in_game"; case Stage::Failed: return "failed"; } return "unknown"; } static double elapsed_seconds(steady_clock_t::time_point start) { return std::chrono::duration_cast(steady_clock_t::now() - start) .count() / 1000.0; } static int choose_slot(const std::vector &slots, int requested) { if (requested >= 0 && requested < (int)slots.size() && !slots[requested].empty()) { return requested; } for (int i = 0; i < (int)slots.size(); ++i) { if (!slots[i].empty()) return i; } return -1; } int main(int argc, char **argv) { if (argc < 5 || argc > 7) { std::fprintf(stderr, "usage: %s [slot] [timeout_seconds]\n", argv[0]); return 64; } const std::string host = argv[1]; const int port = std::atoi(argv[2]); const std::string id = argv[3]; const std::string password = argv[4]; const int requested_slot = argc >= 6 ? std::atoi(argv[5]) : 0; const double timeout = argc >= 7 ? std::atof(argv[6]) : 30.0; if (port <= 0 || port > 65535 || timeout <= 0.0) { std::fprintf(stderr, "invalid port or timeout\n"); return 64; } mtnet::classic::ClassicSession session; bool login_failed = false; std::string login_failure_reason; bool remote_disconnected = false; std::string remote_disconnect_reason; // LoginPhase/AccountConnector reports an application-level rejection to the // login UI while retaining the phase owner. A live probe must observe that // callback instead of waiting for a terminal Stage::Failed (which 40250 does // not enter for WRONGPWD/BESAMEKEY). session.on_login_failure = [&](const std::string &reason) { login_failed = true; login_failure_reason = reason; }; // A connected game peer close is routed through SetLoginPhase by the // reference client. The probe stops at the first notification so a retry // packet cannot hide the original live failure in the report. session.on_remote_disconnect = [&](const std::string &reason) { remote_disconnected = true; remote_disconnect_reason = reason; }; session.set_wire_trace(std::getenv("MT_NET_TRACE") != nullptr && std::string(std::getenv("MT_NET_TRACE")) == "1"); const bool use_auth = std::getenv("MT_CLASSIC_AUTH") != nullptr; const bool use_direct_enter = std::getenv("MT_CLASSIC_DIRECT") != nullptr; const bool expect_empty = std::getenv("MT_CLASSIC_EXPECT_EMPTY") != nullptr && std::string(std::getenv("MT_CLASSIC_EXPECT_EMPTY")) == "1"; const char *warp_command_env = std::getenv("MT_CLASSIC_WARP_COMMAND"); const std::string warp_command = warp_command_env ? warp_command_env : ""; const int requested_empire = std::getenv("MT_CLASSIC_EMPIRE") ? std::atoi(std::getenv("MT_CLASSIC_EMPIRE")) : 0; const int auth_port = std::getenv("MT_CLASSIC_AUTH_PORT") ? std::atoi(std::getenv("MT_CLASSIC_AUTH_PORT")) : 11000; const bool connected = use_auth ? session.connect(host, static_cast(auth_port), host, static_cast(port), id, password) : session.connect(host, static_cast(port), id, password); if (!connected) { std::fprintf(stderr, "FAIL connect: %s\n", session.last_error().c_str()); return 2; } std::printf("classic e2e -> %s:%d account=%s requested_slot=%d\n", host.c_str(), port, id.c_str(), requested_slot); auto start = steady_clock_t::now(); int last_stage = -1; bool selected = false; bool empire_selected = false; bool warp_requested = false; bool warp_left_game = false; while (elapsed_seconds(start) < timeout) { const uint32_t now = static_cast(elapsed_seconds(start) * 1000.0); session.set_now(now); session.pump(); if (login_failed) { std::fprintf(stderr, "FAIL login callback: %s\n", login_failure_reason.empty() ? "unknown" : login_failure_reason.c_str()); session.disconnect(); return 5; } if (remote_disconnected) { std::fprintf(stderr, "FAIL remote disconnect: %s\n", remote_disconnect_reason.empty() ? "disconnected" : remote_disconnect_reason.c_str()); session.disconnect(); return 7; } const int current_stage = static_cast(session.stage()); if (current_stage != last_stage) { last_stage = current_stage; std::printf("[%.1fs] stage=%s\n", elapsed_seconds(start), stage_name(session.stage())); } if (session.stage() == mtnet::INetSession::Stage::CharSelect && !empire_selected && requested_empire >= 1 && requested_empire <= 3 && session.parser().empire() == 0) { const bool sent = session.send_empire(static_cast(requested_empire)); std::printf("[e2e] select empire=%d -> %s\n", requested_empire, sent ? "sent" : "SEND FAILED"); if (!sent) { std::fprintf(stderr, "FAIL: select empire: %s\n", session.last_error().c_str()); session.disconnect(); return 4; } empire_selected = true; } if (session.stage() == mtnet::INetSession::Stage::CharSelect && !selected && session.parser().char_list_ready()) { const auto &slots = session.char_slots(); std::printf("[e2e] character slots=%zu\n", slots.size()); for (size_t i = 0; i < slots.size(); ++i) { const auto &slot = slots[i]; std::printf(" [%zu] %s id=%u level=%u pos=(%d,%d) main_part=%u hair_part=%u change_name=%d\n", i, slot.name.c_str(), slot.id, slot.level, slot.x, slot.y, slot.main_part, slot.hair_part, (int)slot.change_name); } const int first_selectable = choose_slot(slots, -1); if (expect_empty) { if (first_selectable >= 0) { std::fprintf(stderr, "FAIL: MT_CLASSIC_EXPECT_EMPTY=1 but slot %d is occupied\n", first_selectable); session.disconnect(); return 3; } // ClientVS22 accepts a zero-filled LOGIN_SUCCESS3/4 payload and // opens SelectPhase; an empty account is not a login failure. Stop // here so the probe never mutates the account or invents a slot. std::printf("PASS EMPTY CHARACTER LIST PHASE_SELECT slots=%zu\n", slots.size()); session.disconnect(); return 0; } const int slot = choose_slot(slots, requested_slot); if (slot < 0) { std::fprintf(stderr, "FAIL: account has no selectable character\n"); session.disconnect(); return 3; } const bool enter_ok = use_direct_enter ? session.connect_direct_enter(slot) : session.select_char(slot); if (!enter_ok) { std::fprintf(stderr, "FAIL: %s(%d): %s\n", use_direct_enter ? "connect_direct_enter" : "select_char", slot, session.last_error().c_str()); session.disconnect(); return 4; } std::printf("[e2e] %s slot=%d\n", use_direct_enter ? "direct-entered" : "selected", slot); selected = true; } if (session.stage() == mtnet::INetSession::Stage::Failed) { std::fprintf(stderr, "FAIL stage: %s\n", session.last_error().c_str()); session.disconnect(); return 5; } if (warp_requested && session.stage() != mtnet::INetSession::Stage::InGame) { warp_left_game = true; } if (!warp_command.empty()) { mtnet::WarpCue warp{}; if (session.world().take_next_warp(warp)) { const uint32_t addr = static_cast(warp.addr); char warp_host[16]; std::snprintf(warp_host, sizeof(warp_host), "%u.%u.%u.%u", addr & 0xFF, (addr >> 8) & 0xFF, (addr >> 16) & 0xFF, (addr >> 24) & 0xFF); if (!session.connect_warp(warp_host, warp.port)) { std::fprintf(stderr, "FAIL: GC_WARP reconnect: %s\n", session.last_error().c_str()); session.disconnect(); return 4; } std::printf("[e2e] GC_WARP reconnect -> %s:%u\n", warp_host, (unsigned)warp.port); } } if (session.stage() == mtnet::INetSession::Stage::InGame) { if (!warp_command.empty() && !warp_requested) { if (!session.send_chat(0, warp_command)) { std::fprintf(stderr, "FAIL: GC_WARP command send: %s\n", session.last_error().c_str()); session.disconnect(); return 4; } warp_requested = true; std::printf("[e2e] GC_WARP command sent\n"); } if (!warp_command.empty() && (!warp_requested || !warp_left_game)) { std::this_thread::sleep_for(std::chrono::milliseconds(15)); continue; } const auto &world = session.world(); std::array entity_types{}; for (uint32_t vid : world.vids()) { if (const auto *entity = world.get(vid); entity && entity->ch_type < entity_types.size()) { ++entity_types[entity->ch_type]; } } int inventory_count = 0; for (int cell = 0; cell < mtnet::INVENTORY_MAX_NUM; ++cell) { if (!world.inv_slot(cell).empty()) ++inventory_count; } std::printf("PASS PHASE_GAME%s main_vid=%u entities=%zu inventory_slots=%d points_level=%d skill_group=%u\n", warp_command.empty() ? "" : " (GC_WARP round trip)", world.main_vid(), world.size(), inventory_count, world.points().level(), (unsigned)world.skill_group()); std::printf(" entity_types pc=%d npc=%d monster=%d stone=%d warp=%d door=%d building=%d horse=%d goto=%d\n", entity_types[mtnet::classic::CHRTYPE_PC], entity_types[mtnet::classic::CHRTYPE_NPC], entity_types[mtnet::classic::CHRTYPE_MONSTER], entity_types[mtnet::classic::CHRTYPE_STONE], entity_types[mtnet::classic::CHRTYPE_WARP], entity_types[mtnet::classic::CHRTYPE_DOOR], entity_types[mtnet::classic::CHRTYPE_BUILDING], entity_types[mtnet::classic::CHRTYPE_HORSE], entity_types[mtnet::classic::CHRTYPE_GOTO]); if (const mtnet::Entity *me = world.get(world.main_vid())) { std::printf(" main race=%u pos_cm=(%.0f,%.0f,%.0f) parts=[%u,%u,%u,%u] name=%s\n", me->race, me->x, me->y, me->z, me->parts[0], me->parts[1], me->parts[2], me->parts[3], me->name.c_str()); } for (int wear = 0; wear < mtnet::WEAR_MAX_NUM; ++wear) { const mtnet::Item &item = world.equip_slot(wear); if (!item.empty()) { std::printf(" equip wear=%d vnum=%u count=%u\n", wear, item.vnum, (unsigned)item.count); } } for (int pos = 0; pos < mtnet::QUICKSLOT_MAX_NUM; ++pos) { const mtnet::QuickSlot slot = world.quickslot(pos); if (slot.type != 0) { std::printf(" quickslot pos=%d type=%u ref=%u\n", pos, (unsigned)slot.type, (unsigned)slot.position); } } for (int id = 0; id < mtnet::SKILL_MAX_NUM; ++id) { if (world.skill_level(id) != 0) { std::printf(" skill id=%d level=%u master=%u\n", id, (unsigned)world.skill_level(id), (unsigned)world.skill_master(id)); } } session.disconnect(); return 0; } std::this_thread::sleep_for(std::chrono::milliseconds(15)); } std::fprintf(stderr, "FAIL timeout after %.1fs at stage=%s: %s\n", timeout, stage_name(session.stage()), session.last_error().c_str()); session.disconnect(); return 6; }