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
+205
View File
@@ -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 个已知 magicLE u32 序列)
struct KnownMagic { uint32_t v[4]; Magic tag; const char* name; };
constexpr KnownMagic kMagics[] = {
// GRNFileMV_Old —— Metin2 的 gr2v6 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_value4*u32 magic + HeaderSize + HeaderFormat + 2*u32 reserved
constexpr size_t kSectionSize = 44; // grn_section11 * 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