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:
@@ -130,6 +130,19 @@ var _main_retry_at := 0
|
||||
var _main_placed_vid := 0
|
||||
var _main_map_vid := 0
|
||||
const MAIN_MODEL_MAX_ATTEMPTS := 3
|
||||
|
||||
# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(package_render_test.gd)在导出包里
|
||||
# 是全绿的,所以这个故障只出现在服务器驱动的进场路径上,而实机唯一的产物是一张截图。
|
||||
# 进游戏 DIAG_AFTER_MS 后如果地图或主角模型任一没落地,就把决定性的几个值打一次到
|
||||
# godot.log —— 让下一次实机复现直接给出原因,而不是再来一张截图。
|
||||
# MT_DIAG=1 时无论成败都打。
|
||||
const DIAG_AFTER_MS := 8000
|
||||
const DIAG_REPEAT_MS := 2000 # MT_DIAG=1 时连打几次,看主角装配是"没跑"还是"没跑完"
|
||||
const DIAG_REPEATS := 6
|
||||
var _diag_at := 0
|
||||
var _diag_done := false
|
||||
var _diag_left := 1
|
||||
var _diag_started := false
|
||||
const MAIN_MODEL_RETRY_MS := 1000
|
||||
var _audio: Node
|
||||
var _bgm_director: Node
|
||||
@@ -153,6 +166,8 @@ func setup(m2client: Node, assets_root: String,
|
||||
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
||||
client = m2client
|
||||
_assets = assets_root
|
||||
var diag_after := int(OS.get_environment("MT_DIAG_AFTER_MS")) if OS.get_environment("MT_DIAG_AFTER_MS") != "" else DIAG_AFTER_MS
|
||||
_diag_at = Time.get_ticks_msec() + diag_after
|
||||
_ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE
|
||||
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
|
||||
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
|
||||
@@ -658,6 +673,7 @@ func _process(_dt: float) -> void:
|
||||
# Without it the local player stays as the blue CapsuleMesh forever and the
|
||||
# camera follows an ungrounded placeholder, which makes the map look empty.
|
||||
_sync_main_character()
|
||||
_diag_tick()
|
||||
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
||||
# node keeps its listener at the origin and rebases active 3D instances.
|
||||
if _audio == null or player == null or not is_instance_valid(player):
|
||||
@@ -667,6 +683,54 @@ func _process(_dt: float) -> void:
|
||||
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
|
||||
_audio.update_ambience_sources(world.call("get_ambience_sources"))
|
||||
|
||||
# 进游戏若干秒后的一次性取证。只在「地图或主角没落地」时输出,正常局内不产生噪音。
|
||||
# 打印的每个值都对应 _build_world / _sync_main_character 里的一个具体分支,读日志的人
|
||||
# 不用再猜是资源没找到、扩展没注册、race 越界还是坐标落在了别的地图上。
|
||||
func _diag_tick() -> void:
|
||||
if _diag_done or _diag_at == 0 or Time.get_ticks_msec() < _diag_at:
|
||||
return
|
||||
var forced := OS.get_environment("MT_DIAG") == "1"
|
||||
if forced and not _diag_started:
|
||||
_diag_started = true
|
||||
_diag_left = DIAG_REPEATS
|
||||
_diag_left -= 1
|
||||
if _diag_left <= 0:
|
||||
_diag_done = true
|
||||
else:
|
||||
_diag_at = Time.get_ticks_msec() + DIAG_REPEAT_MS
|
||||
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
|
||||
if not forced and (not in_game or (_model_built and _map_loaded())):
|
||||
_diag_done = true
|
||||
return
|
||||
var vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
|
||||
var e: Dictionary = client.get_entity(vid) if vid != 0 and client.has_method("get_entity") else {}
|
||||
var d := {
|
||||
"in_game": in_game, "model_built": _model_built, "map_loaded": _map_loaded(),
|
||||
"map_path": map_path, "assets": _assets, "assets_exists": DirAccess.dir_exists_absolute(_assets),
|
||||
"main_vid": vid, "race": e.get("race", null), "pos": str(e.get("pos", "")),
|
||||
"pos_cm": str(e.get("pos_cm", "")), "entity_empty": e.is_empty(),
|
||||
"main_attempts": _main_attempts, "main_model_attempted": _main_model_attempted,
|
||||
"main_sync_ready": _main_sync_ready, "main_loading": _main_loading,
|
||||
"main_view_key": _main_view_key, "main_map_vid": _main_map_vid,
|
||||
"main_retry_in_ms": _main_retry_at - Time.get_ticks_msec(),
|
||||
"t_ms": Time.get_ticks_msec(),
|
||||
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
|
||||
"Metin2World": ClassDB.class_exists("Metin2World")},
|
||||
"world_null": world == null,
|
||||
"map_report": world.call("get_load_report") if world != null else {},
|
||||
"player_pos": str(player.position) if player != null else "",
|
||||
"map_base_set": MapCoord.has_base(),
|
||||
"map_base_cm": str(MapCoord.to_server_cm(Vector3.ZERO)),
|
||||
}
|
||||
printerr("GAMESCENE_DIAG: ", JSON.stringify(d))
|
||||
# MT_DIAG_SHOT=<png>:最后一次 tick 存一张实机帧,用于人工核对渲染结果。
|
||||
var shot := OS.get_environment("MT_DIAG_SHOT")
|
||||
if not shot.is_empty() and _diag_done:
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
if img != null:
|
||||
img.save_png(shot)
|
||||
|
||||
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
|
||||
func _guild_mark_upload_image() -> Image:
|
||||
var p := "res://ui/default_guild_mark.png"
|
||||
|
||||
Reference in New Issue
Block a user