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,205 @@
|
||||
// M0 T1 —— header + section table + fixup。见 docs/steps/M0-gr2-reader.md
|
||||
// 规格:MobileSource/Cross Platform/Granny-3D-SDK-main/source/granny_file_format.h + .cpp
|
||||
//
|
||||
// 本次实现范围:magic 识别 + grn_file_header + section 表解析(--sections 用)。
|
||||
// section 解压(Oodle0/1)是 T2;类型树 / FileInfo 是 T3/T4 —— 仍是 stub。
|
||||
#include "internal.h"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace gr2 {
|
||||
namespace {
|
||||
|
||||
// 小端读取 + 边界检查
|
||||
inline bool rd32(const uint8_t* p, size_t len, size_t off, uint32_t& out) {
|
||||
if (off + 4 > len) return false;
|
||||
out = uint32_t(p[off]) | (uint32_t(p[off + 1]) << 8) |
|
||||
(uint32_t(p[off + 2]) << 16) | (uint32_t(p[off + 3]) << 24);
|
||||
return true;
|
||||
}
|
||||
|
||||
// granny_file_format.cpp 里的 5 个已知 magic(LE u32 序列)
|
||||
struct KnownMagic { uint32_t v[4]; Magic tag; const char* name; };
|
||||
constexpr KnownMagic kMagics[] = {
|
||||
// GRNFileMV_Old —— Metin2 的 gr2(v6 era)用这个
|
||||
{{0xCAB067B8, 0x0FB16DF8, 0x7E8C7284, 0x1E00195E}, Magic::Bit32_LE, "Old(32LE)"},
|
||||
{{0xC06CDE29, 0x2B53A4BA, 0xA5B7F525, 0xEEE266F6}, Magic::Bit32_LE, "32Bit_LittleEndian"},
|
||||
{{0xB595110E, 0x4BB5A56A, 0x502828EB, 0x04B37825}, Magic::Bit32_BE, "32Bit_BigEndian"},
|
||||
{{0x5E499BE5, 0x141F636F, 0xA9EB131E, 0xC4EDBE90}, Magic::Bit64_LE, "64Bit_LittleEndian"},
|
||||
{{0xE3D49531, 0x624FDC20, 0x3AD036CC, 0x89FF82B1}, Magic::Bit64_BE, "64Bit_BigEndian"},
|
||||
};
|
||||
|
||||
constexpr size_t kMagicStructSize = 32; // grn_file_magic_value:4*u32 magic + HeaderSize + HeaderFormat + 2*u32 reserved
|
||||
constexpr size_t kSectionSize = 44; // grn_section:11 * u32
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* magic_name(Magic m) {
|
||||
switch (m) {
|
||||
case Magic::Bit32_LE: return "32-bit LE";
|
||||
case Magic::Bit32_BE: return "32-bit BE";
|
||||
case Magic::Bit64_LE: return "64-bit LE";
|
||||
case Magic::Bit64_BE: return "64-bit BE";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
const char* compression_name(uint32_t f) {
|
||||
switch (f) {
|
||||
case 0: return "none";
|
||||
case 1: return "Oodle0";
|
||||
case 2: return "Oodle1";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<File> File::load(const uint8_t* bytes, size_t len, LoadError* err) {
|
||||
if (err) *err = {};
|
||||
auto fail = [&](const char* stage, const char* msg) {
|
||||
if (err) *err = {stage, msg};
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
if (!bytes || len < kMagicStructSize + 32)
|
||||
return fail("header", "file too small");
|
||||
|
||||
// ── magic ────────────────────────────────────────────────────────────
|
||||
uint32_t m[4];
|
||||
for (int i = 0; i < 4; ++i) rd32(bytes, len, size_t(i) * 4, m[i]);
|
||||
Magic magic = Magic::Unknown;
|
||||
for (const auto& k : kMagics) {
|
||||
if (m[0] == k.v[0] && m[1] == k.v[1] && m[2] == k.v[2] && m[3] == k.v[3]) {
|
||||
magic = k.tag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (magic == Magic::Unknown)
|
||||
return fail("header", "not a Granny file (unknown magic)");
|
||||
if (magic != Magic::Bit32_LE)
|
||||
return fail("header", "only 32-bit little-endian supported (M0 range)");
|
||||
|
||||
uint32_t header_size = 0, header_format = 0;
|
||||
rd32(bytes, len, 16, header_size);
|
||||
rd32(bytes, len, 20, header_format); // 注意:这不是 section 压缩,section 压缩看每段 Format
|
||||
|
||||
// ── grn_file_header(在 magic 结构之后)─────────────────────────────
|
||||
const size_t H = kMagicStructSize; // header 起点
|
||||
// grn_file_header: Version(0) TotalSize(4) CRC(8) SectionArrayOffset(12)
|
||||
// SectionArrayCount(16) RootObjectTypeDefinition{Section(20) Offset(24)}
|
||||
// RootObject{Section(28) Offset(32)} TypeTag(36) ...
|
||||
uint32_t version = 0, total_size = 0, sec_off = 0, sec_count = 0;
|
||||
uint32_t rt_sec = 0, rt_off = 0, ro_sec = 0, ro_off = 0;
|
||||
if (!rd32(bytes, len, H + 0, version) ||
|
||||
!rd32(bytes, len, H + 4, total_size) ||
|
||||
!rd32(bytes, len, H + 12, sec_off) ||
|
||||
!rd32(bytes, len, H + 16, sec_count) ||
|
||||
!rd32(bytes, len, H + 20, rt_sec) ||
|
||||
!rd32(bytes, len, H + 24, rt_off) ||
|
||||
!rd32(bytes, len, H + 28, ro_sec) ||
|
||||
!rd32(bytes, len, H + 32, ro_off))
|
||||
return fail("header", "truncated grn_file_header");
|
||||
|
||||
// ── section 表 ──────────────────────────────────────────────────────
|
||||
// SectionArrayOffset 相对 grn_file_header 起点(H)
|
||||
const size_t sec_base = H + sec_off;
|
||||
if (sec_count > 64)
|
||||
return fail("section", "implausible section count");
|
||||
if (sec_base + size_t(sec_count) * kSectionSize > len)
|
||||
return fail("section", "section array out of range");
|
||||
|
||||
// ── AC 自检(不致命,仅 GR2_DEBUG 打日志)──────────────────────────
|
||||
if (std::getenv("GR2_DEBUG")) {
|
||||
if (version != 6 && version != 7)
|
||||
std::fprintf(stderr, "gr2: warn: format_version=%u (期望 6/7)\n", version);
|
||||
if (total_size != len)
|
||||
std::fprintf(stderr, "gr2: warn: total_size=%u != file size %zu\n", total_size, len);
|
||||
}
|
||||
|
||||
File out;
|
||||
out.magic_ = magic;
|
||||
out.version_ = version;
|
||||
out.total_size_ = total_size;
|
||||
out.impl_ = std::make_shared<Impl>();
|
||||
out.impl_->sections.resize(sec_count);
|
||||
out.impl_->raw.resize(sec_count);
|
||||
out.sections_.reserve(sec_count);
|
||||
|
||||
// ── 逐 section:读头 + T2 展开 ─────────────────────────────────────
|
||||
for (uint32_t i = 0; i < sec_count; ++i) {
|
||||
const size_t s = sec_base + size_t(i) * kSectionSize;
|
||||
uint32_t f[11];
|
||||
for (int j = 0; j < 11; ++j) rd32(bytes, len, s + size_t(j) * 4, f[j]);
|
||||
// f: Format DataOffset DataSize ExpandedDataSize InternalAlignment
|
||||
// First16Bit First8Bit PtrFixupOff PtrFixupCount MixedFixupOff MixedFixupCount
|
||||
detail::GrnSection gs{};
|
||||
gs.compression = f[0];
|
||||
gs.data_offset = f[1];
|
||||
gs.data_size = f[2];
|
||||
gs.expanded_data_size = f[3];
|
||||
gs.internal_alignment = f[4];
|
||||
gs.first16bit = f[5];
|
||||
gs.first8bit = f[6];
|
||||
gs.pointer_fixup_off = f[7];
|
||||
gs.pointer_fixup_count= f[8];
|
||||
gs.mixed_fixup_off = f[9];
|
||||
gs.mixed_fixup_count = f[10];
|
||||
|
||||
if (size_t(gs.data_offset) > len || size_t(gs.data_size) > len - size_t(gs.data_offset))
|
||||
return fail("section", "section data out of range");
|
||||
|
||||
out.impl_->raw[i] = gs;
|
||||
LoadError sec_err;
|
||||
if (!out.impl_->decompress_section(gs, bytes + gs.data_offset,
|
||||
out.impl_->sections[i], &sec_err)) {
|
||||
if (err) {
|
||||
*err = sec_err;
|
||||
err->message += " (section " + std::to_string(i) + ")";
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
out.sections_.push_back(out.impl_->sections[i].info);
|
||||
}
|
||||
|
||||
// ── T1:fixup 索引(需要展开后的 section 数据 + 原始 section 头)──────
|
||||
out.impl_->build_fixups(bytes, len);
|
||||
out.impl_->root_type = { int32_t(rt_sec), rt_off };
|
||||
out.impl_->root_obj = { int32_t(ro_sec), ro_off };
|
||||
|
||||
// ── T3/T4:类型树 + FileInfo 汇总。失败必须传播;返回一个看似成功但
|
||||
// FileInfo 为空的对象会让上层把损坏文件误判成合法的空模型。
|
||||
LoadError fi_err;
|
||||
if (!out.impl_->build_fileinfo(out.info_, &fi_err)) {
|
||||
if (err) *err = fi_err;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
(void)header_size; (void)header_format;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<File> File::load_path(const std::string& path, LoadError* err) {
|
||||
FILE* f = std::fopen(path.c_str(), "rb");
|
||||
if (!f) { if (err) *err = {"open", "cannot open " + path}; return std::nullopt; }
|
||||
std::fseek(f, 0, SEEK_END);
|
||||
long n = std::ftell(f);
|
||||
std::fseek(f, 0, SEEK_SET);
|
||||
std::vector<uint8_t> buf(n > 0 ? size_t(n) : 0);
|
||||
if (!buf.empty()) {
|
||||
size_t rd = std::fread(buf.data(), 1, buf.size(), f);
|
||||
if (rd != buf.size()) { std::fclose(f); if (err) *err = {"open", "short read"}; return std::nullopt; }
|
||||
}
|
||||
std::fclose(f);
|
||||
return load(buf.data(), buf.size(), err);
|
||||
}
|
||||
|
||||
double File::bind_pose_self_check(int skeleton) const {
|
||||
return impl_ ? impl_->skeleton_self_check(skeleton) : 1e9;
|
||||
}
|
||||
|
||||
std::span<const uint8_t> File::section_bytes(size_t i) const {
|
||||
if (!impl_ || i >= impl_->sections.size()) return {};
|
||||
const auto& d = impl_->sections[i].data;
|
||||
return {d.data(), d.size()};
|
||||
}
|
||||
|
||||
} // namespace gr2
|
||||
Reference in New Issue
Block a user