- 新增 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
211 lines
11 KiB
GDScript
211 lines
11 KiB
GDScript
# Offline real-resource stress/viewport report; NOT 40250 visual parity or
|
|
# server reconnect acceptance. Run with a renderer, --quit-after 3600.
|
|
# suite=offline_synthetic: FakeClient + real assets. STB-01 live numbers only come
|
|
# from the networked soak suite (script/playable_soak.sh); never merge the two.
|
|
extends SceneTree
|
|
const GameScene = preload("res://game_scene.gd")
|
|
const Fixtures = preload("res://gamescene_test.gd")
|
|
const PlayerView = preload("res://ui/player_view.gd")
|
|
const MobView = preload("res://ui/mob_view.gd")
|
|
const Metrics = preload("res://testing/playable_metrics.gd")
|
|
class ScenarioClient extends Fixtures.FakeClient:
|
|
signal warp(pos: Vector3, same_server: bool)
|
|
signal world_reset()
|
|
var failures: Array[String] = []
|
|
var output := ""
|
|
var metrics: RefCounted
|
|
func check(ok: bool, label: String) -> void:
|
|
if not ok:
|
|
failures.append(label)
|
|
printerr("FAIL: " + label)
|
|
func _init() -> void:
|
|
call_deferred("run")
|
|
func run() -> void:
|
|
var run_start := Time.get_ticks_msec()
|
|
var sample_frames := 90
|
|
if OS.has_environment("MT_RENDER_SAMPLE_FRAMES"):
|
|
sample_frames = clampi(int(OS.get_environment("MT_RENDER_SAMPLE_FRAMES")), 90, 36000)
|
|
output = OS.get_environment("MT_RENDER_OUTPUT")
|
|
if output == "":
|
|
output = ProjectSettings.globalize_path("res://../build/rendering/scenarios-followup")
|
|
DirAccess.make_dir_recursive_absolute(output)
|
|
root.size = Vector2i(1280, 720)
|
|
var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity",
|
|
"suite": "offline_synthetic", "suite_note": "离线合成:FakeClient + 真实资源,无服务器;不能代替 soak 实网 suite",
|
|
"engine": Engine.get_version_info(), "backend": DisplayServer.get_name(), "samples": [],
|
|
"sample_frames": sample_frames, "model_builds": [], "viewports": [],
|
|
"percentile": "nearest-rank over window-frame intervals",
|
|
"cold_start_note": "进程冷启动/系统缓存状态未知;测试不清空系统缓存"}
|
|
metrics = Metrics.new()
|
|
metrics.configure({"window_seconds": 3600})
|
|
var client := ScenarioClient.new()
|
|
root.add_child(client)
|
|
var scene := GameScene.new()
|
|
root.add_child(scene)
|
|
await scene.setup(client, AssetRoot.path(), "OutdoorA1/metin2_map_a1")
|
|
client.main = 1000
|
|
client.ents[1000] = {"vid": 1000, "name": "Scenario", "race": 0, "pos_cm": Vector2(454600, 934000),
|
|
"pos": Vector3(4546, 0, -9340), "is_main": true, "func": 0, "moving": false,
|
|
"angle_deg": 0.0, "hp": 100, "max_hp": 100, "dead": false}
|
|
for i in 30: await process_frame
|
|
check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded")
|
|
var centre: Vector3 = scene.player.position
|
|
report["probe_overhead"] = await _probe_overhead(sample_frames)
|
|
metrics.reset_frame_clock()
|
|
metrics.frame()
|
|
for mode in ["0", "1"]:
|
|
OS.set_environment("MTGODOT_GPUSKIN", mode)
|
|
var crowd := Node3D.new()
|
|
scene.add_child(crowd)
|
|
for count in [1, 8, 16]:
|
|
_tags("OutdoorA1", "loading", count, mode == "1")
|
|
while crowd.get_child_count() < count:
|
|
var index := crowd.get_child_count()
|
|
var view := PlayerView.new()
|
|
var build_start := Time.get_ticks_usec()
|
|
check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8))
|
|
var build_ms := (Time.get_ticks_usec() - build_start) / 1000.0
|
|
metrics.mark("model_build", {"race": index % 8, "ms": build_ms})
|
|
report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, "build_ms": build_ms,
|
|
"note": "synchronous build only; OS/shader caches may already be warm"})
|
|
crowd.add_child(view)
|
|
view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2)
|
|
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
|
|
view.set_anim_state("run")
|
|
for i in 12:
|
|
await process_frame
|
|
metrics.frame()
|
|
_tags("OutdoorA1", "gameplay", count, mode == "1")
|
|
var times: Array[float] = []
|
|
var switches: Array[float] = []
|
|
var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC)
|
|
var previous := Time.get_ticks_usec()
|
|
for i in sample_frames:
|
|
if i > 0 and i % 30 == 0:
|
|
var switch_start := Time.get_ticks_usec()
|
|
var state: String = ["run", "attack", "wait"][(i / 30) % 3]
|
|
for view in crowd.get_children(): view.set_anim_state(state)
|
|
switches.append((Time.get_ticks_usec() - switch_start) / 1000.0)
|
|
metrics.mark("motion_switch", {"state": state, "count": count, "ms": switches[-1]})
|
|
await process_frame
|
|
metrics.frame()
|
|
var now := Time.get_ticks_usec()
|
|
times.append((now - previous) / 1000.0)
|
|
previous = now
|
|
for view in crowd.get_children():
|
|
var bounds: AABB = view.model.get_visual_aabb()
|
|
check(bounds.position.is_finite() and bounds.size.is_finite() and bounds.size.length() > 0,
|
|
"finite posed bounds after run/attack/wait blend")
|
|
times.sort()
|
|
switches.sort()
|
|
var sorted := PackedFloat32Array(times)
|
|
report.samples.append({"gpu_skin": mode == "1", "extra_players": count,
|
|
"median_ms": Metrics.percentile(sorted, 0.5), "p95_ms": Metrics.percentile(sorted, 0.95),
|
|
"p99_ms": Metrics.percentile(sorted, 0.99), "max_ms": sorted[-1],
|
|
"p99_note": "nearest-rank; below 100 samples p99 equals max",
|
|
"over_50ms": times.filter(func(ms: float) -> bool: return ms > Metrics.LONG_MS).size(),
|
|
"over_100ms": times.filter(func(ms: float) -> bool: return ms > Metrics.SEVERE_MS).size(),
|
|
"motion_switches": switches.size(), "motion_switch_max_ms": switches[-1],
|
|
"memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC),
|
|
"clip_cache": Metin2AnimPlayer.get_clip_cache_stats(),
|
|
"note": "%d window-frame intervals with repeated motion transitions; not GPU timestamp, RSS or FPS guarantee" % sample_frames})
|
|
check(report.samples[-1].p99_ms <= report.samples[-1].max_ms and report.samples[-1].p99_ms > 0.0, "p99 recorded for gpu=%s count=%d" % [mode, count])
|
|
print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d p99_ms=%.3f max_ms=%.3f" % [mode, count, sample_frames, report.samples[-1].p99_ms, times[-1]])
|
|
crowd.free()
|
|
await process_frame
|
|
metrics.frame()
|
|
OS.set_environment("MTGODOT_GPUSKIN", "0")
|
|
var mobs := Node3D.new()
|
|
scene.add_child(mobs)
|
|
_tags("OutdoorA1", "loading", 3, false)
|
|
for race in [101, 110, 20001]:
|
|
var view := MobView.new()
|
|
var build_start := Time.get_ticks_usec()
|
|
var built: bool = view.build(AssetRoot.path(), null, race)
|
|
metrics.mark("model_build", {"race": race, "ms": (Time.get_ticks_usec() - build_start) / 1000.0})
|
|
if not built:
|
|
check(false, "NPC/monster build race=%d" % race)
|
|
view.free()
|
|
continue
|
|
mobs.add_child(view)
|
|
view.position = centre + Vector3(mobs.get_child_count() * 2, 0, 3)
|
|
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
|
|
view.set_anim_state("wait")
|
|
check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry")
|
|
_tags("OutdoorA1", "gameplay", 3, false)
|
|
for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]:
|
|
root.size = size
|
|
metrics.mark("viewport_resize", {"size": "%dx%d" % [size.x, size.y]})
|
|
for i in 5:
|
|
await process_frame
|
|
metrics.frame()
|
|
# Logical viewport and physical window pixels are both recorded; Retina scale is not a size error.
|
|
report.viewports.append({"logical": [size.x, size.y], "visible": [int(root.get_visible_rect().size.x), int(root.get_visible_rect().size.y)],
|
|
"window_physical": [DisplayServer.window_get_size().x, DisplayServer.window_get_size().y],
|
|
"screen_scale": DisplayServer.screen_get_scale()})
|
|
if scene.quickbar and scene.quickbar._root:
|
|
var rect: Rect2 = scene.quickbar._root.get_global_rect()
|
|
check(Rect2(Vector2.ZERO, root.get_visible_rect().size).encloses(rect), "quickbar inside viewport %s" % size)
|
|
if DisplayServer.get_name() != "headless":
|
|
await RenderingServer.frame_post_draw
|
|
check(root.get_texture().get_image().save_png(output.path_join("scene-%dx%d.png" % [size.x, size.y])) == OK, "screenshot saved")
|
|
# Late server location update exercises the production map-correction path.
|
|
client.ents[1000].pos_cm = Vector2(959109, 269267)
|
|
client.ents[1000].pos = Vector3(9591.09, 0, -2692.67)
|
|
_tags("OutdoorC1", "loading", 3, false)
|
|
metrics.mark("map_switch", {"map": "OutdoorC1"})
|
|
client.world_reset.emit()
|
|
client.warp.emit(client.ents[1000].pos, false)
|
|
for i in 60:
|
|
await process_frame
|
|
metrics.frame()
|
|
check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction")
|
|
report["final_map"] = scene.map_path
|
|
var summary: Dictionary = metrics.summary()
|
|
report["frame_metrics"] = summary
|
|
report["unattributed_long_frames"] = metrics.unattributed_long_frames().size()
|
|
report["memory"] = Metrics.memory_snapshot()
|
|
check(int(summary.frames) > sample_frames * 6 and not summary.segments.is_empty() and not summary.markers.is_empty(), "frame metrics recorded with markers")
|
|
var phases := {}
|
|
for row in summary.segments: phases[row.tags.phase] = true
|
|
check(phases.has("loading") and phases.has("gameplay"), "loading and gameplay are separate segments: %s" % [phases.keys()])
|
|
check(report.memory.gpu_memory_kib == null, "GPU memory is reported unavailable, never 0")
|
|
print("SCENARIO_METRICS frames=%d long=%d unattributed=%d severe=%s" % [summary.frames, summary.long_frames.size(),
|
|
report.unattributed_long_frames, summary.severe_by_phase])
|
|
scene.free()
|
|
client.free()
|
|
await process_frame
|
|
report["failures"] = failures
|
|
report["duration_seconds"] = (Time.get_ticks_msec() - run_start) / 1000.0
|
|
FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t"))
|
|
print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output])
|
|
quit(1 if not failures.is_empty() else 0)
|
|
func _tags(map: String, phase: String, actors: int, gpu_skin: bool) -> void:
|
|
metrics.set_tags({"map": map, "phase": phase, "actor_count": Metrics.actor_bucket(actors), "gpu_skin": gpu_skin})
|
|
## Short alternating baselines with the probe off and on (same scene, same frames),
|
|
## plus the per-sample CPU cost; the difference is reported, never assumed zero.
|
|
func _probe_overhead(frames: int) -> Dictionary:
|
|
var rounds: Array = []
|
|
var probe: RefCounted = Metrics.new()
|
|
probe.configure({"window_seconds": 3600})
|
|
probe.set_tags({"map": "OutdoorA1", "phase": "probe", "actor_count": "0", "gpu_skin": false})
|
|
for i in 4:
|
|
var enabled := i % 2 == 1
|
|
var times := PackedFloat32Array()
|
|
var previous := Time.get_ticks_usec()
|
|
for f in frames:
|
|
await process_frame
|
|
if enabled:
|
|
probe.frame()
|
|
var now := Time.get_ticks_usec()
|
|
times.append((now - previous) / 1000.0)
|
|
previous = now
|
|
times.sort()
|
|
rounds.append({"probe": enabled, "frames": frames, "p50_ms": Metrics.percentile(times, 0.5),
|
|
"p99_ms": Metrics.percentile(times, 0.99), "max_ms": times[-1]})
|
|
var off: Array = rounds.filter(func(r: Dictionary) -> bool: return not r.probe)
|
|
var on: Array = rounds.filter(func(r: Dictionary) -> bool: return r.probe)
|
|
return {"rounds": rounds, "cpu": Metrics.measure_overhead(20000),
|
|
"p50_delta_ms": (on[0].p50_ms + on[1].p50_ms - off[0].p50_ms - off[1].p50_ms) / 2.0,
|
|
"note": "frame pacing noise can exceed the probe cost; per_sample_us is the direct CPU cost"}
|