Files
mtgodot-poc/project/net_trace.gd
T
shenleiandClaude Opus 5 e0c1853909 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
2026-09-08 17:15:55 +09:00

102 lines
3.4 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()])