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
+368
View File
@@ -0,0 +1,368 @@
#include "net_stream.h"
#include <arpa/inet.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>
namespace mtnet {
namespace {
constexpr uint16_t MAX_PACKET_LENGTH = 65000;
void set_nonblocking(int fd) {
int fl = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
} // namespace
NetStream::~NetStream() {
disconnect();
}
void NetStream::set_state(State s) {
if (m_state == s) {
return;
}
m_state = s;
on_state_change(s);
}
bool NetStream::connect(const std::string &host, uint16_t port) {
if (!SecureCipher::ensure_sodium_init()) {
m_last_error = "sodium_init failed";
return false;
}
disconnect();
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo *res = nullptr;
char portbuf[8];
std::snprintf(portbuf, sizeof(portbuf), "%u", port);
if (getaddrinfo(host.c_str(), portbuf, &hints, &res) != 0 || !res) {
m_last_error = "getaddrinfo(" + host + ") failed";
return false;
}
m_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (m_sock < 0) {
m_last_error = std::string("socket: ") + std::strerror(errno);
freeaddrinfo(res);
return false;
}
set_nonblocking(m_sock);
int one = 1;
setsockopt(m_sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
int rc = ::connect(m_sock, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
if (rc == 0) {
set_state(State::Online);
} else if (errno == EINPROGRESS || errno == EWOULDBLOCK) {
set_state(State::Connecting);
} else {
m_last_error = std::string("connect: ") + std::strerror(errno);
::close(m_sock);
m_sock = -1;
return false;
}
m_last_error.clear();
return true;
}
void NetStream::disconnect() {
if (m_sock >= 0) {
::close(m_sock);
m_sock = -1;
}
m_recv.clear();
m_send.clear();
m_cipher.clean_up();
if (m_state != State::Offline) {
set_state(State::Offline);
}
}
bool NetStream::send_packet(const void *struct_bytes, size_t n) {
if (m_sock < 0 || n < PACKET_HEADER_SIZE) {
return false;
}
// copy so we can encrypt in place without touching the caller's struct
const auto *src = static_cast<const uint8_t *>(struct_bytes);
uint8_t stackbuf[512];
uint8_t *tmp = n <= sizeof(stackbuf) ? stackbuf : new uint8_t[n];
std::memcpy(tmp, src, n);
if (m_trace) {
const size_t shown = n < 96 ? n : 96;
std::fprintf(stderr, "[tx-plain %zuB]", n);
for (size_t i = 0; i < shown; ++i) {
std::fprintf(stderr, " %02x", tmp[i]);
}
if (shown != n) {
std::fprintf(stderr, " ...");
}
std::fprintf(stderr, "\n");
}
m_cipher.encrypt_in_place(tmp, n); // no-op until activated; advances tx counter
if (m_trace) {
const size_t shown = n < 96 ? n : 96;
std::fprintf(stderr, "[tx-wire %zuB]", n);
for (size_t i = 0; i < shown; ++i) {
std::fprintf(stderr, " %02x", tmp[i]);
}
if (shown != n) {
std::fprintf(stderr, " ...");
}
std::fprintf(stderr, "\n");
}
m_send.write(tmp, n);
if (tmp != stackbuf) {
delete[] tmp;
}
flush_send();
return true;
}
bool NetStream::recv_into_buffer() {
uint8_t chunk[8192];
ssize_t r = ::recv(m_sock, chunk, sizeof(chunk), 0);
if (r > 0) {
if (m_cipher.is_activated()) {
m_cipher.decrypt_in_place(chunk, static_cast<size_t>(r));
}
if (m_trace) {
size_t n = (size_t)r < 48 ? (size_t)r : 48;
std::fprintf(stderr, "[rx %zdB]", (ssize_t)r);
for (size_t i = 0; i < n; ++i) {
std::fprintf(stderr, " %02x", chunk[i]);
}
std::fprintf(stderr, "\n");
}
m_recv.write(chunk, static_cast<size_t>(r));
return true;
}
if (r == 0) {
m_last_error = "peer closed";
return false;
}
if (errno == EWOULDBLOCK || errno == EAGAIN) {
return true;
}
m_last_error = std::string("recv: ") + std::strerror(errno);
return false;
}
bool NetStream::flush_send() {
while (m_send.readable() > 0) {
ssize_t w = ::send(m_sock, m_send.read_ptr(), m_send.readable(), 0);
if (w > 0) {
m_send.discard(static_cast<size_t>(w));
continue;
}
if (w < 0 && (errno == EWOULDBLOCK || errno == EAGAIN)) {
return true; // try again next process()
}
m_last_error = std::string("send: ") + std::strerror(errno);
return false;
}
return true;
}
void NetStream::process() {
if (m_sock < 0) {
return;
}
fd_set rfd, wfd;
FD_ZERO(&rfd);
FD_ZERO(&wfd);
FD_SET(m_sock, &rfd);
FD_SET(m_sock, &wfd);
timeval tv{0, 0};
if (select(m_sock + 1, &rfd, &wfd, nullptr, &tv) < 0) {
return;
}
if (m_state == State::Connecting) {
if (FD_ISSET(m_sock, &wfd)) {
int err = 0;
socklen_t l = sizeof(err);
getsockopt(m_sock, SOL_SOCKET, SO_ERROR, &err, &l);
if (err != 0) {
m_last_error = std::string("connect: ") + std::strerror(err);
on_disconnect();
disconnect();
return;
}
set_state(State::Online);
} else {
return;
}
}
if (FD_ISSET(m_sock, &wfd) && m_send.readable() > 0) {
if (!flush_send()) {
on_disconnect();
disconnect();
return;
}
}
if (FD_ISSET(m_sock, &rfd)) {
if (!recv_into_buffer()) {
on_disconnect();
disconnect();
return;
}
}
if (!dispatch()) {
on_disconnect();
disconnect();
}
}
bool NetStream::dispatch() {
for (;;) {
PacketHeaderPeek hp;
if (!m_recv.peek(&hp, sizeof(hp))) {
return true; // need more bytes
}
// skip zero padding from cipher block alignment
if (hp.header == 0) {
uint16_t z;
m_recv.read(&z, sizeof(z));
continue;
}
// give a subclass first crack at framing this header (guild-mark stream).
switch (on_raw(hp.header)) {
case Raw::NeedMore:
return true;
case Raw::Consumed:
continue;
case Raw::Error:
return false;
case Raw::NotHandled:
break;
}
DynHeader dh;
if (!m_recv.peek(&dh, sizeof(dh))) {
return true;
}
if (dh.length < PACKET_HEADER_SIZE || dh.length > MAX_PACKET_LENGTH) {
m_last_error = "bad packet length " + std::to_string(dh.length) +
" for header 0x" + std::to_string(dh.header);
return false;
}
if (!m_recv.has(dh.length)) {
return true; // wait for the whole packet
}
if (m_trace) {
std::fprintf(stderr, "[frame] header=0x%04X length=%u avail=%zu\n", dh.header,
dh.length, m_recv.readable());
}
bool ok = true;
switch (dh.header) {
case GC_PHASE:
ok = handle_phase();
break;
case GC_PING:
ok = handle_ping();
break;
case GC_KEY_CHALLENGE:
ok = handle_key_challenge();
break;
case GC_KEY_COMPLETE:
ok = handle_key_complete();
break;
default: {
size_t before = m_recv.readable();
ok = on_packet(dh.header, dh.length);
size_t after = m_recv.readable();
if (m_trace) {
std::fprintf(stderr, "[frame] consumed %zu (expected %u)\n", before - after,
dh.length);
}
// safety: on_packet must consume at least the 4-byte frame header,
// otherwise dispatch() would spin on the same bytes forever.
if (ok && before == after) {
m_last_error = "on_packet consumed nothing for header 0x" +
std::to_string(dh.header);
return false;
}
break;
}
}
if (!ok) {
return false;
}
}
}
bool NetStream::handle_phase() {
GCPhase p;
if (!m_recv.read(&p, sizeof(p))) {
return false;
}
on_phase(p.phase);
return true;
}
bool NetStream::handle_ping() {
GCPing p;
if (!m_recv.read(&p, sizeof(p))) {
return false;
}
CGPong pong{CG_PONG, sizeof(CGPong)};
return send_packet(&pong, sizeof(pong));
}
bool NetStream::handle_key_challenge() {
GCKeyChallenge kc;
if (!m_recv.read(&kc, sizeof(kc))) {
return false;
}
if (!m_cipher.initialize() || !m_cipher.compute_client_keys(kc.server_pk)) {
m_last_error = "cipher key exchange failed";
return false;
}
CGKeyResponse resp{};
resp.header = CG_KEY_RESPONSE;
resp.length = sizeof(resp);
m_cipher.get_public_key(resp.client_pk);
m_cipher.compute_challenge_response(kc.challenge, resp.challenge_response);
return send_packet(&resp, sizeof(resp));
}
bool NetStream::handle_key_complete() {
GCKeyComplete kc;
if (!m_recv.read(&kc, sizeof(kc))) {
return false;
}
uint8_t token[SecureCipher::SESSION_TOKEN_SIZE];
if (!m_cipher.decrypt_token(kc.encrypted_token, sizeof(kc.encrypted_token), kc.nonce, token)) {
m_last_error = "session token decrypt failed";
return false;
}
m_cipher.set_session_token(token);
m_cipher.set_activated(true);
// bytes already buffered after this packet are the first ciphertext bytes;
// rx counter is 0, decrypt them in place now.
size_t pending = m_recv.readable();
if (pending > 0) {
m_cipher.decrypt_in_place(m_recv.mutable_unread(), pending);
}
on_cipher_active();
return true;
}
} // namespace mtnet