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
This commit is contained in:
shenlei
2026-09-11 22:06:48 +09:00
co-authored by Claude Opus 5
parent 374d4165d8
commit f39a55fdd5
66 changed files with 9189 additions and 951 deletions
+195
View File
@@ -0,0 +1,195 @@
extends RefCounted
## MAP-02 地图内验收的机位夹具(test/playable/forest-viewpoints.<name>.local.json)。
##
## 机位必须由测试环境负责人在实服上确认(服务器全局 cm)。未配置的 flat/slope/dense
## 只能由地形/树木密度自动挑“候选”机位:照样截图和测量,但用例保持 BLOCKED,
## 不能冒充“经确认的机位”。传送点无法离线推导,缺配置即 BLOCKED。
const Config = preload("res://testing/playable_config.gd")
const SCHEMA_VERSION := 1
const KINDS := ["flat", "slope", "dense", "warp"]
const AUTO_KINDS := ["flat", "slope", "dense"]
const STATUSES := ["confirmed", "unconfirmed"]
const MAP_CELL_CM := 25600.0
const DEFAULT_YAW_DEG := 45.0
## 自动候选:点位离地图边缘、彼此之间和最近树干的最小距离(米),以及坡地的可行走上限。
const AUTO_EDGE_MARGIN_M := 12.0
const AUTO_SEPARATION_M := 20.0
const AUTO_TREE_CLEARANCE_M := 3.0
const AUTO_DENSITY_RADIUS_M := 12.0
const AUTO_MAX_SLOPE_RATIO := 0.6
static func load_file(path: String) -> Dictionary:
var result := {"ok": false, "data": {}, "errors": []}
if path.strip_edges().is_empty():
result.ok = true # 没有夹具文件:全部机位走候选/BLOCKED,而不是失败
return result
if not FileAccess.file_exists(path):
result.errors.append("viewpoint file does not exist: %s" % path)
return result
var file := FileAccess.open(path, FileAccess.READ)
var parsed: Variant = JSON.parse_string(file.get_as_text()) if file else null
if not (parsed is Dictionary):
result.errors.append("viewpoint file root must be a JSON object")
return result
result.data = parsed
result.ok = true
return result
## bounds = {"base": Vector2 cm, "size": Vector2i tiles}GameScene._map_bounds 的结果)。
## 返回 {errors, viewpoints, missing_kinds, races}viewpoints 带 local_m(地图本地米)。
static func plan(data: Dictionary, map_key: String, bounds: Dictionary) -> Dictionary:
var errors: Array[String] = []
var viewpoints: Array = []
var races: Array = []
var out := {"errors": errors, "viewpoints": viewpoints, "missing_kinds": [], "races": races}
if not data.is_empty():
if not _is_int(data.get("schema_version", null)) or int(data.schema_version) != SCHEMA_VERSION:
errors.append("viewpoint schema_version must be %d" % SCHEMA_VERSION)
if not (data.get("maps", null) is Dictionary):
errors.append("viewpoint maps must be an object keyed by map_key")
Config._reject_secrets(data, "", errors)
var maps: Dictionary = data.get("maps", {}) if data.get("maps", {}) is Dictionary else {}
var entry: Variant = maps.get(map_key, {})
if not (entry is Dictionary):
errors.append("maps.%s must be an object" % map_key)
entry = {}
var seen := {}
var listed: Variant = entry.get("viewpoints", [])
if not (listed is Array):
errors.append("maps.%s.viewpoints must be an array" % map_key)
listed = []
for i in listed.size():
var where := "maps.%s.viewpoints[%d]" % [map_key, i]
var item: Variant = listed[i]
if not (item is Dictionary):
errors.append("%s must be an object" % where)
continue
var id := String(item.get("id", "")).strip_edges()
if id.is_empty() or not id.is_valid_filename() or " " in id:
errors.append("%s.id must be a non-empty file-safe name" % where)
elif seen.has(id):
errors.append("%s.id duplicates %s" % [where, id])
seen[id] = true
var kind := String(item.get("kind", ""))
if not (kind in KINDS):
errors.append("%s.kind must be one of %s" % [where, KINDS])
var status := String(item.get("status", ""))
var confirmed_by := String(item.get("confirmed_by", "")).strip_edges()
if not (status in STATUSES):
errors.append("%s.status must be confirmed or unconfirmed" % where)
elif status == "confirmed" and confirmed_by.is_empty():
errors.append("%s is confirmed but confirmed_by (role) is empty" % where)
elif status == "unconfirmed" and not confirmed_by.is_empty():
errors.append("%s is unconfirmed but carries confirmed_by" % where)
var cm: Variant = item.get("server_cm", null)
var server_cm := Vector2(-1, -1)
if not (cm is Array) or cm.size() != 2 or not _is_int(cm[0]) or not _is_int(cm[1]) \
or int(cm[0]) < 0 or int(cm[1]) < 0:
errors.append("%s.server_cm must be [x,y] non-negative integer centimeters" % where)
else:
server_cm = Vector2(int(cm[0]), int(cm[1]))
if not contains_server_cm(bounds, server_cm):
errors.append("%s.server_cm %s is outside %s" % [where, cm, map_key])
var yaw: Variant = item.get("camera_yaw_deg", DEFAULT_YAW_DEG)
if not (yaw is float or yaw is int):
errors.append("%s.camera_yaw_deg must be a number" % where)
yaw = DEFAULT_YAW_DEG
viewpoints.append({"id": id, "kind": kind, "status": status, "source": "config",
"confirmed_by": confirmed_by, "camera_yaw_deg": float(yaw), "server_cm": server_cm,
"local_m": to_local_m(bounds, server_cm)})
var race_list: Variant = entry.get("races", [])
if not (race_list is Array):
errors.append("maps.%s.races must be an array of mob vnums" % map_key)
else:
for value in race_list:
if not _is_int(value) or int(value) <= 0:
errors.append("maps.%s.races must contain positive integers" % map_key)
break
races.append(int(value))
for kind in KINDS:
if not viewpoints.any(func(v: Dictionary) -> bool: return v.kind == kind):
out.missing_kinds.append(kind)
return out
static func contains_server_cm(bounds: Dictionary, server_cm: Vector2) -> bool:
if bounds.is_empty():
return false
var base: Vector2 = bounds.base
var size: Vector2i = bounds.size
return server_cm.x >= base.x and server_cm.y >= base.y \
and server_cm.x < base.x + size.x * MAP_CELL_CM and server_cm.y < base.y + size.y * MAP_CELL_CM
## 服务器全局 cm -> Metin2World 地图本地米(与 MapCoord.to_world 一致:+Z 朝南)。
static func to_local_m(bounds: Dictionary, server_cm: Vector2) -> Vector2:
var base: Vector2 = bounds.get("base", Vector2.ZERO)
return (server_cm - base) * 0.01
static func to_server_cm(bounds: Dictionary, local_m: Vector2) -> Vector2:
var base: Vector2 = bounds.get("base", Vector2.ZERO)
return Vector2(roundf(local_m.x * 100.0 + base.x), roundf(local_m.y * 100.0 + base.y))
## samples: [{p: Vector2 本地米, range_m: 足迹内地形高差, span_m: 足迹宽度, blocked: bool}]
## trees: 树干本地米坐标。只挑 kinds 里请求的种类;挑不出来的种类不返回(调用方记 BLOCKED)。
static func pick_candidates(samples: Array, trees: PackedVector2Array, size_m: Vector2, kinds: Array) -> Array:
var usable: Array = []
for s: Dictionary in samples:
var p: Vector2 = s.p
if bool(s.blocked) or p.x < AUTO_EDGE_MARGIN_M or p.y < AUTO_EDGE_MARGIN_M \
or p.x > size_m.x - AUTO_EDGE_MARGIN_M or p.y > size_m.y - AUTO_EDGE_MARGIN_M:
continue
var nearest := INF
var density := 0
for t in trees:
var d := t.distance_to(p)
nearest = minf(nearest, d)
if d <= AUTO_DENSITY_RADIUS_M:
density += 1
if nearest < AUTO_TREE_CLEARANCE_M:
continue
var slope := float(s.range_m) / maxf(float(s.span_m), 0.001)
usable.append({"p": p, "range_m": float(s.range_m), "slope_ratio": slope, "density": density})
var chosen: Array = []
var centre := size_m * 0.5
for kind in ["flat", "dense", "slope"]:
if not (kind in kinds):
continue
var best: Dictionary = {}
for u: Dictionary in usable:
if chosen.any(func(c: Dictionary) -> bool: return (c.p as Vector2).distance_to(u.p) < AUTO_SEPARATION_M):
continue
if best.is_empty() or _better(kind, u, best, centre):
best = u
if best.is_empty():
continue
if kind == "slope" and float(best.slope_ratio) <= 0.0:
continue
if kind == "dense" and int(best.density) == 0:
continue
var c := best.duplicate()
c["kind"] = kind
chosen.append(c)
return chosen
static func _better(kind: String, a: Dictionary, b: Dictionary, centre: Vector2) -> bool:
match kind:
"flat":
if not is_equal_approx(float(a.range_m), float(b.range_m)):
return float(a.range_m) < float(b.range_m)
return (a.p as Vector2).distance_to(centre) < (b.p as Vector2).distance_to(centre)
"slope":
var a_ok := float(a.slope_ratio) <= AUTO_MAX_SLOPE_RATIO
var b_ok := float(b.slope_ratio) <= AUTO_MAX_SLOPE_RATIO
if a_ok != b_ok:
return a_ok
return float(a.slope_ratio) > float(b.slope_ratio) if a_ok else float(a.slope_ratio) < float(b.slope_ratio)
"dense":
if int(a.density) != int(b.density):
return int(a.density) > int(b.density)
return float(a.range_m) < float(b.range_m)
return false
static func _is_int(value: Variant) -> bool:
return value is int or (value is float and is_equal_approx(value, roundf(value)))
+393 -32
View File
@@ -12,6 +12,17 @@ const REQUIRED_KEYS := [
"waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases",
"resolution", "loops", "timeout_seconds",
]
const SERVER_KEYS := ["server_index", "channel", "auth_host", "auth_port", "game_host", "game_port"]
const SUITES := ["playable", "full", "soak"]
const MAP_CELL_CM := 25600.0
const GOLD_VNUM := 1
## 技能证据分两类:只有接收端分发(M2Client 信号)才算服务端结果。
## 本地施法开始、特效节点生成只能作为补充证据,单独出现不能让用例 PASS。
const SERVER_EVIDENCE := ["damage", "target_dead", "server_motion", "effect_cue",
"fly_cue", "affect_added", "sp_changed"]
const LOCAL_EVIDENCE := ["cast_started", "fx_spawned", "fx_finished"]
const SKILL_TARGETS := ["enemy", "self", "ground"]
static func load_file(path: String) -> Dictionary:
var result := {"ok": false, "config": {}, "errors": []}
@@ -41,7 +52,7 @@ static func validate(config: Dictionary) -> Dictionary:
for key in REQUIRED_KEYS:
if not config.has(key):
errors.append("missing required key: %s" % key)
if int(config.get("schema_version", -1)) != SCHEMA_VERSION:
if not _is_int(config.get("schema_version", null)) or int(config.get("schema_version", -1)) != SCHEMA_VERSION:
errors.append("schema_version must be %d" % SCHEMA_VERSION)
if String(config.get("scenario_id", "")).strip_edges().is_empty():
errors.append("scenario_id must not be empty")
@@ -52,21 +63,35 @@ static func validate(config: Dictionary) -> Dictionary:
if not (server is Dictionary):
errors.append("server must be an object selected from serverinfo")
else:
for key in ["server_index", "channel_index"]:
if not server.has(key) or int(server[key]) < 0:
errors.append("server.%s must be a non-negative integer" % key)
if not config.has("character_slot") or int(config.get("character_slot", -1)) < 0:
for key in SERVER_KEYS:
if not server.has(key):
errors.append("server.%s is missing; copy the address selected from serverinfo" % key)
if not _is_int(server.get("server_index", null)) or int(server.get("server_index", -1)) < 0:
errors.append("server.server_index must be a non-negative integer")
if not _is_int(server.get("channel", null)) or int(server.get("channel", 0)) < 1:
errors.append("server.channel must be a channel number (>= 1)")
for key in ["auth_host", "game_host"]:
if String(server.get(key, "")).strip_edges().is_empty():
errors.append("server.%s must not be empty" % key)
for key in ["auth_port", "game_port"]:
if not _is_int(server.get(key, null)) or int(server.get(key, 0)) <= 0 or int(server.get(key, 0)) > 65535:
errors.append("server.%s must be 1..65535" % key)
if not _is_int(config.get("character_slot", null)) or int(config.get("character_slot", -1)) < 0:
errors.append("character_slot must be a non-negative integer")
if String(config.get("map_key", "")).strip_edges().is_empty():
var map_key := String(config.get("map_key", "")).strip_edges()
if map_key.is_empty():
errors.append("map_key is empty; resolve it from the server/map fixture")
elif map_key.begins_with("/") or ".." in map_key.split("/") or "\\" in map_key:
errors.append("map_key must be a relative asset directory such as outdoortrent/metin2_map_trent")
var waypoints: Variant = config.get("waypoints_cm", null)
if not (waypoints is Array) or waypoints.is_empty():
errors.append("waypoints_cm must contain at least one [x,y] centimeter point")
else:
for i in waypoints.size():
var point: Variant = waypoints[i]
if not (point is Array) or point.size() != 2 or not _is_number(point[0]) or not _is_number(point[1]):
errors.append("waypoints_cm[%d] must be [x,y] numbers" % i)
if not (point is Array) or point.size() != 2 or not _is_int(point[0]) or not _is_int(point[1]) \
or int(point[0]) < 0 or int(point[1]) < 0:
errors.append("waypoints_cm[%d] must be [x,y] non-negative integer centimeters" % i)
var mobs: Variant = config.get("allowed_mob_vnums", null)
if not (mobs is Array) or mobs.is_empty():
errors.append("allowed_mob_vnums must contain confirmed server mob vnums")
@@ -77,10 +102,347 @@ static func validate(config: Dictionary) -> Dictionary:
errors.append("allowed_drop_vnums must contain a deterministic pickup fixture")
else:
_validate_positive_int_array(drops, "allowed_drop_vnums", errors)
var skills: Variant = config.get("skill_cases", null)
for value in drops:
if _is_int(value) and int(value) == GOLD_VNUM:
errors.append("allowed_drop_vnums must not contain gold (vnum 1); money is a separate points case")
_validate_skill_cases(config.get("skill_cases", null), errors)
var resolution: Variant = config.get("resolution", null)
if not (resolution is Array) or resolution.size() != 2 or not _is_int(resolution[0]) or not _is_int(resolution[1]) \
or int(resolution[0]) <= 0 or int(resolution[1]) <= 0:
errors.append("resolution must be [width,height]")
if not _is_int(config.get("loops", null)) or int(config.get("loops", 0)) < 1:
errors.append("loops must be at least 1")
if not _is_int(config.get("timeout_seconds", null)) or int(config.get("timeout_seconds", 0)) < 1:
errors.append("timeout_seconds must be positive")
# This config is deliberately data-only. Reject the common accidental secret paths anywhere in the tree.
_reject_secrets(config, "", errors)
return {"ok": errors.is_empty(), "errors": errors}
## test/playable/skill-cases.json:每职业 × {单体, 范围, 自身增益, 飞行效果} 的实际技能矩阵。
## 条目只能是 unconfirmed(缺夹具 -> BLOCKED)、confirmed(环境负责人确认的已学技能)或
## not_applicable(附清单确认记录)。脚本从不补技能 ID;confirmed 的 skill_id 必须属于该职业
## 且不是被动技,每技能至少成功施放 SKILL_MATRIX_REPEATS 次。
const SKILL_MATRIX_TYPES := ["single_target", "area", "self_buff", "flying"]
const SKILL_MATRIX_JOBS := ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"]
const SKILL_MATRIX_REPEATS := 10
static func skill_matrix_cases(matrix: Dictionary, job: String, skill_table: RefCounted = null) -> Dictionary:
var errors: Array[String] = []
var blocked: Array[String] = []
var not_applicable: Array[String] = []
var cases: Array = []
var result := {"errors": errors, "blocked": blocked, "not_applicable": not_applicable, "cases": cases}
if not _is_int(matrix.get("schema_version", null)) or int(matrix.get("schema_version", -1)) != SCHEMA_VERSION:
errors.append("skill matrix schema_version must be %d" % SCHEMA_VERSION)
var jobs: Variant = matrix.get("jobs", null)
if not (jobs is Dictionary):
errors.append("skill matrix jobs must be an object")
return result
for name in jobs:
if not (String(name) in SKILL_MATRIX_JOBS):
errors.append("skill matrix has unknown job: %s" % name)
if not (job in SKILL_MATRIX_JOBS) or not (jobs.get(job, null) is Dictionary):
errors.append("skill matrix has no entry for job %s" % job)
return result
var row: Dictionary = jobs[job]
for type in row:
if not (String(type) in SKILL_MATRIX_TYPES):
errors.append("%s has unknown skill type: %s" % [job, type])
for type in SKILL_MATRIX_TYPES:
var where := "%s/%s" % [job, type]
var entry: Variant = row.get(type, null)
if not (entry is Dictionary):
errors.append("%s is missing" % where)
continue
match String(entry.get("status", "")):
"unconfirmed":
if entry.has("skill_id"):
errors.append("%s is unconfirmed but carries a skill_id" % where)
blocked.append(where)
"not_applicable":
if String(entry.get("checklist_ref", "")).strip_edges().is_empty():
errors.append("%s not_applicable needs checklist_ref" % where)
not_applicable.append(where)
"confirmed":
var skill_id := int(entry.skill_id) if _is_int(entry.get("skill_id", null)) else 0
if skill_table != null and skill_id > 0:
if String(skill_table.category_of(skill_id)) != job:
errors.append("%s skill_id %d does not belong to %s" % [where, skill_id, job])
elif skill_table.is_passive(skill_id):
errors.append("%s skill_id %d is passive" % [where, skill_id])
if entry.has("repeats") and _is_int(entry.repeats) and int(entry.repeats) < SKILL_MATRIX_REPEATS:
errors.append("%s repeats must be at least %d" % [where, SKILL_MATRIX_REPEATS])
cases.append({"case_id": "%s-%s" % [job.to_lower(), type.replace("_", "-")],
"skill_id": entry.get("skill_id", null), "target": entry.get("target", "enemy"),
"repeats": entry.get("repeats", SKILL_MATRIX_REPEATS),
"required_evidence": entry.get("required_evidence", null)})
_:
errors.append("%s status must be unconfirmed, confirmed or not_applicable" % where)
if not cases.is_empty():
_validate_skill_cases(cases, errors)
elif blocked.is_empty():
errors.append("%s has no confirmed skill case" % job)
return result
## 必测用例清单由配置推导,客户端与父运行器共用同一份结果,
## 运行器在启动前落盘,校验器据此拒绝“少报一个用例”的 PASS。
static func required_cases(config: Dictionary, suite: String) -> Array[String]:
var out: Array[String] = ["CONFIG-01", "NET-LOGIN-01", "NET-SELECT-01", "NET-WORLD-01"]
var waypoints: Variant = config.get("waypoints_cm", [])
if waypoints is Array:
for i in waypoints.size():
out.append("NET-MOVE-%02d" % (i + 1))
out.append_array(["NET-MOVE-CONFIRM-01", "NET-TARGET-01", "NET-ATTACK-01", "NET-DROP-01", "NET-PICKUP-01"])
if suite == "full":
var skills: Variant = config.get("skill_cases", [])
if skills is Array:
for item in skills:
if item is Dictionary:
out.append(skill_case_id(item))
if suite == "soak":
# STB-MEMORY-01 is a runner case (external RSS sampler); exits are counted by the runner.
out.append_array(SOAK_CASES)
return out
## STB-01 §9.2 soak block. The minimums are the release acceptance numbers, not
## defaults: a shorter or smaller soak is rejected instead of silently reported as PASS.
## Warp routes and real transport faults need environment-owner confirmation; the
## unconfirmed status is valid config but the matching case stays BLOCKED.
const SOAK_CASES := ["STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01",
"STB-DISCONNECT-01", "STB-RESIZE-01"]
const SOAK_MIN_DURATION_S := 7200
const SOAK_MIN_REST_S := 30
const SOAK_MIN_ROUNDS := 10
const SOAK_MIN_RECONNECTS := 10
const SOAK_MIN_EXITS := 10
const SOAK_MIN_WARPS := 20
const SOAK_RESOLUTION_COUNT := 3
const SOAK_MIN_SWITCHES := 10
const SOAK_TIMEOUT_MARGIN_S := 900
const SOAK_MAX_UNREACHABLE_S := 20
const SOAK_FAULT_TYPES := ["close", "unreachable"]
## soak_client=false validates a soak block seen by another suite (the playable exit runs
## sharing the scenario): everything except the client timeout margin, which only the
## 2 h soak client needs.
static func validate_soak(config: Dictionary, soak_client := true) -> Dictionary:
var errors: Array[String] = []
var soak: Variant = config.get("soak", null)
if not (soak is Dictionary):
errors.append("soak must be an object for the soak suite")
return {"ok": false, "errors": errors}
var minimums := {"duration_seconds": SOAK_MIN_DURATION_S, "rest_seconds": SOAK_MIN_REST_S, "warmup_rounds": 0,
"min_rounds": SOAK_MIN_ROUNDS, "reconnects": SOAK_MIN_RECONNECTS, "exits": SOAK_MIN_EXITS}
for key in minimums:
if not _is_int(soak.get(key, null)) or int(soak.get(key, -1)) < int(minimums[key]):
errors.append("soak.%s must be an integer >= %d" % [key, minimums[key]])
var duration := int(soak.get("duration_seconds", 0)) if _is_int(soak.get("duration_seconds", null)) else 0
if soak_client and _is_int(config.get("timeout_seconds", null)) and int(config.timeout_seconds) < duration + SOAK_TIMEOUT_MARGIN_S:
errors.append("timeout_seconds must be at least soak.duration_seconds + %d" % SOAK_TIMEOUT_MARGIN_S)
var resolutions: Variant = soak.get("resolutions", null)
if not (resolutions is Dictionary):
errors.append("soak.resolutions must be an object")
else:
var sizes: Variant = resolutions.get("sizes", null)
var seen := {}
if not (sizes is Array) or sizes.size() != SOAK_RESOLUTION_COUNT:
errors.append("soak.resolutions.sizes must list exactly %d logical sizes" % SOAK_RESOLUTION_COUNT)
else:
for i in sizes.size():
var size: Variant = sizes[i]
if not (size is Array) or size.size() != 2 or not _is_int(size[0]) or not _is_int(size[1]) \
or int(size[0]) <= 0 or int(size[1]) <= 0:
errors.append("soak.resolutions.sizes[%d] must be [width,height] logical points" % i)
continue
var key := "%dx%d" % [int(size[0]), int(size[1])]
if seen.has(key):
errors.append("soak.resolutions.sizes[%d] duplicates %s" % [i, key])
seen[key] = true
if not _is_int(resolutions.get("switches_per_size", null)) or int(resolutions.get("switches_per_size", 0)) < SOAK_MIN_SWITCHES:
errors.append("soak.resolutions.switches_per_size must be >= %d" % SOAK_MIN_SWITCHES)
var warp: Variant = soak.get("warp", null)
if not (warp is Dictionary):
errors.append("soak.warp must be an object with status unconfirmed|confirmed")
else:
match String(warp.get("status", "")):
"unconfirmed":
pass
"confirmed":
if not _is_int(warp.get("required", null)) or int(warp.get("required", 0)) < SOAK_MIN_WARPS or int(warp.get("required", 0)) % 2 != 0:
errors.append("soak.warp.required must be an even integer >= %d (out-and-back legs)" % SOAK_MIN_WARPS)
for key in ["portal_cm", "return_portal_cm"]:
if not _is_cm_point(warp.get(key, null)):
errors.append("soak.warp.%s must be [x,y] non-negative integer centimeters" % key)
var destination := String(warp.get("destination_map_key", "")).strip_edges()
if destination.is_empty() or destination.begins_with("/") or ".." in destination.split("/") or "\\" in destination:
errors.append("soak.warp.destination_map_key must be a relative asset directory")
elif destination == String(config.get("map_key", "")):
errors.append("soak.warp.destination_map_key must differ from map_key")
if not (warp.get("cross_server", null) is bool):
errors.append("soak.warp.cross_server must be true or false as verified on the test server")
_:
errors.append("soak.warp.status must be unconfirmed or confirmed")
var faults: Variant = soak.get("faults", null)
if not (faults is Dictionary):
errors.append("soak.faults must be an object with status unconfirmed|confirmed")
else:
match String(faults.get("status", "")):
"unconfirmed":
pass
"confirmed":
# Only a test-connection proxy on 127.0.0.1; never global routes or firewall rules.
if String(faults.get("mode", "")) != "local_proxy":
errors.append("soak.faults.mode must be local_proxy")
if not _is_int(faults.get("per_type", null)) or int(faults.get("per_type", 0)) < 1:
errors.append("soak.faults.per_type must be a positive integer")
var seconds: Variant = faults.get("unreachable_seconds", null)
if not _is_int(seconds) or int(seconds) < 1 or int(seconds) > SOAK_MAX_UNREACHABLE_S:
errors.append("soak.faults.unreachable_seconds must be 1..%d" % SOAK_MAX_UNREACHABLE_S)
_check_fault_upstream(config, faults.get("upstream", null), errors)
_:
errors.append("soak.faults.status must be unconfirmed or confirmed")
_reject_secrets(soak, "soak.", errors)
return {"ok": errors.is_empty(), "errors": errors}
## The proxy (script/playable_fault_proxy.mjs) listens on server.* which must be loopback,
## and forwards to upstream (the real test server). It only carries this client's connection.
static func _check_fault_upstream(config: Dictionary, upstream: Variant, errors: Array[String]) -> void:
var server: Variant = config.get("server", {})
if not (server is Dictionary):
return
for key in ["auth_host", "game_host"]:
if String(server.get(key, "")) != "127.0.0.1":
errors.append("soak.faults local_proxy needs server.%s = 127.0.0.1 (the proxy listen address)" % key)
if not (upstream is Dictionary):
errors.append("soak.faults.upstream must give auth_host/auth_port/game_host/game_port of the real test server")
return
for role in ["auth", "game"]:
var host := String(upstream.get(role + "_host", "")).strip_edges()
var port: Variant = upstream.get(role + "_port", null)
if host.is_empty() or " " in host:
errors.append("soak.faults.upstream.%s_host is missing" % role)
if not _is_int(port) or int(port) < 1 or int(port) > 65535:
errors.append("soak.faults.upstream.%s_port must be 1..65535" % role)
elif host == String(server.get(role + "_host", "")) and int(port) == int(server.get(role + "_port", -1)):
errors.append("soak.faults.upstream.%s points at the proxy itself" % role)
static func _is_cm_point(point: Variant) -> bool:
return point is Array and point.size() == 2 and _is_int(point[0]) and _is_int(point[1]) \
and int(point[0]) >= 0 and int(point[1]) >= 0
static func skill_case_id(item: Dictionary) -> String:
return "CBT-%s" % String(item.get("case_id", "")).strip_edges()
## Resolve the configured address through the same ServerInfo object the
## client uses. The config must copy the resolved address verbatim; any drift
## between the precheck target and the client connect target is an error.
static func resolve_server(config: Dictionary, serverinfo: RefCounted) -> Dictionary:
var errors: Array[String] = []
var server: Dictionary = config.get("server", {}) if config.get("server", null) is Dictionary else {}
var index := int(server.get("server_index", -1))
var channel := int(server.get("channel", 0))
var entry: Dictionary = serverinfo.server(index) if serverinfo != null else {}
if entry.is_empty():
errors.append("server_index %d is not present in serverinfo" % index)
return {"ok": false, "address": {}, "errors": errors}
var channels: Array = entry.get("channels", [])
if not (channel in channels):
errors.append("channel %d is not listed by serverinfo server %d" % [channel, index])
var address: Dictionary = serverinfo.address(index, channel)
for key in ["auth_host", "game_host"]:
if String(server.get(key, "")) != String(address.get(key, "")):
errors.append("server.%s differs from serverinfo" % key)
for key in ["auth_port", "game_port"]:
if int(server.get(key, -1)) != int(address.get(key, -2)):
errors.append("server.%s differs from serverinfo" % key)
return {"ok": errors.is_empty(), "errors": errors, "address": {
"server_index": index, "channel": channel,
"auth_host": String(address.get("auth_host", "")), "auth_port": int(address.get("auth_port", 0)),
"game_host": String(address.get("game_host", "")), "game_port": int(address.get("game_port", 0)),
}}
## Resource preconditions: the map must exist in the asset root and every
## waypoint must fall inside the same Setting.txt rectangle GameScene uses to
## resolve a map from the server position.
static func check_resources(config: Dictionary, assets_root: String) -> Dictionary:
var errors: Array[String] = []
var root := assets_root.trim_suffix("/")
var map_key := String(config.get("map_key", ""))
var setting := root.path_join(map_key).path_join("Setting.txt")
if root.is_empty() or not DirAccess.dir_exists_absolute(root):
errors.append("asset root does not exist")
return {"ok": false, "errors": errors, "bounds": {}}
if not FileAccess.file_exists(setting):
errors.append("map_key has no Setting.txt in the asset root: %s" % map_key)
return {"ok": false, "errors": errors, "bounds": {}}
var bounds := map_bounds(setting)
if bounds.is_empty():
errors.append("map Setting.txt lacks BasePosition/MapSize: %s" % map_key)
return {"ok": false, "errors": errors, "bounds": {}}
var base: Vector2 = bounds.base
var size: Vector2i = bounds.size
var waypoints: Array = config.get("waypoints_cm", [])
for i in waypoints.size():
var point: Array = waypoints[i]
var x := float(point[0])
var y := float(point[1])
if x < base.x or y < base.y or x >= base.x + size.x * MAP_CELL_CM or y >= base.y + size.y * MAP_CELL_CM:
errors.append("waypoints_cm[%d] is outside %s bounds" % [i, map_key])
return {"ok": errors.is_empty(), "errors": errors,
"bounds": {"base_cm": [base.x, base.y], "size_cells": [size.x, size.y]}}
static func map_bounds(setting_path: String) -> Dictionary:
var f := FileAccess.open(setting_path, FileAccess.READ)
if f == null:
return {}
var base := Vector2.ZERO
var size := Vector2i.ZERO
var have_base := false
var have_size := false
while not f.eof_reached():
var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false)
if fields.size() < 3:
continue
match String(fields[0]).to_lower():
"baseposition":
base = Vector2(float(fields[1]), float(fields[2]))
have_base = true
"mapsize":
size = Vector2i(int(fields[1]), int(fields[2]))
have_size = size.x > 0 and size.y > 0
if not have_base or not have_size:
return {}
return {"base": base, "size": size}
static func _reject_secrets(value: Variant, prefix: String, errors: Array[String]) -> void:
if value is Dictionary:
for key in value:
var name := String(key).to_lower()
for forbidden in ["account", "username", "user_id", "login", "password", "passwd", "token", "secret"]:
if name == forbidden or name.ends_with("_" + forbidden):
errors.append("credentials must not be stored in config: %s%s" % [prefix, key])
break
_reject_secrets(value[key], "%s%s." % [prefix, key], errors)
elif value is Array:
for i in value.size():
_reject_secrets(value[i], "%s%d." % [prefix, i], errors)
static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void:
for i in values.size():
if not _is_int(values[i]) or int(values[i]) <= 0:
errors.append("%s[%d] must be a positive integer" % [name, i])
## JSON numbers arrive as float; accept only integral values.
static func _is_int(value: Variant) -> bool:
if value is int:
return true
return value is float and is_finite(value) and float(value) == floorf(value)
static func _validate_skill_cases(skills: Variant, errors: Array[String]) -> void:
if not (skills is Array) or skills.is_empty():
errors.append("skill_cases must contain at least one confirmed skill case")
else:
var seen := {}
for i in skills.size():
if not (skills[i] is Dictionary):
errors.append("skill_cases[%d] must be an object" % i)
@@ -89,29 +451,28 @@ static func validate(config: Dictionary) -> Dictionary:
for key in ["case_id", "skill_id", "required_evidence"]:
if not item.has(key):
errors.append("skill_cases[%d] missing %s" % [i, key])
if String(item.get("case_id", "")).strip_edges().is_empty():
var case_id := String(item.get("case_id", "")).strip_edges()
if case_id.is_empty():
errors.append("skill_cases[%d].case_id is empty" % i)
if int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255:
elif seen.has(case_id):
errors.append("skill_cases[%d].case_id is duplicated: %s" % [i, case_id])
seen[case_id] = true
if not _is_int(item.get("skill_id", null)) or int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255:
errors.append("skill_cases[%d].skill_id must be 1..254" % i)
if not (item.get("required_evidence", null) is Array) or item.required_evidence.is_empty():
if item.has("target") and not (String(item.target) in SKILL_TARGETS):
errors.append("skill_cases[%d].target must be one of %s" % [i, SKILL_TARGETS])
if item.has("repeats") and (not _is_int(item.repeats) or int(item.repeats) < 1):
errors.append("skill_cases[%d].repeats must be a positive integer" % i)
var evidence: Variant = item.get("required_evidence", null)
if not (evidence is Array) or evidence.is_empty():
errors.append("skill_cases[%d].required_evidence must be non-empty" % i)
var resolution: Variant = config.get("resolution", null)
if not (resolution is Array) or resolution.size() != 2 or int(resolution[0]) <= 0 or int(resolution[1]) <= 0:
errors.append("resolution must be [width,height]")
if int(config.get("loops", 0)) < 1:
errors.append("loops must be at least 1")
if int(config.get("timeout_seconds", 0)) < 1:
errors.append("timeout_seconds must be positive")
# This config is deliberately data-only. Reject the common accidental secret paths.
for forbidden in ["account", "username", "password", "token", "secret"]:
if config.has(forbidden):
errors.append("credentials must not be stored in config: %s" % forbidden)
return {"ok": errors.is_empty(), "errors": errors}
static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void:
for i in values.size():
if not _is_number(values[i]) or int(values[i]) <= 0 or float(values[i]) != float(int(values[i])):
errors.append("%s[%d] must be a positive integer" % [name, i])
static func _is_number(value: Variant) -> bool:
return value is int or value is float
continue
var has_server := false
for kind in evidence:
var name := String(kind)
if name in SERVER_EVIDENCE:
has_server = true
elif not (name in LOCAL_EVIDENCE):
errors.append("skill_cases[%d].required_evidence has unknown kind: %s" % [i, name])
if not has_server:
errors.append("skill_cases[%d].required_evidence needs at least one server-side kind %s" % [i, SERVER_EVIDENCE])
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
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)
+174 -51
View File
@@ -3,113 +3,236 @@ extends Node
## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入,
## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。
##
## 每条观察都带 source
## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调)
## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据
##
## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次
## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch
## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。
signal observed(kind: String, payload: Dictionary)
var client: Node
var flow: Node
var connection_epoch := 0
var _connections: Array[Dictionary] = []
const SERVER_SIGNALS := {
"entered_game": "_on_entered_game",
"disconnected": "_on_disconnected",
"login_failed": "_on_login_failed",
"char_list": "_on_char_list",
"entity_main_set": "_on_entity_main_set",
"entity_spawned": "_on_entity_spawned",
"entity_despawned": "_on_entity_despawned",
"entity_moved": "_on_entity_moved",
"entity_dead": "_on_entity_dead",
"damage": "_on_damage",
"motion": "_on_motion",
"vitals_changed": "_on_vitals",
"points_changed": "_on_points_changed",
"target_info": "_on_target_info",
"ground_item_added": "_on_ground_item_added",
"ground_item_removed": "_on_ground_item_removed",
"item_picked_up": "_on_item_picked_up",
"inventory_changed": "_on_inventory_changed",
"affect_added": "_on_affect_added",
"effect_cue": "_on_effect_cue",
"fly_cue": "_on_fly_cue",
"fly_targeting": "_on_fly_targeting",
}
func setup(app_flow: Node, m2client: Node) -> void:
var client: Object
var flow: Object
var connection_epoch := 1
var _entered_in_epoch := false
var _connections: Array[Dictionary] = []
var _last_sp := -1
func setup(app_flow: Object, m2client: Object) -> void:
flow = app_flow
client = m2client
if client == null:
return
_bind("entered_game", Callable(self, "_on_entered_game"))
_bind("disconnected", Callable(self, "_on_disconnected"))
_bind("char_list", Callable(self, "_on_char_list"))
_bind("entity_spawned", Callable(self, "_on_entity_spawned"))
_bind("entity_despawned", Callable(self, "_on_entity_despawned"))
_bind("entity_moved", Callable(self, "_on_entity_moved"))
_bind("entity_dead", Callable(self, "_on_entity_dead"))
_bind("damage", Callable(self, "_on_damage"))
_bind("vitals_changed", Callable(self, "_on_vitals"))
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
_bind("effect_cue", Callable(self, "_on_effect_cue"))
_bind("fly_cue", Callable(self, "_on_fly_cue"))
_bind("fly_targeting", Callable(self, "_on_fly_targeting"))
for signal_name in SERVER_SIGNALS:
_bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name]))
## Subscribe to a local presentation object (quickbar / skill fx). Its events
## are tagged source=local and never satisfy a server-evidence requirement.
func watch_local(source: Object, signal_name: String, kind: String) -> void:
if source == null or not source.has_signal(signal_name):
return
var callback := func(a: Variant = null, b: Variant = null, c: Variant = null,
d: Variant = null, e: Variant = null) -> void:
_emit_local(kind, [a, b, c, d, e])
_bind(source, signal_name, callback)
func snapshot() -> Dictionary:
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0
var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0
var state := int(flow.state()) if flow != null and flow.has_method("state") else -1
var alive := client != null and is_instance_valid(client)
var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game()
var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0
var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0
var ground_count: int = client.get_ground_items().size() if alive and client.has_method("get_ground_items") else 0
var flow_alive := flow != null and is_instance_valid(flow)
var state := int(flow.state()) if flow_alive and flow.has_method("state") else -1
return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count,
"ground_item_count": ground_count, "app_state": state, "connection_epoch": connection_epoch}
"ground_item_count": ground_count, "app_state": state}
func disconnect_all() -> void:
for entry in _connections:
var source: Object = entry.source
var signal_name: String = entry.signal
var callback: Callable = entry.callback
if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback):
client.disconnect(signal_name, callback)
if source != null and is_instance_valid(source) and source.has_signal(signal_name) \
and source.is_connected(signal_name, callback):
source.disconnect(signal_name, callback)
_connections.clear()
func bound_count() -> int:
return _connections.size()
func _exit_tree() -> void:
disconnect_all()
func _bind(signal_name: String, callback: Callable) -> void:
if client.has_signal(signal_name) and not client.is_connected(signal_name, callback):
client.connect(signal_name, callback)
_connections.append({"signal": signal_name, "callback": callback})
func _bind(source: Object, signal_name: String, callback: Callable) -> void:
if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
source.connect(signal_name, callback)
_connections.append({"source": source, "signal": signal_name, "callback": callback})
func _emit(kind: String, payload := {}) -> void:
func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void:
var data: Dictionary = payload.duplicate(true)
data["source"] = "server"
data["connection_epoch"] = connection_epoch
data["actor_vid"] = actor_vid
data["target_vid"] = target_vid
data["received_us"] = Time.get_ticks_usec()
observed.emit(kind, data)
func _emit_local(kind: String, args: Array) -> void:
var data := {"source": "local", "connection_epoch": connection_epoch,
"actor_vid": 0, "target_vid": 0, "received_us": Time.get_ticks_usec()}
match kind:
"cast_started":
data["skill_id"] = int(args[0]) if args[0] != null else 0
data["target_vid"] = int(args[1]) if args[1] != null else 0
"skill_rejected":
data["skill_id"] = int(args[0]) if args[0] != null else 0
data["code"] = String(args[1]) if args[1] != null else ""
"fx_spawned", "fx_finished":
# EffectRegistry lifecycle: (effect, fx_id, lifetime_ms[, elapsed_ms, reason]).
data["effect"] = String(args[0]).get_file().get_basename().left(80) if args[0] != null else ""
data["fx_id"] = int(args[1]) if args[1] != null else 0
data["lifetime_ms"] = int(args[2]) if args[2] != null else -1
if kind == "fx_finished":
data["elapsed_ms"] = int(args[3]) if args[3] != null else -1
data["reason"] = String(args[4]) if args[4] != null else ""
_:
if args[0] is int:
data["skill_id"] = args[0]
observed.emit(kind, data)
func _on_entered_game() -> void:
connection_epoch += 1
if _entered_in_epoch:
connection_epoch += 1
_entered_in_epoch = true
_last_sp = -1
_emit("entered_game")
func _on_disconnected(reason: String) -> void:
_emit("disconnected", {"reason": reason})
_emit("disconnected", {"reason": reason.left(120)})
connection_epoch += 1
_entered_in_epoch = false
_last_sp = -1
func _on_login_failed(reason: String) -> void:
_emit("login_failed", {"reason": reason.left(120)})
func _on_char_list(chars: Array) -> void:
_emit("char_list", {"count": chars.size()})
var slots: Array = []
for character in chars:
if character is Dictionary:
slots.append(int(character.get("index", -1)))
_emit("char_list", {"count": chars.size(), "slots": slots})
func _on_entity_main_set(vid: int) -> void:
_emit("entity_main_set", {}, vid)
func _on_entity_spawned(entity: Dictionary) -> void:
_emit("entity_spawned", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))})
_emit("entity_spawned", {"race": int(entity.get("race", 0)), "ch_type": int(entity.get("ch_type", -1)),
"dead": bool(entity.get("dead", false)), "pos_cm": entity.get("pos_cm", Vector3.ZERO)},
int(entity.get("vid", 0)))
func _on_entity_despawned(vid: int) -> void:
_emit("entity_despawned", {"vid": vid})
_emit("entity_despawned", {}, vid)
func _on_entity_moved(vid: int) -> void:
_emit("entity_moved", {"vid": vid})
var pos: Variant = Vector3.ZERO
if client != null and client.has_method("get_entity"):
pos = client.get_entity(vid).get("pos_cm", Vector3.ZERO)
_emit("entity_moved", {"pos_cm": pos}, vid)
func _on_entity_dead(vid: int) -> void:
_emit("entity_dead", {"vid": vid})
_emit("entity_dead", {}, 0, vid)
func _on_damage(vid: int, amount: int, flag: int) -> void:
_emit("damage", {"vid": vid, "amount": amount, "flag": flag})
_emit("damage", {"amount": amount, "flag": flag}, 0, vid)
func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
_emit("server_motion", {"motion": motion}, vid, victim_vid)
func _on_vitals(vid: int) -> void:
_emit("vitals_changed", {"vid": vid})
var entity: Dictionary = client.get_entity(vid) if client != null and client.has_method("get_entity") else {}
_emit("vitals_changed", {"hp": int(entity.get("hp", -1)), "max_hp": int(entity.get("max_hp", -1)),
"dead": bool(entity.get("dead", false))}, vid)
func _on_points_changed(points: Dictionary) -> void:
# points_changed carries the main character's point table. Only a change in
# SP is interesting as skill-resource evidence.
var sp := int(points.get("sp", points.get(8, -1))) if points.has("sp") or points.has(8) else -1
if sp >= 0 and _last_sp >= 0 and sp != _last_sp:
_emit("sp_changed", {"before": _last_sp, "after": sp, "delta": sp - _last_sp})
if sp >= 0:
_last_sp = sp
func _on_target_info(vid: int, hp_percent: int) -> void:
_emit("target_info", {"hp_percent": hp_percent}, 0, vid)
func _on_ground_item_added(item: Dictionary) -> void:
_emit("ground_item_added", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))})
_emit("ground_item_added", {"iid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)),
"owner_match": _owner_matches(String(item.get("owner", ""))), "pos_cm": _ground_pos_cm(item)})
func _on_ground_item_removed(vid: int) -> void:
_emit("ground_item_removed", {"vid": vid})
_emit("ground_item_removed", {"iid": vid})
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
_emit("item_picked_up", {"vnum": vnum, "count": count, "source": source})
# `source` may be a player name; keep only whether it was attributed at all.
_emit("item_picked_up", {"vnum": vnum, "count": count, "selected": not source.is_empty()})
func _on_inventory_changed(window: int, cell: int) -> void:
_emit("inventory_changed", {"window": window, "cell": cell})
func _on_affect_added(affect: Dictionary) -> void:
_emit("affect_added", {"type": int(affect.get("type", 0))})
func _on_effect_cue(vid: int, name: String, special: int) -> void:
_emit("effect_cue", {"vid": vid, "name": name, "special": special})
_emit("effect_cue", {"effect": name.get_file(), "special": special}, vid)
func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void:
_emit("fly_cue", {"type": kind, "start_vid": start_vid, "end_vid": end_vid})
_emit("fly_cue", {"type": kind}, start_vid, end_vid)
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
_emit("fly_targeting", {"shooter_vid": shooter_vid, "target_vid": target_vid,
"target_cm": [target_cm.x, target_cm.y], "append": append})
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, _append: bool) -> void:
_emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid)
## Ownership is evaluated in-process so the owner's name never reaches a report:
## an empty owner is free-for-all, otherwise it must equal the main character.
func _owner_matches(owner: String) -> bool:
if owner.is_empty():
return true
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
return false
var main: Dictionary = client.get_entity(int(client.get_main_vid()))
return String(main.get("name", "")) == owner
func _ground_pos_cm(item: Dictionary) -> Vector2:
var pos: Variant = item.get("pos", null)
if pos is Vector3:
# M2Client ground pos is Godot metres (x, y-up, -y_cm); convert back to server cm.
return Vector2(roundf(pos.x * 100.0), roundf(-pos.z * 100.0))
return Vector2(-1, -1)
+142 -16
View File
@@ -2,15 +2,54 @@ class_name PlayableReport
## 联网测试进程内的报告构建器。
## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。
##
## 事件契约(events.jsonl 每行):monotonic_us、run_id、case_id、connection_epoch、
## stage、kind、actor_vid、target_vid、payload。payload 只保留 PAYLOAD_KEYS
## 白名单字段;其它字段丢弃并计数,避免把角色名、聊天内容或环境变量写进报告。
const SCHEMA_VERSION := 1
const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"]
const EVENT_KEYS := ["monotonic_us", "run_id", "case_id", "connection_epoch", "stage",
"kind", "actor_vid", "target_vid", "payload"]
const PAYLOAD_KEYS := [
"source", "count", "race", "ch_type", "amount", "flag", "vnum", "window", "cell",
"special", "type", "motion", "reason_code", "hp", "max_hp", "hp_percent", "dead",
"pos_cm", "local_pos_cm", "distance_cm", "tolerance_cm", "waypoint_index", "skill_id",
"code", "state", "map_key", "expected_map_key", "iid", "before", "after", "delta",
"attempt", "owner_match", "allow_gameplay", "main_vid", "entity_count",
"ground_item_count", "app_state", "scene_ready", "hud_ready", "player_ready",
"in_game", "reconnecting", "character_count", "slot_present", "stale_epoch",
"elapsed_ms", "timeout_ms", "server_index", "channel", "address_match", "evidence",
"screenshot", "suite", "effect", "lifetime_ms", "selected", "accepted", "reason", "slots",
"fx_id", "open_effects", "phase",
# STB-01 soak
"round", "wall_ms", "requested_size", "logical_size", "physical_size", "viewport_size",
"screen_scale", "duplicate_vids", "target_residue", "portal_cm", "unreachable_seconds",
"cross_server", "interaction", "severe", "elapsed_s", "leg", "required", "size",
]
## Top-level report sections a suite may attach; anything else is rejected.
const SECTIONS := ["soak", "frame_metrics", "client_memory", "probe_overhead"]
## Values that look like credentials are masked even inside whitelisted keys.
const SECRET_KEYS := ["password", "passwd", "token", "secret", "account", "login", "username"]
var _report: Dictionary = {}
var _started_us := 0
var _events_path := ""
var _event_count := 0
var _dropped_payload_keys := {}
var _redact_literals: Array[String] = []
func begin(run_id: String, suite: String, config: Dictionary) -> void:
func begin(run_id: String, suite: String, config: Dictionary, events_path := "") -> void:
_started_us = Time.get_ticks_usec()
_events_path = events_path
_event_count = 0
_dropped_payload_keys = {}
# Account/password literals may appear inside server messages; never persist them.
_redact_literals.clear()
for name in ["MT_ACCOUNT", "MT_PASSWORD"]:
var literal := OS.get_environment(name)
if literal.length() >= 2:
_redact_literals.append(literal)
_report = {
"schema_version": SCHEMA_VERSION,
"run_id": run_id,
@@ -19,7 +58,9 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
"build": _build_info(),
"environment": _environment_info(),
"cases": [],
"events": "events.jsonl",
"required_cases": [],
"events": events_path.get_file() if not events_path.is_empty() else "",
"event_count": 0,
"failures": [],
"blocked": [],
"coverage": {"required": 0, "passed": 0},
@@ -28,37 +69,97 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
# Keep configuration useful for reproducing a run without copying any secret.
_report["scenario_id"] = String(config.get("scenario_id", ""))
## Declares the case IDs that must appear; missing ones become BLOCKED on finish().
func set_required_cases(ids: Array) -> void:
var out: Array = []
for id in ids:
out.append(String(id))
_report.required_cases = out
func required_cases() -> Array:
return _report.get("required_cases", []).duplicate()
func has_case(case_id: String) -> bool:
for item in _report.get("cases", []):
if String(item.id) == case_id:
return true
return false
func case_status(case_id: String) -> String:
for item in _report.get("cases", []):
if String(item.id) == case_id:
return String(item.status)
return ""
func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void:
if has_case(case_id):
_fail("duplicate case result for %s" % case_id)
return
var normalized := status.to_upper()
if not VALID_STATUSES.has(normalized):
normalized = "FAIL"
_fail("invalid case status for %s" % case_id)
var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms,
"reason": reason, "evidence": _sanitize(evidence)}
"reason": _redact_text(reason), "evidence": _sanitize(evidence)}
_report.cases.append(item)
_report.coverage.required = int(_report.coverage.required) + 1
if normalized == "PASS":
_report.coverage.passed = int(_report.coverage.passed) + 1
elif normalized == "FAIL":
_fail("%s: %s" % [case_id, reason if not reason.is_empty() else "failed"])
_fail("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "failed"])
elif normalized == "BLOCKED":
_report.blocked.append(reason if not reason.is_empty() else case_id)
_report.blocked.append("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "blocked"])
func add_event(event: Dictionary, events_path: String) -> void:
var safe: Variant = _sanitize(event)
var file := FileAccess.open(events_path, FileAccess.READ_WRITE)
## A run-level failure that belongs to no open case (e.g. an effect from an
## already-passed case outliving its lifetime). Any failure makes the run FAIL.
func add_failure(message: String) -> void:
_fail(message)
## Attach a whitelisted top-level section (sanitized like payloads). Returns false for unknown names.
func set_section(name: String, value: Dictionary) -> bool:
if _report.is_empty() or not (name in SECTIONS):
return false
_report[name] = _sanitize(value)
return true
func failures() -> Array:
return _report.get("failures", []).duplicate()
## Append one contract event. Returns the event actually written (for tests).
func record(kind: String, stage: String, case_id: String, connection_epoch: int,
actor_vid := 0, target_vid := 0, payload := {}) -> Dictionary:
var event := {
"monotonic_us": Time.get_ticks_usec(),
"run_id": String(_report.get("run_id", "")),
"case_id": case_id,
"connection_epoch": connection_epoch,
"stage": stage,
"kind": kind,
"actor_vid": actor_vid,
"target_vid": target_vid,
"payload": _whitelist(payload),
}
_event_count += 1
_report.event_count = _event_count
if _events_path.is_empty():
return event
var file := FileAccess.open(_events_path, FileAccess.READ_WRITE)
if file == null:
file = FileAccess.open(events_path, FileAccess.WRITE)
file = FileAccess.open(_events_path, FileAccess.WRITE)
if file == null:
_fail("cannot open events file")
return
return event
file.seek_end()
file.store_line(JSON.stringify(safe))
file.store_line(JSON.stringify(event))
file.close()
return event
func finish() -> Dictionary:
if _report.is_empty():
return {}
for required in _report.required_cases:
if not has_case(String(required)):
add_case(String(required), "BLOCKED", "not_reached")
var required := int(_report.coverage.required)
var passed := int(_report.coverage.passed)
var failures: Array = _report.failures
@@ -68,6 +169,8 @@ func finish() -> Dictionary:
_report.status = "BLOCKED"
else:
_report.status = "PASS"
if not _dropped_payload_keys.is_empty():
_report["dropped_payload_keys"] = _dropped_payload_keys.keys()
_report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0
return _report
@@ -83,7 +186,7 @@ func write(path: String) -> bool:
return true
func _fail(message: String) -> void:
_report.failures.append(message)
_report.failures.append(_redact_text(message))
func _build_info() -> Dictionary:
return {
@@ -94,19 +197,40 @@ func _build_info() -> Dictionary:
}
func _environment_info() -> Dictionary:
var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO
var headless := DisplayServer.get_name() == "headless"
var size := DisplayServer.window_get_size() if not headless else Vector2i.ZERO
var logical := size
if not headless and DisplayServer.screen_get_scale() > 0.0:
logical = Vector2i(roundi(size.x / DisplayServer.screen_get_scale()), roundi(size.y / DisplayServer.screen_get_scale()))
return {
"os": OS.get_name(),
"renderer": RenderingServer.get_video_adapter_name(),
"resolution": [size.x, size.y],
"logical_resolution": [logical.x, logical.y],
}
func _whitelist(payload: Dictionary) -> Dictionary:
var out := {}
for key in payload:
var name := String(key)
if name in PAYLOAD_KEYS:
out[name] = _sanitize(payload[key])
else:
_dropped_payload_keys[name] = true
return out
func _redact_text(text: String) -> String:
var out := text
for literal in _redact_literals:
out = out.replace(literal, "[redacted]")
return out
func _sanitize(value: Variant) -> Variant:
if value is Dictionary:
var out := {}
for key in value:
var name := String(key).to_lower()
if name in ["password", "passwd", "token", "secret", "account"]:
if name in SECRET_KEYS:
out[key] = "[redacted]"
else:
out[key] = _sanitize(value[key])
@@ -116,9 +240,11 @@ func _sanitize(value: Variant) -> Variant:
for item in value:
out_array.append(_sanitize(item))
return out_array
if value is Vector2:
if value is String or value is StringName:
return _redact_text(String(value))
if value is Vector2 or value is Vector2i:
return [value.x, value.y]
if value is Vector3:
if value is Vector3 or value is Vector3i:
return [value.x, value.y, value.z]
if value is Object:
return "[object]"
+65
View File
@@ -0,0 +1,65 @@
extends RefCounted
## STB-RESIZE-01 的真实窗口适配器(PlayableFlow.window)。
##
## macOS Retina 下 DisplayServer 的窗口尺寸是物理像素,逻辑尺寸 = 物理像素 / 屏幕缩放。
## 流程只按逻辑尺寸判定;物理像素、缩放和视口尺寸一并写进事件,Retina 的 2x 不是分辨率错误。
## 请求尺寸超出当前屏幕可用区域(含标题栏)时如实拒绝(request_logical_size 返回 false),
## 由流程记为 BLOCKED,不缩小目标冒充通过。headless / 全屏 / 最大化窗口不可调整。
var _viewport: Viewport
func _init(viewport: Viewport = null) -> void:
_viewport = viewport
func availability() -> Dictionary:
if DisplayServer.get_name() == "headless":
return {"ok": false, "reason": "headless display server has no window"}
match DisplayServer.window_get_mode():
DisplayServer.WINDOW_MODE_FULLSCREEN, DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN:
return {"ok": false, "reason": "window is fullscreen; logical size switching needs windowed mode"}
DisplayServer.WINDOW_MODE_MAXIMIZED:
return {"ok": false, "reason": "window is maximized; logical size switching needs windowed mode"}
DisplayServer.WINDOW_MODE_MINIMIZED:
return {"ok": false, "reason": "window is minimized"}
if screen_scale() <= 0.0:
return {"ok": false, "reason": "screen scale unavailable"}
return {"ok": true, "reason": ""}
func screen_scale() -> float:
return DisplayServer.screen_get_scale(DisplayServer.window_get_current_screen())
func physical_size() -> Vector2i:
return DisplayServer.window_get_size()
func logical_size() -> Vector2i:
var scale := screen_scale()
var size := physical_size()
if scale <= 0.0:
return size
return Vector2i(roundi(size.x / scale), roundi(size.y / scale))
func viewport_size() -> Vector2i:
if _viewport == null or not is_instance_valid(_viewport):
return Vector2i.ZERO
return Vector2i(_viewport.get_visible_rect().size)
## Returns false (no request sent) when the size cannot fit the usable screen area.
func request_logical_size(size: Vector2i) -> bool:
var scale := screen_scale()
if scale <= 0.0 or size.x <= 0 or size.y <= 0:
return false
var target := Vector2i(roundi(size.x * scale), roundi(size.y * scale))
var screen := DisplayServer.window_get_current_screen()
var usable := DisplayServer.screen_get_usable_rect(screen)
var decorations := DisplayServer.window_get_size_with_decorations() - DisplayServer.window_get_size()
if target.x + decorations.x > usable.size.x or target.y + decorations.y > usable.size.y:
return false
DisplayServer.window_set_size(target)
# Keep the whole window on the usable area so the OS does not clamp the size back.
var position := DisplayServer.window_get_position()
var clamped := Vector2i(clampi(position.x, usable.position.x, usable.end.x - target.x - decorations.x),
clampi(position.y, usable.position.y, usable.end.y - target.y - decorations.y))
if clamped != position:
DisplayServer.window_set_position(clamped)
return true