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:
@@ -0,0 +1,165 @@
|
||||
# package_render_test —— 在**导出包内部**跑的渲染自检。
|
||||
#
|
||||
# MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc
|
||||
#
|
||||
# 存在的理由:导出模板不接受 `--script`,所以 model_render_test.gd /
|
||||
# gamescene_visual_test.gd 这类 `extends SceneTree` 的离线回归**只能在编辑器里跑**。
|
||||
# 编辑器全绿而实机进游戏是「灰紫虚空 + 蓝胶囊」时,两边差异无从定位——包内没有
|
||||
# 任何不依赖服务器的入口。本脚本就是那个入口:由 client_main.gd 在
|
||||
# MT_TEST_MODE=render 时挂到主场景下,走真实 GameScene + 真实 Metin2World +
|
||||
# 真实 PlayerView,不连服务器、不写账号,跑完把报告和截图落到 user:// 并退出。
|
||||
#
|
||||
# 覆盖范围:包内的资源解析、GDExtension 注册、地图装载、角色装配、落地高度。
|
||||
# **不覆盖**:网络驱动的进场时序、40250 像素比对、性能。
|
||||
extends Node
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
|
||||
const MAP := "OutdoorA1/metin2_map_a1"
|
||||
# 与 gamescene_visual_test.gd 同一组坐标和阈值,两边结论才可比。
|
||||
const SERVER_CM := Vector2(454600, 934000)
|
||||
const FEET_GAP_MAX := 0.03
|
||||
const SILHOUETTE_MIN := 3000
|
||||
|
||||
var _fail: Array[String] = []
|
||||
var _out := ""
|
||||
|
||||
func _ready() -> void:
|
||||
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if _out.is_empty():
|
||||
_out = ProjectSettings.globalize_path("user://render-check")
|
||||
DirAccess.make_dir_recursive_absolute(_out)
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
var report := {"env": _env(), "scope":
|
||||
"in-package offline check: real GameScene + Metin2World + PlayerView, no network"}
|
||||
|
||||
# 1) 扩展必须注册 —— 少了它 GameScene 会退回蓝胶囊 + 无地形灰底,
|
||||
# 而这两件事在画面上和「资源没找到」长得一模一样,必须先分开。
|
||||
for cls in ["Metin2Model", "Metin2World", "Metin2AnimPlayer"]:
|
||||
if not ClassDB.class_exists(cls):
|
||||
_fail.append("GDExtension 类未注册:%s" % cls)
|
||||
# 2) 资源根必须落在包内(或 MT_ASSETS 指定处)且真的有地图和角色
|
||||
var root: String = report["env"]["asset_root"]
|
||||
for probe in [MAP, "PC/ymir work/pc/warrior", "asset_index.txt"]:
|
||||
var p := root.path_join(probe)
|
||||
if not (FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p)):
|
||||
_fail.append("资源缺失:%s" % p)
|
||||
|
||||
if _fail.is_empty():
|
||||
report["scene"] = await _build_and_measure()
|
||||
else:
|
||||
report["scene"] = {"skipped": "前置检查已失败,不再装场景"}
|
||||
|
||||
report["passed"] = _fail.is_empty()
|
||||
report["failures"] = _fail
|
||||
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(report, "\t"))
|
||||
f.close()
|
||||
print("PKGRENDER: ", _out)
|
||||
print(JSON.stringify(report))
|
||||
for m in _fail:
|
||||
printerr("PKGRENDER FAIL: ", m)
|
||||
print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
|
||||
get_tree().quit(0 if _fail.is_empty() else 1)
|
||||
|
||||
# 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。
|
||||
func _env() -> Dictionary:
|
||||
var cands := {}
|
||||
for c in _asset_candidates():
|
||||
cands[c] = DirAccess.dir_exists_absolute(c)
|
||||
return {
|
||||
"engine": Engine.get_version_info()["string"],
|
||||
"executable": OS.get_executable_path(),
|
||||
"res_globalized": ProjectSettings.globalize_path("res://"),
|
||||
"user_dir": OS.get_user_data_dir(),
|
||||
"features": {"template": OS.has_feature("template"), "debug": OS.has_feature("debug"),
|
||||
"release": OS.has_feature("release"), "editor": OS.has_feature("editor")},
|
||||
"MT_ASSETS": OS.get_environment("MT_ASSETS"),
|
||||
"MT_PROTOCOL": OS.get_environment("MT_PROTOCOL"),
|
||||
"asset_candidates": cands,
|
||||
"asset_root": AssetRoot.path(),
|
||||
"asset_root_available": AssetRoot.available(),
|
||||
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
|
||||
"Metin2World": ClassDB.class_exists("Metin2World"),
|
||||
"Metin2AnimPlayer": ClassDB.class_exists("Metin2AnimPlayer")},
|
||||
"rendering_driver": DisplayServer.get_name(),
|
||||
"viewport": str(get_window().size),
|
||||
}
|
||||
|
||||
# AssetRoot._candidates() 是私有的;这里复述一遍只为把每个候选的命中情况写进报告。
|
||||
func _asset_candidates() -> Array:
|
||||
var out: Array = []
|
||||
var env := OS.get_environment("MT_ASSETS")
|
||||
if env != "":
|
||||
out.append(env)
|
||||
if FileAccess.file_exists("res://assets/asset_index.txt"):
|
||||
out.append("res://assets")
|
||||
out.append(ProjectSettings.globalize_path("res://../assets"))
|
||||
var exe := OS.get_executable_path()
|
||||
if exe != "":
|
||||
var d := exe.get_base_dir()
|
||||
out.append(d.path_join("../Resources/assets").simplify_path())
|
||||
out.append(d.path_join("../../../assets").simplify_path())
|
||||
out.append(d.path_join("assets"))
|
||||
return out
|
||||
|
||||
func _build_and_measure() -> Dictionary:
|
||||
var fc := Fixtures.FakeClient.new()
|
||||
add_child(fc)
|
||||
var scene := GameScene.new()
|
||||
add_child(scene)
|
||||
await scene.setup(fc, AssetRoot.path(), MAP)
|
||||
fc.main = 1000
|
||||
fc.ents[1000] = {"vid": 1000, "name": "PkgRenderTest", "race": 0,
|
||||
"pos": Vector3(SERVER_CM.x * 0.01, 0, -SERVER_CM.y * 0.01),
|
||||
"pos_cm": SERVER_CM, "is_main": true, "angle_deg": 0.0,
|
||||
"func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false}
|
||||
for i in 30:
|
||||
await get_tree().process_frame
|
||||
|
||||
var expected := MapCoord.to_world(fc.ents[1000].pos)
|
||||
expected.y = scene.world.sample_height(expected.x, expected.z) if scene.world else 0.0
|
||||
var surfaces := 0
|
||||
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh:
|
||||
surfaces += mi.mesh.get_surface_count()
|
||||
if scene._model_built and scene.player.anim:
|
||||
scene.player.anim.set_time(0.5)
|
||||
scene.player.anim.set_process(false)
|
||||
await get_tree().process_frame
|
||||
var feet_y := INF
|
||||
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh:
|
||||
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||
var feet_gap := feet_y - expected.y
|
||||
|
||||
if not scene._model_built:
|
||||
_fail.append("主角模型没装出来(仍是 _make_placeholder_player 的蓝胶囊)")
|
||||
if not scene._map_loaded():
|
||||
_fail.append("地图没装出来(灰底虚空)")
|
||||
if surfaces == 0:
|
||||
_fail.append("玩家节点下 0 个 surface")
|
||||
if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX:
|
||||
_fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX])
|
||||
|
||||
var shot := "SKIPPED_HEADLESS"
|
||||
if DisplayServer.get_name() != "headless":
|
||||
scene.cam.snap_to_target()
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := get_window().get_texture().get_image()
|
||||
shot = "game.png" if img.save_png(_out.path_join("game.png")) == OK else "SAVE_FAILED"
|
||||
|
||||
var d := {"map": scene.map_path, "model_built": scene._model_built,
|
||||
"map_loaded": scene._map_loaded(), "surfaces": surfaces,
|
||||
"world_position": str(scene.player.position), "expected": str(expected),
|
||||
"feet_y": feet_y, "feet_gap": feet_gap, "feet_gap_max": FEET_GAP_MAX,
|
||||
"map_report": scene.world.get_load_report() if scene.world else {},
|
||||
"camera_transform": str(scene.cam.global_transform), "screenshot": shot}
|
||||
scene.queue_free()
|
||||
fc.queue_free()
|
||||
await get_tree().process_frame
|
||||
return d
|
||||
Reference in New Issue
Block a user