fix(rendering): checkpoint main-character recovery and visual regression work

Handle late main-character data, guarded model construction, bounded retries and map correction. Validate player model sources and add lifecycle and real-asset visual regressions.

Include pending material and character-selection changes, updated A1 screenshot, and the detailed rendering repair plan. Hair occlusion, full UI parity and final macOS package acceptance remain unfinished.
This commit is contained in:
shen
2026-09-08 08:52:30 +08:00
parent 8092ff44e2
commit c3a6fb973e
11 changed files with 620 additions and 23 deletions
+3
View File
@@ -29,6 +29,9 @@ func _run() -> void:
quit(1)
return
print("Selected armor: ", screen._pv.model.get("gr2_path"))
print("Hair skin: ", screen._pv.model.get("hair_skin"))
var ua = preload("res://ui/ui_assets.gd")
ua.load_dds_image(String(screen._pv.model.get("hair_skin"))).save_png("/tmp/metin2-hair.png")
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
for surface in mi.mesh.get_surface_count():
print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface))
+101 -14
View File
@@ -120,6 +120,17 @@ var weather: Node3D # Weather
var _assets := ""
var map_path := ""
var _model_built := false
var _main_model_attempted := false
var _main_sync_ready := false
var _main_loading := false
var _main_generation := 0
var _main_view_key := ""
var _main_attempts := 0
var _main_retry_at := 0
var _main_placed_vid := 0
var _main_map_vid := 0
const MAIN_MODEL_MAX_ATTEMPTS := 3
const MAIN_MODEL_RETRY_MS := 1000
var _audio: Node
var _bgm_director: Node
var _mount: Node3D
@@ -548,6 +559,7 @@ func setup(m2client: Node, assets_root: String,
_setup_mobile_ui()
client.entity_main_set.connect(_on_main_set)
_main_sync_ready = true
if client.get_main_vid() != 0: # 重连 / 已在局内
# net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
# 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会
@@ -639,6 +651,13 @@ func _lifecycle_node() -> Node:
return null
func _process(_dt: float) -> void:
# `entered_game` is emitted by the phase transition, while the first
# GC_MAIN_CHARACTER / EntityStore change is drained later in the same or a
# following frame. The setup-time catch-up below cannot see that packet in
# all server timings, so keep the event path and add this idempotent fallback.
# 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()
# 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):
@@ -853,6 +872,14 @@ func _on_warp(pos: Vector3, same_server: bool) -> void:
_place_player_at_net_pos(pos)
func _on_world_reset() -> void:
_main_generation += 1
_main_view_key = ""
_main_attempts = 0
_main_retry_at = 0
_main_placed_vid = 0
_main_map_vid = 0
_model_built = false
_main_model_attempted = false
if net_world and net_world.has_method("clear_for_map_change"):
net_world.clear_for_map_change()
if ground_items and ground_items.has_method("clear_for_map_change"):
@@ -1369,6 +1396,10 @@ func _build_lighting() -> void:
_env = e
func _on_main_set(vid: int) -> void:
if not _main_sync_ready or _main_loading or vid <= 0 or client == null:
return
if vid != int(client.get_main_vid()):
return # A queued event may belong to the previous character.
# GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID
# as local before building its view so NetWorld removes (and never recreates)
# the generic remote-player capsule for our own character.
@@ -1379,11 +1410,45 @@ func _on_main_set(vid: int) -> void:
var e: Dictionary = client.get_entity(vid)
if e.is_empty() or player == null:
return
var net_pos: Variant = e.get("pos", null)
if not net_pos is Vector3 or not net_pos.is_finite():
return
# setup can only choose a map from data already received. Resolve again
# once the late authoritative position is available, before placing/viewing.
if _main_map_vid != vid:
_main_map_vid = vid
if _assets != "":
var next_map := _resolve_map_path(map_path, Vector2(net_pos.x * 100.0, -net_pos.z * 100.0))
if next_map != map_path:
_main_loading = true
_reload_map_for_warp(net_pos)
_main_loading = false
var key := "%d:%d:%s" % [vid, int(e.get("race", -1)), _assets]
if key != _main_view_key:
_main_view_key = key
_main_attempts = 0
_main_retry_at = 0
_main_model_attempted = false
_model_built = false
# 首次拿到 race -> 换成真 Metin2Model
if not _model_built and _assets != "" and int(e.get("race", 0)) >= 0:
var pv := PlayerView.new()
if not _model_built and _assets != "" and int(e.get("race", -1)) in range(8) \
and _main_attempts < MAIN_MODEL_MAX_ATTEMPTS and Time.get_ticks_msec() >= _main_retry_at:
_main_model_attempted = true
_main_attempts += 1
_main_loading = true
var generation := _main_generation
var pv = _create_main_view()
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
if pv.build(_assets, int(e.get("race", 0)), pump):
var built: bool = pv.build(_assets, int(e.get("race", 0)), pump)
_main_loading = false
# build() polls the network. World reset / new main data may arrive
# inside it; do not publish a view belonging to an obsolete generation.
if generation != _main_generation or is_queued_for_deletion() \
or vid != int(client.get_main_vid()) \
or int(client.get_entity(vid).get("race", -1)) != int(e.get("race", -1)):
pv.free()
return
if built:
pv.set_audio(_audio)
set_player_model(pv)
_model_built = true
@@ -1391,21 +1456,43 @@ func _on_main_set(vid: int) -> void:
net_play.player_view = pv
if pv.anim and pv.anim.has_signal("motion_event"):
pv.anim.motion_event.connect(_on_local_motion_event)
if pc and pc.has_signal("anim_state"):
pc.anim_state.connect(func(s: String):
if is_instance_valid(player) and player.has_method("set_anim_state"):
player.set_anim_state(s))
if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state):
pc.anim_state.connect(_on_main_anim_state)
if equip_model:
equip_model.set_race(int(e.get("race", 0)))
equip_model.refresh()
else:
pv.queue_free()
var p: Vector3 = MapCoord.to_world(e.get("pos", Vector3.ZERO))
if world and world.has_method("set_focus_position"):
world.call("set_focus_position", p.x, p.z) # 先对准,才建得出脚下的区块
if world and world.has_method("sample_height"):
p.y = float(world.call("sample_height", p.x, p.z))
player.position = p
_main_retry_at = Time.get_ticks_msec() + MAIN_MODEL_RETRY_MS
push_warning("GameScene: 主角模型加载失败(vid=%d race=%d attempt=%d/%d" % [vid, int(e.get("race", 0)), _main_attempts, MAIN_MODEL_MAX_ATTEMPTS])
pv.free()
# Polling for model readiness must not snap a moving player to the initial
# server position on every frame or on duplicate main-character events.
if _main_placed_vid == vid:
return
_place_player_at_net_pos(net_pos)
_main_placed_vid = vid
func _create_main_view() -> Node3D:
return PlayerView.new()
func _on_main_anim_state(state: String) -> void:
if is_instance_valid(player) and player.has_method("set_anim_state"):
player.set_anim_state(state)
func _sync_main_character() -> void:
if not _main_sync_ready or _main_loading or player == null or not is_instance_valid(player):
return
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
return
var vid := int(client.get_main_vid())
if vid == 0:
return
var e: Dictionary = client.get_entity(vid)
# Do not mark the attempt until the authoritative character row exists. The
# phase packet can precede the row by several frames on a busy connection.
if e.is_empty():
return
_on_main_set(vid)
# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。
# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。
+72
View File
@@ -0,0 +1,72 @@
# Real GameScene/assets, delayed authoritative row, no network or account writes.
extends SceneTree
const GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd")
var failed := false
func _initialize() -> void:
call_deferred("run")
func run() -> void:
root.size = Vector2i(1280, 720)
var output := OS.get_environment("MT_RENDER_OUTPUT")
if output.is_empty():
output = ProjectSettings.globalize_path("res://../build/rendering/gamescene-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(output)
var fc := Fixtures.FakeClient.new()
root.add_child(fc)
var scene := GameScene.new()
root.add_child(scene)
await scene.setup(fc, AssetRoot.path(), "OutdoorA1/metin2_map_a1")
# Choose C1 to exercise late map correction too (setup initially had no VID).
var use_c1 := "--c1" in OS.get_cmdline_user_args()
var server := Vector2(959109, 269267) if use_c1 else Vector2(454600, 934000)
fc.main = 1000
fc.ents[1000] = {"vid": 1000, "name": "RenderTest", "race": 0,
"pos": Vector3(server.x * 0.01, 0, -server.y * 0.01),
"pos_cm": server, "is_main": true, "angle_deg": 0.0,
"func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false}
for i in 30: await process_frame
var expected := MapCoord.to_world(fc.ents[1000].pos)
expected.y = scene.world.sample_height(expected.x, expected.z)
failed = not scene._model_built or scene.player.position.distance_to(expected) > 0.02
if use_c1: failed = failed or scene.map_path != "OutdoorC1/metin2_map_c1"
var surfaces := 0
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
if mi.mesh: surfaces += mi.mesh.get_surface_count()
failed = failed or surfaces == 0 or not scene._map_loaded()
if scene._model_built and scene.player.anim:
scene.player.anim.set_time(0.5)
scene.player.anim.set_process(false)
scene.cam.snap_to_target()
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK
var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server),
"world_position": str(scene.player.position), "expected": str(expected),
"surfaces": surfaces, "map_report": scene.world.get_load_report(),
"engine": Engine.get_version_info(), "viewport": str(root.size),
"camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov,
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
"scope": "real GameScene, delayed main row, no main signal; no server/40250 parity"}
var hashes := {}
for path in ["res://game_scene.gd", "res://ui/player_view.gd", "res://net/mapcoord.gd",
"res://gamescene_visual_test.gd", "res://bin/libmtgodot.macos.template_debug.dylib"]:
hashes[path] = FileAccess.get_sha256(path)
if scene._model_built:
for property in ["gr2_path", "hair_gr2", "hair_skin"]:
var path := String(scene.player.model.get(property))
if not path.is_empty(): hashes[path] = FileAccess.get_sha256(path)
var motion := String(scene.player.anim.get("anim_path")) if scene.player.anim else ""
if not motion.is_empty(): hashes[motion] = FileAccess.get_sha256(motion)
report["sha256"] = hashes
report["animation_sample_seconds"] = 0.5
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("GAMESCENE_VISUAL: ", JSON.stringify(report))
scene.queue_free()
fc.queue_free()
await process_frame
quit(1 if failed else 0)
+116
View File
@@ -0,0 +1,116 @@
extends SceneTree
const Scene = preload("res://game_scene.gd")
const View = preload("res://ui/player_view.gd")
class Client extends Node:
var main := 0
var rows := {}
func get_main_vid() -> int: return main
func get_entity(vid: int) -> Dictionary: return rows.get(vid, {})
class StubView extends Node3D:
var anim: Node
var succeeds := true
var during_build := Callable()
func build(_assets: String, _race: int, _pump: Callable) -> bool:
if during_build.is_valid(): during_build.call()
return succeeds
func set_audio(_node: Node) -> void: pass
class Harness extends Scene:
var builds := 0
var succeeds := true
var during_build := Callable()
func _create_main_view() -> Node3D:
builds += 1
var view := StubView.new()
view.succeeds = succeeds
view.during_build = during_build
return view
class Controller extends Node:
signal anim_state(state: String)
var player: Node3D
class Camera extends Camera3D:
var target: Node3D
var failures := 0
func check(value: bool, label: String) -> void:
if not value:
failures += 1
printerr("FAIL: " + label)
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var client := Client.new()
var scene := Harness.new()
root.add_child(client)
root.add_child(scene)
scene.set_process(false)
scene.client = client
scene._assets = "synthetic-assets"
scene.player = Node3D.new()
scene.add_child(scene.player)
scene.pc = Controller.new()
scene.add_child(scene.pc)
scene.cam = Camera.new()
scene.add_child(scene.cam)
scene._sync_main_character()
check(scene.builds == 0, "setup incomplete does not build")
scene._main_sync_ready = true
client.main = 10
scene._sync_main_character()
check(scene.builds == 0, "late entity is awaited")
client.rows[10] = {"pos": Vector3(20, 0, -30)}
scene._sync_main_character()
check(scene.builds == 0, "missing race does not build")
check(scene.player.position == Vector3(20, 0, 30), "late row places placeholder")
client.rows[10].race = 0
scene.during_build = func(): scene._on_main_set(10)
scene._sync_main_character()
check(scene.builds == 1 and scene._model_built, "reentrant network event creates one view")
check(scene.cam.target == scene.player and scene.pc.player == scene.player, "replacement updates consumers")
scene.player.position = Vector3(25, 0, 35)
for i in 5:
scene._sync_main_character()
scene._on_main_set(10)
check(scene.builds == 1, "duplicate main events are idempotent")
check(scene.player.position == Vector3(25, 0, 35), "polling does not undo movement")
client.main = 20
client.rows[20] = {"race": 1, "pos": Vector3(40, 0, -50)}
scene.during_build = Callable()
scene.succeeds = false
scene._sync_main_character()
check(scene.builds == 2 and not scene._model_built, "failed replacement is not marked ready")
for i in 5: scene._sync_main_character()
check(scene.builds == 2, "failure does not retry every frame")
scene._main_retry_at = 0
scene.succeeds = true
scene._sync_main_character()
check(scene.builds == 3 and scene._model_built, "transient failure recovers")
check(scene.pc.anim_state.get_connections().size() == 1, "replacement does not duplicate animation subscriptions")
var previous = scene.player
client.main = 30
client.rows[30] = {"race": 0, "pos": Vector3.ZERO}
scene.during_build = func(): client.main = 40
scene._sync_main_character()
check(scene.player == previous, "obsolete build is discarded")
client.rows[40] = {"race": 0, "pos": Vector3.ZERO}
scene.during_build = Callable()
scene.succeeds = false
for i in 5:
scene._main_retry_at = 0
scene._sync_main_character()
check(scene._main_attempts == 3, "permanent failure has bounded attempts")
var view := View.new()
check(not view.build("missing-assets", 8), "unsupported race rejected")
view.free()
scene.queue_free()
client.queue_free()
await process_frame
print("main_character_sync_test: failures=", failures)
quit(1 if failures else 0)
+2 -3
View File
@@ -305,9 +305,8 @@ func _rebuild_model() -> void:
# Expansion armor textures live beside their GR2, outside the base PC pack.
var body_path := String(pv.model.get("gr2_path"))
pv.model.set("texture_dir", body_path.get_base_dir())
# Shape zero uses the head/hair already included in the armor mesh.
if int(c.get("hair_part", 0)) == 0:
pv.model.set("hair_gr2", "")
pv.model.set("lod_enabled", false)
pv.model.set("flip_winding", true)
pv._ground_model()
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
if pv.anim and FileAccess.file_exists(intro):
+13 -4
View File
@@ -29,7 +29,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin"
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
func build(assets_root: String, race: int, pump := Callable()) -> bool:
_assets_root = assets_root
if not ClassDB.class_exists("Metin2Model"):
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
return false
var cls: String = CLASS_OF[race & 3]
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
@@ -46,9 +46,18 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
model.set("texture_dir", base)
if pump.is_valid(): pump.call()
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
if body != "":
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
if pump.is_valid(): pump.call()
if body == "":
model.free()
model = null
return false
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
# This view is built off-tree: set_gr2_path only stores the path until ready.
# Validate the source explicitly rather than reading a not-yet-loaded report.
if String(model.call("probe_gr2", body)).contains("FAILED"):
model.free()
model = null
return false
if pump.is_valid(): pump.call()
var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")])
if hair != "":
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格