Files
mtgodot-poc/project/forest_mob_render_test.gd
shenleiandClaude Opus 5 f39a55fdd5 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
2026-09-11 22:06:48 +09:00

295 lines
13 KiB
GDScript
Raw Permalink 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.
extends SceneTree
## 鬼木林 / 赤鬼木林树怪的离线资源和 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")
func _fail(message: String) -> void:
_failures.append(message)
printerr("FOREST MOB FAIL: " + message)
func run() -> void:
_output = OS.get_environment("MT_RENDER_OUTPUT")
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()
cam.fov = 38.0
cam.near = 0.02
root.add_child(cam)
cam.make_current()
for gpu in ["0", "1"]:
OS.set_environment("MTGODOT_GPUSKIN", gpu)
for race in _races:
await _check_race(int(race), gpu, cam)
OS.set_environment("MTGODOT_GPUSKIN", "0")
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()
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("%s build returned false" % tag)
view.queue_free()
await process_frame
return
# 交叉淡入只在 _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("%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
view.anim.set_process(false)
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/fractionCPU 先记录,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)
sun.light_energy = 1.1
root.add_child(sun)
var env := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR
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
env.environment = settings
root.add_child(env)