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:
@@ -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
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
// item_proto / mob_proto reader for this m2dev fork.
|
||||
//
|
||||
// Outer: MIPX (item) = [u32 'MIPX'][u32 ver=1][u32 stride][u32 elements][u32 datasize][blob]
|
||||
// MMPT (mob) = [u32 'MMPT'][u32 elements][u32 datasize][blob]
|
||||
// Blob = CLZO container: [u32 'MCOZ'][u32 encryptSize][u32 compressedSize][u32 realSize]
|
||||
// data @ blob+20. encryptSize>0 -> XChaCha20-decrypt (the fork's tea_*: key =
|
||||
// BLAKE2b(key16,"M2DevPackEncrypt"), nonce = BLAKE2b(key16,"M2DevNonce")[:24]) of
|
||||
// encryptSize bytes starting at blob+16, yielding [u32 'MCOZ'][lzo1x] -> LZO ->
|
||||
// realSize bytes. encryptSize==0 -> LZO straight from blob+20.
|
||||
// Decompressed blob = `elements` records of `stride` bytes (stride from the MIPX header,
|
||||
// or realSize/elements for MMPT). Records are #pragma pack(1).
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mtproto {
|
||||
|
||||
// The 4-DWORD keys the client hardcodes (GameLib/ItemManager.cpp, PythonNonPlayer.cpp).
|
||||
inline constexpr std::array<uint32_t, 4> ITEM_PROTO_KEY = {173217u, 72619434u, 408587239u, 27973291u};
|
||||
inline constexpr std::array<uint32_t, 4> MOB_PROTO_KEY = {4813894u, 18955u, 552631u, 6822045u};
|
||||
|
||||
struct Proto {
|
||||
uint32_t fourcc = 0;
|
||||
uint32_t version = 0;
|
||||
uint32_t stride = 0; // record size
|
||||
uint32_t elements = 0; // record count
|
||||
std::vector<uint8_t> blob; // elements * stride bytes
|
||||
|
||||
const uint8_t *record(uint32_t i) const {
|
||||
return (i < elements) ? blob.data() + static_cast<size_t>(i) * stride : nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
// Load + decompress. `key` is ITEM_PROTO_KEY or MOB_PROTO_KEY.
|
||||
// Parse from an already-read buffer (host reads via godot::FileAccess for res://).
|
||||
bool load_proto_bytes(const std::vector<uint8_t> &bytes, const std::array<uint32_t, 4> &key,
|
||||
Proto &out, std::string *err);
|
||||
bool load_proto(const std::string &path, const std::array<uint32_t, 4> &key, Proto &out,
|
||||
std::string *err = nullptr);
|
||||
|
||||
// --- typed views over the leading fields (rest is offset-stable per stride) ---
|
||||
|
||||
struct ItemRecord {
|
||||
uint32_t vnum = 0;
|
||||
uint32_t vnum_range = 0;
|
||||
std::string name; // szName[65] @ 8
|
||||
std::string locale_name; // szLocaleName[65] @ 73
|
||||
uint8_t type = 0; // @ 138
|
||||
uint8_t sub_type = 0; // @ 139
|
||||
uint8_t weight = 0; // @ 140
|
||||
uint8_t size = 0; // @ 141
|
||||
uint32_t wear_flags = 0; // @ 150
|
||||
uint32_t buy_price = 0; // @ 158
|
||||
uint32_t sell_price = 0; // @ 162
|
||||
int32_t values[6] = {0}; // alValues[6] @ 191 (armor: values[3] = body shape index)
|
||||
uint8_t specular = 0; // @ 234 -> PARITY §2.7 fSpecular = specular/100
|
||||
};
|
||||
ItemRecord parse_item(const uint8_t *rec, uint32_t stride);
|
||||
|
||||
struct MobRecord {
|
||||
uint32_t vnum = 0;
|
||||
std::string name; // szName[65] @ 4
|
||||
std::string locale_name; // @ 69
|
||||
uint8_t type = 0; // @ 134
|
||||
uint8_t rank = 0; // @ 135
|
||||
uint8_t battle_type = 0; // @ 136
|
||||
uint8_t level = 0; // @ 137
|
||||
uint8_t size = 0; // @ 138
|
||||
};
|
||||
MobRecord parse_mob(const uint8_t *rec, uint32_t stride);
|
||||
|
||||
} // namespace mtproto
|
||||
@@ -0,0 +1,106 @@
|
||||
#include "proto_node.h"
|
||||
|
||||
#include "../asset_io.h"
|
||||
|
||||
#include <godot_cpp/core/class_db.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
void Metin2Proto::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("load_item_proto", "path"), &Metin2Proto::load_item_proto);
|
||||
ClassDB::bind_method(D_METHOD("load_mob_proto", "path"), &Metin2Proto::load_mob_proto);
|
||||
ClassDB::bind_method(D_METHOD("item", "vnum"), &Metin2Proto::item);
|
||||
ClassDB::bind_method(D_METHOD("mob", "vnum"), &Metin2Proto::mob);
|
||||
ClassDB::bind_method(D_METHOD("item_count"), &Metin2Proto::item_count);
|
||||
ClassDB::bind_method(D_METHOD("mob_count"), &Metin2Proto::mob_count);
|
||||
ClassDB::bind_method(D_METHOD("get_last_error"), &Metin2Proto::get_last_error);
|
||||
}
|
||||
|
||||
bool Metin2Proto::load_item_proto(const String &path) {
|
||||
std::string err;
|
||||
PackedByteArray bytes = mtgodot::read_file(path);
|
||||
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
|
||||
if (!mtproto::load_proto_bytes(buf, mtproto::ITEM_PROTO_KEY, m_item, &err)) {
|
||||
last_error = String(err.c_str());
|
||||
return false;
|
||||
}
|
||||
m_item_ix.clear();
|
||||
m_item_ix.reserve(m_item.elements);
|
||||
for (uint32_t i = 0; i < m_item.elements; ++i) {
|
||||
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(i), m_item.stride);
|
||||
m_item_ix[r.vnum] = i;
|
||||
}
|
||||
last_error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Metin2Proto::load_mob_proto(const String &path) {
|
||||
std::string err;
|
||||
PackedByteArray bytes = mtgodot::read_file(path);
|
||||
std::vector<uint8_t> buf(bytes.ptr(), bytes.ptr() + bytes.size());
|
||||
if (!mtproto::load_proto_bytes(buf, mtproto::MOB_PROTO_KEY, m_mob, &err)) {
|
||||
last_error = String(err.c_str());
|
||||
return false;
|
||||
}
|
||||
m_mob_ix.clear();
|
||||
m_mob_ix.reserve(m_mob.elements);
|
||||
for (uint32_t i = 0; i < m_mob.elements; ++i) {
|
||||
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(i), m_mob.stride);
|
||||
m_mob_ix[r.vnum] = i;
|
||||
}
|
||||
last_error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
Dictionary Metin2Proto::item(int vnum) const {
|
||||
Dictionary d;
|
||||
auto it = m_item_ix.find((uint32_t)vnum);
|
||||
if (it == m_item_ix.end() || m_item.record(it->second) == nullptr) {
|
||||
return d;
|
||||
}
|
||||
mtproto::ItemRecord r = mtproto::parse_item(m_item.record(it->second), m_item.stride);
|
||||
d["vnum"] = (int)r.vnum;
|
||||
d["vnum_range"] = (int)r.vnum_range;
|
||||
d["name"] = String::utf8(r.name.c_str());
|
||||
d["locale_name"] = String::utf8(r.locale_name.c_str());
|
||||
d["type"] = (int)r.type;
|
||||
d["sub_type"] = (int)r.sub_type;
|
||||
d["weight"] = (int)r.weight;
|
||||
d["size"] = (int)r.size;
|
||||
d["wear_flags"] = (int)r.wear_flags;
|
||||
d["buy_price"] = (int)r.buy_price;
|
||||
d["sell_price"] = (int)r.sell_price;
|
||||
{
|
||||
Array vals;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
vals.push_back((int)r.values[i]);
|
||||
}
|
||||
d["values"] = vals; // armor: values[3] = body shape index for the race .msm
|
||||
}
|
||||
d["specular"] = (int)r.specular;
|
||||
return d;
|
||||
}
|
||||
|
||||
Dictionary Metin2Proto::mob(int vnum) const {
|
||||
Dictionary d;
|
||||
auto it = m_mob_ix.find((uint32_t)vnum);
|
||||
if (it == m_mob_ix.end() || m_mob.record(it->second) == nullptr) {
|
||||
return d;
|
||||
}
|
||||
mtproto::MobRecord r = mtproto::parse_mob(m_mob.record(it->second), m_mob.stride);
|
||||
d["vnum"] = (int)r.vnum;
|
||||
d["name"] = String::utf8(r.name.c_str());
|
||||
d["locale_name"] = String::utf8(r.locale_name.c_str());
|
||||
d["type"] = (int)r.type;
|
||||
d["rank"] = (int)r.rank;
|
||||
d["battle_type"] = (int)r.battle_type;
|
||||
d["level"] = (int)r.level;
|
||||
d["size"] = (int)r.size;
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace mtgodot
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
// Metin2Proto — GDExtension node exposing item_proto / mob_proto to GDScript.
|
||||
//
|
||||
// var proto = Metin2Proto.new()
|
||||
// proto.load_item_proto("<assets>/locale/locale/en/item_proto")
|
||||
// var d := proto.item(19) # { vnum, name, locale_name, type, sub_type, ... }
|
||||
//
|
||||
// Used by the P2 inventory/equipment windows for names / types / tooltips and by
|
||||
// the equip->model path.
|
||||
|
||||
#include <godot_cpp/classes/node.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/string.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "proto.h"
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
class Metin2Proto : public godot::Node {
|
||||
GDCLASS(Metin2Proto, godot::Node)
|
||||
|
||||
public:
|
||||
bool load_item_proto(const godot::String &path);
|
||||
bool load_mob_proto(const godot::String &path);
|
||||
|
||||
godot::Dictionary item(int vnum) const;
|
||||
godot::Dictionary mob(int vnum) const;
|
||||
|
||||
int item_count() const { return (int)m_item.elements; }
|
||||
int mob_count() const { return (int)m_mob.elements; }
|
||||
godot::String get_last_error() const { return last_error; }
|
||||
|
||||
protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
mtproto::Proto m_item;
|
||||
mtproto::Proto m_mob;
|
||||
std::unordered_map<uint32_t, uint32_t> m_item_ix; // vnum -> record index
|
||||
std::unordered_map<uint32_t, uint32_t> m_mob_ix;
|
||||
godot::String last_error;
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
Reference in New Issue
Block a user