# playable_metrics_test —— STB-01 §9.1 帧间隔/长帧指标的离线自检(注入时钟,不等真实时间)。 # godot --headless --path project --script playable_metrics_test.gd extends SceneTree const Metrics = preload("res://testing/playable_metrics.gd") var _fail := 0 var _done := false var _now := 0 func _ck(ok: bool, message: String) -> void: if not ok: _fail += 1 printerr("FAIL: " + message) func _init() -> void: _run() _ck(_done, "all metrics assertions executed") print("playable_metrics_test: failures=%d" % _fail) if _fail == 0: print("PASS: playable_metrics_test") quit(1 if _fail else 0) func _metrics(options := {}) -> RefCounted: var m: RefCounted = Metrics.new() m.clock_us = func() -> int: return _now m.configure(options) return m ## Advance the injected clock by `ms` and close one frame. func _frame(m: RefCounted, ms: float) -> void: _now += int(ms * 1000.0) m.frame() func _run() -> void: # Percentiles are nearest-rank over the window samples; max and counts are exact. var p: RefCounted = _metrics({"window_seconds": 3600}) p.set_tags({"map": "m", "phase": "play", "actor_count": 8, "gpu_skin": true}) p.frame() for i in range(1, 101): _frame(p, float(i)) var s: Dictionary = p.summary() var w: Dictionary = s.windows[0] _ck(w.count == 100 and w.sampled == 100 and not w.truncated, "one window with 100 samples: %s" % [w]) _ck(w.p50_ms == 50.0 and w.p95_ms == 95.0 and w.p99_ms == 99.0 and w.max_ms == 100.0, "nearest-rank p50/p95/p99/max: %s" % [w]) _ck(w.over_50ms == 50 and w.over_100ms == 0, ">50ms and >100ms are strict thresholds: %s" % [w]) _ck(s.print_frames == false and p.print_frames == false, "per-frame printing is off by default") _ck(s.long_frames.size() == 50 and s.long_frames[0].ms == 51.0, "only samples above 50ms become long-frame events") _ck(s.long_frames[0].tags == {"map": "m", "phase": "play", "actor_count": "8", "gpu_skin": "true"}, "long frames carry map/phase/actor_count/gpu_skin tags: %s" % [s.long_frames[0].tags]) _ck(s.frames == 100, "first frame() only arms the clock") # Fixed-size ring: a window longer than the capacity keeps exact counters but # bounded sample storage, and says so. var ring: RefCounted = _metrics({"capacity": 64, "window_seconds": 3600, "max_long_frames": 8, "max_markers": 4}) ring.set_tags({"phase": "play"}) ring.frame() for i in 10000: _frame(ring, 120.0 if i == 17 else (60.0 if i % 1000 == 0 else 16.0)) if i % 100 == 0: ring.mark("entity_batch", {"count": i}) _ck(ring.stored_sample_count() <= 64, "ring buffer never grows past capacity (%d)" % ring.stored_sample_count()) var rs: Dictionary = ring.summary() var rw: Dictionary = rs.windows[0] _ck(rw.count == 10000 and rw.sampled == 64 and rw.truncated, "overlong window is marked truncated: %s" % [rw]) _ck(rw.max_ms == 120.0 and rw.over_100ms == 1 and rw.over_50ms == 11, "counters stay exact beyond capacity: %s" % [rw]) _ck(rs.long_frames.size() == 8 and rs.long_frames_dropped == 3, "long-frame events are bounded with a drop count") _ck(rs.markers.size() == 4 and rs.markers_dropped == 96, "markers are bounded with a drop count") _ck(ring.long_unattributed_by_phase() == {"play": 11} and rs.long_frames.size() == 8, "unattributed counts stay exact while the long-frame list is bounded: %s" % [ring.long_unattributed_by_phase()]) # Wall-clock windows close on the injected clock, not on a frame count. var timed: RefCounted = _metrics({"window_seconds": 1, "max_windows": 3}) timed.set_tags({"phase": "play"}) timed.frame() for i in 400: _frame(timed, 16.0) var ts: Dictionary = timed.summary() _ck(ts.windows.size() == 3 and ts.windows_dropped >= 3, "closed windows are bounded (%d kept, %d dropped)" % [ts.windows.size(), ts.windows_dropped]) _ck(ts.windows.all(func(x: Dictionary) -> bool: return x.end_ms - x.start_ms <= 1016), "each window spans about one wall-clock second") # A tag change closes the window and opens a separate segment; segments are capped. var seg: RefCounted = _metrics({"max_segments": 2, "window_seconds": 3600}) seg.set_tags({"phase": "loading", "map": "a"}) seg.frame() _frame(seg, 300.0) seg.set_tags({"phase": "play", "map": "a"}) _frame(seg, 16.0) seg.set_tags({"phase": "play", "map": "b"}) _frame(seg, 16.0) var ss: Dictionary = seg.summary() _ck(ss.windows.size() == 3 and ss.windows[0].tags.phase == "loading", "tag changes split windows: %s" % [ss.windows]) _ck(ss.segments.size() == 2 and ss.unsegmented_frames == 1, "segment table is capped: %s" % [ss.segments]) _ck(ss.segments[0].max_ms == 300.0 and ss.segments[0].over_100ms == 1, "loading segment is reported separately") _ck(seg.severe_by_phase() == {"loading": 1}, "severe counts by phase: %s" % [seg.severe_by_phase()]) # Markers from the frame that was measured (and the one before it) attribute a long frame. var att: RefCounted = _metrics({"window_seconds": 3600}) att.set_tags({"phase": "play"}) att.frame() _frame(att, 16.0) att.mark("model_build", {"race": 2301, "ms": 70.0}) _frame(att, 16.0) _frame(att, 80.0) _frame(att, 16.0) _frame(att, 16.0) _frame(att, 75.0) var at: Dictionary = att.summary() _ck(at.long_frames.size() == 2 and at.long_frames[0].markers == ["model_build"], "long frame attributed to the recent marker: %s" % [at.long_frames]) _ck(at.long_frames[1].markers.is_empty(), "later long frame without a marker stays unattributed") _ck(att.unattributed_long_frames().size() == 1, "unattributed long frames are listed for analysis") _ck(att.long_unattributed_by_phase() == {"play": 1} and at.long_unattributed_by_phase == {"play": 1}, "unattributed long frames are counted per phase: %s" % [att.long_unattributed_by_phase()]) _ck(at.markers[0].detail == {"race": 2301, "ms": 70.0}, "marker detail keeps only whitelisted keys") att.mark("model_build", {"name": "Hero", "count": 2}) _ck(not att.summary().markers[-1].detail.has("name"), "non-whitelisted marker detail is dropped") _ck([0, 1, 8, 9, 16, 17, 64, 65].map(func(n: int) -> String: return Metrics.actor_bucket(n)) == ["0", "1-8", "1-8", "9-16", "9-16", "17-32", "33-64", "65+"], "actor counts are bucketed") # §9.3: the same interaction repeated 3 times with >100ms in at least 2 blocks. _ck(Metrics.repeated_severe({"ATTACK": [true, false, true], "MOVE": [true, false, false, true], "DROP": [false, true, true]}) == ["ATTACK", "DROP"], "2-of-3 consecutive repetitions with a >100ms frame are flagged") _ck(Metrics.repeated_severe({"ATTACK": [true, true]}).is_empty(), "fewer than 3 repetitions cannot be judged yet") # Memory: MEMORY_STATIC is recorded; GPU memory is never reported as 0. var mem: Dictionary = Metrics.memory_snapshot() _ck(mem.memory_static_kib is int and mem.memory_static_kib > 0, "MEMORY_STATIC in KiB: %s" % [mem]) _ck(mem.gpu_memory_kib == null and mem.gpu_memory_status == "unavailable", "GPU memory is null/unavailable") _ck(mem.godot_video_mem_kib == null or int(mem.godot_video_mem_kib) > 0, "Godot video allocation is null when unknown, not 0") # Probe cost is measured, not assumed. var cost: Dictionary = Metrics.measure_overhead(2000) _ck(cost.iterations == 2000 and cost.per_sample_us > 0.0 and cost.per_sample_us < 1000.0, "per-sample probe cost measured: %s" % [cost]) _done = true