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