Files
mtgodot-poc/libgr2/include/gr2/gr2.h
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

107 lines
5.1 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.
// libgr2 —— gr2 v6 只读读取器(demo 子集)。
// 施工文档:docs/reference/steps/M0-gr2-reader.md
#pragma once
#include "types.h"
#include <cstdint>
#include <cstddef>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <vector>
namespace gr2 {
enum class Magic { Unknown, Bit32_LE, Bit32_BE, Bit64_LE, Bit64_BE };
struct LoadError {
std::string stage; // "header" / "section" / "typetree" / "fileinfo" / ...
std::string message;
};
// T3:通用类型树遍历(gr2dump 打印成员用;不硬编码 struct 布局)
struct MemberDump { std::string name; int member_type; int array_width; };
class File;
std::vector<MemberDump> dump_root_members(const File&);
// 材质视图(类型树遍历 root.Materials → granny_material)。
// 只读文件名,不解码贴图。diffuse_texture 取 Usage 含 "diffuse" 的 map 的
// 贴图,退而取第一张;all_textures 是从该材质可达的全部 FromFileName。
struct MaterialInfo {
std::string name;
std::string diffuse_texture;
std::vector<std::string> all_textures;
// Metin2/EterGrnLib 渲染态推断(gr2 不带 D3DRS,靠名字 + map 数 + ExtendedData):
int map_count = 0; // granny_material.MapCount(可达子材质的 Texture 数)
bool alpha_blend = false;// Name 以 "Blend" 开头且 map_count>1EterGrnLib Material.cpp:233
bool two_sided = false; // 名字含 2side/twoside/double/leaf/tree/ivy/fenceExtendedData "Two-sided" 的近似)
};
std::vector<MaterialInfo> dump_materials(const File&);
// 已解析、fixup 后的文件。析构释放所有 section 缓冲。
class File {
public:
static std::optional<File> load(const uint8_t* bytes, size_t len, LoadError* err);
static std::optional<File> load_path(const std::string& path, LoadError* err);
// T1
Magic magic() const { return magic_; }
uint32_t format_version()const { return version_; } // 期望 6
uint32_t total_size() const { return total_size_; }
const std::vector<SectionInfo>& sections() const { return sections_; }
// T4 —— 完整汇总(内部会按需触发 T5/T6/T7a)
const FileInfo& file_info() const { return info_; }
// 展开后的 section 字节(T2 产出)。空 section 返回空 span。调试 / 校验用。
std::span<const uint8_t> section_bytes(size_t i) const;
// T5 —— bind pose 自洽检查(不依赖 oracle)。返回 max|world_bind·invBind I|
// 阈值策略见 M0-gr2-reader.md T5noise_floor 前用 1e-3
double bind_pose_self_check(int skeleton = 0) const;
// M2 便捷封装(同文件内的 skeleton + animation)。跨文件(model gr2 + anim gr2
// 用自由函数 gr2::sample_pose(skeleton, animation, t, ...)。anim/skeleton 是 file_info() 索引。
bool sample_pose(int anim, float t, int skeleton,
std::vector<Mat4>& world_pose, std::vector<Mat4>& skin) const;
bool bind_pose(int skeleton, std::vector<Mat4>& world_pose, std::vector<Mat4>& skin) const;
private:
File() = default;
Magic magic_ = Magic::Unknown;
uint32_t version_ = 0;
uint32_t total_size_ = 0;
std::vector<SectionInfo> sections_;
FileInfo info_;
// 内部:section 缓冲、root object 指针、类型树 —— 放 .cpp 的 Impl
struct Impl;
std::shared_ptr<Impl> impl_;
friend std::vector<MemberDump> dump_root_members(const File&);
friend std::vector<MaterialInfo> dump_materials(const File&);
};
// ── M2:姿势采样(model 的 skeleton + anim 的 tracks 可来自不同 gr2)──────
// Granny 语义、行主序、平移在 elem 12..14
// world[i] = Composite(local[i]) · (parent<0 ? root_offset : world[parent])
// skin[i] = sk.bones[i].inverse_world · world[i]
// tracks 按骨骼名匹配 sk;无匹配的骨骼用 sk 的 bind LocalTransform。
//
// root_offset = GrannyBuildWorldPose 的 Offset4x4。默认(nullptr)用
// sk.initial_placement —— 与 oracle/oracle.c 的 GrannyGetModelInitialPlacement4x4
// 一致,是 bind pose 与 inverse_world 自洽所要求的那一个(bind_pose 只用这一种)。
// 渲染动画时客户端另有取法:EterGrnLib 对 Offset4x4 传身位矩阵而不是导出期的
// InitialPlacement,而 Metin2 的 .gr2 动画 track 里根骨的位移已经含了骨盆高度,
// 所以再乘一次 InitialPlacement 会把整个人抬高一个骨盆高。要那条语义就显式传
// 单位阵(见 extension/src/metin2_anim.cpp 与 test/rendering/README.md 的实测)。
void sample_pose(const Skeleton& sk, const Animation& an, float t,
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4* root_offset = nullptr);
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin);
// 打印辅助
const char* magic_name(Magic);
const char* compression_name(uint32_t section_format); // 0=none 1=Oodle0 2=Oodle1
} // namespace gr2