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
+228
View File
@@ -0,0 +1,228 @@
#include "proto.h"
#include <sodium.h>
#include <cstring>
#include <fstream>
extern "C" {
#include <lzo/lzo1x.h>
}
namespace mtproto {
namespace {
constexpr uint32_t FOURCC_MIPX = 0x5850494D; // "MIPX" bytes 4D 49 50 58 (LE u32)
constexpr uint32_t FOURCC_MIPT = 0x5450494D; // "MIPT"
constexpr uint32_t FOURCC_MMPT = 0x54504D4D; // "MMPT"
constexpr uint32_t FOURCC_MCOZ = 0x5A4F434D; // "MCOZ"
uint32_t rd_u32(const uint8_t *p) {
uint32_t v;
std::memcpy(&v, p, 4);
return v;
}
// The fork's tea_decrypt (EterBase/tea.cpp): XChaCha20 with key/nonce derived from
// the 16-byte input key via BLAKE2b. size is rounded up to a multiple of 8.
void tea_decrypt(uint8_t *dst, const uint8_t *src, const std::array<uint32_t, 4> &key32,
size_t size) {
uint8_t key16[16];
std::memcpy(key16, key32.data(), 16); // 4 LE dwords -> raw bytes
uint8_t dkey[crypto_stream_xchacha20_KEYBYTES];
uint8_t nonce[crypto_stream_xchacha20_NONCEBYTES];
crypto_generichash(dkey, sizeof(dkey), key16, 16,
reinterpret_cast<const uint8_t *>("M2DevPackEncrypt"), 16);
uint8_t nonce_seed[crypto_stream_xchacha20_NONCEBYTES + 8];
crypto_generichash(nonce_seed, sizeof(nonce_seed), key16, 16,
reinterpret_cast<const uint8_t *>("M2DevNonce"), 10);
std::memcpy(nonce, nonce_seed, sizeof(nonce));
size_t rs = (size % 8 == 0) ? size : size + 8 - (size % 8);
crypto_stream_xchacha20_xor(dst, src, rs, nonce, dkey);
sodium_memzero(dkey, sizeof(dkey));
}
// CLZO container -> decompressed bytes.
bool clzo_decompress(const uint8_t *blob, size_t blob_len, const std::array<uint32_t, 4> &key,
std::vector<uint8_t> &out, std::string *err) {
if (blob_len < 20 || rd_u32(blob) != FOURCC_MCOZ) {
if (err) *err = "CLZO: bad MCOZ header";
return false;
}
const uint32_t enc_size = rd_u32(blob + 4);
const uint32_t comp_size = rd_u32(blob + 8);
const uint32_t real_size = rd_u32(blob + 12);
out.assign(real_size, 0);
lzo_uint out_len = real_size;
int r;
if (enc_size > 0) {
size_t rs = (enc_size % 8 == 0) ? enc_size : enc_size + 8 - (enc_size % 8);
if (16 + rs > blob_len) {
if (err) *err = "CLZO: encrypted region past end";
return false;
}
std::vector<uint8_t> dec(rs);
tea_decrypt(dec.data(), blob + 16, key, enc_size); // src = blob+16 (== m_pbIn-4)
if (rd_u32(dec.data()) != FOURCC_MCOZ) {
if (err) *err = "CLZO: wrong key (inner MCOZ mismatch)";
return false;
}
r = lzo1x_decompress_safe(dec.data() + 4, comp_size, out.data(), &out_len, nullptr);
} else {
if (20u + comp_size > blob_len) {
if (err) *err = "CLZO: compressed region past end";
return false;
}
r = lzo1x_decompress_safe(blob + 20, comp_size, out.data(), &out_len, nullptr);
}
if (r != LZO_E_OK) {
if (err) *err = "CLZO: lzo1x_decompress_safe failed (" + std::to_string(r) + ")";
return false;
}
if (out_len != real_size) {
if (err) *err = "CLZO: size mismatch " + std::to_string(out_len) + " != " +
std::to_string(real_size);
return false;
}
return true;
}
std::string cstr(const uint8_t *p, size_t maxn) {
size_t n = 0;
while (n < maxn && p[n]) {
++n;
}
return std::string(reinterpret_cast<const char *>(p), n);
}
} // namespace
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
std::string *err) {
std::ifstream f(path, std::ios::binary);
if (!f) {
if (err) *err = "cannot open " + path;
return false;
}
std::vector<uint8_t> file((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
return load_proto_bytes(file, key, out, err);
}
bool load_proto_bytes(const std::vector<uint8_t> &file, const std::array<uint32_t, 4> &key,
Proto &out, std::string *err) {
if (sodium_init() < 0) {
if (err) *err = "sodium_init failed";
return false;
}
if (lzo_init() != LZO_E_OK) {
if (err) *err = "lzo_init failed";
return false;
}
if (file.size() < 16) {
if (err) *err = "file too small";
return false;
}
const uint8_t *p = file.data();
out.fourcc = rd_u32(p);
p += 4;
uint32_t data_size = 0;
if (out.fourcc == FOURCC_MIPX) {
out.version = rd_u32(p);
p += 4;
out.stride = rd_u32(p);
p += 4;
out.elements = rd_u32(p);
p += 4;
data_size = rd_u32(p);
p += 4;
if (out.version != 1) {
if (err) *err = "MIPX version != 1";
return false;
}
} else if (out.fourcc == FOURCC_MIPT || out.fourcc == FOURCC_MMPT) {
out.elements = rd_u32(p);
p += 4;
data_size = rd_u32(p);
p += 4;
} else {
if (err) *err = "unknown proto fourcc";
return false;
}
if (static_cast<size_t>(p - file.data()) + data_size > file.size()) {
if (err) *err = "declared data_size past end of file";
return false;
}
if (!clzo_decompress(p, data_size, key, out.blob, err)) {
return false;
}
if (out.elements == 0) {
if (err) *err = "0 elements";
return false;
}
if (out.stride == 0) {
if (out.blob.size() % out.elements != 0) {
if (err) *err = "blob not divisible by element count";
return false;
}
out.stride = static_cast<uint32_t>(out.blob.size() / out.elements);
}
if (static_cast<size_t>(out.stride) * out.elements != out.blob.size()) {
if (err) *err = "stride * elements != blob size";
return false;
}
return true;
}
ItemRecord parse_item(const uint8_t *r, uint32_t stride) {
ItemRecord it;
if (!r || stride < 236) {
return it;
}
it.vnum = rd_u32(r + 0);
it.vnum_range = rd_u32(r + 4);
it.name = cstr(r + 8, 65);
it.locale_name = cstr(r + 73, 65);
it.type = r[138];
it.sub_type = r[139];
it.weight = r[140];
it.size = r[141];
it.wear_flags = rd_u32(r + 150);
it.buy_price = rd_u32(r + 158);
it.sell_price = rd_u32(r + 162);
// aLimits[2] (5B each) @166, aApplies[3] (5B each) @176, alValues[6] @191.
// (bSpecular @234 anchors the whole chain.)
for (int i = 0; i < 6; ++i) {
it.values[i] = (int32_t)rd_u32(r + 191 + i * 4);
}
it.specular = r[234];
return it;
}
MobRecord parse_mob(const uint8_t *r, uint32_t stride) {
MobRecord m;
if (!r || stride < 139) {
return m;
}
m.vnum = rd_u32(r + 0);
m.name = cstr(r + 4, 65);
m.locale_name = cstr(r + 69, 65);
m.type = r[134];
m.rank = r[135];
m.battle_type = r[136];
m.level = r[137];
m.size = r[138];
return m;
}
} // namespace mtproto