实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,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
107 lines
5.1 KiB
C++
107 lines
5.1 KiB
C++
// 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>1(EterGrnLib Material.cpp:233)
|
||
bool two_sided = false; // 名字含 2side/twoside/double/leaf/tree/ivy/fence(ExtendedData "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 T5(noise_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
|