// net_e2e — drive the full client login flow against a REAL server: // auth handshake -> CG_LOGIN3 -> GC_AUTH_SUCCESS // -> game server: CG_LOGIN2 -> char list -> select_character -> PHASE_GAME // -> pump a few seconds, dump entities / points / inventory / party. // // net_e2e [auth_host] [auth_port] [game_host] [game_port] [id] [pw] [char_index] [seconds] // defaults: 192.168.21.203 11000 192.168.21.203 11011 admin 123456789 0 8 // MT_E2E_SWEEP=1 enables the one-session safe protocol matrix; add // MT_E2E_UNSAFE=1 only when warp/dungeon probes are explicitly authorized. // // Mirrors M2Client::pump_auth/pump_game orchestration without the Godot layer. #include "../src/net/auth_client.h" #include "../src/net/game_client.h" #include #include #include #include #include #include using clock_t_ = std::chrono::steady_clock; static double elapsed(clock_t_::time_point start) { return std::chrono::duration_cast(clock_t_::now() - start).count() / 1000.0; } int main(int argc, char **argv) { std::string auth_host = argc > 1 ? argv[1] : "192.168.21.203"; uint16_t auth_port = argc > 2 ? (uint16_t)std::stoi(argv[2]) : 11000; std::string game_host = argc > 3 ? argv[3] : "192.168.21.203"; uint16_t game_port = argc > 4 ? (uint16_t)std::stoi(argv[4]) : 11011; std::string id = argc > 5 ? argv[5] : "admin"; std::string pw = argc > 6 ? argv[6] : "123456789"; int char_index = argc > 7 ? std::atoi(argv[7]) : 0; double run_secs = argc > 8 ? std::atof(argv[8]) : 8.0; std::printf("net_e2e -> auth %s:%u game %s:%u id=%s char=%d\n", auth_host.c_str(), auth_port, game_host.c_str(), game_port, id.c_str(), char_index); bool trace = std::getenv("MT_NET_TRACE") != nullptr; // ---- phase 1: auth server ------------------------------------------------ mtnet::AuthClient auth(id, pw); auth.set_wire_trace(trace); if (!auth.connect(auth_host, auth_port)) { std::printf("FAIL: auth connect(): %s\n", auth.last_error().c_str()); return 2; } auto start = clock_t_::now(); while (!auth.done() && elapsed(start) < 15.0) { auth.process(); std::this_thread::sleep_for(std::chrono::milliseconds(15)); } if (!auth.done() || !auth.success()) { std::printf("FAIL: auth (%s)\n", auth.fail_reason().empty() ? auth.last_error().c_str() : auth.fail_reason().c_str()); return 3; } uint32_t login_key = auth.login_key(); std::printf("[auth] OK login_key=0x%08X\n", login_key); auth.disconnect(); // ---- phase 2: game server --------------------------------------------- mtnet::GameClient game(id, login_key); game.set_wire_trace(trace); game.set_auto_enter_game(false); // this tool sends it on its own schedule if (std::getenv("MT_NET_DUMP")) { for (uint16_t h : {0x0205, 0x0206, 0x0207, 0x0209, 0x020A, 0x0214, 0x0215, // round 2 0x021B, 0x0519, 0x051A, 0x051B, 0x0730, 0x0A20, 0x0514, 0x0A30, 0x0A31, 0x0A50, 0x0912, 0x0603, 0x0304, 0x0307, 0x0217, 0x0216, 0x0410, 0x0413, 0x0A11, 0x0A12, 0x0A13, // post-2026-08-30: create/delete, dragon soul, mall 0x020C, 0x020D, 0x020E, 0x020F, 0x051F, 0x0841, 0x0842, 0x0843, 0x0109}) { game.dump_header(h); } } const bool e2e_charcreate = std::getenv("MT_E2E_CHARCREATE") != nullptr; const bool e2e_myshop = std::getenv("MT_E2E_MYSHOP") != nullptr; const bool e2e_cube = std::getenv("MT_E2E_CUBE") != nullptr; const bool e2e_sweep = std::getenv("MT_E2E_SWEEP") != nullptr; const bool e2e_unsafe = std::getenv("MT_E2E_UNSAFE") != nullptr; if (!game.connect(game_host, game_port)) { std::printf("FAIL: game connect(): %s\n", game.last_error().c_str()); return 4; } start = clock_t_::now(); bool selected = false; double select_t = 0; bool tried_empire = false; double loading_t = 0; bool enter_sent = false; int last_phase = -1; while (elapsed(start) < 30.0) { if (game.phase() == mtnet::PHASE_LOADING && loading_t == 0) { loading_t = elapsed(start); } // give the server ~1.5s to finish the spawn burst, then say we're ready if (game.phase() == mtnet::PHASE_LOADING && !enter_sent && loading_t > 0 && elapsed(start) - loading_t > 1.5) { std::printf("[game] send CG_ENTERGAME (%.1fs into LOADING)\n", elapsed(start) - loading_t); game.send_enter_game(); enter_sent = true; } game.world().set_now((uint32_t)(elapsed(start) * 1000.0)); game.process(); game.world().tick(); if ((int)game.phase() != last_phase) { last_phase = (int)game.phase(); std::printf("[game] phase = %d (t=%.1fs)\n", last_phase, elapsed(start)); } if (game.state() == mtnet::NetStream::State::Offline) { std::printf("FAIL: game server dropped us (%s) at phase %d\n", game.last_error().c_str(), (int)game.phase()); return 8; } if (game.char_list_ready() && !selected) { std::printf("[game] char list: %zu slot(s) (empire byte=%d seen=%d)\n", game.chars().size(), game.empire(), (int)game.empire_seen()); for (const auto &c : game.chars()) { std::printf(" [%d] %-16s job=%d lv=%d (%d,%d)\n", c.index, c.name.c_str(), c.job, c.level, c.x, c.y); } if (game.chars().empty()) { std::printf("FAIL: no characters on the account\n"); return 5; } int idx = game.chars()[0].index; for (const auto &c : game.chars()) { if (c.index == char_index) { idx = c.index; } } // --- non-destructive create test: CG_CHARACTER_CREATE on an OCCUPIED // slot must come back as GC_PLAYER_CREATE_FAILURE (nothing mutated). if (e2e_charcreate) { std::printf("[e2e] CG_CHARACTER_CREATE on occupied slot %d (expect FAILURE)\n", idx); game.create_character(idx, "ZzTestName", 0, 0, 4, 3, 6, 3); double t0 = elapsed(start); bool got = false; while (elapsed(start) - t0 < 3.0 && !got) { game.process(); for (const auto &ev : game.drain_char_events()) { using K = mtnet::GameClient::CharEvent::Kind; if (ev.kind == K::CreateFail) { std::printf("[e2e] -> GC_PLAYER_CREATE_FAILURE type=%d (struct OK)\n", ev.fail_type); got = true; } else if (ev.kind == K::CreateOk) { std::printf("[e2e] !! GC_PLAYER_CREATE_SUCCESS on occupied slot " "(slot=%d) — unexpected, check slot choice\n", ev.slot); got = true; } } std::this_thread::sleep_for(std::chrono::milliseconds(20)); } if (!got) { std::printf("[e2e] !! no create result in 3s (last_unknown=0x%04X) — " "CG_CHARACTER_CREATE layout may be wrong\n", game.last_unknown_header()); } } std::printf("[game] select_character(%d) -> %s\n", idx, game.select_character(idx) ? "sent" : "SEND FAILED"); selected = true; select_t = elapsed(start); } // stuck at SELECT >3s after selecting: try an explicit empire choice + reselect if (selected && !tried_empire && game.phase() == mtnet::PHASE_SELECT && elapsed(start) - select_t > 3.0) { std::printf("[game] still at SELECT %.1fs after select; sending CG_EMPIRE(1) + reselect\n", elapsed(start) - select_t); game.send_empire(1); std::this_thread::sleep_for(std::chrono::milliseconds(100)); game.process(); game.select_character(char_index); tried_empire = true; } if (game.failed()) { std::printf("FAIL: game (%s)\n", game.fail_reason().c_str()); return 6; } if (game.in_game()) { break; } std::this_thread::sleep_for(std::chrono::milliseconds(15)); } if (!game.in_game()) { std::printf("FAIL: never reached PHASE_GAME (phase=%d, last_unknown=0x%04X)\n", (int)game.phase(), game.last_unknown_header()); return 7; } std::printf("[game] IN GAME (t=%.1fs)\n", elapsed(start)); // ---- phase 2.25: one-session protocol sweep --------------------------- // These are deliberately sent one at a time with a short pump between them. // A live socket only proves that the server accepted/framed the packet; a // state/event change is printed separately as stronger evidence. Destructive // requests (warp/dungeon/change-name/item mutation) stay behind MT_E2E_UNSAFE. if (e2e_sweep) { std::printf("\n=== one-session protocol sweep (safe subset) ===\n"); int sweep_sent = 0; int sweep_alive = 0; int sweep_events = 0; auto pump_probe = [&](double seconds) { auto p0 = clock_t_::now(); while (elapsed(p0) < seconds) { game.world().set_now((uint32_t)(12000 + elapsed(p0) * 1000.0)); game.process(); game.world().tick(); if (game.state() == mtnet::NetStream::State::Offline) { break; } std::this_thread::sleep_for(std::chrono::milliseconds(20)); } }; auto probe = [&](const char *name, uint16_t header, const std::function &send, const std::function &events = std::function()) { const uint16_t unknown_before = game.last_unknown_header(); const bool sent = send(); if (sent) { ++sweep_sent; } pump_probe(0.45); const bool alive = game.state() != mtnet::NetStream::State::Offline; if (alive) { ++sweep_alive; } int event_count = events ? events() : 0; sweep_events += event_count; std::printf("[sweep] %-24s tx=0x%04X sent=%d alive=%d events=%d " "unknown_before=0x%04X unknown_after=0x%04X\n", name, header, (int)sent, (int)alive, event_count, unknown_before, game.last_unknown_header()); }; const uint32_t self_vid = game.world().main_vid(); const mtnet::Entity *self = game.world().get(self_vid); const int32_t self_x = self ? (int32_t)self->x : 0; const int32_t self_y = self ? (int32_t)self->y : 0; probe("CG_CHARACTER_POSITION", mtnet::CG_CHARACTER_POSITION, [&]() { return game.send_character_position(0); }); mtnet::CGSyncPositionElement sync{}; sync.vid = self_vid; sync.x = self_x; sync.y = self_y; probe("CG_SYNC_POSITION", mtnet::CG_SYNC_POSITION, [&]() { return game.send_sync_positions({sync}); }); probe("CG_SCRIPT_SELECT_ITEM", mtnet::CG_SCRIPT_SELECT_ITEM, [&]() { return game.send_script_select_item(0); }, [&]() { return (int)game.world().drain_scripts().size(); }); probe("CG_QUEST_CANCEL", mtnet::CG_QUEST_CANCEL, [&]() { return game.send_quest_cancel(); }, [&]() { return (int)game.world().drain_quest_changes().size(); }); probe("CG_PARTY_USE_SKILL", mtnet::CG_PARTY_USE_SKILL, [&]() { return game.send_party_use_skill(0, self_vid); }); probe("CG_FLY_TARGETING", mtnet::CG_FLY_TARGETING, [&]() { return game.send_fly_targeting(self_vid, self_x, self_y); }, [&]() { return (int)game.world().drain_fly_target_cues().size(); }); probe("CG_ADD_FLY_TARGETING", mtnet::CG_ADD_FLY_TARGETING, [&]() { return game.send_add_fly_targeting(self_vid, self_x, self_y); }, [&]() { return (int)game.world().drain_fly_target_cues().size(); }); probe("CG_FISHING", mtnet::CG_FISHING, [&]() { return game.send_fishing(0); }, [&]() { return (int)game.world().drain_fishing_events().size(); }); probe("CG_SHOOT", mtnet::CG_SHOOT, [&]() { return game.send_shoot(0); }); probe("CG_USE_SKILL", mtnet::CG_USE_SKILL, [&]() { return game.send_use_skill(0, self_vid); }); if (e2e_unsafe) { probe("CG_DUNGEON (unsafe)", mtnet::CG_DUNGEON, [&]() { return game.send_dungeon(); }, [&]() { return (int)game.world().drain_dungeon_events().size(); }); probe("CG_WARP (unsafe)", mtnet::CG_WARP, [&]() { return game.send_warp(); }); } else { std::printf("[sweep] CG_DUNGEON/CG_WARP skipped (set MT_E2E_UNSAFE=1)\n"); } std::printf("[sweep] summary sent=%d alive_after_probe=%d observed_events=%d " "still_connected=%d\n", sweep_sent, sweep_alive, sweep_events, (int)(game.state() != mtnet::NetStream::State::Offline)); } // ---- phase 2.5: exercise post-2026-08-30 protocols ------------------- std::printf("\n=== post-2026-08-30 protocol probes ===\n"); std::printf("[e2e] GC_EMPIRE: empire byte=%d seen=%d\n", game.empire(), (int)game.empire_seen()); // cube: /cube rList is a plain chat command; server only answers when // the player is actually at a cube NPC, so this mostly proves the send path. if (e2e_cube) { uint32_t cube_npc = 20383; // common blacksmith/cube NPC vnum std::printf("[e2e] send '/cube rList %u'\n", cube_npc); game.send_cube_result_list(cube_npc); } if (e2e_myshop) { // open a 1-item shop from inventory cell 0 at a silly price, then close. std::vector items; bool have_item = false; for (int c = 0; c < mtnet::INVENTORY_MAX_NUM && !have_item; ++c) { const mtnet::Item &it = game.world().inv_slot(c); if (!it.empty()) { mtnet::MyShopItem e{}; e.vnum = it.vnum; e.count = it.count ? it.count : 1; e.pos = {mtnet::WINDOW_INVENTORY, (uint16_t)c}; e.price = 99999999; e.display_pos = 0; items.push_back(e); have_item = true; std::printf("[e2e] CG_MYSHOP: 1 item (vnum=%u inv_cell=%d) price=99999999\n", e.vnum, c); } } if (!have_item) { std::printf("[e2e] CG_MYSHOP: inventory empty, opening a 0-item shop\n"); } game.send_open_private_shop("e2e test shop", items); } // pump ~3.5s so any server push (guild skill/war, shop broadcast) lands { auto p0 = clock_t_::now(); while (elapsed(p0) < 3.5) { game.world().set_now((uint32_t)(15000 + elapsed(p0) * 1000.0)); game.process(); game.world().tick(); if (game.state() == mtnet::NetStream::State::Offline) { std::printf("[e2e] !! DROPPED during probe pump: %s\n", game.last_error().c_str()); break; } std::this_thread::sleep_for(std::chrono::milliseconds(20)); } } { const mtnet::GuildSkillState &gs = game.world().guild_skill(); std::printf("[e2e] GUILD_GC_SKILL_INFO: valid=%d skill_point=%d guild_point=%d/%d " "levels=[", (int)gs.valid, gs.skill_point, gs.guild_point, gs.max_guild_point); for (int i = 0; i < mtnet::GUILD_SKILL_MAX_NUM; ++i) { std::printf("%d%s", gs.levels[i], i + 1 < mtnet::GUILD_SKILL_MAX_NUM ? "," : ""); } std::printf("]\n"); const mtnet::GuildWarStatus &gw = game.world().guild_war(); std::printf("[e2e] GUILD_GC_WAR: state=%d type=%d opp_guild=%u active GvG pairs=%zu\n", gw.state, gw.type, gw.opp_guild_id, game.world().guild_wars().size()); } if (e2e_myshop) { std::printf("[e2e] CG_MYSHOP: closing shop (SHOP_CG_END)\n"); game.send_close_private_shop(); auto p0 = clock_t_::now(); while (elapsed(p0) < 1.5) { game.process(); std::this_thread::sleep_for(std::chrono::milliseconds(20)); } for (const auto &err : game.world().drain_shop_errors()) { std::printf("[e2e] shop error: %s\n", err.c_str()); } } std::printf("[e2e] cube state: open=%d npc=%u recipes=%zu results=%zu\n", (int)game.world().cube().open, game.world().cube().npc_vnum, game.world().cube().recipes.size(), game.world().cube().results.size()); std::printf("[e2e] mall state: open=%d size=%d\n", (int)game.world().mall_open(), game.world().mall_size()); std::printf("[e2e] still connected after probes: %s\n", game.state() == mtnet::NetStream::State::Offline ? "NO (dropped)" : "yes"); // ---- phase 3: observe + poke (move / attack with zero CRC) --------- auto game_start = clock_t_::now(); bool poked = false; while (elapsed(game_start) < run_secs) { game.world().set_now((uint32_t)(20000 + elapsed(game_start) * 1000.0)); game.process(); game.world().tick(); if (game.state() == mtnet::NetStream::State::Offline) { std::printf("[game] disconnected after %.1fs in game: %s\n", elapsed(game_start), game.last_error().c_str()); break; } // 2s in: send a move + an attack (crc fields 0) and see if we survive if (!poked && elapsed(game_start) > 2.0) { const mtnet::Entity *me = game.world().get(game.world().main_vid()); int32_t x = me ? (int32_t)me->x : 0, y = me ? (int32_t)me->y : 0; game.send_move(mtnet::FUNC_MOVE, 0, 0, x + 100, y, 2000); game.send_attack(0, game.world().main_vid()); std::printf("[game] sent CG_MOVE + CG_ATTACK (crc=0) at +2s\n"); poked = true; } std::this_thread::sleep_for(std::chrono::milliseconds(20)); } if (game.state() != mtnet::NetStream::State::Offline) { std::printf("[game] still connected at +%.0fs (zero-CRC attack accepted)\n", run_secs); } mtnet::EntityStore &w = game.world(); std::printf("\n=== snapshot after %.0fs in game ===\n", run_secs); std::printf("main vid: %u\n", w.main_vid()); if (const mtnet::Entity *me = w.get(w.main_vid())) { std::printf(" name=%s race=%u pos=(%.0f,%.0f,%.0f) hp=%d/%d level=%d\n", me->name.c_str(), me->race, me->x, me->y, me->z, me->hp, me->max_hp, me->level); } std::printf("entities in view: %zu\n", w.size()); int named = 0, mounted = 0; for (uint32_t vid : w.vids()) { const mtnet::Entity *e = w.get(vid); if (!e->name.empty() && vid != w.main_vid()) { if (named++ < 10) { std::printf(" NAMED vid=%-7u race=%-5u lvl=%-4d guild=%d pk=%d mount=%u \"%s\"\n", vid, e->race, e->level, e->guild, e->pk_mode, e->mount_vnum, e->name.c_str()); } } if (e->mount_vnum != 0) { ++mounted; } } std::printf("named entities: %d mounted: %d\n", named, mounted); const mtnet::PlayerPoints &p = w.points(); std::printf("points: hp=%d/%d sp=%d/%d level=%d exp=%d/%d gold=%d\n", p.hp(), p.max_hp(), p.sp(), p.max_sp(), p.level(), p.exp(), p.next_exp(), p.gold()); int inv_n = 0; for (int c = 0; c < mtnet::INVENTORY_MAX_NUM; ++c) { if (!w.inv_slot(c).empty()) { ++inv_n; } } std::printf("inventory: %d non-empty slot(s)\n", inv_n); std::printf("party: %zu member(s) friends: %zu\n", w.party_pids().size(), w.friends().size()); int skn = 0, qsn = 0; for (int i = 0; i < mtnet::SKILL_MAX_NUM; ++i) { if (w.skill_level(i) > 0) { ++skn; } } for (int i = 0; i < mtnet::QUICKSLOT_MAX_NUM; ++i) { if (w.quickslot(i).type != 0) { ++qsn; } } std::printf("skills known: %d quickslots set: %d\n", skn, qsn); const mtnet::GuildState &gld = w.guild(); if (gld.in_guild) { std::printf("guild: \"%s\" lvl %d members %d/%d gold %u (%zu in list)\n", gld.name.c_str(), gld.level, gld.member_count, gld.max_member_count, gld.gold, w.guild_members().size()); } else { std::printf("guild: (none)\n"); } for (int i = 0; i < mtnet::SKILL_MAX_NUM && skn > 0; ++i) { if (w.skill_level(i) > 0) { std::printf(" skill %d: lvl %d master %d\n", i, w.skill_level(i), w.skill_master(i)); if (--skn == 0 || i > 60) { break; } } } std::printf("\nRESULT: e2e OK — reached PHASE_GAME and pumped %.0fs\n", run_secs); return 0; }