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
This commit is contained in:
shenlei
2026-09-08 17:15:55 +09:00
co-authored by Claude Opus 5
parent fb4d2d222b
commit e0c1853909
31 changed files with 1368 additions and 42 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# libgr2
自研 gr2 v6/v7 **只读**读取器。零第三方依赖。施工文档 [`../docs/steps/M0-gr2-reader.md`](../docs/steps/M0-gr2-reader.md)。
自研 gr2 v6/v7 **只读**读取器。零第三方依赖。施工文档 [`../docs/reference/steps/M0-gr2-reader.md`](../docs/reference/steps/M0-gr2-reader.md)。
## 能做什么
+12 -3
View File
@@ -1,5 +1,5 @@
// libgr2 —— gr2 v6 只读读取器(demo 子集)。
// 施工文档:docs/steps/M0-gr2-reader.md
// 施工文档:docs/reference/steps/M0-gr2-reader.md
#pragma once
#include "types.h"
#include <cstdint>
@@ -83,11 +83,20 @@ private:
// ── M2:姿势采样(model 的 skeleton + anim 的 tracks 可来自不同 gr2)──────
// Granny 语义、行主序、平移在 elem 12..14
// world[i] = Composite(local[i]) · (parent<0 ? sk.initial_placement : world[parent])
// 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);
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);
// 打印辅助
+1 -1
View File
@@ -10,7 +10,7 @@
namespace gr2 {
using Mat4 = std::array<float, 16>; // 行主序,Granny 原生。坐标约定见 docs/steps/M0-gr2-reader.md「约定」
using Mat4 = std::array<float, 16>; // 行主序,Granny 原生。坐标约定见 docs/reference/steps/M0-gr2-reader.md「约定」
// ── 骨架 ──────────────────────────────────────────────────────────────
struct Bone {
+1 -1
View File
@@ -1,5 +1,5 @@
// M0 T7a —— 曲线子类型分类(gr2fuzz 直方图依赖)。T7b(解码)见文末。
// 见 docs/steps/M0-gr2-reader.md
// 见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_track_group.cpp (TrackGroupType/TransformTrackType/Curve2Type) /
// granny_curve.cpp (CurveData*Type,均以 CurveDataHeaderType 开头) /
// granny_curve.h (curve_data_header{ u8 Format; u8 Degree })
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T2 —— section 解压分派。见 docs/steps/M0-gr2-reader.md
// M0 T2 —— section 解压分派。见 docs/reference/steps/M0-gr2-reader.md
#include "internal.h"
#include "oodle1.h"
#include <cstring>
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T1 —— header + section table + fixup。见 docs/steps/M0-gr2-reader.md
// 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 用)。
+1 -1
View File
@@ -1,5 +1,5 @@
// M0 T4 —— FileInfo 汇总(驱动 T5 骨架 / T6 网格 / T7a 曲线分类)。
// 见 docs/steps/M0-gr2-reader.md
// 见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_file_info.cpp (FileInfoType) / granny_animation.cpp (AnimationType)
#include "internal.h"
#include <cstdio>
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T6 —— 顶点 / 索引 / 三角组 / BoneBindings。见 docs/steps/M0-gr2-reader.md
// M0 T6 —— 顶点 / 索引 / 三角组 / BoneBindings。见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_mesh.cpp (MeshType/BoneBindingType) / granny_vertex_data.cpp (VertexDataType
// + 各顶点格式) / granny_tri_topology.cpp (TriTopologyType/TriMaterialGroupType)
// 顶点格式是自描述的(Vertices 是 ReferenceToVariantArray,带 data_type_definition*),
+8 -6
View File
@@ -1,4 +1,4 @@
// M0 T5 —— 骨架提取 + bind pose 自洽检查。见 docs/steps/M0-gr2-reader.md
// M0 T5 —— 骨架提取 + bind pose 自洽检查。见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_transform.cpp BuildCompositeTransform4x4 / granny_math.cpp
// MatrixEqualsQuaternion3x3 + MatrixMultiply3x3 /
// granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl /
@@ -131,14 +131,15 @@ bool File::Impl::extract_skeleton(Ref skel_obj, Ref skel_type, Skeleton& out) {
// ── M2:姿势采样(自由函数 = 主 primitive;File::* 是便捷封装)──────────
namespace {
void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
std::vector<Mat4>& world, std::vector<Mat4>& skin) {
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4& root_offset) {
const size_t n = sk.bones.size();
const Mat4 I{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
world.assign(n, I);
skin.assign(n, I);
for (size_t i = 0; i < n; ++i) {
int32_t par = sk.bones[i].parent;
const Mat4& parent_w = (par < 0 || (size_t)par >= i) ? sk.initial_placement : world[par];
const Mat4& parent_w = (par < 0 || (size_t)par >= i) ? root_offset : world[par];
world[i] = mul4x3((i < local.size()) ? local[i] : sk.bones[i].local_transform, parent_w);
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
}
@@ -148,11 +149,12 @@ void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin) {
std::vector<Mat4> local(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) local[i] = sk.bones[i].local_transform;
accumulate_world(sk, local, world, skin);
accumulate_world(sk, local, world, skin, sk.initial_placement);
}
void sample_pose(const Skeleton& sk, const Animation& an, float t,
std::vector<Mat4>& world, std::vector<Mat4>& skin) {
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4* root_offset) {
std::vector<Mat4> track_local;
an.sample_local(t, track_local);
@@ -167,7 +169,7 @@ void sample_pose(const Skeleton& sk, const Animation& an, float t,
if (sk.bones[k].name == tr.bone_name) { bi = int(k); break; }
if (bi >= 0) local[bi] = track_local[ti];
}
accumulate_world(sk, local, world, skin);
accumulate_world(sk, local, world, skin, root_offset ? *root_offset : sk.initial_placement);
}
bool File::bind_pose(int skeleton, std::vector<Mat4>& world, std::vector<Mat4>& skin) const {
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T1fixup 索引)+ T3(自描述类型树遍历)。见 docs/steps/M0-gr2-reader.md
// M0 T1fixup 索引)+ T3(自描述类型树遍历)。见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_file_format.hfixup+ granny_data_type_definition.h/.cpp(成员类型 + stride
#include "internal.h"
#include <cstring>
+1 -1
View File
@@ -3,7 +3,7 @@
* Ported (decode path only) from the leaked Granny 2.9.12 SDK:
* granny_oodle1_compression.cpp + radlz.c + radarith.c + arithbit.c
* Used as algorithm spec; RAD's build machinery / compressor are NOT included.
* See docs/steps/M0-gr2-reader.md T2.
* See docs/reference/steps/M0-gr2-reader.md T2.
*/
#include "oodle1.h"
#include <stdlib.h>