Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+467
View File
@@ -0,0 +1,467 @@
// 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 <chrono>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <string>
#include <thread>
using clock_t_ = std::chrono::steady_clock;
static double elapsed(clock_t_::time_point start) {
return std::chrono::duration_cast<std::chrono::milliseconds>(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<bool()> &send,
const std::function<int()> &events = std::function<int()>()) {
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 <npc> 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<mtnet::MyShopItem> 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;
}