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:
@@ -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,
|
||||
|
||||
@@ -554,12 +554,6 @@ void Metin2Model::_load_hair() {
|
||||
hair_parts = mtgodot::build_parts(hfi);
|
||||
|
||||
// hair 贴图:同名 .dds,alpha-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)
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user