feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
@@ -1,17 +1,36 @@
|
||||
extends SceneTree
|
||||
|
||||
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收。
|
||||
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收(FIRST-MAC-PLAYABLE §8.2)。
|
||||
## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。
|
||||
##
|
||||
## 每个 race × CPU/GPU × wait/run/attack/damage/dead,在动作起点/25%/50%/75%/末段取样:
|
||||
## - 报告 requested_state / resolved_motion / fallback_reason,实际播放路径必须等于解析结果;
|
||||
## - 资源确无动作时按旧客户端规则(GetMotionKey 失败 -> 保留当前动作)单独判定,
|
||||
## 只允许移动循环动作走这条回退,并写进 report.fallbacks;
|
||||
## - 贴图实际加载、材质有 shader、包围盒有限;
|
||||
## - 同一姿态 CPU/GPU 包围盒差 <= max(1cm, 高度0.5%);
|
||||
## - 非 headless 时输出正面、侧面和每 race 动作 contact sheet,截图不能是纯背景。
|
||||
## env: MT_RENDER_OUTPUT 输出目录;MT_FOREST_RACES 逗号分隔的 race 子集(调试用)。
|
||||
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]
|
||||
const STATES := ["wait", "run", "attack", "damage", "dead"]
|
||||
## 旧客户端 CActorInstance::Move -> SetLoopMotion(RUN/WALK):缺动作时直接返回保留当前循环。
|
||||
const MOVE_STATES := ["run"]
|
||||
const SAMPLES := [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||
const SIZE := 640
|
||||
const THUMB := 128
|
||||
const BACKGROUND := Color(0.08, 0.1, 0.14)
|
||||
const TOLERANCE_FLOOR_M := 0.01
|
||||
const TOLERANCE_HEIGHT_RATIO := 0.005
|
||||
const MIN_FOREGROUND_RATIO := 0.002
|
||||
|
||||
var _output := ""
|
||||
var _failures: Array[String] = []
|
||||
var _fallbacks: Array[Dictionary] = []
|
||||
var _pose_bounds := {}
|
||||
var _results: Array[Dictionary] = []
|
||||
var _races: Array = RACES
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
@@ -25,6 +44,11 @@ func run() -> void:
|
||||
if _output.is_empty():
|
||||
_output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(_output)
|
||||
var subset := OS.get_environment("MT_FOREST_RACES").strip_edges()
|
||||
if not subset.is_empty():
|
||||
_races = []
|
||||
for part in subset.split(",", false):
|
||||
_races.append(int(part))
|
||||
root.size = Vector2i(SIZE, SIZE)
|
||||
_build_lighting()
|
||||
var cam := Camera3D.new()
|
||||
@@ -34,80 +58,226 @@ func run() -> void:
|
||||
cam.make_current()
|
||||
for gpu in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", gpu)
|
||||
for race in RACES:
|
||||
await _check_race(race, gpu, cam)
|
||||
for race in _races:
|
||||
await _check_race(int(race), gpu, cam)
|
||||
OS.set_environment("MTGODOT_GPUSKIN", "0")
|
||||
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": RACES,
|
||||
"states": STATES, "gpu_modes": ["0", "1"], "results": _results,
|
||||
"assets": AssetRoot.path(), "viewport": str(root.size),
|
||||
"headless": DisplayServer.get_name() == "headless",
|
||||
var headless := DisplayServer.get_name() == "headless"
|
||||
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": _races,
|
||||
"states": STATES, "gpu_modes": ["0", "1"], "sample_fractions": SAMPLES,
|
||||
"tolerance_rule": "max(%.2fm, %.1f%% of posed height)" % [TOLERANCE_FLOOR_M, TOLERANCE_HEIGHT_RATIO * 100.0],
|
||||
"fallback_rule": "reference CRaceData::GetMotionKey miss -> SetLoopMotion returns and the current loop keeps playing",
|
||||
"fallbacks": _fallbacks, "results": _results,
|
||||
"assets": AssetRoot.path(), "viewport": str(root.size), "headless": headless,
|
||||
"screenshots": not headless,
|
||||
"manual_review": "contact sheets and front/side views need one human sign-off; AABB bottom is not foot contact",
|
||||
"scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"}
|
||||
var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(report, "\t"))
|
||||
file.close()
|
||||
print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), "results": _results.size(), "output": _output}))
|
||||
else:
|
||||
_fail("cannot write report.json")
|
||||
print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(),
|
||||
"fallbacks": _fallbacks.size(), "results": _results.size(), "output": _output}))
|
||||
# Batch marker (rendering_batch_test.sh); the JSON line above keeps the details.
|
||||
print("PASS: forest_mob_render_test (manual sign-off pending)" if _failures.is_empty() else "FAIL: forest_mob_render_test")
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
func _check_race(race: int, gpu: String, cam: Camera3D) -> void:
|
||||
var tag := "race=%d GPU=%s" % [race, gpu]
|
||||
var view := MobView.new()
|
||||
view.name = "ForestMob_%d_%s" % [race, gpu]
|
||||
root.add_child(view)
|
||||
if not view.build(AssetRoot.path(), null, race):
|
||||
_fail("race=%d GPU=%s build returned false" % [race, gpu])
|
||||
_fail("%s build returned false" % tag)
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
return
|
||||
var row := {"race": race, "gpu_skin": gpu == "1", "states": {}}
|
||||
for state in STATES:
|
||||
var requested := view.get_motion_path(state)
|
||||
if requested.is_empty() or not FileAccess.file_exists(requested):
|
||||
_fail("race=%d GPU=%s state=%s has no resolved .msa" % [race, gpu, state])
|
||||
continue
|
||||
# 交叉淡入只在 _process 里推进;取样要看动作本身的姿态,不看上一动作的残留混合。
|
||||
if view.anim:
|
||||
view.anim.set("blend_time", 0.0)
|
||||
var row := {"race": race, "gpu_skin": gpu == "1", "directory": view._dir,
|
||||
"materials": _material_report(view.model, tag), "states": {}, "views": {}, "contact_sheet": ""}
|
||||
var shoot := DisplayServer.get_name() != "headless"
|
||||
var sheet: Image = null
|
||||
if shoot:
|
||||
sheet = Image.create(THUMB * SAMPLES.size(), THUMB * STATES.size(), false, Image.FORMAT_RGBA8)
|
||||
sheet.fill(BACKGROUND)
|
||||
var frame := {}
|
||||
for state_index in STATES.size():
|
||||
var state: String = STATES[state_index]
|
||||
var resolution: Dictionary = view.resolve_motion(state)
|
||||
var before := String(view.anim.get("anim_path")) if view.anim else ""
|
||||
view.set_anim_state(state)
|
||||
if view.anim == null:
|
||||
_fail("race=%d GPU=%s state=%s has no animation player" % [race, gpu, state])
|
||||
_fail("%s state=%s has no animation player" % [tag, state])
|
||||
continue
|
||||
var entry := {"requested_state": state, "resolved_motion": String(resolution.motion),
|
||||
"motion_path": String(resolution.path), "fallback_reason": String(resolution.fallback_reason),
|
||||
"status": "PASS", "samples": []}
|
||||
row.states[state] = entry
|
||||
var actual := String(view.anim.get("anim_path"))
|
||||
if String(resolution.path).is_empty():
|
||||
if state in MOVE_STATES and resolution.fallback_reason == "reference_keep_current_motion" and actual == before and not before.is_empty():
|
||||
entry.status = "REFERENCE_FALLBACK"
|
||||
entry["kept_motion_path"] = actual
|
||||
_fallbacks.append({"race": race, "gpu_skin": gpu == "1", "requested_state": state,
|
||||
"kept_motion_path": actual, "fallback_reason": resolution.fallback_reason,
|
||||
"needs_live_confirmation": "server movement of this race shows the kept loop while its position changes"})
|
||||
else:
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s has no motion (%s; still playing %s)" % [tag, state, resolution.fallback_reason, actual])
|
||||
continue
|
||||
if not FileAccess.file_exists(String(resolution.path)):
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s resolved a missing file %s" % [tag, state, resolution.path])
|
||||
continue
|
||||
if actual != String(resolution.path):
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s resolved %s but played %s" % [tag, state, resolution.path, actual])
|
||||
continue
|
||||
if state == "dead" and bool(view.anim.get("loop")):
|
||||
_fail("%s dead motion is looping" % tag)
|
||||
view.anim.set_process(true)
|
||||
await process_frame
|
||||
var actual := String(view.anim.get("anim_path"))
|
||||
if actual != requested:
|
||||
_fail("race=%d GPU=%s state=%s resolved %s but played %s" % [race, gpu, state, requested, actual])
|
||||
var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 1.0
|
||||
var fraction := 0.5
|
||||
if state == "dead":
|
||||
fraction = 0.95
|
||||
view.anim.call("set_time", maxf(0.0, duration * fraction))
|
||||
view.anim.set_process(false)
|
||||
await process_frame
|
||||
var bounds: AABB = view.model.call("get_visual_aabb")
|
||||
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0:
|
||||
_fail("race=%d GPU=%s state=%s has invalid visual AABB" % [race, gpu, state])
|
||||
_pose_bounds["%d/%s" % [race, state]] = bounds if gpu == "0" else _pose_bounds.get("%d/%s" % [race, state], AABB())
|
||||
if gpu == "1":
|
||||
var cpu_bounds: AABB = _pose_bounds.get("%d/%s" % [race, state], AABB())
|
||||
var tolerance := maxf(0.01, maxf(cpu_bounds.size.length(), bounds.size.length()) * 0.005)
|
||||
if cpu_bounds.size.length() <= 0.0 or cpu_bounds.position.distance_to(bounds.position) > tolerance or cpu_bounds.size.distance_to(bounds.size) > tolerance:
|
||||
_fail("race=%d state=%s CPU/GPU AABB differs beyond %.4fm" % [race, state, tolerance])
|
||||
var image_name := ""
|
||||
if DisplayServer.get_name() != "headless":
|
||||
var centre: Vector3 = view.model.global_transform * bounds.get_center()
|
||||
var visual_size: float = bounds.size.length() * view.model.scale.length() / sqrt(3.0)
|
||||
var distance: float = maxf(3.0, visual_size * 1.8)
|
||||
cam.position = centre + Vector3(distance * 0.35, distance * 0.22, distance)
|
||||
cam.look_at(centre, Vector3.UP)
|
||||
await RenderingServer.frame_post_draw
|
||||
image_name = "mob-%d-%s.png" % [race, state] if gpu == "1" else "mob-%d-%s-cpu.png" % [race, state]
|
||||
if root.get_texture().get_image().save_png(_output.path_join(image_name)) != OK:
|
||||
_fail("race=%d state=%s screenshot save failed" % [race, state])
|
||||
row.states[state] = {"requested_motion": requested, "resolved_motion": actual,
|
||||
"duration": duration, "sample_fraction": fraction,
|
||||
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z],
|
||||
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z], "screenshot": image_name}
|
||||
var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 0.0
|
||||
entry["duration"] = duration
|
||||
if duration <= 0.0:
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s has zero duration" % [tag, state])
|
||||
continue
|
||||
for sample_index in SAMPLES.size():
|
||||
var fraction: float = SAMPLES[sample_index]
|
||||
# 末段取最后一帧之前,避免非循环动作 set_time(duration) 被播放器夹回/结束。
|
||||
var at := clampf(duration * fraction, 0.0, maxf(0.0, duration - 1.0 / 60.0))
|
||||
view.anim.call("set_time", at)
|
||||
await process_frame
|
||||
var sample := _sample_pose(view, race, gpu, state, fraction, tag)
|
||||
sample["time"] = at
|
||||
entry.samples.append(sample)
|
||||
if not bool(sample.valid):
|
||||
entry.status = "FAIL"
|
||||
if shoot:
|
||||
if frame.is_empty():
|
||||
frame = _frame_for(view)
|
||||
var image := await _capture(cam, frame, Vector3(0.35, 0.22, 1.0))
|
||||
if not _has_foreground(image):
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s sample=%.2f screenshot is empty" % [tag, state, fraction])
|
||||
image.convert(Image.FORMAT_RGBA8)
|
||||
image.resize(THUMB, THUMB, Image.INTERPOLATE_BILINEAR)
|
||||
sheet.blit_rect(image, Rect2i(0, 0, THUMB, THUMB), Vector2i(sample_index * THUMB, state_index * THUMB))
|
||||
if state == "wait" and is_equal_approx(fraction, 0.5):
|
||||
for view_name in ["front", "side"]:
|
||||
var direction := Vector3(0.0, 0.18, 1.0) if view_name == "front" else Vector3(1.0, 0.18, 0.0)
|
||||
var shot := await _capture(cam, frame, direction)
|
||||
var file_name := "mob-%d-%s-%s.png" % [race, view_name, "gpu" if gpu == "1" else "cpu"]
|
||||
if not _has_foreground(shot) or shot.save_png(_output.path_join(file_name)) != OK:
|
||||
_fail("%s %s view screenshot failed" % [tag, view_name])
|
||||
row.views[view_name] = file_name
|
||||
if shoot:
|
||||
var sheet_name := "mob-%d-sheet-%s.png" % [race, "gpu" if gpu == "1" else "cpu"]
|
||||
if not _has_foreground(sheet) or sheet.save_png(_output.path_join(sheet_name)) != OK:
|
||||
_fail("%s contact sheet save failed" % tag)
|
||||
row.contact_sheet = sheet_name
|
||||
_results.append(row)
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
|
||||
## 同一 race/state/fraction:CPU 先记录,GPU 比较。容差 max(1cm, 高度0.5%),米制世界空间。
|
||||
func _sample_pose(view: Node3D, race: int, gpu: String, state: String, fraction: float, tag: String) -> Dictionary:
|
||||
var local: AABB = view.model.call("get_visual_aabb")
|
||||
var bounds: AABB = view.model.global_transform * local
|
||||
var out := {"fraction": fraction, "valid": true,
|
||||
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z],
|
||||
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z]}
|
||||
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0:
|
||||
out.valid = false
|
||||
_fail("%s state=%s sample=%.2f has invalid visual AABB" % [tag, state, fraction])
|
||||
return out
|
||||
var key := "%d/%s/%.2f" % [race, state, fraction]
|
||||
if gpu == "0":
|
||||
_pose_bounds[key] = bounds
|
||||
return out
|
||||
if not _pose_bounds.has(key):
|
||||
out.valid = false
|
||||
_fail("%s state=%s sample=%.2f has no CPU pose to compare" % [tag, state, fraction])
|
||||
return out
|
||||
var cpu: AABB = _pose_bounds[key]
|
||||
var tolerance := maxf(TOLERANCE_FLOOR_M, maxf(cpu.size.y, bounds.size.y) * TOLERANCE_HEIGHT_RATIO)
|
||||
var delta := maxf(cpu.position.distance_to(bounds.position), cpu.end.distance_to(bounds.end))
|
||||
out["cpu_gpu_delta_m"] = delta
|
||||
out["tolerance_m"] = tolerance
|
||||
if delta > tolerance:
|
||||
out.valid = false
|
||||
_fail("%s state=%s sample=%.2f CPU/GPU AABB differs %.4fm > %.4fm" % [tag, state, fraction, delta, tolerance])
|
||||
return out
|
||||
|
||||
## 贴图实际绑定到材质、材质有 shader。双面/混合按资源记录,不做统一强制。
|
||||
func _material_report(model: Node, tag: String) -> Dictionary:
|
||||
var surfaces := 0
|
||||
var textured := 0
|
||||
var two_sided := 0
|
||||
var invalid := 0
|
||||
for node in model.find_children("*", "MeshInstance3D", true, false):
|
||||
var instance := node as MeshInstance3D
|
||||
if instance.mesh == null:
|
||||
continue
|
||||
for surface in instance.mesh.get_surface_count():
|
||||
surfaces += 1
|
||||
var material := instance.get_active_material(surface)
|
||||
var shader_material := material as ShaderMaterial
|
||||
if shader_material != null:
|
||||
if shader_material.shader == null:
|
||||
invalid += 1
|
||||
continue
|
||||
if bool(shader_material.get_shader_parameter("use_texture")) and shader_material.get_shader_parameter("albedo_tex") != null:
|
||||
textured += 1
|
||||
if shader_material.shader.code.contains("cull_disabled"):
|
||||
two_sided += 1
|
||||
elif material is BaseMaterial3D:
|
||||
if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null:
|
||||
textured += 1
|
||||
if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED:
|
||||
two_sided += 1
|
||||
else:
|
||||
invalid += 1
|
||||
if surfaces == 0:
|
||||
_fail("%s model has no mesh surfaces" % tag)
|
||||
if invalid > 0:
|
||||
_fail("%s has %d surfaces without a valid material" % [tag, invalid])
|
||||
if textured < surfaces:
|
||||
_fail("%s only %d/%d surfaces have a loaded texture" % [tag, textured, surfaces])
|
||||
return {"surfaces": surfaces, "textured": textured, "two_sided": two_sided, "invalid": invalid}
|
||||
|
||||
## 按首个采样姿态固定机位,同一 race 的所有截图可直接对比。
|
||||
func _frame_for(view: Node3D) -> Dictionary:
|
||||
var bounds: AABB = view.model.global_transform * (view.model.call("get_visual_aabb") as AABB)
|
||||
var radius := maxf(0.5, bounds.size.length() * 0.5)
|
||||
return {"centre": bounds.get_center(), "distance": maxf(3.0, radius * 3.6)}
|
||||
|
||||
func _capture(cam: Camera3D, frame: Dictionary, direction: Vector3) -> Image:
|
||||
var centre: Vector3 = frame.centre
|
||||
cam.position = centre + direction.normalized() * float(frame.distance)
|
||||
cam.look_at(centre, Vector3.UP)
|
||||
await RenderingServer.frame_post_draw
|
||||
return root.get_texture().get_image()
|
||||
|
||||
func _has_foreground(image: Image) -> bool:
|
||||
if image == null or image.is_empty():
|
||||
return false
|
||||
var hits := 0
|
||||
var step := 4
|
||||
var total := 0
|
||||
for y in range(0, image.get_height(), step):
|
||||
for x in range(0, image.get_width(), step):
|
||||
total += 1
|
||||
var c := image.get_pixel(x, y)
|
||||
if absf(c.r - BACKGROUND.r) + absf(c.g - BACKGROUND.g) + absf(c.b - BACKGROUND.b) > 0.06:
|
||||
hits += 1
|
||||
return total > 0 and float(hits) / float(total) >= MIN_FOREGROUND_RATIO
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-42, 155, 0)
|
||||
@@ -116,7 +286,7 @@ func _build_lighting() -> void:
|
||||
var env := WorldEnvironment.new()
|
||||
var settings := Environment.new()
|
||||
settings.background_mode = Environment.BG_COLOR
|
||||
settings.background_color = Color(0.08, 0.1, 0.14)
|
||||
settings.background_color = BACKGROUND
|
||||
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
settings.ambient_light_color = Color(0.55, 0.58, 0.65)
|
||||
settings.ambient_light_energy = 0.8
|
||||
|
||||
Reference in New Issue
Block a user