Files
mtgodot-poc/extension/tests/net_loopback_test.cpp
T
shenandClaude Sonnet 5 47baf6c0c6 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
2026-08-31 20:02:12 +09:00

1074 lines
38 KiB
C++

// Offline end-to-end test for the net client. An in-process MockServer speaks
// the real wire protocol (framing + libsodium KX + scripted phase packets) over
// a localhost socket; AuthClient then GameClient are driven through the full
// auth -> char list -> select -> game flow with no external server.
#include "../src/net/auth_client.h"
#include "../src/net/game_client.h"
#include "../src/net/mark_client.h"
#include "../src/net/wire.h"
#include <arpa/inet.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <functional>
#include <netinet/in.h>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
using namespace mtnet;
static int g_fail = 0;
#define CHECK(c, msg) \
do { \
if (!(c)) { \
std::fprintf(stderr, "FAIL: %s\n", msg); \
++g_fail; \
} \
} while (0)
// --- MockServer: minimal server-side NetStream over one accepted socket -------
class MockServer {
public:
enum class Mode { Auth, Game, Mark };
explicit MockServer(Mode m) : m_mode(m) {}
~MockServer() {
if (m_conn >= 0) {
::close(m_conn);
}
if (m_listen >= 0) {
::close(m_listen);
}
}
uint16_t listen_ephemeral() {
m_listen = ::socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(m_listen, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
a.sin_port = 0;
bind(m_listen, (sockaddr *)&a, sizeof(a));
::listen(m_listen, 1);
socklen_t l = sizeof(a);
getsockname(m_listen, (sockaddr *)&a, &l);
int fl = fcntl(m_listen, F_GETFL, 0);
fcntl(m_listen, F_SETFL, fl | O_NONBLOCK);
return ntohs(a.sin_port);
}
// Pump: accept, recv+decrypt, dispatch scripted responses, flush.
void poll() {
if (m_conn < 0) {
int c = ::accept(m_listen, nullptr, nullptr);
if (c < 0) {
return;
}
m_conn = c;
int fl = fcntl(m_conn, F_GETFL, 0);
fcntl(m_conn, F_SETFL, fl | O_NONBLOCK);
// server opens the conversation
m_cipher.initialize();
send_phase(PHASE_HANDSHAKE);
send_key_challenge();
}
uint8_t buf[4096];
ssize_t r = ::recv(m_conn, buf, sizeof(buf), 0);
if (r > 0) {
if (m_cipher.is_activated()) {
m_cipher.decrypt_in_place(buf, (size_t)r);
}
m_recv.write(buf, (size_t)r);
dispatch();
}
flush();
}
private:
void send_raw(const void *p, size_t n) {
uint8_t tmp[1024];
std::memcpy(tmp, p, n);
m_cipher.encrypt_in_place(tmp, n); // no-op until activated
m_send.write(tmp, n);
}
void flush() {
while (m_send.readable() > 0) {
ssize_t w = ::send(m_conn, m_send.read_ptr(), m_send.readable(), 0);
if (w > 0) {
m_send.discard((size_t)w);
} else {
break;
}
}
}
void send_phase(uint8_t phase) {
GCPhase p{GC_PHASE, sizeof(GCPhase), phase};
send_raw(&p, sizeof(p));
}
void send_command(const std::string &text) {
std::vector<uint8_t> buf(sizeof(GCChat) + text.size() + 1);
GCChat h{};
h.header = GC_CHAT;
h.length = (uint16_t)buf.size();
h.type = CHAT_TYPE_COMMAND;
std::memcpy(buf.data(), &h, sizeof(h));
std::memcpy(buf.data() + sizeof(h), text.c_str(), text.size() + 1);
send_raw(buf.data(), buf.size());
}
void send_key_challenge() {
GCKeyChallenge kc{};
kc.header = GC_KEY_CHALLENGE;
kc.length = sizeof(kc);
m_cipher.get_public_key(kc.server_pk);
randombytes_buf(m_challenge, sizeof(m_challenge));
std::memcpy(kc.challenge, m_challenge, sizeof(m_challenge));
kc.server_time = 12345;
send_raw(&kc, sizeof(kc));
}
void dispatch() {
for (;;) {
DynHeader dh;
if (!m_recv.peek(&dh, sizeof(dh))) {
return;
}
if (dh.length < 4 || !m_recv.has(dh.length)) {
return;
}
switch (dh.header) {
case CG_KEY_RESPONSE: {
CGKeyResponse kr;
m_recv.read(&kr, sizeof(kr));
m_cipher.compute_server_keys(kr.client_pk);
bool ok = m_cipher.verify_challenge_response(m_challenge, kr.challenge_response);
if (!ok) {
std::fprintf(stderr, "MockServer: challenge verify FAILED\n");
}
// KeyComplete plaintext, then activate, then encrypted phase
GCKeyComplete kc{};
kc.header = GC_KEY_COMPLETE;
kc.length = sizeof(kc);
uint8_t token[32];
randombytes_buf(token, sizeof(token));
m_cipher.encrypt_token(token, sizeof(token), kc.encrypted_token, kc.nonce);
send_raw(&kc, sizeof(kc));
m_cipher.set_activated(true);
send_phase(m_mode == Mode::Auth ? PHASE_AUTH : PHASE_LOGIN);
break;
}
case CG_LOGIN3: {
CGLogin3 l;
m_recv.read(&l, sizeof(l));
GCAuthSuccess as{GC_AUTH_SUCCESS, sizeof(GCAuthSuccess), 0x0BADF00D, 1};
send_raw(&as, sizeof(as));
break;
}
case CG_LOGIN2: {
CGLogin2 l;
m_recv.read(&l, sizeof(l));
m_got_login_key = l.login_key;
GCLoginSuccess3 ok{};
ok.header = GC_LOGIN_SUCCESS3;
ok.length = sizeof(ok);
std::strcpy(ok.players[0].name, "Tester");
ok.players[0].id = 42;
ok.players[0].job = 0;
ok.players[0].level = 7;
ok.players[0].x = 100;
ok.players[0].y = 200;
send_raw(&ok, sizeof(ok));
send_phase(PHASE_SELECT);
break;
}
case CG_CHARACTER_SELECT: {
CGCharacterSelect s;
m_recv.read(&s, sizeof(s));
m_selected = s.player_index;
send_phase(PHASE_LOADING);
GCMainCharacter mc{};
mc.header = GC_MAIN_CHARACTER;
mc.length = sizeof(mc);
mc.vid = 1000;
mc.race = 0;
std::strcpy(mc.name, "Tester");
mc.x = 100;
mc.y = 200;
mc.z = 0;
send_raw(&mc, sizeof(mc));
// a nearby NPC + a move for it
GCCharacterAdd add{};
add.header = GC_CHARACTER_ADD;
add.length = sizeof(add);
add.vid = 2000;
add.race = 101;
add.x = 300;
add.y = 400;
send_raw(&add, sizeof(add));
GCMove mv{};
mv.header = GC_MOVE;
mv.length = sizeof(mv);
mv.vid = 2000;
mv.func = FUNC_MOVE;
mv.x = 800;
mv.y = 400;
mv.duration = 1000;
send_raw(&mv, sizeof(mv));
GCDigMotion dig{GC_DIG_MOTION, sizeof(GCDigMotion), 2000, 1000, 2};
send_raw(&dig, sizeof(dig));
send_phase(PHASE_GAME);
DynHeader guild_request{GC_REQUEST_MAKE_GUILD, sizeof(DynHeader)};
send_raw(&guild_request, sizeof(guild_request));
// item-mall: open + one item
{
GCMallOpen mo{GC_MALL_OPEN, sizeof(GCMallOpen), 45};
send_raw(&mo, sizeof(mo));
GCItemSet is{};
is.header = GC_MALL_SET;
is.length = sizeof(is);
is.pos = {WINDOW_MALL, 0};
is.vnum = 27993;
is.count = 1;
send_raw(&is, sizeof(is));
}
// cube: open + result list via GC_CHAT/COMMAND
send_command("cube open 20383");
send_command("cube r_list 20383 1 72723,1");
break;
}
case CG_MOVE: {
CGMove m;
m_recv.read(&m, sizeof(m));
m_last_move = m;
++m_move_count;
break;
}
case CG_CHARACTER_POSITION: {
CGCharacterPosition p;
m_recv.read(&p, sizeof(p));
m_last_character_position = p;
++m_character_position_count;
break;
}
case CG_SYNC_POSITION: {
std::vector<uint8_t> tmp(dh.length);
m_recv.read(tmp.data(), tmp.size());
if (tmp.size() >= sizeof(CGSyncPosition)) {
CGSyncPosition h{};
std::memcpy(&h, tmp.data(), sizeof(h));
const size_t payload = tmp.size() - sizeof(h);
if (h.length == tmp.size() && payload % sizeof(CGSyncPositionElement) == 0) {
m_sync_positions.clear();
for (size_t off = sizeof(h); off < tmp.size(); off += sizeof(CGSyncPositionElement)) {
CGSyncPositionElement e{};
std::memcpy(&e, tmp.data() + off, sizeof(e));
m_sync_positions.push_back(e);
}
++m_sync_position_count;
}
}
break;
}
case CG_WARP: {
CGWarp p;
m_recv.read(&p, sizeof(p));
m_warp_seen = true;
GCWarp w{GC_WARP, sizeof(GCWarp), 4321, 8765, 0, 0};
send_raw(&w, sizeof(w));
break;
}
case CG_FISHING: {
CGFishing p;
m_recv.read(&p, sizeof(p));
m_fishing_seen = true;
m_last_fishing_dir = p.dir;
GCFishing f{GC_FISHING, sizeof(GCFishing), FISHING_START, 1000, p.dir};
send_raw(&f, sizeof(f));
break;
}
case CG_DUNGEON: {
CGDungeon p;
m_recv.read(&p, sizeof(p));
m_dungeon_seen = true;
std::vector<uint8_t> out(sizeof(GCDungeon) + 8);
GCDungeon h{GC_DUNGEON, (uint16_t)out.size(), DUNGEON_DESTINATION_POSITION};
std::memcpy(out.data(), &h, sizeof(h));
int32_t x = 3210, y = 6540;
std::memcpy(out.data() + sizeof(h), &x, 4);
std::memcpy(out.data() + sizeof(h) + 4, &y, 4);
send_raw(out.data(), out.size());
break;
}
case CG_ATTACK: {
CGAttack a;
m_recv.read(&a, sizeof(a));
m_last_attack = a;
++m_attack_count;
break;
}
case CG_USE_SKILL: {
CGUseSkill s;
m_recv.read(&s, sizeof(s));
m_last_use_skill = s;
++m_use_skill_count;
break;
}
case CG_SHOOT: {
CGShoot s;
m_recv.read(&s, sizeof(s));
m_last_shoot = s;
++m_shoot_count;
break;
}
case CG_FLY_TARGETING:
case CG_ADD_FLY_TARGETING: {
CGFlyTargeting t;
m_recv.read(&t, sizeof(t));
if (dh.header == CG_FLY_TARGETING) {
m_last_primary_fly_targeting = t;
++m_primary_fly_targeting_count;
} else {
m_last_fly_targeting = t;
m_last_fly_header = dh.header;
}
++m_fly_targeting_count;
break;
}
case CG_TARGET: {
CGTarget t;
m_recv.read(&t, sizeof(t));
m_last_target_vid = t.vid;
break;
}
case CG_QUICKSLOT_ADD: {
CGQuickSlotAdd q;
m_recv.read(&q, sizeof(q));
m_last_quickslot_add = q;
break;
}
case CG_QUICKSLOT_DEL: {
CGQuickSlotDel q;
m_recv.read(&q, sizeof(q));
m_last_quickslot_del = q;
break;
}
case CG_QUICKSLOT_SWAP: {
CGQuickSlotSwap q;
m_recv.read(&q, sizeof(q));
m_last_quickslot_swap = q;
break;
}
case CG_SCRIPT_SELECT_ITEM: {
CGScriptSelectItem p;
m_recv.read(&p, sizeof(p));
m_last_script_selection = p.selection;
++m_script_selection_count;
break;
}
case CG_QUEST_CANCEL: {
CGQuestCancel p;
m_recv.read(&p, sizeof(p));
m_quest_cancel_count++;
break;
}
case CG_CHAT: {
std::vector<uint8_t> tmp(dh.length);
m_recv.read(tmp.data(), dh.length);
// [u16 head][u16 len][u8 type][utf8 text][NUL]
if (dh.length >= 6) {
m_last_chat_type = tmp[4];
m_last_chat.assign(reinterpret_cast<char *>(tmp.data() + 5),
dh.length - 5 - 1);
if (m_last_chat_type == CHAT_TYPE_COMMAND) {
m_last_command = m_last_chat;
}
}
break;
}
case CG_MYSHOP: {
std::vector<uint8_t> tmp(dh.length);
m_recv.read(tmp.data(), dh.length);
CGMyShopHead h{};
std::memcpy(&h, tmp.data(), sizeof(h));
m_myshop_sign.assign(h.sign, strnlen(h.sign, sizeof(h.sign)));
m_myshop_count = h.count;
m_myshop_items.clear();
for (int i = 0; i < h.count; ++i) {
MyShopItem e{};
std::memcpy(&e, tmp.data() + sizeof(h) + i * sizeof(MyShopItem),
sizeof(MyShopItem));
m_myshop_items.push_back(e);
}
m_myshop_seen = true;
break;
}
case CG_MALL_CHECKOUT: {
CGMallCheckout c;
m_recv.read(&c, sizeof(c));
m_last_mall_checkout = c;
m_mall_checkout_seen = true;
break;
}
case CG_ITEM_MOVE: {
CGItemMove m;
m_recv.read(&m, sizeof(m));
m_last_item_move = m;
++m_item_move_count;
break;
}
case CG_ITEM_USE: {
CGItemUse u;
m_recv.read(&u, sizeof(u));
m_last_item_use = u;
break;
}
case CG_ITEM_DROP2: {
CGItemDrop2 d;
m_recv.read(&d, sizeof(d));
m_last_item_drop2 = d;
break;
}
case CG_ITEM_USE_TO_ITEM: {
CGItemUseToItem u;
m_recv.read(&u, sizeof(u));
m_last_item_use_to_item = u;
break;
}
case CG_ITEM_GIVE: {
CGGiveItem g;
m_recv.read(&g, sizeof(g));
m_last_give_item = g;
break;
}
case CG_ITEM_PICKUP: {
CGItemPickUp p;
m_recv.read(&p, sizeof(p));
m_last_pickup_vid = p.vid;
break;
}
case CG_PARTY_INVITE: {
CGPartyInvite p;
m_recv.read(&p, sizeof(p));
m_last_party_invite_vid = p.vid;
break;
}
case CG_PARTY_USE_SKILL: {
CGPartyUseSkill p;
m_recv.read(&p, sizeof(p));
m_last_party_skill = p;
++m_party_skill_count;
break;
}
case CG_SHOP: {
std::vector<uint8_t> tmp(dh.length);
m_recv.read(tmp.data(), dh.length);
m_last_shop_sub = tmp[4];
if (dh.length >= 7) {
m_last_shop_a = tmp[5];
m_last_shop_b = tmp[6];
}
break;
}
case CG_EXCHANGE: {
CGExchange x;
m_recv.read(&x, sizeof(x));
m_last_exchange_sub = x.subheader;
m_last_exchange_arg1 = x.arg1;
break;
}
case CG_SAFEBOX_CHECKIN: {
CGSafeboxCheckin s;
m_recv.read(&s, sizeof(s));
m_last_safebox_checkin_pos = s.safe_pos;
m_last_safebox_checkin_cell = s.inv_pos.cell;
break;
}
case CG_CLIENT_VERSION: {
CGClientVersion v;
m_recv.read(&v, sizeof(v));
m_client_version_seen = true;
break;
}
case CG_CHARACTER_CREATE: {
CGCreateCharacter c;
m_recv.read(&c, sizeof(c));
m_last_create = c;
m_create_seen = true;
if (std::string(c.name) == "taken") {
GCCreateFailure f{GC_PLAYER_CREATE_FAILURE, sizeof(GCCreateFailure), 1};
send_raw(&f, sizeof(f));
} else {
GCPlayerCreateSuccess ok{};
ok.header = GC_PLAYER_CREATE_SUCCESS;
ok.length = sizeof(ok);
ok.slot = c.index;
ok.player.id = 500 + c.index;
std::strncpy(ok.player.name, c.name, CHARACTER_NAME_MAX_LEN);
ok.player.job = (uint8_t)c.job;
ok.player.level = 1;
ok.player.st = c.str;
ok.player.ht = c.con;
ok.player.dx = c.dex;
ok.player.iq = c.intel;
send_raw(&ok, sizeof(ok));
}
break;
}
case CG_CHARACTER_DELETE: {
CGDeleteCharacter d;
m_recv.read(&d, sizeof(d));
m_last_delete = d;
m_delete_seen = true;
if (std::string(d.private_code, strnlen(d.private_code, PRIVATE_CODE_LENGTH)) == "1234567") {
GCDeleteSuccess ok{GC_PLAYER_DELETE_SUCCESS, sizeof(GCDeleteSuccess), d.index};
send_raw(&ok, sizeof(ok));
} else {
DynHeader bad{GC_PLAYER_DELETE_WRONG_SOCIAL_ID, sizeof(DynHeader)};
send_raw(&bad, sizeof(bad));
}
break;
}
case CG_CHANGE_NAME: {
CGChangeName c;
m_recv.read(&c, sizeof(c));
m_last_change_name = c;
m_change_name_seen = true;
// Character slot 0 in the scripted list has pid 42.
GCChangeName n{GC_CHANGE_NAME, sizeof(GCChangeName), 42, {}};
std::strncpy(n.name, c.name, CHARACTER_NAME_MAX_LEN);
send_raw(&n, sizeof(n));
break;
}
case CG_MARK_LOGIN: {
CGMarkLogin l;
m_recv.read(&l, sizeof(l));
m_mark_login_handle = l.handle;
m_mark_login_key = l.random_key;
break;
}
case CG_SYMBOL_CRC: {
CGSymbolCRC c;
m_recv.read(&c, sizeof(c));
m_symbol_crc_seen = true;
m_symbol_crc_gid = c.guild_id;
std::vector<uint8_t> out(sizeof(GCSymbolData) + 200);
GCSymbolData h{GC_SYMBOL_DATA, (uint16_t)out.size(), c.guild_id};
std::memcpy(out.data(), &h, sizeof(h));
for (size_t i = sizeof(h); i < out.size(); ++i)
out[i] = (uint8_t)(i * 3);
send_raw(out.data(), out.size());
break;
}
case CG_MARK_UPLOAD: {
CGMarkUpload u;
m_recv.read(&u, sizeof(u));
m_mark_upload_gid = u.gid;
m_mark_upload_seen = true;
m_mark_upload_sum = 0;
for (size_t k = 0; k < sizeof(u.image); ++k) {
m_mark_upload_sum += u.image[k];
}
break;
}
case CG_GUILD_SYMBOL_UPLOAD: {
std::vector<uint8_t> tmp(dh.length);
m_recv.read(tmp.data(), dh.length);
CGSymbolUpload head{};
std::memcpy(&head, tmp.data(), sizeof(head));
m_symbol_upload_gid = head.handle;
m_symbol_upload_bytes = (uint32_t)(dh.length - (uint16_t)sizeof(CGSymbolUpload));
m_symbol_upload_seen = true;
break;
}
default:
// consume unknown
std::vector<uint8_t> tmp(dh.length);
m_recv.read(tmp.data(), dh.length);
break;
}
}
}
Mode m_mode;
int m_listen = -1, m_conn = -1;
ByteBuffer m_recv, m_send;
SecureCipher m_cipher;
uint8_t m_challenge[32]{};
public:
uint32_t m_got_login_key = 0;
int m_selected = -1;
CGMove m_last_move{};
int m_move_count = 0;
CGCharacterPosition m_last_character_position{};
int m_character_position_count = 0;
std::vector<CGSyncPositionElement> m_sync_positions;
int m_sync_position_count = 0;
CGAttack m_last_attack{};
int m_attack_count = 0;
CGUseSkill m_last_use_skill{};
int m_use_skill_count = 0;
CGShoot m_last_shoot{};
int m_shoot_count = 0;
CGFlyTargeting m_last_primary_fly_targeting{};
int m_primary_fly_targeting_count = 0;
CGFlyTargeting m_last_fly_targeting{};
uint16_t m_last_fly_header = 0;
int m_fly_targeting_count = 0;
uint32_t m_last_target_vid = 0;
CGQuickSlotAdd m_last_quickslot_add{};
CGQuickSlotDel m_last_quickslot_del{};
CGQuickSlotSwap m_last_quickslot_swap{};
uint32_t m_last_script_selection = 0;
int m_script_selection_count = 0;
int m_quest_cancel_count = 0;
uint8_t m_last_chat_type = 0xFF;
std::string m_last_chat;
std::string m_last_command;
std::string m_myshop_sign;
int m_myshop_count = -1;
std::vector<MyShopItem> m_myshop_items;
bool m_myshop_seen = false;
CGMallCheckout m_last_mall_checkout{};
bool m_mall_checkout_seen = false;
CGItemMove m_last_item_move{};
int m_item_move_count = 0;
CGItemUse m_last_item_use{};
CGItemDrop2 m_last_item_drop2{};
CGItemUseToItem m_last_item_use_to_item{};
CGGiveItem m_last_give_item{};
uint32_t m_last_pickup_vid = 0;
uint32_t m_last_party_invite_vid = 0;
CGPartyUseSkill m_last_party_skill{};
int m_party_skill_count = 0;
uint8_t m_last_shop_sub = 0xFF, m_last_shop_a = 0, m_last_shop_b = 0;
uint8_t m_last_exchange_sub = 0xFF;
uint32_t m_last_exchange_arg1 = 0;
uint8_t m_last_safebox_checkin_pos = 0xFF;
uint16_t m_last_safebox_checkin_cell = 0xFFFF;
bool m_client_version_seen = false;
CGCreateCharacter m_last_create{};
bool m_create_seen = false;
CGDeleteCharacter m_last_delete{};
bool m_delete_seen = false;
CGChangeName m_last_change_name{};
bool m_change_name_seen = false;
bool m_warp_seen = false;
bool m_fishing_seen = false;
uint8_t m_last_fishing_dir = 0;
bool m_dungeon_seen = false;
uint32_t m_mark_login_handle = 0, m_mark_login_key = 0;
bool m_mark_upload_seen = false;
uint32_t m_mark_upload_gid = 0;
uint64_t m_mark_upload_sum = 0;
bool m_symbol_upload_seen = false;
uint32_t m_symbol_upload_gid = 0, m_symbol_upload_bytes = 0;
bool m_symbol_crc_seen = false;
uint32_t m_symbol_crc_gid = 0;
};
// --- driver -----------------------------------------------------------------
template <class Client>
static void run(MockServer &srv, Client &cli, int max_iters,
const std::function<bool()> &stop) {
for (int i = 0; i < max_iters && !stop(); ++i) {
srv.poll();
cli.process();
usleep(1000);
}
}
int main() {
CHECK(SecureCipher::ensure_sodium_init(), "sodium init");
// --- auth server: connect -> KX -> CG_LOGIN3 -> GC_AUTH_SUCCESS ---
uint32_t login_key = 0;
{
MockServer srv(MockServer::Mode::Auth);
uint16_t port = srv.listen_ephemeral();
AuthClient auth("admin", "123456789");
CHECK(auth.connect("127.0.0.1", port), "auth connect");
run(srv, auth, 5000, [&] { return auth.done(); });
CHECK(auth.done(), "auth reached a result");
CHECK(auth.handshaked(), "auth: cipher activated");
CHECK(auth.sent_login(), "auth: CG_LOGIN3 sent at PHASE_AUTH");
CHECK(auth.success(), "auth: GC_AUTH_SUCCESS");
CHECK(auth.login_key() == 0x0BADF00D, "auth: login_key");
login_key = auth.login_key();
}
// --- game server: connect -> KX -> CG_LOGIN2 -> char list -> select -> GAME ---
{
MockServer srv(MockServer::Mode::Game);
uint16_t port = srv.listen_ephemeral();
GameClient game("admin", login_key);
CHECK(game.connect("127.0.0.1", port), "game connect");
run(srv, game, 5000, [&] { return game.char_list_ready(); });
CHECK(game.char_list_ready(), "game: got char list");
CHECK(srv.m_got_login_key == 0x0BADF00D, "game: server received our login_key");
CHECK(game.chars().size() == 1, "game: 1 character");
if (game.chars().size() == 1) {
CHECK(game.chars()[0].name == "Tester", "game: char name");
CHECK(game.chars()[0].level == 7, "game: char level");
CHECK(game.chars()[0].x == 100 && game.chars()[0].y == 200, "game: char position");
}
// --- CG_CHANGE_NAME -> GC_CHANGE_NAME updates the local slot list ---
CHECK(game.change_name(0, "Renamed"), "game: change_name sent");
run(srv, game, 3000, [] { return false; });
CHECK(srv.m_change_name_seen && srv.m_last_change_name.index == 0,
"game: server got CG_CHANGE_NAME idx=0");
CHECK(std::string(srv.m_last_change_name.name) == "Renamed",
"game: change name byte-exact");
CHECK(game.chars().size() == 1 && game.chars()[0].name == "Renamed",
"game: GC_CHANGE_NAME patched slot name");
{
auto evs = game.drain_name_events();
bool got = false;
for (const auto &e : evs)
if (e.pid == 42 && e.name == "Renamed")
got = true;
CHECK(got, "game: name change event for pid 42");
}
// --- CG_CHARACTER_CREATE -> GC_PLAYER_CREATE_SUCCESS patches the slot list ---
CHECK(game.create_character(1, "Newbie", 1, 0, /*con*/3, /*int*/2, /*str*/4, /*dex*/1),
"game: create_character sent");
run(srv, game, 3000, [&] { return game.chars().size() == 2; });
CHECK(srv.m_create_seen && std::string(srv.m_last_create.name) == "Newbie",
"game: server got CG_CHARACTER_CREATE (name byte-exact)");
CHECK(srv.m_last_create.job == 1 && srv.m_last_create.str == 4 && srv.m_last_create.con == 3,
"game: create job/stats byte-exact");
CHECK(game.chars().size() == 2, "game: slot list grew to 2 after create");
{
const GameClient::CharSlot *ns = nullptr;
for (const auto &c : game.chars())
if (c.index == 1)
ns = &c;
CHECK(ns && ns->name == "Newbie" && ns->job == 1, "game: new slot 1 = Newbie/job1");
}
{
auto evs = game.drain_char_events();
bool got = false;
for (const auto &e : evs)
if (e.kind == GameClient::CharEvent::CreateOk && e.slot == 1)
got = true;
CHECK(got, "game: CreateOk event for slot 1");
}
// --- create name clash -> GC_PLAYER_CREATE_FAILURE(type) ---
CHECK(game.create_character(2, "taken", 0, 0, 1, 1, 1, 1), "game: create (clash) sent");
run(srv, game, 3000, [&] {
for (const auto &e : game.peek_char_events())
if (e.kind == GameClient::CharEvent::CreateFail)
return true;
return false;
});
{
auto evs = game.drain_char_events();
bool fail = false;
for (const auto &e : evs)
if (e.kind == GameClient::CharEvent::CreateFail && e.fail_type == 1)
fail = true;
CHECK(fail, "game: CreateFail(type=1) event");
CHECK(game.chars().size() == 2, "game: clash did not add a slot");
}
// --- CG_CHARACTER_DELETE: wrong code rejected, right code removes the slot ---
CHECK(game.delete_character(1, "0000000"), "game: delete (wrong code) sent");
run(srv, game, 3000, [&] {
for (const auto &e : game.peek_char_events())
if (e.kind == GameClient::CharEvent::DeleteFail)
return true;
return false;
});
{
auto evs = game.drain_char_events();
bool df = false;
for (const auto &e : evs)
if (e.kind == GameClient::CharEvent::DeleteFail)
df = true;
CHECK(df, "game: DeleteFail (wrong social id)");
CHECK(game.chars().size() == 2, "game: wrong code kept the slot");
}
CHECK(game.delete_character(1, "1234567"), "game: delete (right code) sent");
run(srv, game, 3000, [&] { return game.chars().size() == 1; });
CHECK(srv.m_delete_seen && srv.m_last_delete.index == 1, "game: server got CG_CHARACTER_DELETE idx=1");
CHECK(game.chars().size() == 1, "game: slot 1 removed after delete");
{
auto evs = game.drain_char_events();
bool ok = false;
for (const auto &e : evs)
if (e.kind == GameClient::CharEvent::DeleteOk && e.slot == 1)
ok = true;
CHECK(ok, "game: DeleteOk event for slot 1");
}
CHECK(game.select_character(0), "game: select_character sent");
run(srv, game, 5000, [&] { return game.in_game(); });
CHECK(game.in_game(), "game: reached PHASE_GAME");
CHECK(srv.m_selected == 0, "game: server saw our selection");
run(srv, game, 200, [] { return false; });
CHECK(game.drain_guild_make_requests() == 1,
"game: GC_REQUEST_MAKE_GUILD surfaced as an event");
CHECK(game.send_warp(), "game: CG_WARP sent");
run(srv, game, 200, [] { return false; });
CHECK(srv.m_warp_seen, "game: server got CG_WARP");
{
auto warps = game.world().drain_warps();
CHECK(!warps.empty() && warps.back().x == 4321 && warps.back().y == 8765,
"game: GC_WARP destination surfaced");
}
CHECK(game.send_fishing(36), "game: CG_FISHING sent");
run(srv, game, 200, [] { return false; });
CHECK(srv.m_fishing_seen && srv.m_last_fishing_dir == 36,
"game: server got CG_FISHING direction");
{
auto fishing = game.world().drain_fishing_events();
CHECK(!fishing.empty() && fishing.back().subheader == FISHING_START &&
fishing.back().info == 1000 && fishing.back().dir == 36,
"game: GC_FISHING event surfaced");
}
CHECK(game.send_dungeon(), "game: CG_DUNGEON sent");
run(srv, game, 200, [] { return false; });
CHECK(srv.m_dungeon_seen, "game: server got CG_DUNGEON");
{
auto dungeons = game.world().drain_dungeon_events();
CHECK(!dungeons.empty() && dungeons.back().subheader == DUNGEON_DESTINATION_POSITION &&
dungeons.back().has_destination && dungeons.back().x == 3210 && dungeons.back().y == 6540,
"game: GC_DUNGEON destination surfaced");
}
// game-phase packets flowed into the EntityStore
run(srv, game, 200, [] { return false; }); // drain a few more polls
mtnet::EntityStore &wm = game.world();
const EntityStore &w = wm;
CHECK(w.main_vid() == 1000, "world: main vid from GC_MAIN_CHARACTER");
CHECK(w.get(1000) && w.get(1000)->name == "Tester", "world: main entity");
CHECK(srv.m_client_version_seen,
"client auto-sent CG_CLIENT_VERSION after GC_MAIN_CHARACTER");
CHECK(w.get(2000) != nullptr, "world: NPC spawned from GC_CHARACTER_ADD");
{
auto digs = wm.drain_dig_motions();
CHECK(!digs.empty() && digs.back().vid == 2000 && digs.back().target_vid == 1000 && digs.back().count == 2,
"world: dig motion surfaced");
}
CHECK(w.get(2000) && w.get(2000)->moving, "world: NPC moving from GC_MOVE");
// interpolate it forward
wm.set_now(500);
wm.tick();
CHECK(wm.get(2000) && std::abs(wm.get(2000)->x - 550.0f) < 2.0f,
"world: NPC interpolated halfway (300->800)");
// --- outbound in-game intents reach the server, byte-exact ---
CHECK(game.send_move(FUNC_MOVE, 0, /*rot=*/18, 12345, 67890, 111), "send_move");
CHECK(game.send_character_position(3), "send_character_position");
CHECK(game.send_sync_positions({{2000, 321, 654}, {3000, -12, 99}}), "send_sync_positions");
CHECK(!game.send_sync_positions({}), "send_sync_positions rejects empty batch");
CHECK(!game.send_sync_positions(std::vector<CGSyncPositionElement>(17)),
"send_sync_positions rejects batches over 16");
CHECK(game.send_attack(/*motion=*/4, 2000), "send_attack");
CHECK(game.send_fly_targeting(2000, 800, 400), "send_fly_targeting");
CHECK(game.send_add_fly_targeting(3000, 700, 500), "send_add_fly_targeting");
CHECK(game.send_shoot(/*skill=*/7), "send_shoot");
CHECK(game.send_use_skill(/*vnum=*/1, /*target_vid=*/2000), "send_use_skill");
CHECK(game.send_target(2000), "send_target");
CHECK(game.send_quickslot_add(/*pos=*/35, /*type=*/2, /*ref=*/16), "send_quickslot_add");
CHECK(game.send_quickslot_del(/*pos=*/34), "send_quickslot_del");
CHECK(game.send_quickslot_swap(/*pos=*/32, /*change=*/33), "send_quickslot_swap");
CHECK(game.send_script_select_item(/*selection=*/91), "send_script_select_item");
CHECK(game.send_quest_cancel(), "send_quest_cancel");
CHECK(game.send_chat(1, "hello"), "send_chat");
run(srv, game, 300, [] { return false; });
CHECK(srv.m_move_count == 1 && srv.m_last_move.func == FUNC_MOVE &&
srv.m_last_move.rot == 18 && srv.m_last_move.x == 12345 &&
srv.m_last_move.y == 67890 && srv.m_last_move.time == 111,
"server got CG_MOVE with our fields");
CHECK(srv.m_character_position_count == 1 && srv.m_last_character_position.position == 3 &&
srv.m_last_character_position.length == sizeof(CGCharacterPosition),
"server got CG_CHARACTER_POSITION{3}");
CHECK(srv.m_sync_position_count == 1 && srv.m_sync_positions.size() == 2 &&
srv.m_sync_positions[0].vid == 2000 && srv.m_sync_positions[0].x == 321 &&
srv.m_sync_positions[0].y == 654 && srv.m_sync_positions[1].vid == 3000 &&
srv.m_sync_positions[1].x == -12 && srv.m_sync_positions[1].y == 99,
"server got CG_SYNC_POSITION batch");
CHECK(srv.m_attack_count == 1 && srv.m_last_attack.type == 4 &&
srv.m_last_attack.victim_vid == 2000,
"server got CG_ATTACK{motion=4, vid=2000}");
CHECK(srv.m_fly_targeting_count == 2 && srv.m_primary_fly_targeting_count == 1 &&
srv.m_last_primary_fly_targeting.target_vid == 2000 &&
srv.m_last_primary_fly_targeting.x == 800 &&
srv.m_last_primary_fly_targeting.y == 400,
"server got CG_FLY_TARGETING{vid=2000,x=800,y=400}");
CHECK(srv.m_last_fly_header == CG_ADD_FLY_TARGETING &&
srv.m_last_fly_targeting.target_vid == 3000 &&
srv.m_last_fly_targeting.x == 700 && srv.m_last_fly_targeting.y == 500,
"server got CG_ADD_FLY_TARGETING{vid=3000,x=700,y=500}");
CHECK(srv.m_shoot_count == 1 && srv.m_last_shoot.type == 7,
"server got CG_SHOOT{skill=7}");
CHECK(srv.m_use_skill_count == 1 && srv.m_last_use_skill.vnum == 1 &&
srv.m_last_use_skill.target_vid == 2000,
"server got CG_USE_SKILL{vnum=1, target=2000}");
CHECK(srv.m_last_target_vid == 2000, "server got CG_TARGET{2000}");
CHECK(srv.m_last_quickslot_add.pos == 35 && srv.m_last_quickslot_add.slot.type == 2 &&
srv.m_last_quickslot_add.slot.position == 16,
"server got CG_QUICKSLOT_ADD{35,skill,16}");
CHECK(srv.m_last_quickslot_del.pos == 34, "server got CG_QUICKSLOT_DEL{34}");
CHECK(srv.m_last_quickslot_swap.pos == 32 && srv.m_last_quickslot_swap.change_pos == 33,
"server got CG_QUICKSLOT_SWAP{32,33}");
CHECK(srv.m_script_selection_count == 1 && srv.m_last_script_selection == 91,
"server got CG_SCRIPT_SELECT_ITEM{91}");
CHECK(srv.m_quest_cancel_count == 1, "server got CG_QUEST_CANCEL");
CHECK(srv.m_last_chat_type == 1 && srv.m_last_chat == "hello",
"server got CG_CHAT{type=1,'hello'}");
// --- outbound item intents ---
CHECK(game.send_item_move(WINDOW_INVENTORY, 5, WINDOW_EQUIPMENT, 1, 1), "send_item_move");
CHECK(game.send_item_use(WINDOW_INVENTORY, 12), "send_item_use");
CHECK(game.send_item_drop2(WINDOW_INVENTORY, 13, 0, 3), "send_item_drop2");
CHECK(game.send_item_use_to_item(WINDOW_INVENTORY, 14, WINDOW_EQUIPMENT, 90), "send_item_use_to_item");
CHECK(game.send_give_item(2000, WINDOW_INVENTORY, 15, 2), "send_give_item");
CHECK(game.send_item_pickup(5000), "send_item_pickup");
run(srv, game, 300, [] { return false; });
CHECK(srv.m_item_move_count == 1 && srv.m_last_item_move.pos.window_type == WINDOW_INVENTORY
&& srv.m_last_item_move.pos.cell == 5
&& srv.m_last_item_move.change_pos.window_type == WINDOW_EQUIPMENT
&& srv.m_last_item_move.change_pos.cell == 1,
"server got CG_ITEM_MOVE inv5 -> equip1");
CHECK(srv.m_last_item_use.pos.window_type == WINDOW_INVENTORY
&& srv.m_last_item_use.pos.cell == 12,
"server got CG_ITEM_USE inv12");
CHECK(srv.m_last_item_drop2.pos.cell == 13 && srv.m_last_item_drop2.gold == 0 &&
srv.m_last_item_drop2.count == 3, "server got CG_ITEM_DROP2 inv13 x3");
CHECK(srv.m_last_item_use_to_item.source_pos.cell == 14 &&
srv.m_last_item_use_to_item.target_pos.window_type == WINDOW_EQUIPMENT &&
srv.m_last_item_use_to_item.target_pos.cell == 90,
"server got CG_ITEM_USE_TO_ITEM inv14 -> equip90");
CHECK(srv.m_last_give_item.target_vid == 2000 && srv.m_last_give_item.pos.cell == 15 &&
srv.m_last_give_item.count == 2, "server got CG_ITEM_GIVE target2000 inv15 x2");
CHECK(srv.m_last_pickup_vid == 5000, "server got CG_ITEM_PICKUP{5000}");
// --- P8 outbound social / shop / storage intents ---
CHECK(game.send_party_invite(2000), "send_party_invite");
CHECK(game.send_party_use_skill(/*skill_index=*/4, /*target_vid=*/2000), "send_party_use_skill");
CHECK(game.send_shop_buy(/*pos=*/3, /*count=*/2), "send_shop_buy");
CHECK(game.send_exchange_start(2000), "send_exchange_start");
CHECK(game.send_safebox_checkin(/*safe_pos=*/7, WINDOW_INVENTORY, 9), "send_safebox_checkin");
run(srv, game, 300, [] { return false; });
CHECK(srv.m_last_party_invite_vid == 2000, "server got CG_PARTY_INVITE{2000}");
CHECK(srv.m_party_skill_count == 1 && srv.m_last_party_skill.skill_index == 4 &&
srv.m_last_party_skill.target_vid == 2000,
"server got CG_PARTY_USE_SKILL{4,2000}");
CHECK(srv.m_last_shop_sub == SHOP_CG_BUY && srv.m_last_shop_a == 2 && srv.m_last_shop_b == 3,
"server got CG_SHOP BUY{count=2,pos=3}");
CHECK(srv.m_last_exchange_sub == EXCHANGE_SUB_START && srv.m_last_exchange_arg1 == 2000,
"server got CG_EXCHANGE START{vid=2000}");
CHECK(srv.m_last_safebox_checkin_pos == 7 && srv.m_last_safebox_checkin_cell == 9,
"server got CG_SAFEBOX_CHECKIN{safe=7,inv=9}");
// --- item-mall: server pushed OPEN + one item on entering game ---
CHECK(w.mall_open() && w.mall_size() == 45, "mall: client saw GC_MALL_OPEN{45}");
CHECK(w.mall_slot(0).vnum == 27993, "mall: client saw GC_MALL_SET slot 0");
CHECK(game.send_mall_checkout(/*mall_pos=*/0, WINDOW_INVENTORY, 11), "send_mall_checkout");
// --- cube: server pushed open + result list; client asks for materials/make ---
CHECK(w.cube().open && w.cube().npc_vnum == 20383, "cube: client saw 'cube open'");
CHECK(w.cube().results.size() == 1 && w.cube().results[0].vnum == 72723,
"cube: client saw 'cube r_list'");
CHECK(game.send_cube_material_info(0, 1), "send_cube_material_info");
CHECK(game.send_cube_make(0), "send_cube_make");
// --- private (PC) shop ---
std::vector<MyShopItem> shop_items;
MyShopItem si{};
si.vnum = 11901;
si.count = 1;
si.pos = {WINDOW_INVENTORY, 4};
si.price = 250000;
si.display_pos = 0;
shop_items.push_back(si);
CHECK(game.send_open_private_shop("Cheap swords", shop_items), "send_open_private_shop");
run(srv, game, 300, [] { return false; });
CHECK(srv.m_mall_checkout_seen && srv.m_last_mall_checkout.mall_pos == 0 &&
srv.m_last_mall_checkout.inv_pos.cell == 11,
"server got CG_MALL_CHECKOUT{mall=0,inv=11}");
CHECK(srv.m_last_command == "/cube make 0", "server got last /cube command");
CHECK(srv.m_myshop_seen && srv.m_myshop_sign == "Cheap swords" && srv.m_myshop_count == 1 &&
srv.m_myshop_items.size() == 1 && srv.m_myshop_items[0].vnum == 11901 &&
srv.m_myshop_items[0].price == 250000 && srv.m_myshop_items[0].pos.cell == 4,
"server got CG_MYSHOP{sign,1 item byte-exact}");
}
// --- guild-mark side connection: KX -> CG_MARK_LOGIN -> CG_MARK_UPLOAD ---
{
MockServer srv(MockServer::Mode::Mark);
uint16_t port = srv.listen_ephemeral();
MarkClient mc(/*handle=*/0xABCD1234, /*random_key=*/0x0F0F0F0F);
uint32_t px[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT];
uint64_t want_sum = 0;
for (int i = 0; i < GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT; ++i) {
px[i] = 0x11223344u + (uint32_t)i;
const uint8_t *b = (const uint8_t *)&px[i];
want_sum += b[0] + b[1] + b[2] + b[3];
}
mc.set_upload_mark(/*guild_id=*/77, px);
CHECK(mc.connect("127.0.0.1", port), "mark: connect");
run(srv, mc, 5000, [&] { return srv.m_mark_upload_seen && mc.upload_done(); });
CHECK(mc.logged_in(), "mark: CG_MARK_LOGIN sent after KX");
CHECK(srv.m_mark_login_handle == 0xABCD1234 && srv.m_mark_login_key == 0x0F0F0F0F,
"mark: server got handle + random_key");
CHECK(srv.m_mark_upload_seen, "mark: server got CG_MARK_UPLOAD");
CHECK(srv.m_mark_upload_gid == 77, "mark: upload guild id");
CHECK(srv.m_mark_upload_sum == want_sum, "mark: 16x12 image bytes intact");
CHECK(mc.upload_done(), "mark: upload_done once send buffer drained");
}
// --- guild-symbol upload: CG_GUILD_SYMBOL_UPLOAD head + raw bytes ---
{
MockServer srv(MockServer::Mode::Mark);
uint16_t port = srv.listen_ephemeral();
MarkClient mc(0xABCD1234, 0x0F0F0F0F);
std::vector<uint8_t> sym(2000);
for (size_t i = 0; i < sym.size(); ++i) {
sym[i] = (uint8_t)(i * 7);
}
mc.set_upload_symbol(/*guild_id=*/88, sym);
CHECK(mc.connect("127.0.0.1", port), "symbol: connect");
run(srv, mc, 5000, [&] { return srv.m_symbol_upload_seen && mc.upload_done(); });
CHECK(srv.m_symbol_upload_seen, "symbol: server got CG_GUILD_SYMBOL_UPLOAD");
CHECK(srv.m_symbol_upload_gid == 88, "symbol: upload guild id (in handle field)");
CHECK(srv.m_symbol_upload_bytes == 2000, "symbol: raw file bytes appended");
}
// --- guild-symbol download: CG_SYMBOL_CRC -> GC_SYMBOL_DATA ---
{
MockServer srv(MockServer::Mode::Mark);
uint16_t port = srv.listen_ephemeral();
MarkClient mc(0xABCD1234, 0x0F0F0F0F);
mc.set_download_symbol(/*guild_id=*/99);
CHECK(mc.connect("127.0.0.1", port), "symbol download: connect");
run(srv, mc, 5000, [&] { return mc.complete(); });
CHECK(srv.m_symbol_crc_seen && srv.m_symbol_crc_gid == 99,
"symbol download: server got CG_SYMBOL_CRC");
CHECK(mc.complete() && mc.symbol_guild_id() == 99 && mc.symbol_data().size() == 200,
"symbol download: GC_SYMBOL_DATA received");
}
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
return 1;
}
std::printf("all checks passed\n");
return 0;
}