Files
mtgodot-poc/project/rendering_scenario_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

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"}