Files
mtgodot-poc/libgr2/src/gr2_file.cpp
T
shenleiandClaude Opus 5 e0c1853909 fix(net): 主角 VID 归参考端所有,GC_CHARACTER_DEL 不再把玩家「去主角化」
实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,2D UI 与小地图正常。

根因(已实证,非推测)。`EntityStore::mut_despawn()` 在 despawn 的 vid 等于
主角 vid 时把 `m_main_vid` 清零。40250 服务器在 spawn-in 头几秒会对玩家自己的
vid 反复下发 GC_CHARACTER_DEL + GC_CHARACTER_ADD(视野重算,实测约 10 次)。
第一次 DEL 就把玩家永久去主角化:`get_main_vid()` 返回 0 →
`_sync_main_character()` 直接 return → 建模代码从未跑过(main_attempts=0)→
占位胶囊留在 y=0 → 相机跟着它钻到 C1 地形底下 → 满屏灰紫。地图其实一直是好的
(chunks_built 20/20、objects_placed 565)。

参考端怎么做(40250 ClientVS22)。主角身份属于 `CNetworkActorManager::m_dwMainVID`,
只由 `SetMainActorVID()`(GC_MAIN_CHARACTER)写,只在管理器析构时清;
`RemoveActor()` 删 actor 行和角色实例,但绝不碰它;重新加入时
`__AppendCharacterManagerActor()` 用 `kCreateData.m_isMain = __IsMainActorVID(dwVID)`
重新推导,并再跑一次 `SetMainCharacterIndex(dwVID)`。

修法(entity_store.cpp):
- `mut_despawn()` 只删行、只发 Despawn,不动 m_main_vid;m2dev 分支的
  GC_CHARACTER_DEL 同规则。
- `mut_spawn_full()` 在 `e.vid == m_main_vid` 时把 `is_main` 置回 true,
  并重新广播 MainSet,对齐参考端的重新推导。

顺带落地的渲染修复(同一次实机排查中定位):
- libgr2 `sample_pose()` 增加 `root_offset` 形参;`Metin2AnimPlayer::apply_pose()`
  显式传单位阵而不是模型的 InitialPlacement。Metin2 动画 gr2 的根骨轨道自带骨盆
  高度,再乘一次 IP 会把整个人抬高一个骨盆高(武 99.4cm / 刺 92.6 / 巫 103.7 /
  萨 95.5,正好各自的 IP.z)——即此前的「人物悬空」。`bind_pose()` 语义不变。
- `Metin2Model::_load_hair()` 改用与身体/武器同一条 `make_material()`
  (specular_disabled)而不是 roughness=1.0 的 StandardMaterial3D,头发不再被
  PBR 高光洗成奶白色块。

取证与回归工具:
- `project/net_trace.gd`(新):主角 vid 生命周期取证。挂在 AppFlow 之后逐帧读
  `get_main_vid()`,归零时打印 `NETTRACE: MAIN_LOST was=.. now=0 reason=..` 加最近
  24 条环形事件;`reason` 区分 despawn 与 silent(reset_for_map_change 清空
  m_changes,是静默清零)。`MT_NETTRACE=1` 打开逐事件日志。
- `game_scene.gd`:诊断 tick 支持重复采样(分辨主角装配是「没跑」还是「没跑完」),
  新增 main_sync_ready / main_loading / main_view_key / main_map_vid /
  main_retry_in_ms 字段;`MT_DIAG_SHOT=<png>` 让游戏自己存一帧实机画面
  (screencapture 拍到的是终端窗口,没用)。
- `project/model_render_test.gd` + `test/rendering/model_baseline.json`(新):
  八个种族的模型渲染基线(--bless 重新落盘)。
- `project/char_bench_test.gd`(新):无网络无地图的单角色实验台,五变体 ×
  六机位出证据图,只产图不做阈值判定。
- `project/package_render_test.gd`(新):导出包内的渲染自检入口
  (导出模板不接受 --script,只能走 MT_TEST_MODE)。
- `/project/build/` 加入 .gitignore(渲染用例的落图输出)。

实机复验(真实服务器,非模拟):
- 修前 `LIVE_SMOKE FAIL: 主角色 VID 有效`;
  `NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn t=+7737ms`,
  环里紧跟 `spawn vid=25910 is_main=false`。
- 修后 `LIVE_SMOKE RESULT: PASS`,main_vid=25933、entities=19;
  40s 实机跑 16 次诊断采样全部 model_built=True / main_attempts=1 /
  main_sync_ready=True,player_pos 落在 C1 地形上而非 y=0;
  MT_DIAG_SHOT 存下的帧里地形、建筑、NPC、树木、真实角色模型、HUD、小地图俱全。

回归:gamescene_test / equip_model_test / gamescene_visual_test(feet_gap
0.001373)/ model_render_test(8 races)/ char_select_visual_test / 包内
MT_TEST_MODE=render 全绿;ctest 22/23,唯一失败 net.classic_session 已用
「还原本次改动 → 重跑 → diff 失败集合」确认与本次修改无关(失败集合完全相同)。

注:画面整体偏暗属于光照/色调差距,受 PARITY-GAP.md §0 门禁约束(对照帧未落盘前
不做相机/光照/色调/材质调参),本次不碰。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
2026-09-08 17:15:55 +09:00

206 lines
8.7 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// M0 T1 —— header + section table + fixup。见 docs/reference/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