Files
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

293 lines
12 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.
class_name PlayableMetrics
extends RefCounted
## STB-01 §9.1 帧间隔与长帧指标(纯数据,时钟可注入)。
##
## - frame() 每帧调用一次,记录与上一帧的 monotonic 间隔;首个调用只起表。
## - 所有存储都有固定上限:当前窗口的样本在 capacity 大小的环形缓冲里,
## 已关闭窗口 / 长帧事件 / 标记 / 分段表各有上限,超出只计丢弃数,
## 测试自身不会因为长跑而无限增长内存。
## - 窗口按注入时钟(墙钟期限)关闭,或在分段标签变化时关闭;不按帧数。
## 窗口样本超过 capacity 时分位数只来自最近 capacity 个样本并标 truncated
## max 与 >50ms / >100ms 计数始终精确。
## - 分段标签只取 map / phase / actor_count / gpu_skinactor_count 请用 actor_bucket() 分桶。
## - 默认不逐帧打印,只保留长帧事件(>50ms),附带本帧和上一帧内的标记用于归因。
## - GPU 内存拿不到时写 null + "unavailable",从不写 0。
const TAG_KEYS := ["map", "phase", "actor_count", "gpu_skin"]
const LONG_MS := 50.0
const SEVERE_MS := 100.0
## 9.3 标记:同步模型 build、动作 reload、GR2 解析、材质初次使用、实体批量生成、缓存命中等。
const MARKER_DETAIL_KEYS := ["race", "count", "ms", "state", "map", "hits", "misses", "size", "round", "kind"]
const MAX_PENDING_MARKERS := 16
const MAX_MARKER_KIND_LENGTH := 32
var capacity := 4096
var window_us := 60_000_000
var max_windows := 512
var max_long_frames := 512
var max_markers := 1024
var max_segments := 64
var print_frames := false
var clock_us: Callable = func() -> int: return Time.get_ticks_usec()
var _ring := PackedFloat32Array()
var _ring_head := 0
var _ring_len := 0
var _window_count := 0
var _window_over_long := 0
var _window_over_severe := 0
var _window_max := 0.0
var _window_start_us := -1
var _origin_us := -1
var _last_frame_us := -1
var _tags := {}
var _tag_key := ""
var _frames := 0
var _windows: Array = []
var _windows_dropped := 0
var _segments := {}
var _unsegmented_frames := 0
var _severe_by_phase := {}
var _long_unattributed_by_phase := {}
var _long_frames: Array = []
var _long_frames_dropped := 0
var _markers: Array = []
var _markers_dropped := 0
var _pending_markers: Array[String] = []
var _previous_markers: Array[String] = []
func _init() -> void:
_ring.resize(capacity)
func configure(options: Dictionary) -> void:
capacity = maxi(1, int(options.get("capacity", capacity)))
window_us = maxi(1, int(float(options.get("window_seconds", window_us / 1_000_000.0)) * 1_000_000.0))
max_windows = maxi(1, int(options.get("max_windows", max_windows)))
max_long_frames = maxi(1, int(options.get("max_long_frames", max_long_frames)))
max_markers = maxi(1, int(options.get("max_markers", max_markers)))
max_segments = maxi(1, int(options.get("max_segments", max_segments)))
print_frames = bool(options.get("print_frames", false))
_ring.resize(capacity)
_ring_head = 0
_ring_len = 0
## Switches the segment. A different tag set closes the current window first.
func set_tags(tags: Dictionary) -> void:
var normalized := {}
for key in TAG_KEYS:
if tags.has(key):
normalized[key] = String(str(tags[key])).left(80)
var key := JSON.stringify(normalized)
if key == _tag_key:
return
if _window_count > 0:
close_window()
_tags = normalized
_tag_key = key
func tags() -> Dictionary:
return _tags.duplicate()
## Call once per rendered frame. The first call only arms the clock.
func frame(now_us := -1) -> void:
var now := now_us if now_us >= 0 else int(clock_us.call())
if _last_frame_us >= 0:
add_interval_us(now - _last_frame_us, now)
else:
_arm(now)
_last_frame_us = now
## Forget the previous frame time (e.g. before a deliberately unmeasured pause).
func reset_frame_clock() -> void:
_last_frame_us = -1
func add_interval_us(interval_us: int, now_us: int) -> void:
if _origin_us < 0:
_arm(now_us - interval_us)
if _window_start_us < 0:
_window_start_us = now_us - interval_us
var ms := float(interval_us) / 1000.0
_ring[_ring_head] = ms
_ring_head = (_ring_head + 1) % capacity
_ring_len = mini(_ring_len + 1, capacity)
_window_count += 1
_frames += 1
_window_max = maxf(_window_max, ms)
var segment := _segment()
if not segment.is_empty():
segment.frames = int(segment.frames) + 1
segment.max_ms = maxf(float(segment.max_ms), ms)
var attribution: Array[String] = []
for kind in _previous_markers + _pending_markers:
if not (kind in attribution):
attribution.append(kind)
_previous_markers = _pending_markers
_pending_markers = []
if ms > LONG_MS:
_window_over_long += 1
if not segment.is_empty():
segment.over_50ms = int(segment.over_50ms) + 1
_push_bounded(_long_frames, {"t_ms": _t_ms(now_us), "ms": ms, "tags": _tags.duplicate(),
"markers": attribution}, max_long_frames, "_long_frames_dropped")
if attribution.is_empty():
var long_phase := String(_tags.get("phase", ""))
if _long_unattributed_by_phase.has(long_phase) or _long_unattributed_by_phase.size() < max_segments:
_long_unattributed_by_phase[long_phase] = int(_long_unattributed_by_phase.get(long_phase, 0)) + 1
if ms > SEVERE_MS:
_window_over_severe += 1
if not segment.is_empty():
segment.over_100ms = int(segment.over_100ms) + 1
var phase := String(_tags.get("phase", ""))
if _severe_by_phase.has(phase) or _severe_by_phase.size() < max_segments:
_severe_by_phase[phase] = int(_severe_by_phase.get(phase, 0)) + 1
if print_frames:
print("FRAME ms=%.3f tags=%s" % [ms, _tag_key])
if now_us - _window_start_us >= window_us:
close_window(now_us)
## Attribution marker (model build, motion reload, GR2 parse, material first use, entity batch, ...).
func mark(kind: String, detail := {}) -> void:
var name := kind.left(MAX_MARKER_KIND_LENGTH)
var clean := {}
for key in detail:
if String(key) in MARKER_DETAIL_KEYS:
var value: Variant = detail[key]
clean[String(key)] = value if (value is int or value is float or value is bool) else String(str(value)).left(80)
var now := int(clock_us.call())
_push_bounded(_markers, {"t_ms": _t_ms(now), "kind": name, "tags": _tags.duplicate(), "detail": clean},
max_markers, "_markers_dropped")
if _pending_markers.size() < MAX_PENDING_MARKERS and not (name in _pending_markers):
_pending_markers.append(name)
func close_window(now_us := -1) -> void:
if _window_count == 0:
return
var end := now_us if now_us >= 0 else maxi(_last_frame_us, _window_start_us)
var sorted := PackedFloat32Array()
sorted.resize(_ring_len)
for i in _ring_len:
sorted[i] = _ring[(_ring_head - _ring_len + i + capacity) % capacity]
sorted.sort()
var window := {"tags": _tags.duplicate(), "start_ms": _t_ms(_window_start_us), "end_ms": _t_ms(end),
"count": _window_count, "sampled": _ring_len, "truncated": _window_count > _ring_len,
"p50_ms": percentile(sorted, 0.50), "p95_ms": percentile(sorted, 0.95), "p99_ms": percentile(sorted, 0.99),
"max_ms": _window_max, "over_50ms": _window_over_long, "over_100ms": _window_over_severe}
_push_bounded(_windows, window, max_windows, "_windows_dropped")
var segment: Dictionary = _segments.get(_tag_key, {})
if not segment.is_empty():
segment.windows = int(segment.windows) + 1
segment.worst_p99_ms = maxf(float(segment.worst_p99_ms), float(window.p99_ms))
_ring_head = 0
_ring_len = 0
_window_count = 0
_window_over_long = 0
_window_over_severe = 0
_window_max = 0.0
_window_start_us = end
func summary() -> Dictionary:
close_window()
var segments: Array = []
for key in _segments:
segments.append(_segments[key].duplicate(true))
return {"schema_version": 1, "capacity": capacity, "window_seconds": window_us / 1_000_000.0,
"thresholds_ms": [LONG_MS, SEVERE_MS], "print_frames": print_frames, "frames": _frames,
"segments": segments, "unsegmented_frames": _unsegmented_frames,
"windows": _windows.duplicate(true), "windows_dropped": _windows_dropped,
"long_frames": _long_frames.duplicate(true), "long_frames_dropped": _long_frames_dropped,
"markers": _markers.duplicate(true), "markers_dropped": _markers_dropped,
"severe_by_phase": _severe_by_phase.duplicate(),
"long_unattributed_by_phase": _long_unattributed_by_phase.duplicate()}
func stored_sample_count() -> int:
return _ring_len
## Cumulative >100ms counts per phase tag; diff two snapshots to judge one repetition.
func severe_by_phase() -> Dictionary:
return _severe_by_phase.duplicate()
## Exact per-phase count of >50ms frames without a marker (not limited by max_long_frames).
func long_unattributed_by_phase() -> Dictionary:
return _long_unattributed_by_phase.duplicate()
## Retained long frames that have no marker in their frame or the one before. They
## need manual attribution (§9.3); the automated gate never explains them away.
func unattributed_long_frames() -> Array:
return _long_frames.filter(func(item: Dictionary) -> bool: return item.markers.is_empty())
## Nearest-rank percentile of an ascending array.
static func percentile(sorted: PackedFloat32Array, q: float) -> float:
if sorted.is_empty():
return 0.0
var index := clampi(int(ceil(q * sorted.size())) - 1, 0, sorted.size() - 1)
return sorted[index]
static func actor_bucket(count: int) -> String:
if count <= 0:
return "0"
for limit in [8, 16, 32, 64]:
if count <= limit:
return "%d-%d" % [limit / 2 + 1 if limit > 8 else 1, limit]
return "65+"
## §9.3: an interaction repeated at least 3 times where 2 of any 3 consecutive
## repetitions contained a >100ms frame. history: interaction -> Array[bool].
static func repeated_severe(history: Dictionary) -> Array[String]:
var out: Array[String] = []
for key in history:
var runs: Array = history[key]
for i in range(0, runs.size() - 2):
var hits := int(bool(runs[i])) + int(bool(runs[i + 1])) + int(bool(runs[i + 2]))
if hits >= 2:
out.append(String(key))
break
return out
## MEMORY_STATIC is Godot's own allocator counter, not process RSS (the runner
## samples RSS externally). GPU memory has no reliable source here.
static func memory_snapshot() -> Dictionary:
var video := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED))
return {"memory_static_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC)) / 1024,
"memory_static_max_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)) / 1024,
"gpu_memory_kib": null, "gpu_memory_status": "unavailable",
"godot_video_mem_kib": video / 1024 if video > 0 else null,
"godot_video_mem_note": "Godot RenderingDevice allocation counter; not system GPU memory"}
## Cost of one sample on this machine; compare with the frame budget in reports.
static func measure_overhead(iterations := 20000) -> Dictionary:
var probe := new()
probe.configure({"capacity": 4096, "window_seconds": 3600})
probe.set_tags({"map": "overhead", "phase": "probe", "actor_count": "1-8", "gpu_skin": false})
var started := Time.get_ticks_usec()
for i in iterations:
probe.add_interval_us(16_000 + (i % 7) * 1000, (i + 1) * 16_000)
var elapsed := Time.get_ticks_usec() - started
return {"iterations": iterations, "total_us": elapsed, "per_sample_us": float(elapsed) / maxf(1.0, iterations)}
func _arm(now_us: int) -> void:
if _origin_us < 0:
_origin_us = now_us
if _window_start_us < 0:
_window_start_us = now_us
func _t_ms(us: int) -> float:
return float(us - maxi(_origin_us, 0)) / 1000.0
func _segment() -> Dictionary:
if _segments.has(_tag_key):
return _segments[_tag_key]
if _segments.size() >= max_segments:
_unsegmented_frames += 1
return {}
var row := {"tags": _tags.duplicate(), "frames": 0, "windows": 0, "max_ms": 0.0, "worst_p99_ms": 0.0,
"over_50ms": 0, "over_100ms": 0}
_segments[_tag_key] = row
return row
func _push_bounded(list: Array, item: Dictionary, limit: int, dropped_field: String) -> void:
if list.size() >= limit:
list.pop_front()
set(dropped_field, int(get(dropped_field)) + 1)
list.append(item)