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
+15 -2
View File
@@ -415,7 +415,20 @@ void Metin2AnimPlayer::apply_pose(double t) {
tt = std::fmax(0.0, std::fmin(t, duration));
}
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
//
// gr2::sample_pose defaults to sk.initial_placement because that is what the
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
// carries the root bone's pelvis height inside its own track, so composing it
// onto InitialPlacement counts that placement twice and lifts the whole actor
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
// Measured with tools/rendering foot probe: with identity the lowest skinned
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
// only leaves the ground during run's airborne frames.
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
// skin_buf = invWorld · world_blended (PARITY §2.9).
@@ -423,7 +436,7 @@ void Metin2AnimPlayer::apply_pose(double t) {
const gr2::FileInfo &pfi = prev_anim_file->file_info();
if (!pfi.animations.empty()) {
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
prev_skin_buf);
prev_skin_buf, &kNoOffset);
double w = blend_elapsed / blend_time;
// ease-in on the incoming clip, matching the client's
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
+14 -10
View File
@@ -554,12 +554,6 @@ void Metin2Model::_load_hair() {
hair_parts = mtgodot::build_parts(hfi);
// hair 贴图:同名 .ddsalpha-scissor 镂空
Ref<StandardMaterial3D> hm;
hm.instantiate();
hm->set_roughness(1.0f);
hm->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
hm->set_alpha_scissor_threshold(0.5f);
hm->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
// SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4)
String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin;
Ref<ImageTexture> ht = _load_dds(hdds);
@@ -567,10 +561,20 @@ void Metin2Model::_load_hair() {
UtilityFunctions::push_warning(String("[Metin2Model] hair_skin not found: ") + hair_skin);
ht = _load_dds(hair_gr2.get_basename() + ".dds"); // fall back to SourceSkin
}
if (ht.is_valid())
hm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, ht);
else
hm->set_albedo(Color(0.18f, 0.12f, 0.08f));
// R2-02: the same forward material the body/weapon surfaces use, not a
// StandardMaterial3D. The old one ran Godot's PBR specular at roughness 1.0
// and washed the fringe out into flat cream patches (bisection: roughness 0
// or unshaded clean, alpha/cull/metallic changes not). make_material's
// shaders are `specular_disabled`, so the hair now lights like the body.
// CPU path: skinning already happened on the CPU, so skinned = false.
mtgodot::MaterialDesc hd;
hd.albedo = ht;
hd.blend = mtgodot::BlendMode::AlphaTest; // discard < 0.5, still depth-opaque
hd.alpha_scissor = 0.5f;
hd.two_sided = true; // hair cards are single-sided geometry
Ref<ShaderMaterial> hm = mtgodot::make_material(hd);
if (ht.is_null()) // keep the untextured fallback colour (make_material's is pale grey)
hm->set_shader_parameter("modulate", Color(0.18f, 0.12f, 0.08f, 1.0f));
hair_mat = hm;
hair_tex = ht; // reused by the GPU-skin path (§2.2)
+23 -9
View File
@@ -119,11 +119,24 @@ void EntityStore::mut_spawn_full(const Entity &src) {
// caller ever passes `store.get(vid)` back in.
Entity e = src;
e.vid = src.vid;
m_ents[src.vid] = e;
if (e.is_main) {
m_main_vid = src.vid;
// Reference CNetworkActorManager::__AppendCharacterManagerActor():
// kCreateData.m_isMain = __IsMainActorVID(dwVID);
// The main VID is owned by the manager, not by the actor row, so an actor
// re-added under the main VID becomes the main instance again. The 40250
// server does exactly this at spawn-in: GC_CHARACTER_DEL + GC_CHARACTER_ADD
// on our own VID moments after GC_MAIN_CHARACTER. Without this, the re-added
// row is a plain PC and the local player stays the fallback capsule.
if (e.vid == m_main_vid) {
e.is_main = true;
}
m_ents[src.vid] = e;
m_changes.push_back({ChangeKind::Spawn, src.vid});
if (e.is_main) {
// __AppendCharacterManagerActor() re-runs SetMainCharacterIndex(dwVID)
// on every main re-add, so re-announce it here too.
m_main_vid = src.vid;
m_changes.push_back({ChangeKind::MainSet, src.vid});
}
}
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
@@ -170,12 +183,14 @@ void EntityStore::mut_map_bgm(const std::string &name, float volume) {
}
void EntityStore::mut_despawn(uint32_t vid) {
// Reference CNetworkActorManager::RemoveActor() drops the actor row and the
// character instance but never touches m_dwMainVID — that field is written
// only by SetMainActorVID() (GC_MAIN_CHARACTER) and cleared only when the
// manager is destroyed (our reset_for_map_change()). Clearing it here made a
// routine GC_CHARACTER_DEL on our own VID permanently un-main the player.
if (m_ents.erase(vid)) {
m_changes.push_back({ChangeKind::Despawn, vid});
}
if (vid == m_main_vid) {
m_main_vid = 0;
}
}
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
@@ -1658,12 +1673,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCCharacterDel p;
std::memcpy(&p, body, sizeof(p));
// Same rule as mut_despawn(): the main VID survives a
// GC_CHARACTER_DEL and is re-bound by the matching re-add.
if (m_ents.erase(p.vid)) {
m_changes.push_back({ChangeKind::Despawn, p.vid});
}
if (p.vid == m_main_vid) {
m_main_vid = 0;
}
return;
}
case GC_MOVE: {