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
+101
View File
@@ -0,0 +1,101 @@
# net_trace —— 主角 vid 生命周期取证。
#
# 实机进游戏后画面全灰、主角是蓝胶囊:GAMESCENE_DIAG 显示地图正常装载,
# 但 main_vid=0、主角实体为空 —— 主角在 setup() 之后被从 EntityStore 里
# 摘掉了。C++ 侧能把 m_main_vid 清零的路只有三条(mut_despawn /
# m2dev GC_CHARACTER_DEL / reset_for_map_change),前两条会发
# entity_despawned 信号,第三条会清空 m_changes、悄悄归零。
# 这个节点每帧轮询 main_vid 并记录信号,于是「哪条路」直接可读:
# MAIN_LOST reason=despawn → GC_CHARACTER_DEL 命中了主角
# MAIN_LOST reason=silent → reset_for_map_changewarp)或别的静默清零
#
# 常驻但极廉价(一次 get_main_vid + 一个整数比较);只在事件发生时打印。
extends Node
const RING := 24
var _client: Node = null
var _main_vid := 0
var _t0 := 0
var _ring: Array[String] = []
var _verbose := false
func setup(client: Node) -> void:
_client = client
_t0 = Time.get_ticks_msec()
# 主角在进场那几秒会被服务器 DEL+ADD 若干次(参考端同样如此),逐条打太吵,
# 默认只报异常;MT_NETTRACE=1 才打全量。
_verbose = OS.get_environment("MT_NETTRACE") == "1"
for sig in ["entity_main_set", "entity_despawned", "entity_spawned",
"entered_game", "disconnected", "stage_changed", "phase_changed",
"world_reset", "warp"]:
if client.has_signal(sig):
client.connect(sig, Callable(self, "_on_" + sig))
func _ms() -> int:
return Time.get_ticks_msec() - _t0
func _note(line: String) -> void:
var s := "+%dms %s" % [_ms(), line]
_ring.append(s)
if _ring.size() > RING:
_ring.pop_front()
func _dump(head: String) -> void:
printerr("NETTRACE: ", head)
for s in _ring:
printerr("NETTRACE: ", s)
func _on_entity_main_set(vid: int) -> void:
_note("main_set vid=%d" % vid)
if _verbose:
printerr("NETTRACE: MAIN_SET vid=%d t=+%dms" % [vid, _ms()])
func _on_entity_spawned(entity: Dictionary) -> void:
var vid := int(entity.get("vid", 0))
if vid == _main_vid or bool(entity.get("is_main", false)):
_note("spawn vid=%d race=%s is_main=%s" % [vid,
str(entity.get("race", null)), str(entity.get("is_main", false))])
func _on_entity_despawned(vid: int) -> void:
_note("despawn vid=%d" % vid)
if vid == _main_vid:
_note("*** despawn hit MAIN vid=%d" % vid)
func _on_entered_game() -> void:
_note("entered_game")
func _on_disconnected(reason: String) -> void:
_note("disconnected " + reason)
printerr("NETTRACE: DISCONNECTED %s t=+%dms" % [reason, _ms()])
func _on_world_reset() -> void:
_note("world_reset (reset_for_map_change)")
func _on_warp(pos: Vector3, _reconnect: bool) -> void:
_note("warp " + str(pos))
func _on_stage_changed(stage: String) -> void:
_note("stage=" + stage)
func _on_phase_changed(phase: String) -> void:
_note("phase=" + phase)
func _process(_dt: float) -> void:
if _client == null or not _client.has_method("get_main_vid"):
return
var vid := int(_client.get_main_vid())
if vid == _main_vid:
return
if vid != 0 and _main_vid == 0:
_main_vid = vid
_note("main_vid 0 -> %d" % vid)
return
# 丢主角:把最近的事件环一起打出来,能读出是 despawn 还是静默清零。
var was := _main_vid
_main_vid = vid
var reason := "silent"
for s in _ring:
if s.ends_with("despawn hit MAIN vid=%d" % was):
reason = "despawn"
_dump("MAIN_LOST was=%d now=%d reason=%s t=+%dms" % [was, vid, reason, _ms()])