Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea2c93d66 | ||
|
|
1c5f28cd41 | ||
|
|
cb1c293dcf | ||
|
|
0ffb2d43bb | ||
|
|
6238873312 | ||
|
|
a78a536476 | ||
|
|
e4e534f2a2 |
@@ -22,10 +22,54 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。
|
||||
| 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** |
|
||||
| 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 |
|
||||
| 连接保活与设备去重 | OK welcome/config 声明静默超时与心跳;应用层 ping/pong;按 deviceId 去重 |
|
||||
| 发布整包 | OK 单个 `opendance-windows-v0.2.2.zip` + `SHA256SUMS`(不拆分卷) |
|
||||
| 发布整包 | OK 单个 `opendance-windows-v0.2.4.zip` + `SHA256SUMS`(不拆分卷) |
|
||||
| 版本号展示 | OK 「关于」清单显示 `v0.2.2`;`project.godot` / `project.yaml` / `export_presets.cfg` / exe 的 PE 版本资源 / 产物名 / tag 全部一致 |
|
||||
| 场景物理与保活 | OK 新增真实 GLB 端到端用例 + 17 秒保活对照用例(751 项断言全过) |
|
||||
|
||||
## v0.2.4 修订(编码取整对齐 + 产物不再夹带测试夹具)
|
||||
|
||||
**缺陷 1(编码取整与参考实现不一致)**:`KeypointFrame.encode()` 用 GDScript 的
|
||||
`roundi`(半数远离零),而契约参考实现(Web 的 `Math.round`)与**生产发送方 Android**
|
||||
(Kotlin `Math.round`)都是「半数朝 +∞」—— `-2.5` 分别是 `-3` 与 `-2`。
|
||||
实测在 12 个跨实现向量里有 1 例 **33 字节不同**。本端是接收方、`encode()` 只用于自测,
|
||||
对产品无影响;但它让「跨实现一致性」只能停留在宽松口径("差异仅限取整边界"),
|
||||
而宽松口径会退化成永远没人看的已知差异。**修复**:新增 `_round_like_js(v) = floor(v + 0.5)`
|
||||
并用于 4 处取整;`accept-frame-vectors.sh` 的口径**收紧为「编码差异必须为 0」**。
|
||||
|
||||
**缺陷 2(产物里夹带开发/验收工具)**:`export_filter="all_resources"` +
|
||||
`exclude_filter` 没有 `build/*`,于是每次新增构建/验收脚本都会被编译进 pck ——
|
||||
包括**故意不合规**的坏服务端夹具 `probe-ws-bad.gd` 与向量文件。
|
||||
`OpenDance.pck` 因此从 145,400 B 涨到 **315,464 B(54% 是测试夹具)**。
|
||||
**修复**:`exclude_filter` 增加 `build/*`;并新增**产物级防线**
|
||||
`build/integration/list-pck.gd`(遍历包内条目、标出开发残留)+ `accept-release.sh`
|
||||
断言「无残留」,避免下次加个新工具目录又重演。
|
||||
|
||||
## v0.2.3 修订(跨端集成验收 + 两处真实缺陷)
|
||||
|
||||
**缺陷 1(契约字段缺失)**:`CatalogTypes.Motion` 没有 `thumbnail_url` 字段,
|
||||
而服务端 `catalog/export` 从 `data-models 0.6.0` 起一直在发 `thumbnailUrl`
|
||||
(同一文件的 `Model` / `Scene` 都有)。客户端默默丢掉该字段。
|
||||
**修复**:补字段与解析(缺失时为 `""`,兼容旧服务端),并新增
|
||||
`tests/test_catalog_types.gd`(4 项)用**服务端真实报文形状**锁住三个资源类型的
|
||||
`thumbnailUrl` 覆盖;同时显式记录一处**刻意不消费**的差异
|
||||
(`Model.usable`/`unusableReason` —— 目录只下发可用模型,恒为 true)。
|
||||
|
||||
**缺陷 2(二进制响应白做 UTF-8 解码)**:`ApiClient._request()` 对**所有**响应
|
||||
无条件执行 `raw.get_string_from_utf8()`。数据本身没错(二进制路径返回的是原始字节),
|
||||
但每下载一个文件都会打印一条
|
||||
`Unicode parsing error … Invalid UTF-8 leading byte (N)` 噪声,
|
||||
并把整个文件(模型可达数十 MB)解码成一份 String 拷贝后丢弃。
|
||||
**修复**:只在需要文本时解码。跨端集成探针第一次注意到这条噪声。
|
||||
|
||||
**新增跨端集成验收**(`build/accept-integration.sh`):用**发布包里的客户端**
|
||||
(`--main-pack` 加载包内 pck)打**真实 Web 服务端**,逐项比对契约与物理参数,
|
||||
并跑通「真 VMD → 内部标准 33 点 → 客户端解析 2901 帧」的完整闭环。
|
||||
详见 [`tasks/019-cross-end-integration.md`](tasks/019-cross-end-integration.md)。
|
||||
|
||||
此外修掉 `tests/test_api_integration.gd` 里一处**写死的契约版本**(`0.1.0`):
|
||||
该用例只在服务端可达时才跑,平时一直 skip,于是契约升到 0.5.0 后
|
||||
没有任何地方发现它。现在改为从仓库 `contracts/VERSION` 读期望值并与服务端 `/health` 比对。
|
||||
|
||||
## v0.2.2 修订(发布包 exe 的版本元数据)
|
||||
|
||||
**缺陷**:v0.2.1 的包在 Windows 上「右键 → 属性 → 详细信息」显示
|
||||
@@ -83,10 +127,70 @@ GODOT_BIN=/path/to/godot bash build/verify.sh
|
||||
GODOT_BIN=/path/to/godot bash build/export-windows.sh release
|
||||
|
||||
# 打包发布产物(单个完整 zip + SHA256SUMS)
|
||||
bash build/package-release.sh 0.2.2
|
||||
bash build/package-release.sh 0.2.4
|
||||
|
||||
# 发布包级验收(对**发布包**跑,而不是对源码;文档 §7.5)
|
||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.4.zip
|
||||
```
|
||||
|
||||
当前版本:**v0.2.2**(`project.godot` 的 `application/config/version`,
|
||||
`accept-release.sh` 校验 sha256 → 解压 → 文件清单(并检查没有 `.partN` 残留)→
|
||||
exe 的 PE 版本资源 → **用 Godot 直接加载包内 pck 跑 180 帧** → 包内运行时探针
|
||||
(`build/probe-release.gd`:包内版本号、`AboutScreen.app_version()`、主场景、autoload、
|
||||
15 个关键资源、以及**包内 `Credits.CONTRACT_VERSIONS` 与 `contracts/VERSION` 快照是否一致**)。
|
||||
它读的全是包内资源,因此结论就是「用户装到机器上的那一份能不能用」。
|
||||
已对**已发布的 v0.2.2 资产**(重新下载)跑通,并用「篡改包内 contracts/VERSION」与
|
||||
「期望版本写错」两个负例反向验证过。
|
||||
|
||||
### 关键点帧跨实现一致性验收(T-WIN-021)
|
||||
|
||||
`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的**参考实现**
|
||||
(其 `decodeBinaryFrame` 注释就是「Windows 端参考实现(T-WIN-004)」)。
|
||||
Android 已用「金标准字节向量」与它对齐;而**本端的解码器此前只跟自己比过**
|
||||
(自己编码、自己解码)。这条边由下列脚本补上:
|
||||
|
||||
```bash
|
||||
GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.4.zip
|
||||
# 没有 web 仓 / 未装 node_modules 时:... --skip-regen
|
||||
```
|
||||
|
||||
1. 用**参考实现**(`npm run vectors:frame`)重新生成向量,与仓库锁定的
|
||||
`build/integration/frame-vectors.json` 比对 —— 参考实现变了却不同步向量,这里就会失败;
|
||||
2. 用**包内 pck** 解码同一批字节,与参考解码结果**逐值精确比对**
|
||||
(float32 表示精度内):实测 **12 用例 / 396 点 / 0 差异**;
|
||||
3. 附带比对两边编码器:本端不是发送方(`encode()` 只用于自测),差异只归类记录,
|
||||
但**必须只出现在「量化前恰好 .5」的用例上**。
|
||||
|
||||
> 已测量并归类的一处差异:JS `Math.round` 与 Kotlin `Math.round` 对 `-2.5` 取 `-2`
|
||||
> (半数朝 +∞),而本端 `roundi` 取 `-3`(半数远离零)。生产发送方是 Android,
|
||||
> 与参考实现一致;Windows 是接收方,`encode()` 不在生产链路上。
|
||||
> 详见 [`tasks/021-frame-vector-conformance.md`](tasks/021-frame-vector-conformance.md)。
|
||||
|
||||
### WebSocket 契约一致性验收(T-WIN-020)
|
||||
|
||||
`tests/test_pose_server.gd` 用的是**同引擎内的客户端**,而真实对端是
|
||||
**Android(Kotlin/OkHttp)** —— 同引擎自测会共享同样的假设(超时判定、分包、
|
||||
文本/二进制边界、关闭语义、子协议协商)。因此另配一套**独立实现**的客户端:
|
||||
|
||||
```bash
|
||||
GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.4.zip
|
||||
# 跳过耗时的两个场景(保活 19s / 静默超时 15s):
|
||||
GODOT_BIN=... bash build/accept-ws.sh <zip> --skip-slow
|
||||
```
|
||||
|
||||
* `build/integration/probe-ws.gd`:把**包内** PoseServer 跑起来并汇总事件成 JSON(自身不判定);
|
||||
* `build/integration/ws-client.mjs`:Node 实现的契约一致性客户端(无依赖,用内置 `WebSocket`),
|
||||
逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景;
|
||||
* `build/accept-ws.sh`:编排两者,并核对**客户端看不到的那一半**(服务端侧
|
||||
`frames_dropped` / `malformed_frames` / 控制帧计数 / 去重事件 / 关闭原因)。
|
||||
|
||||
实测(已发布的 v0.2.3 资产):客户端 **30 通过 / 0 失败** + 服务端侧 16 项事实全过,
|
||||
其中最关键的是**保活对照**——只发应用层 `ping` 持续 19 秒(> 静默超时 15 秒)连接
|
||||
**仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。
|
||||
反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明
|
||||
保活字段、不回 pong、不做去重)跑同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。
|
||||
详见 [`tasks/020-ws-contract-conformance.md`](tasks/020-ws-contract-conformance.md)。
|
||||
|
||||
当前版本:**v0.2.4**(`project.godot` 的 `application/config/version`,
|
||||
与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、
|
||||
以及 exe 的 PE 版本资源全部一致)。
|
||||
|
||||
@@ -169,7 +273,7 @@ GODOT_BIN=/path/to/godot bash build/export-windows.sh release
|
||||
powershell -ExecutionPolicy Bypass -File build\export-windows.ps1
|
||||
|
||||
# 打成可分发的 zip(单个完整包 + SHA256SUMS)
|
||||
bash build/package-release.sh 0.2.2
|
||||
bash build/package-release.sh 0.2.4
|
||||
```
|
||||
|
||||
| 产物 | 位置 | 说明 |
|
||||
@@ -177,7 +281,7 @@ bash build/package-release.sh 0.2.2
|
||||
| `OpenDance.exe` | `build/windows/` | 主程序,约 104 MB;脚本校验前 2 字节为 `MZ`(合法 PE) |
|
||||
| `OpenDance.pck` | `build/windows/` | 资源包,约 107 KB; **必须与 exe 同目录** |
|
||||
| `OpenDance.console.exe` | `build/windows/` | 控制台包装器,可能生成,非必需 |
|
||||
| `opendance-windows-v0.2.2.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
|
||||
| `opendance-windows-v0.2.4.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
|
||||
| `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 |
|
||||
|
||||
导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。
|
||||
@@ -281,7 +385,7 @@ src/
|
||||
等格式,但本项目未在 Windows 上实测)。
|
||||
若 IIS 上限已放开、或你能把包放到别处分发(例如 Web 服务端的 `/static`,
|
||||
不受 IIS 那个限制约束),请改用资源管理器可直接双击解压的 deflate 包:
|
||||
`ZIP_METHOD=deflate bash build/package-release.sh 0.2.2`(约 38 MB)。
|
||||
`ZIP_METHOD=deflate bash build/package-release.sh 0.2.4`(约 38 MB)。
|
||||
7. **发布包 exe 的版本元数据**(T-WIN-017):v0.2.1 之前在 Windows
|
||||
「属性 → 详细信息」里显示的是 `0.1.0`,因为 `export_presets.cfg` 的
|
||||
`application/file_version` / `application/product_version` 被硬编码且无人校验。
|
||||
|
||||
+35
-5
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Windows 客户端 v0.2.2 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
|
||||
OpenDance Studio -- Windows 客户端 v0.2.4 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
|
||||
================================================================
|
||||
|
||||
摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket
|
||||
@@ -8,6 +8,29 @@ OpenDance Studio -- Windows 客户端 v0.2.2 使用说明(zip 为 LZMA 压缩
|
||||
|
||||
本客户端只接收关键点坐标,不接收、不存储任何视频画面。
|
||||
|
||||
v0.2.4 修订
|
||||
1. 编码取整与契约参考实现对齐: 本端 encode() 原用 GDScript roundi(半数远离零),
|
||||
而参考实现与生产发送方(Android 的 Math.round)是「半数朝 +∞」—— -2.5 分别是
|
||||
-3 与 -2。本端是接收方(encode 只用于自测),故对产品无影响,但跨实现一致性
|
||||
只能停留在"差异仅限取整边界"的宽松口径。现改为 floor(x + 0.5),并把验收口径
|
||||
收紧为「编码差异必须为 0」。
|
||||
2. 产物不再夹带开发/验收工具: export_filter="all_resources" 且 exclude_filter
|
||||
没有 build/*,导致探针脚本(含故意不合规的坏服务端夹具)与向量文件被打进 pck ——
|
||||
pck 从 145,400 B 涨到 315,464 B(54% 是测试夹具)。现已排除 build/*,
|
||||
并新增产物级残留检查(accept-release.sh 会断言「无开发残留」)。
|
||||
3. 无头测试 785 项断言(原 774)。
|
||||
|
||||
v0.2.3 修订
|
||||
1. 补 Motion.thumbnail_url: 服务端 catalog/export 从 data-models 0.6.0 起一直
|
||||
在发 thumbnailUrl,客户端类型里却缺这个字段(Model/Scene 都有),等于默默丢掉。
|
||||
2. ApiClient 不再对所有响应无条件做 UTF-8 解码: 原来下载每个文件(模型 / GLB /
|
||||
缩略图)都会打印一条 "Unicode parsing error ... Invalid UTF-8 leading byte"
|
||||
噪声,并白做一份 String 拷贝;二进制路径现在直接返回原始字节。
|
||||
3. 新增跨端集成验收 build/accept-integration.sh: 用发布包里的客户端打真实 Web
|
||||
服务端,逐项比对契约与场景物理参数,并跑通真实 VMD → 内部标准 33 点 → 客户端
|
||||
解析 2901 帧的闭环。
|
||||
4. 测试规模 820 项断言(含真实服务端在线的 API 集成组)。
|
||||
|
||||
v0.2.2 修订
|
||||
1. 修复发布包 exe 的版本元数据: v0.2.1 的包在 Windows「右键 -> 属性 ->
|
||||
详细信息」里显示的是 文件版本 0.1.0 / 产品版本 0.1.0, 而产物名与 Git tag
|
||||
@@ -129,11 +152,11 @@ OpenDance.exe 的用户看第一节即可。
|
||||
|
||||
打成发布包:
|
||||
|
||||
bash build/package-release.sh 0.2.2
|
||||
bash build/package-release.sh 0.2.4
|
||||
|
||||
得到 dist-release/ 下两个资产:
|
||||
|
||||
opendance-windows-v0.2.2.zip <- 单个完整包,禁止 .partN 分卷
|
||||
opendance-windows-v0.2.4.zip <- 单个完整包,禁止 .partN 分卷
|
||||
SHA256SUMS <- 裸文件名,可直接 sha256sum -c
|
||||
|
||||
包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、
|
||||
@@ -152,13 +175,20 @@ OpenDance.exe 的用户看第一节即可。
|
||||
如果你能把包放到别处分发(例如 Web 服务端的 /static,不受 IIS 那个
|
||||
上限约束),建议改用资源管理器可直接双击解压的 deflate 包:
|
||||
|
||||
ZIP_METHOD=deflate bash build/package-release.sh 0.2.2 # 约 38 MB
|
||||
ZIP_METHOD=deflate bash build/package-release.sh 0.2.4 # 约 38 MB
|
||||
|
||||
上限一旦放开,Release 资产也应换回 deflate 包。
|
||||
|
||||
7. 自检清单
|
||||
|
||||
1) 一键验证:GODOT_BIN=/path/to/godot bash build/verify.sh
|
||||
0) 发布包级验收(对**发布包**而不是源码;文档 §7.5)
|
||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.4.zip
|
||||
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
|
||||
读 exe 的 PE 版本资源、用 Godot 直接加载包内 pck 跑 180 帧、并跑包内运行时
|
||||
探针(包内版本号 / 「关于」清单显示值 / 主场景 / autoload / 关键资源 /
|
||||
包内契约表与 contracts/VERSION 快照是否一致)。
|
||||
|
||||
1) 源码一键验证:GODOT_BIN=/path/to/godot bash build/verify.sh
|
||||
预期末行「验证全部通过」。
|
||||
2) 无头测试:$GODOT --headless --path . --script res://tests/run_tests.gd
|
||||
预期「== 结果:751 通过 / 0 失败 ==」与「全部通过」(缺 VRM 素材或
|
||||
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# OpenDance Studio Windows 客户端 —— **关键点帧跨实现一致性验收**(T-WIN-021)
|
||||
#
|
||||
# opendance-web 的 TypeScript 参考实现(契约 §2)× 发布包里的 GDScript 解码器
|
||||
#
|
||||
# 为什么需要它:`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的
|
||||
# **参考实现**(其 `decodeBinaryFrame` 的注释就是「Windows 端参考实现(T-WIN-004)」)。
|
||||
# Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;而**本端的 GDScript 解码器
|
||||
# 此前只跟自己比过**(自己编码、自己解码)。本脚本把这条缺失的边补上:
|
||||
#
|
||||
# 1. 用**参考实现**重新生成向量,与仓库里锁定的 `build/integration/frame-vectors.json`
|
||||
# 比对 —— 参考实现一旦变化,这里必须失败(否则"锁定"就没有意义);
|
||||
# 2. 用**发布包里的 pck** 解码同一批字节,与参考实现的解码结果**逐值精确比对**
|
||||
# (float32 表示精度内);
|
||||
# 3. 附带比对两边**编码器**的字节:本端不是发送方(`encode()` 只用于自测),
|
||||
# 因此只归类记录 —— 但差异必须**只出现在量化前恰好落在 .5 的用例**上。
|
||||
#
|
||||
# 用法:
|
||||
# GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.3.zip
|
||||
# ... --skip-regen # 不重新生成向量(没有 web 仓 / 没装 node_modules 时)
|
||||
#
|
||||
# 环境变量:
|
||||
# WEB_REPO opendance-web 仓库路径(默认 ../opendance-web)
|
||||
# WORK_DIR 工作目录(默认 mktemp -d)
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
TARGET="${1:-}"
|
||||
SKIP_REGEN=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-regen) SKIP_REGEN=1 ;;
|
||||
"$TARGET") : ;;
|
||||
*) echo "✗ 未知参数:$arg(只支持 --skip-regen)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
WEB_REPO="${WEB_REPO:-$PROJECT_DIR/../opendance-web}"
|
||||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||||
mkdir -p "$WORK_DIR"
|
||||
VECTORS="$PROJECT_DIR/build/integration/frame-vectors.json"
|
||||
REPORT="$WORK_DIR/frame-vectors-report.json"
|
||||
|
||||
FAILED=0
|
||||
log() { printf '\033[1;34m[accept-fv]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[accept-fv] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||||
|
||||
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
|
||||
echo "用法:GODOT_BIN=... bash build/accept-frame-vectors.sh <发布包 zip 或已解压目录> [--skip-regen]" >&2
|
||||
exit 2
|
||||
fi
|
||||
[ -f "$VECTORS" ] || { fail "找不到锁定的向量文件:$VECTORS"; exit 1; }
|
||||
|
||||
# ---- 1. 用参考实现重新生成并比对(确保向量仍与参考实现一致)----
|
||||
log "参考实现:$WEB_REPO/web-server/src/preprocess/keypoint-codec.ts"
|
||||
if [ -n "$SKIP_REGEN" ]; then
|
||||
log "跳过重新生成(--skip-regen)"
|
||||
elif [ ! -f "$WEB_REPO/web-server/scripts/gen-frame-vectors.mts" ]; then
|
||||
fail "找不到生成脚本($WEB_REPO/web-server/scripts/gen-frame-vectors.mts)—— 用 WEB_REPO 指定仓库,或 --skip-regen"
|
||||
elif [ ! -d "$WEB_REPO/web-server/node_modules" ]; then
|
||||
fail "web 仓没有 node_modules,无法运行参考实现(先在那边 npm install,或 --skip-regen)"
|
||||
else
|
||||
log "用参考实现重新生成向量并比对…"
|
||||
REGEN="$WORK_DIR/frame-vectors.regen.json"
|
||||
if ( cd "$WEB_REPO/web-server" && timeout 300 npx vite-node scripts/gen-frame-vectors.mts -- --out "$REGEN" >"$WORK_DIR/regen.log" 2>&1 ); then
|
||||
if diff -q "$VECTORS" "$REGEN" >/dev/null; then
|
||||
echo " ✓ 锁定的向量与参考实现重新生成的结果逐字节一致"
|
||||
else
|
||||
fail "锁定的向量已与参考实现不一致(参考实现变了却没同步向量)"
|
||||
diff "$VECTORS" "$REGEN" | head -20 | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
fail "参考实现生成失败(见 $WORK_DIR/regen.log)"
|
||||
tail -10 "$WORK_DIR/regen.log" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 2. 取包内 pck ----
|
||||
if [ -f "$TARGET" ]; then
|
||||
log "解压发布包…"
|
||||
if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
|
||||
command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; }
|
||||
fi
|
||||
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
else
|
||||
PACKAGE_DIR="$TARGET"
|
||||
fi
|
||||
PCK="$PACKAGE_DIR/OpenDance.pck"
|
||||
[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; }
|
||||
log "包内 pck:$PCK"
|
||||
|
||||
GODOT_BIN="${GODOT_BIN:-}"
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
for candidate in godot godot4 Godot; do
|
||||
command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; }
|
||||
done
|
||||
fi
|
||||
[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; }
|
||||
|
||||
# ---- 3. 用包内解码器逐值比对 ----
|
||||
log "包内解码器 × 参考向量…"
|
||||
PROBE_LOG="$WORK_DIR/probe.log"
|
||||
rm -f "$REPORT"
|
||||
if timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" \
|
||||
--script "$PROJECT_DIR/build/integration/probe-frame-vectors.gd" \
|
||||
-- --vectors "$VECTORS" --report "$REPORT" >"$PROBE_LOG" 2>&1; then
|
||||
PROBE_OK=1
|
||||
else
|
||||
PROBE_OK=0
|
||||
fi
|
||||
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
|
||||
[ "$PROBE_OK" = 1 ] || fail "解码比对未通过"
|
||||
|
||||
# ---- 4. 报告核对 ----
|
||||
if [ ! -f "$REPORT" ]; then
|
||||
fail "探针没有产出报告($REPORT)"
|
||||
exit 1
|
||||
fi
|
||||
log "报告核对:"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
let bad = 0;
|
||||
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
|
||||
say(r.cases > 0 && r.decodedCases === r.cases, `解码用例 ${r.decodedCases}/${r.cases}`);
|
||||
say(r.pointsCompared === r.cases * 33, `逐点比对 ${r.pointsCompared} 个(期望 ${r.cases * 33})`);
|
||||
say(Array.isArray(r.decodeFailures) && r.decodeFailures.length === 0,
|
||||
`解码差异 ${r.decodeFailures?.length ?? "?"} 项(必须为 0)`);
|
||||
if (r.decodeFailures?.length) for (const f of r.decodeFailures.slice(0, 5)) console.log(` · ${f.case}:${f.problem}`);
|
||||
// 编码器差异:v0.2.4 起本端 `_round_like_js`(floor(x+0.5))已与参考实现的
|
||||
// `Math.round` 对齐,因此**必须为 0**。原先允许「仅限 .5 取整边界」的宽松口径已收紧:
|
||||
// 宽松口径下,规则性偏差会长期以「已知差异」的身份留在日志里,没人会再看它。
|
||||
const diffs = r.encodeDifferences ?? [];
|
||||
say(diffs.length === 0,
|
||||
diffs.length === 0
|
||||
? "编码器字节与参考实现完全一致(0 项差异)"
|
||||
: `编码器差异 ${diffs.length} 项(必须为 0):${diffs.map((d) => d.case + "(" + d.diffBytes + "B," + (d.halfValueInput ? "取整边界" : "非取整边界") + ")").join("、")}`);
|
||||
process.exit(bad);
|
||||
' "$REPORT" || fail "报告核对未通过"
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "============================================================"
|
||||
echo " 关键点帧跨实现一致性验收失败(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================================"
|
||||
echo " 关键点帧跨实现一致性验收全部通过(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# OpenDance Studio Windows 客户端 —— **跨端集成验收**(T-WIN-019)
|
||||
#
|
||||
# Windows 发布包里的客户端(包内 pck) × 真实 Web 服务端
|
||||
#
|
||||
# 为什么需要它:本仓单测打的是**假 HTTP 夹具**,Web 端测试只验自己那一侧。
|
||||
# 两端各自绿灯 ≠ 契约字段真的对得上(字段名/嵌套/null/单位/范围都可能一侧对一侧错)。
|
||||
# 契约 0.5.0 新增的 `scenes[]` 与重力/风向参数尤其如此。
|
||||
#
|
||||
# 本脚本:
|
||||
# 1. 要求一个**已部署好的** Web 服务端(不做部署,与项目边界一致);
|
||||
# 2. 用真实素材播种一份目录(真 VMD → 内部标准;真 PMX → VRM;场景 GLB + 物理参数),
|
||||
# 并写出期望值 JSON(`--skip-seed` 可跳过,复用已有目录);
|
||||
# 3. 用 Godot 加载**发布包里的 pck**(`--main-pack`)跑
|
||||
# `build/integration/probe-integration.gd`,逐项断言并给出退出码。
|
||||
#
|
||||
# 用法:
|
||||
# GODOT_BIN=/path/to/godot API_BASE=http://127.0.0.1:3000 \
|
||||
# bash build/accept-integration.sh dist-release/opendance-windows-v0.2.3.zip
|
||||
#
|
||||
# # 已播种过、只重跑探针:
|
||||
# GODOT_BIN=... API_BASE=... bash build/accept-integration.sh <zip> --skip-seed
|
||||
#
|
||||
# 环境变量:
|
||||
# API_BASE Web 服务端基地址(默认 http://127.0.0.1:3000)
|
||||
# OPENDANCE_SAMPLES_DIR 真实素材目录(默认 仓库根/示例文件)
|
||||
# EXPECT_PATH 期望值 JSON 输出路径(默认 $WORK_DIR/catalog-expect.json)
|
||||
# WORK_DIR 解包目录(默认 mktemp -d)
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
TARGET="${1:-}"
|
||||
SKIP_SEED=0
|
||||
# 只认 --skip-seed;其余参数一律报错退出,避免「参数写错被静默忽略」
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-seed) SKIP_SEED=1 ;;
|
||||
"$TARGET") : ;;
|
||||
*) echo "✗ 未知参数:$arg(只支持 --skip-seed;期望值路径用 EXPECT_PATH 环境变量)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
API_BASE="${API_BASE:-http://127.0.0.1:3000}"
|
||||
SAMPLES_DIR="${OPENDANCE_SAMPLES_DIR:-$PROJECT_DIR/../示例文件}"
|
||||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||||
EXPECT_PATH="${EXPECT_PATH:-$WORK_DIR/catalog-expect.json}"
|
||||
|
||||
FAILED=0
|
||||
log() { printf '\033[1;34m[accept-int]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[accept-int] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||||
|
||||
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
|
||||
echo "用法:GODOT_BIN=... API_BASE=... bash build/accept-integration.sh <发布包 zip 或已解压目录> [--skip-seed]" >&2
|
||||
exit 2
|
||||
fi
|
||||
# 必须**先**建目录:期望值 JSON 默认就落在 $WORK_DIR 下,播种阶段就要写它
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# ---- 0. 服务端可达性 ----
|
||||
log "服务端:$API_BASE"
|
||||
HEALTH="$(curl -sf -m 10 "$API_BASE/health" || true)"
|
||||
if [ -z "$HEALTH" ]; then
|
||||
fail "GET $API_BASE/health 不可达 —— 请先部署 Web 服务端(本项目不部署它)"
|
||||
exit 1
|
||||
fi
|
||||
echo "$HEALTH" | node -e '
|
||||
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
||||
const j = JSON.parse(s);
|
||||
console.log(` ✓ 服务端版本 ${j.version}|数据库 ${j.database?.connected ? "已连接" : "未连接"}|scenes.available=${j.scenes?.available}`);
|
||||
});'
|
||||
|
||||
# ---- 1. 播种目录 ----
|
||||
if [ "$SKIP_SEED" = "1" ]; then
|
||||
log "跳过播种(--skip-seed);期望值文件:$EXPECT_PATH"
|
||||
else
|
||||
log "播种真实目录(真 VMD / 真 PMX / 场景 GLB + 物理参数)…"
|
||||
if [ ! -d "$SAMPLES_DIR" ]; then
|
||||
fail "找不到素材目录 $SAMPLES_DIR(用 OPENDANCE_SAMPLES_DIR 指定)"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
fail "播种需要 node(Web 端已依赖 node,本机应已安装)"
|
||||
exit 1
|
||||
fi
|
||||
API_BASE="$API_BASE" OPENDANCE_SAMPLES_DIR="$SAMPLES_DIR" EXPECT_PATH="$EXPECT_PATH" \
|
||||
node "$PROJECT_DIR/build/integration/seed-catalog.mjs" || { fail "播种失败"; exit 1; }
|
||||
fi
|
||||
if [ ! -f "$EXPECT_PATH" ]; then
|
||||
fail "缺少期望值文件 $EXPECT_PATH(首次运行不要加 --skip-seed)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 2. 取包内 pck ----
|
||||
if [ -f "$TARGET" ]; then
|
||||
log "解压发布包…"
|
||||
if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
|
||||
command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; }
|
||||
fi
|
||||
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
else
|
||||
PACKAGE_DIR="$TARGET"
|
||||
fi
|
||||
PCK="$PACKAGE_DIR/OpenDance.pck"
|
||||
[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; }
|
||||
log "包内 pck:$PCK($(stat -c%s "$PCK") 字节)"
|
||||
|
||||
# ---- 3. Godot ----
|
||||
GODOT_BIN="${GODOT_BIN:-}"
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
for candidate in godot godot4 Godot; do
|
||||
command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; }
|
||||
done
|
||||
fi
|
||||
[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需与本项目同版本 4.7.x)"; exit 1; }
|
||||
|
||||
# ---- 4. 跨端探针 ----
|
||||
log "跨端集成探针(包内客户端 × $API_BASE)…"
|
||||
PROBE_LOG="$WORK_DIR/probe-integration.log"
|
||||
if timeout 600 "$GODOT_BIN" --headless --main-pack "$PCK" \
|
||||
--script "$PROJECT_DIR/build/integration/probe-integration.gd" \
|
||||
-- --api-base "$API_BASE" --expect "$EXPECT_PATH" >"$PROBE_LOG" 2>&1; then
|
||||
PROBE_OK=1
|
||||
else
|
||||
PROBE_OK=0
|
||||
fi
|
||||
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
|
||||
[ "$PROBE_OK" = 1 ] || fail "跨端集成探针未通过"
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "============================================================"
|
||||
echo " 跨端集成验收失败(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================================"
|
||||
echo " 跨端集成验收全部通过(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# OpenDance Studio Windows 客户端 —— **发布包级端到端验收**(T-WIN-018)
|
||||
#
|
||||
# 为什么需要它:`build/verify.sh` 跑的是**仓库源码**(`godot --path .`),
|
||||
# 而交付给用户的是 zip 里的 `OpenDance.pck` + `OpenDance.exe`。
|
||||
# 差别全在「导出」这一步 —— 导出预设漏资源、pck 没重新生成、版本号没同步、
|
||||
# 契约快照没更新,都只在包里才暴露。本项目已经因此翻过一次车:
|
||||
# v0.2.1 的包解开后 exe 属性页显示 0.1.0(配置文件却全一致)。
|
||||
#
|
||||
# 本脚本对一个 zip(或已解压目录)跑完:
|
||||
# 1. sha256 校验(有 SHA256SUMS 时);
|
||||
# 2. 解压 + 文件清单完整性(exe / pck / README / THIRD_PARTY / contracts);
|
||||
# 3. exe 的 **PE 版本资源** = 本次发布版本(build/check-pe-version.sh);
|
||||
# 4. 用 Godot 直接加载**包内 pck** 跑 180 帧,断言无 SCRIPT ERROR;
|
||||
# 5. 包内运行时探针(build/probe-release.gd):版本号、AboutScreen 显示值、
|
||||
# 主场景、autoload、关键资源可加载、**pck 内契约表与包内 contracts/VERSION 一致**。
|
||||
#
|
||||
# 需要与本项目同版本的 Godot 4.7.x(导出模板要求的版本)。
|
||||
#
|
||||
# 用法:
|
||||
# GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2.zip
|
||||
# GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2
|
||||
# GODOT_BIN=... bash build/accept-release.sh <zip> 0.2.2 # 显式指定期望版本
|
||||
#
|
||||
# 可选环境变量:
|
||||
# WORK_DIR 解压目录(默认 mktemp -d)
|
||||
# BOOT_FRAMES 启动检查帧数(默认 180)
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
TARGET="${1:-}"
|
||||
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
|
||||
EXPECTED="${2:-$YAML_VERSION}"
|
||||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||||
BOOT_FRAMES="${BOOT_FRAMES:-180}"
|
||||
|
||||
FAILED=0
|
||||
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||||
|
||||
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
|
||||
echo "用法:GODOT_BIN=/path/to/godot bash build/accept-release.sh <发布包 zip 或已解压目录> [期望版本]" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -z "$EXPECTED" ]; then
|
||||
echo "✗ 无法确定期望版本(project.yaml 里没有 version,也没有显式传入)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "目标:$TARGET"
|
||||
log "期望版本:$EXPECTED"
|
||||
log "工作目录:$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# ---- 1. sha256 + 解压 ----
|
||||
if [ -f "$TARGET" ]; then
|
||||
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
|
||||
# 但 sums 文件本身必须用绝对路径 —— 否则 cd 之后相对路径会被二次拼接成
|
||||
# dist-release/dist-release/SHA256SUMS(这个 bug 在本脚本首跑时就命中了)。
|
||||
TARGET_DIR_ABS="$(cd "$(dirname "$TARGET")" && pwd)"
|
||||
SUMS="$TARGET_DIR_ABS/SHA256SUMS"
|
||||
if [ -f "$SUMS" ]; then
|
||||
log "sha256 校验($SUMS):"
|
||||
if ( cd "$TARGET_DIR_ABS" && sha256sum -c "$SUMS" --ignore-missing ); then
|
||||
:
|
||||
else
|
||||
fail "sha256 校验失败"
|
||||
fi
|
||||
else
|
||||
log "未找到 $SUMS,跳过 sha256 校验"
|
||||
fi
|
||||
|
||||
log "解压…"
|
||||
case "$TARGET" in
|
||||
*.zip)
|
||||
if unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
|
||||
log " 用 unzip 解压成功(deflate 包)"
|
||||
elif command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null; then
|
||||
log " 用 7z 解压成功(LZMA 包 —— 注意 Windows 10 资源管理器不支持它)"
|
||||
else
|
||||
fail "解压失败(既不是可 unzip 的 deflate 包,也没有 7z 处理 LZMA)"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "只支持 .zip 或已解压目录"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
else
|
||||
PACKAGE_DIR="$TARGET"
|
||||
fi
|
||||
[ -n "$PACKAGE_DIR" ] && [ -d "$PACKAGE_DIR" ] || { fail "找不到解压后的包目录"; exit 1; }
|
||||
log "包目录:$PACKAGE_DIR"
|
||||
|
||||
# ---- 2. 文件清单 ----
|
||||
log "文件清单:"
|
||||
for required in OpenDance.exe OpenDance.pck README.txt THIRD_PARTY.md contracts/VERSION; do
|
||||
if [ -f "$PACKAGE_DIR/$required" ]; then
|
||||
printf ' ✓ %s\n' "$required"
|
||||
else
|
||||
fail "缺少 $required"
|
||||
fi
|
||||
done
|
||||
# exe 与 pck 必须同名同目录,否则 Godot 找不到资源
|
||||
if [ "$(basename "$PACKAGE_DIR/OpenDance.exe" .exe)" != "$(basename "$PACKAGE_DIR/OpenDance.pck" .pck)" ]; then
|
||||
fail "exe 与 pck 主文件名不一致"
|
||||
fi
|
||||
# 不允许出现历史分卷残留
|
||||
if find "$PACKAGE_DIR" -maxdepth 1 -name "*.part[0-9]*" | grep -q .; then
|
||||
fail "包内出现 .partN 分卷残留(发布规则要求单个完整包)"
|
||||
fi
|
||||
|
||||
# ---- 3. exe 的 PE 版本资源 ----
|
||||
log "产物 exe 的 PE 版本资源:"
|
||||
if [ -f "$PACKAGE_DIR/OpenDance.exe" ]; then
|
||||
bash "$PROJECT_DIR/build/check-pe-version.sh" "$PACKAGE_DIR/OpenDance.exe" "$EXPECTED" \
|
||||
|| fail "exe 的 PE 版本资源不是 v$EXPECTED(Windows 属性页会显示错版本)"
|
||||
fi
|
||||
|
||||
# ---- 4/5. 用 Godot 加载包内 pck ----
|
||||
GODOT_BIN="${GODOT_BIN:-}"
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
for candidate in godot godot4 Godot; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then GODOT_BIN="$candidate"; break; fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
fail "未找到 Godot(本端验收需要与本项目同版本的 Godot 4.7.x;用 GODOT_BIN=<路径> 指定)"
|
||||
echo "(其余检查已完成,见上)" >&2
|
||||
exit 1
|
||||
fi
|
||||
log "Godot:$GODOT_BIN($("$GODOT_BIN" --version 2>/dev/null | head -1))"
|
||||
|
||||
PCK="$PACKAGE_DIR/OpenDance.pck"
|
||||
if [ -f "$PCK" ]; then
|
||||
log "包内 pck 启动检查($BOOT_FRAMES 帧,无头)…"
|
||||
BOOT_LOG="$WORK_DIR/pck-boot.log"
|
||||
timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" --quit-after "$BOOT_FRAMES" >"$BOOT_LOG" 2>&1
|
||||
BOOT_STATUS=$?
|
||||
if [ "$BOOT_STATUS" -ne 0 ]; then
|
||||
fail "包内 pck 启动退出码 $BOOT_STATUS"
|
||||
tail -20 "$BOOT_LOG" | sed 's/^/ /'
|
||||
elif grep -qE "SCRIPT ERROR|Parse Error|Compile Error" "$BOOT_LOG"; then
|
||||
fail "包内 pck 启动出现脚本错误"
|
||||
grep -E "SCRIPT ERROR|Parse Error|Compile Error" "$BOOT_LOG" | head -10 | sed 's/^/ /'
|
||||
else
|
||||
echo " ✓ 包内 pck 启动 $BOOT_FRAMES 帧无错误(退出码 0)"
|
||||
fi
|
||||
|
||||
# 产物内不得混入开发/验收工具(T-WIN-023):export_filter="all_resources" 只看
|
||||
# exclude_filter,加了新的工具目录却忘了排除,就会把探针(乃至**故意不合规**的
|
||||
# 坏服务端夹具)与向量文件打进 pck —— 实测曾占 pck 的 54%(315,464 B → 145,400 B)。
|
||||
log "产物内开发残留检查(build/ tests/ _scratch/ *.md)…"
|
||||
LIST_JSON="$WORK_DIR/pck-entries.json"
|
||||
if timeout 120 "$GODOT_BIN" --headless --main-pack "$PCK" \
|
||||
--script "$PROJECT_DIR/build/integration/list-pck.gd" \
|
||||
-- --json --out "$LIST_JSON" >"$WORK_DIR/pck-list.log" 2>&1; then
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
console.log(` ✓ pck 共 ${r.count} 项,无开发残留(build/ tests/ _scratch/ *.md)`);
|
||||
' "$LIST_JSON" || fail "pck 残留清单解析失败"
|
||||
else
|
||||
fail "产物里混入了开发/验收残留(见 $WORK_DIR/pck-list.log)"
|
||||
grep -E "✗|·" "$WORK_DIR/pck-list.log" | head -10 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
log "包内运行时探针(版本号 / 界面显示值 / 契约表 / 资源可加载)…"
|
||||
PROBE="$PROJECT_DIR/build/probe-release.gd"
|
||||
PROBE_LOG="$WORK_DIR/probe.log"
|
||||
# 探针自己断言并 quit(0/1),因此以**退出码**为准;日志单独落盘再打印,
|
||||
# 避免把「grep 的退出码」误当成「探针的退出码」。
|
||||
if timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" --script "$PROBE" \
|
||||
-- --expect-version "$EXPECTED" --snapshot "$PACKAGE_DIR/contracts/VERSION" \
|
||||
>"$PROBE_LOG" 2>&1; then
|
||||
PROBE_OK=1
|
||||
else
|
||||
PROBE_OK=0
|
||||
fi
|
||||
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
|
||||
[ "$PROBE_OK" = 1 ] || fail "包内运行时探针未通过"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "============================================================"
|
||||
echo " 发布包级验收失败(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================================"
|
||||
echo " 发布包级验收全部通过(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# OpenDance Studio Windows 客户端 —— **WebSocket 契约一致性验收**(T-WIN-020)
|
||||
#
|
||||
# contracts/websocket-keypoints.md v0.2.0 × 发布包里的 PoseServer
|
||||
#
|
||||
# 为什么需要它:本仓的 `tests/test_pose_server.gd` 用的是**同引擎内的客户端**
|
||||
# (Godot 自己的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。
|
||||
# 同引擎自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义、
|
||||
# 子协议协商)——**必须有一个独立实现的客户端来对照**:
|
||||
# * `build/integration/probe-ws.gd`:把**发布包里**的 PoseServer 跑起来
|
||||
# (只加载包内代码),把连接/控制/帧/断开事件汇总成 JSON 报告;
|
||||
# * `build/integration/ws-client.mjs`:Node 实现的契约一致性客户端,
|
||||
# 逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景并断言;
|
||||
# * 本脚本:编排两者,并把**服务端侧**的事实(丢帧计数、malformed、去重事件、
|
||||
# 关闭原因)也一并核对 —— 客户端只能看到自己那一半。
|
||||
#
|
||||
# 用法:
|
||||
# GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3.zip
|
||||
# GODOT_BIN=... bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3 # 已解压目录
|
||||
# ... --skip-slow # 跳过「保活 19s」与「静默超时 15s」两个耗时场景
|
||||
#
|
||||
# 可选环境变量:
|
||||
# PORT 监听端口(默认 18765)
|
||||
# WORK_DIR 工作目录(默认 mktemp -d)
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
TARGET="${1:-}"
|
||||
SKIP_SLOW=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-slow) SKIP_SLOW="--skip-slow" ;;
|
||||
"$TARGET") : ;;
|
||||
*) echo "✗ 未知参数:$arg(只支持 --skip-slow)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PORT="${PORT:-18765}"
|
||||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||||
mkdir -p "$WORK_DIR"
|
||||
REPORT="$WORK_DIR/ws-report.json"
|
||||
STOP_FILE="$WORK_DIR/ws-stop"
|
||||
PROBE_LOG="$WORK_DIR/probe-ws.log"
|
||||
|
||||
FAILED=0
|
||||
log() { printf '\033[1;34m[accept-ws]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[accept-ws] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||||
|
||||
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
|
||||
echo "用法:GODOT_BIN=... bash build/accept-ws.sh <发布包 zip 或已解压目录> [--skip-slow]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---- 1. 取包内 pck ----
|
||||
if [ -f "$TARGET" ]; then
|
||||
log "解压发布包…"
|
||||
if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
|
||||
command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; }
|
||||
fi
|
||||
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
else
|
||||
PACKAGE_DIR="$TARGET"
|
||||
fi
|
||||
PCK="$PACKAGE_DIR/OpenDance.pck"
|
||||
[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; }
|
||||
log "包内 pck:$PCK"
|
||||
|
||||
# ---- 2. 启探针(包内 PoseServer)----
|
||||
GODOT_BIN="${GODOT_BIN:-}"
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
for candidate in godot godot4 Godot; do
|
||||
command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; }
|
||||
done
|
||||
fi
|
||||
[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; }
|
||||
|
||||
rm -f "$REPORT" "$STOP_FILE"
|
||||
log "启动包内 PoseServer 探针(端口 $PORT)…"
|
||||
nohup "$GODOT_BIN" --headless --main-pack "$PCK" \
|
||||
--script "$PROJECT_DIR/build/integration/probe-ws.gd" \
|
||||
-- --port "$PORT" --duration-ms 180000 --report "$REPORT" --stop-file "$STOP_FILE" \
|
||||
>"$PROBE_LOG" 2>&1 &
|
||||
PROBE_PID=$!
|
||||
cleanup() { kill "$PROBE_PID" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
READY=0
|
||||
for _ in $(seq 1 60); do
|
||||
grep -q "\[ws\] listening" "$PROBE_LOG" 2>/dev/null && { READY=1; break; }
|
||||
kill -0 "$PROBE_PID" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$READY" != 1 ]; then
|
||||
fail "探针 30 秒内未开始监听"
|
||||
tail -20 "$PROBE_LOG" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
grep "\[ws\] listening" "$PROBE_LOG" | sed 's/^/ /'
|
||||
|
||||
# ---- 3. 跑契约一致性客户端 ----
|
||||
log "契约一致性客户端(Node 独立实现)…"
|
||||
CLIENT_LOG="$WORK_DIR/ws-client.log"
|
||||
if timeout 300 node "$PROJECT_DIR/build/integration/ws-client.mjs" \
|
||||
--url "ws://127.0.0.1:$PORT/pose" $SKIP_SLOW >"$CLIENT_LOG" 2>&1; then
|
||||
CLIENT_OK=1
|
||||
else
|
||||
CLIENT_OK=0
|
||||
fi
|
||||
sed 's/^/ /' "$CLIENT_LOG"
|
||||
[ "$CLIENT_OK" = 1 ] || fail "契约一致性客户端未全通过"
|
||||
|
||||
# ---- 4. 收尾并取服务端侧报告 ----
|
||||
touch "$STOP_FILE"
|
||||
for _ in $(seq 1 40); do
|
||||
kill -0 "$PROBE_PID" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
if [ ! -f "$REPORT" ]; then
|
||||
fail "探针没有产出报告($REPORT)"
|
||||
tail -20 "$PROBE_LOG" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 5. 服务端侧事实核对 ----
|
||||
log "服务端侧事实核对(客户端看不到的那一半):"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const skipSlow = process.argv[2] === "1";
|
||||
let bad = 0;
|
||||
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
|
||||
|
||||
say(r.server.silent_timeout_ms === 15000 && r.server.heartbeat_interval_ms === 5000,
|
||||
`服务端声明 silentTimeoutMs=${r.server.silent_timeout_ms} / heartbeatIntervalMs=${r.server.heartbeat_interval_ms}`);
|
||||
say(r.connected.length >= 6, `收到 ${r.connected.length} 次连接(握手完成)`);
|
||||
say(r.framesReceived >= 72, `收到 ${r.framesReceived} 个关键点帧`);
|
||||
say(r.lastFrameId >= 74, `最大 frame_id = ${r.lastFrameId}`);
|
||||
say(r.frameIdRegressions === 0, `frame_id 无回退(回退 ${r.frameIdRegressions} 次)`);
|
||||
// 客户端故意跳号 60→63,服务端应按 §8 计入丢帧
|
||||
say(r.server.frames_dropped >= 3, `丢帧检测:frames_dropped=${r.server.frames_dropped}(期望 ≥3,来自故意的 60→63 跳号)`);
|
||||
// 客户端故意发 100 字节非法帧,服务端应计数且不崩
|
||||
say(r.server.malformed_frames === 1, `非法帧计数:malformed_frames=${r.server.malformed_frames}(期望 1)`);
|
||||
// 控制帧分类计数
|
||||
const c = r.controls;
|
||||
say((c.hello ?? 0) >= 6, `hello=${c.hello ?? 0};ping=${c.ping ?? 0};stats=${c.stats ?? 0};bye=${c.bye ?? 0}`);
|
||||
say((c.ping ?? 0) >= (skipSlow ? 1 : 8), `应用层 ping 计数=${c.ping ?? 0}(保活场景)`);
|
||||
// 去重事件
|
||||
say(Array.isArray(r.deviceReplaced) && r.deviceReplaced.includes("aaaa000000000001"),
|
||||
`device_replaced 事件:${JSON.stringify(r.deviceReplaced)}`);
|
||||
// 断开原因:替换(1000) / 静默超时 / bye(1000) / 协议错误(1002)
|
||||
const reasons = r.disconnected.map((x) => x.reason).join(" | ");
|
||||
say(reasons.includes("被同一设备的新连接替换"), "服务端记录了「同设备替换」断开原因");
|
||||
say(reasons.includes("user_stop"), "服务端记录了 bye 的 reason");
|
||||
say(reasons.includes("code=1002"), "服务端记录了协议不匹配的 1002 关闭");
|
||||
if (!skipSlow) {
|
||||
say(reasons.includes("静默超时"), "服务端记录了静默超时踢人");
|
||||
// 写这条时第一版写成了 !reasons.includes("静默超时 1") —— 恰好把「消息里带 15001 ms」
|
||||
// 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。
|
||||
say(/静默超时 \d+ ms(阈值 \d+ ms/.test(reasons),
|
||||
"静默超时消息带实测毫秒数与阈值(可核对):" + (reasons.match(/静默超时 [^|]*/) ?? [""])[0].trim());
|
||||
}
|
||||
process.exit(bad);
|
||||
' "$REPORT" "$([ -n "$SKIP_SLOW" ] && echo 1 || echo 0)" || fail "服务端侧事实核对未通过"
|
||||
|
||||
# 探针日志里不允许出现脚本错误(WARNING 是故意的负例产生的,允许)
|
||||
if grep -qE "SCRIPT ERROR|Parse Error" "$PROBE_LOG"; then
|
||||
fail "探针日志出现脚本错误"
|
||||
grep -E "SCRIPT ERROR|Parse Error" "$PROBE_LOG" | head -5 | sed 's/^/ /'
|
||||
else
|
||||
echo " ✓ 探针日志无脚本错误(负例产生的 WARNING 属预期)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "============================================================"
|
||||
echo " WebSocket 契约一致性验收失败(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================================"
|
||||
echo " WebSocket 契约一致性验收全部通过(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
extends SceneTree
|
||||
|
||||
## 列出发布包内 pck 的资源清单(T-WIN-023)
|
||||
##
|
||||
## 用途:`accept-release.sh` 用它断言**开发/验收工具不会被打进产物** ——
|
||||
## 起因是一个真实缺陷:`export_filter="all_resources"` + `exclude_filter` 里没有 `build/*`,
|
||||
## 于是 `build/` 下的探针脚本(含**故意不合规**的 `probe-ws-bad.gd`)与
|
||||
## `frame-vectors.json` 全被编译打包进 `OpenDance.pck`:
|
||||
## pck 315,464 B → 加上 exclude 后 145,400 B(**54% 是测试夹具**)。
|
||||
##
|
||||
## 用法:
|
||||
## godot --headless --main-pack <pck> --script <本文件> [-- --json]
|
||||
const DEV_PREFIXES := ["res://build/", "res://tests/", "res://_scratch/", "res://dist-release/"]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var as_json := args.has("--json")
|
||||
var found: Array = []
|
||||
_walk("res://", found)
|
||||
found.sort()
|
||||
|
||||
var dev_entries: Array = []
|
||||
for f in found:
|
||||
for prefix in DEV_PREFIXES:
|
||||
if f.begins_with(prefix):
|
||||
dev_entries.append(f)
|
||||
break
|
||||
if f.ends_with(".md"):
|
||||
if not dev_entries.has(f):
|
||||
dev_entries.append(f)
|
||||
|
||||
if as_json:
|
||||
var report := {
|
||||
"entries": found,
|
||||
"count": found.size(),
|
||||
"devEntries": dev_entries,
|
||||
}
|
||||
var path := ""
|
||||
var i := args.find("--out")
|
||||
if i >= 0 and i + 1 < args.size():
|
||||
path = args[i + 1]
|
||||
if path != "":
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file != null:
|
||||
file.store_string(JSON.stringify(report, " "))
|
||||
file.close()
|
||||
print("[pck] 清单已写入 %s(%d 项,开发残留 %d 项)" % [path, found.size(), dev_entries.size()])
|
||||
else:
|
||||
print(JSON.stringify(report))
|
||||
quit(0 if dev_entries.is_empty() else 1)
|
||||
return
|
||||
|
||||
print("[pck] 文件数 %d" % found.size())
|
||||
for f in found:
|
||||
print(" ", f)
|
||||
if dev_entries.is_empty():
|
||||
print("[pck] ✓ 无开发/验收残留(build/ tests/ _scratch/ *.md)")
|
||||
else:
|
||||
print("[pck] ✗ 产物里混入了开发残留 %d 项:" % dev_entries.size())
|
||||
for f in dev_entries:
|
||||
print(" · ", f)
|
||||
quit(0 if dev_entries.is_empty() else 1)
|
||||
func _walk(dir: String, out: Array) -> void:
|
||||
var d := DirAccess.open(dir)
|
||||
if d == null: return
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var name := d.get_next()
|
||||
if name == "": break
|
||||
var path := dir.path_join(name) if dir != "res://" else "res://" + name
|
||||
if d.current_is_dir():
|
||||
if name != "." and name != "..": _walk(path, out)
|
||||
else:
|
||||
out.append(path)
|
||||
d.list_dir_end()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cp5nggx7y2164
|
||||
@@ -0,0 +1,213 @@
|
||||
extends SceneTree
|
||||
|
||||
## 关键点帧跨实现一致性探针(T-WIN-021)
|
||||
##
|
||||
## 用**发布包里的** `KeypointFrame` 解码由 opendance-web 参考实现(TypeScript)
|
||||
## 生成的 239 字节向量,逐值比对。用法(由 build/accept-frame-vectors.sh 调用):
|
||||
##
|
||||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||||
## --script /绝对路径/build/integration/probe-frame-vectors.gd \
|
||||
## -- --vectors /绝对路径/frame-vectors.json --report /tmp/frame-vectors-report.json
|
||||
##
|
||||
## 为什么需要:`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的
|
||||
## **参考实现**(其 `decodeBinaryFrame` 的注释就是「Windows 端参考实现(T-WIN-004)」),
|
||||
## Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;而**本端的 GDScript 解码器
|
||||
## 此前从未与它逐值比对过** —— 只有本端自己的单测(自己编码、自己解码)。
|
||||
##
|
||||
## 比对口径(重要):
|
||||
## * `flags` / `frame_id`:**精确相等**;
|
||||
## * 坐标与置信度:本端把点存进 `PackedVector3Array`、置信度存进 `PackedFloat32Array`,
|
||||
## 即 **float32**;参考实现是 float64。因此先把期望值收敛到 float32
|
||||
## (GDScript 里借 `Vector3(...)` / `PackedFloat32Array([...])` 天然完成)
|
||||
## 再比**精确相等** —— 这比用容差更严格,且把差异限定在「表示精度」上。
|
||||
## * 另附:用本端 `encode()` 对向量里的 `f32InputPoints`(float32 同输入基准)
|
||||
## 重新编码,与 `f32Hex` 逐字节比对。v0.2.4 起本端取整已改为 `floor(x + 0.5)`
|
||||
## (与 JS `Math.round` 的「半数朝 +∞」一致),因此**差异必须为 0**;
|
||||
## 收敛前的宽松口径(只允许出现在 .5 取整边界)已废弃 ——
|
||||
## 规则性偏差留在日志里会变成"永远没人看的已知差异"。
|
||||
|
||||
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
|
||||
|
||||
var _vectors_path := ""
|
||||
var _report_path := ""
|
||||
var _done := false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
match args[i]:
|
||||
"--vectors":
|
||||
_vectors_path = args[i + 1]; i += 2
|
||||
"--report":
|
||||
_report_path = args[i + 1]; i += 2
|
||||
_:
|
||||
i += 1
|
||||
_run()
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
return _done
|
||||
|
||||
|
||||
func _hex_to_bytes(hex: String) -> PackedByteArray:
|
||||
var out := PackedByteArray()
|
||||
out.resize(hex.length() / 2)
|
||||
for i in out.size():
|
||||
out[i] = hex.substr(i * 2, 2).hex_to_int()
|
||||
return out
|
||||
|
||||
|
||||
## 把 float64 收敛成 float32:GDScript 的 Vector3 分量 / PackedFloat32Array 都是 float32
|
||||
static func _to_f32(value: float) -> float:
|
||||
return Vector3(value, 0.0, 0.0).x
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
if _vectors_path == "" or not FileAccess.file_exists(_vectors_path):
|
||||
print("[frame-vectors] ✗ 找不到向量文件:%s" % _vectors_path)
|
||||
quit(2)
|
||||
return
|
||||
var file := FileAccess.open(_vectors_path, FileAccess.READ)
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
print("[frame-vectors] ✗ 向量文件不是 JSON 对象")
|
||||
quit(2)
|
||||
return
|
||||
|
||||
var cases: Array = parsed.get("cases", [])
|
||||
print("[frame-vectors] 参考实现:%s" % parsed.get("source", "(未标注)"))
|
||||
print("[frame-vectors] 用例 %d 个" % cases.size())
|
||||
|
||||
var decoded_cases := 0
|
||||
var decode_failures: Array = []
|
||||
var encode_differences: Array = []
|
||||
var total_points := 0
|
||||
|
||||
for c in cases:
|
||||
var name := str(c.get("name", "(未命名)"))
|
||||
var bytes := _hex_to_bytes(str(c.get("hex", "")))
|
||||
var expect_frame_id := int(c.get("frameId", 0))
|
||||
var expect_flags := int(c.get("flags", 0))
|
||||
var expect_points: Array = c.get("points", [])
|
||||
|
||||
# ---- 校验 + 解码 ----
|
||||
var problem := KeypointFrame.validate(bytes)
|
||||
if problem != "":
|
||||
decode_failures.append({"case": name, "problem": "validate: " + problem})
|
||||
continue
|
||||
var frame = KeypointFrame.decode(bytes)
|
||||
if frame == null:
|
||||
decode_failures.append({"case": name, "problem": "decode 返回 null"})
|
||||
continue
|
||||
decoded_cases += 1
|
||||
|
||||
if int(frame.frame_id) != expect_frame_id:
|
||||
decode_failures.append({
|
||||
"case": name,
|
||||
"problem": "frame_id 期望 %d,实际 %d" % [expect_frame_id, frame.frame_id],
|
||||
})
|
||||
if int(frame.flags) != expect_flags:
|
||||
decode_failures.append({
|
||||
"case": name,
|
||||
"problem": "flags 期望 %d,实际 %d" % [expect_flags, frame.flags],
|
||||
})
|
||||
|
||||
# ---- 逐点比对(float32 精确相等)----
|
||||
for i in expect_points.size():
|
||||
total_points += 1
|
||||
var e: Array = expect_points[i]
|
||||
var want := Vector3(_to_f32(float(e[0])), _to_f32(float(e[1])), _to_f32(float(e[2])))
|
||||
var want_conf := PackedFloat32Array([float(e[3])])[0]
|
||||
var got: Vector3 = frame.points[i]
|
||||
var got_conf: float = frame.confidence[i]
|
||||
if got != want:
|
||||
decode_failures.append({
|
||||
"case": name,
|
||||
"problem": "点 %d 坐标不符:期望 %s,实际 %s" % [i, str(want), str(got)],
|
||||
})
|
||||
elif got_conf != want_conf:
|
||||
decode_failures.append({
|
||||
"case": name,
|
||||
"problem": "点 %d 置信度不符:期望 %s,实际 %s" % [i, str(want_conf), str(got_conf)],
|
||||
})
|
||||
|
||||
# ---- 附带:本端 encode() 与参考字节的差异(本端不是发送方,记录不判死)----
|
||||
# 必须用 **f32InputPoints**(已收敛到 float32)来重编码:本端点/置信度就是 float32,
|
||||
# 若拿 float64 的原始输入比,差异会同时来自「输入精度」与「取整规则」,无法归因。
|
||||
var input_points: Array = c.get("f32InputPoints", c.get("inputPoints", []))
|
||||
# 与**同输入**基准比:`f32Hex` 是用 f32InputPoints 编出来的字节。
|
||||
# 拿 `hex`(float64 输入)比会把「输入精度差异」也算进来(第一版就是这么误判的:
|
||||
# 「正数恰好 .5」整例 99 字节不同,其实只是输入从 float64 收敛到 float32 后的量化差)。
|
||||
var baseline_hex := str(c.get("f32Hex", c.get("hex", "")))
|
||||
var baseline := _hex_to_bytes(baseline_hex)
|
||||
if input_points.size() == 33:
|
||||
var pts := PackedVector3Array()
|
||||
var conf := PackedFloat32Array()
|
||||
pts.resize(33)
|
||||
conf.resize(33)
|
||||
for i in 33:
|
||||
var p: Array = input_points[i]
|
||||
pts[i] = Vector3(float(p[0]), float(p[1]), float(p[2]))
|
||||
conf[i] = float(p[3])
|
||||
var re_encoded := KeypointFrame.encode(expect_frame_id, pts, conf, expect_flags)
|
||||
var diff_offsets: Array = []
|
||||
for i in re_encoded.size():
|
||||
if re_encoded[i] != baseline[i]:
|
||||
diff_offsets.append(i)
|
||||
if diff_offsets.size() > 0:
|
||||
# 归类:本端 `roundi` 对「.5」的进位方向与 JS `Math.round` 不同
|
||||
# (-2.5:GDScript → -3,JS → -2)。**同输入**下,这类差异只可能
|
||||
# 出现在量化前恰好落在 X.5 的用例上 —— 这里直接**算**出来,
|
||||
# 而不是靠用例名猜(第一版按名字猜,漏判了「置信度 128/255」,
|
||||
# 因为那一例的 y 也取了 -0.5)。
|
||||
var has_half := false
|
||||
for p in input_points:
|
||||
for value in [p[0], p[1], p[2]]:
|
||||
var scaled: float = float(value) * 32767.0
|
||||
var a := absf(scaled)
|
||||
if absf(a - floorf(a) - 0.5) < 1e-9:
|
||||
has_half = true
|
||||
var c_scaled: float = float(p[3]) * 255.0
|
||||
var ac := absf(c_scaled)
|
||||
if absf(ac - floorf(ac) - 0.5) < 1e-9:
|
||||
has_half = true
|
||||
encode_differences.append({
|
||||
"case": name,
|
||||
"diffBytes": diff_offsets.size(),
|
||||
"halfValueInput": has_half,
|
||||
})
|
||||
|
||||
var report := {
|
||||
"source": parsed.get("source", ""),
|
||||
"cases": cases.size(),
|
||||
"decodedCases": decoded_cases,
|
||||
"pointsCompared": total_points,
|
||||
"decodeFailures": decode_failures,
|
||||
"encodeDifferences": encode_differences,
|
||||
}
|
||||
if _report_path != "":
|
||||
var out := FileAccess.open(_report_path, FileAccess.WRITE)
|
||||
if out != null:
|
||||
out.store_string(JSON.stringify(report, " "))
|
||||
out.close()
|
||||
|
||||
print("[frame-vectors] 解码用例 %d/%d,比对点 %d 个" % [decoded_cases, cases.size(), total_points])
|
||||
if decode_failures.is_empty():
|
||||
print("[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)")
|
||||
else:
|
||||
print("[frame-vectors] ✗ 解码差异 %d 项:" % decode_failures.size())
|
||||
for f in decode_failures.slice(0, 8):
|
||||
print(" · %s:%s" % [f["case"], f["problem"]])
|
||||
if encode_differences.is_empty():
|
||||
print("[frame-vectors] ✓ 本端 encode() 与参考字节完全一致")
|
||||
else:
|
||||
print("[frame-vectors] ✗ 本端 encode() 与参考字节存在差异(取整规则应已对齐,必须为 0):")
|
||||
for d in encode_differences:
|
||||
print(" · %s:%d 字节不同(量化前存在 .5 值=%s)" % [
|
||||
d["case"], d["diffBytes"], str(d.get("halfValueInput", false))])
|
||||
|
||||
_done = true
|
||||
quit(1 if decode_failures.size() > 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://chtushi1p64ng
|
||||
@@ -0,0 +1,275 @@
|
||||
extends SceneTree
|
||||
|
||||
## 跨端集成探针(T-WIN-019):**发布包里的 Windows 客户端** × **真实 Web 服务端**
|
||||
##
|
||||
## 用法(由 build/accept-integration.sh 调用,也可手工执行):
|
||||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||||
## --script /绝对路径/build/integration/probe-integration.gd \
|
||||
## -- --api-base http://127.0.0.1:3100 --expect /绝对路径/catalog-expect.json
|
||||
##
|
||||
## 为什么需要它:本仓的单测用的是**假 HTTP 夹具**(`tests/test_api_integration.gd`
|
||||
## 打的是本地起的小服务),Web 端的测试又只验自己那一侧。两端各自绿灯,
|
||||
## 不代表**契约字段真的对得上**:字段名大小写、嵌套层级、null 与缺字段、
|
||||
## 单位(米/度/秒)、归一化坐标范围……都可能在一侧对、另一侧错。
|
||||
## 契约 0.5.0 新增的 `scenes[]` 与物理参数尤其如此。
|
||||
##
|
||||
## 本探针只加载**包内**代码(`--main-pack` + `res://`),打真实服务端:
|
||||
## 1. `/health`:连通性 + 契约版本;
|
||||
## 2. `/api/catalog/version`:目录指纹;
|
||||
## 3. `/api/catalog/export` → `CatalogStore` 解析出的**类型化**目录:
|
||||
## 曲目 / 动作 / 模型 / 场景 + 默认模型;
|
||||
## 4. 场景物理参数逐项比对(重力 / 风 / 地面 / 碰撞体 / 出生点 / 相机 / 环境)
|
||||
## —— ⑤「场景选择与物理参数落地」的跨端证据;
|
||||
## 5. 动作关键点序列(NDJSON)→ `KeypointSequence.parse_ndjson`:
|
||||
## 帧数、33 点、时间轴、服务端关节角 —— ②「动作内部标准」跨端闭环;
|
||||
## 6. 模型 / 场景 / 缩略图资源**可下载**且魔数正确(① ③ 的消费侧)。
|
||||
|
||||
const CatalogTypes = preload("res://src/net/catalog_types.gd")
|
||||
const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
|
||||
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
|
||||
|
||||
var _api_base := ""
|
||||
var _expect_path := ""
|
||||
var _expect: Dictionary = {}
|
||||
var _failed := 0
|
||||
var _started := false
|
||||
var _done := false
|
||||
var _exit_code := 0
|
||||
## 看门狗:协程里一旦出现运行时错误,`_finish()` 就不会被调用,进程会永远不退出
|
||||
## (调试时表现为「无输出卡住」,很难看出问题)。超过时限就自己判失败退出。
|
||||
const WATCHDOG_MS := 120_000
|
||||
var _start_ms := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
if args[i] == "--api-base" and i + 1 < args.size():
|
||||
_api_base = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--expect" and i + 1 < args.size():
|
||||
_expect_path = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
|
||||
## 必须在 _process 里跑:HTTPRequest 需要跨帧等待(同 tests/run_tests.gd)
|
||||
func _process(_delta: float) -> bool:
|
||||
if not _started:
|
||||
_started = true
|
||||
_start_ms = Time.get_ticks_msec()
|
||||
_run()
|
||||
if not _done and Time.get_ticks_msec() - _start_ms > WATCHDOG_MS:
|
||||
print(" ✗ 看门狗超时(%d ms):协程可能因运行时错误中断" % WATCHDOG_MS)
|
||||
_exit_code = 1
|
||||
_done = true
|
||||
quit(_exit_code)
|
||||
return _done
|
||||
|
||||
|
||||
func _check(ok: bool, text: String) -> void:
|
||||
print(" %s %s" % ["✓" if ok else "✗", text])
|
||||
if not ok:
|
||||
_failed += 1
|
||||
|
||||
|
||||
func _near(a: float, b: float, tol := 0.0001) -> bool:
|
||||
return absf(a - b) <= tol
|
||||
|
||||
|
||||
func _load_expect() -> bool:
|
||||
if _expect_path.is_empty() or not FileAccess.file_exists(_expect_path):
|
||||
print(" ✗ 找不到期望值文件:%s" % _expect_path)
|
||||
return false
|
||||
var file := FileAccess.open(_expect_path, FileAccess.READ)
|
||||
if file == null:
|
||||
print(" ✗ 无法读取期望值文件:%s" % _expect_path)
|
||||
return false
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
print(" ✗ 期望值文件不是 JSON 对象")
|
||||
return false
|
||||
_expect = parsed
|
||||
return true
|
||||
|
||||
|
||||
func _fail(text: String) -> void:
|
||||
print(" ✗ %s" % text)
|
||||
_failed += 1
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("[integration] Windows 包内客户端 × 真实 Web 服务端:%s" % _api_base)
|
||||
if _api_base.is_empty() or not _load_expect():
|
||||
_finish()
|
||||
return
|
||||
|
||||
# autoload 单例在 `--script` 模式下不能当全局标识符用(编译期找不到 AppConfig /
|
||||
# CatalogStore 这两个名字),要从场景树里取节点,再用动态调用访问。
|
||||
var app_config = root.get_node_or_null("AppConfig")
|
||||
var catalog_store = root.get_node_or_null("CatalogStore")
|
||||
if app_config == null or catalog_store == null:
|
||||
_fail("场景树里没有 AppConfig / CatalogStore(autoload 未加载)")
|
||||
_finish()
|
||||
return
|
||||
app_config.api_base_url = _api_base
|
||||
var client = catalog_store.client()
|
||||
client.set_base_url(_api_base)
|
||||
|
||||
# ---------- 1. /health ----------
|
||||
var health: Dictionary = await client.health()
|
||||
if not health["ok"]:
|
||||
_fail("GET /health 失败:%s" % health["error"])
|
||||
_finish()
|
||||
return
|
||||
var health_data: Dictionary = health["data"]
|
||||
_check(health["status"] == 200, "GET /health 200(实际 %d)" % health["status"])
|
||||
_check(health_data.get("version", "") != "", "服务端版本 = %s" % health_data.get("version", ""))
|
||||
var contract: Dictionary = health_data.get("contractVersion", {})
|
||||
# 期望值文件缺键时给可读错误,而不是在运行时抛 "Invalid access to property"
|
||||
var want_contract: Dictionary = _expect.get("contractVersions", {})
|
||||
if want_contract.is_empty():
|
||||
_fail("期望值文件缺少 contractVersions(请重新播种,或用本仓 seed-catalog.mjs 生成)")
|
||||
for key in want_contract.keys():
|
||||
_check(contract.get(key, "") == want_contract[key],
|
||||
"契约 %s = %s(服务端 %s)" % [key, want_contract[key], contract.get(key, "缺失")])
|
||||
|
||||
# ---------- 2/3. 目录 ----------
|
||||
var refresh: Dictionary = await catalog_store.refresh()
|
||||
if not refresh["ok"]:
|
||||
_fail("GET /api/catalog/export 失败:%s" % refresh.get("error", ""))
|
||||
_finish()
|
||||
return
|
||||
var bundle: CatalogTypes.CatalogBundle = catalog_store.bundle
|
||||
_check(catalog_store.revision != "", "目录指纹非空:%s" % catalog_store.revision)
|
||||
if not want_contract.is_empty():
|
||||
_check(catalog_store.contract_version.get("rest-api", "") == want_contract.get("rest-api", ""),
|
||||
"目录内嵌契约版本与 /health 一致")
|
||||
|
||||
var songs: Array = catalog_store.songs()
|
||||
var motions: Array = catalog_store.motions()
|
||||
var models: Array = catalog_store.models()
|
||||
var scenes: Array = catalog_store.scenes()
|
||||
_check(songs.size() >= 1 and motions.size() >= 1 and models.size() >= 1 and scenes.size() >= 1,
|
||||
"目录四类资源齐备:songs=%d motions=%d models=%d scenes=%d" % [
|
||||
songs.size(), motions.size(), models.size(), scenes.size()])
|
||||
if not _expect.has("modelId") or not _expect.has("sceneId") or not _expect.has("motionId"):
|
||||
_fail("期望值文件缺少 modelId / sceneId / motionId")
|
||||
_finish()
|
||||
return
|
||||
_check(bundle.default_model_id == _expect["modelId"],
|
||||
"defaultModelId 解析为 %d(期望 %d)" % [bundle.default_model_id, _expect["modelId"]])
|
||||
|
||||
# ---------- 4. ⑤ 场景选择 + 物理参数落地 ----------
|
||||
var scene: CatalogTypes.Scene = bundle.find_scene(_expect["sceneId"])
|
||||
if scene == null:
|
||||
_fail("目录里找不到场景 #%d" % _expect["sceneId"])
|
||||
else:
|
||||
var cfg: CatalogTypes.SceneConfig = scene.config
|
||||
var want: Dictionary = _expect["sceneConfig"]
|
||||
var w_phys: Dictionary = want["physics"]
|
||||
_check(scene.glb_url.begins_with("/static/"), "场景 glbUrl = %s" % scene.glb_url)
|
||||
_check(scene.file_size > 0, "场景 fileSize = %d" % scene.file_size)
|
||||
_check(_near(cfg.physics.gravity.y, w_phys["gravity"]["y"]),
|
||||
"重力 y = %.2f(期望 %.2f)" % [cfg.physics.gravity.y, w_phys["gravity"]["y"]])
|
||||
_check(_near(cfg.physics.wind.base.x, w_phys["wind"]["base"]["x"])
|
||||
and _near(cfg.physics.wind.base.z, w_phys["wind"]["base"]["z"]),
|
||||
"基础风 = (%.1f, %.1f, %.1f)" % [cfg.physics.wind.base.x, cfg.physics.wind.base.y, cfg.physics.wind.base.z])
|
||||
_check(_near(cfg.physics.wind.gust_amplitude, w_phys["wind"]["gustAmplitude"])
|
||||
and _near(cfg.physics.wind.gust_frequency, w_phys["wind"]["gustFrequency"]),
|
||||
"阵风幅度/频率 = %.1f m/s @ %.2f Hz" % [cfg.physics.wind.gust_amplitude, cfg.physics.wind.gust_frequency])
|
||||
_check(cfg.physics.wind.space == w_phys["wind"]["space"],
|
||||
"风坐标系 = %s" % cfg.physics.wind.space)
|
||||
_check(_near(cfg.physics.ground.friction, w_phys["ground"]["friction"])
|
||||
and _near(cfg.physics.ground.slope_limit, w_phys["ground"]["slopeLimit"]),
|
||||
"地面摩擦/坡度 = %.2f / %.0f°" % [cfg.physics.ground.friction, cfg.physics.ground.slope_limit])
|
||||
_check(cfg.physics.colliders == w_phys["colliders"],
|
||||
"碰撞体模式 = %s" % cfg.physics.colliders)
|
||||
_check(cfg.spawn_points.size() >= 1 and _near(cfg.spawn_points[0].yaw, want["spawnPoints"][0]["yaw"]),
|
||||
"出生点 yaw = %.0f°" % (cfg.spawn_points[0].yaw if cfg.spawn_points.size() > 0 else -999.0))
|
||||
_check(cfg.camera.mode == want["camera"]["mode"]
|
||||
and _near(cfg.camera.distance, want["camera"]["distance"])
|
||||
and _near(cfg.camera.height, want["camera"]["height"]),
|
||||
"相机 %s / 距离 %.1f / 高度 %.1f" % [cfg.camera.mode, cfg.camera.distance, cfg.camera.height])
|
||||
_check(_near(cfg.environment.ambient_intensity, want["environment"]["ambientIntensity"])
|
||||
and _near(cfg.environment.fog_density, want["environment"]["fogDensity"]),
|
||||
"环境光强度 %.1f / 雾 %.2f" % [cfg.environment.ambient_intensity, cfg.environment.fog_density])
|
||||
# 阵风采样:t=0 应等于基础风,t=1/(4f) 应叠加一个完整峰值
|
||||
if not cfg.physics.wind.is_calm():
|
||||
var s0: Vector3 = cfg.physics.wind.sample(0.0)
|
||||
_check(s0.is_equal_approx(cfg.physics.wind.base), "风采样 t=0 等于基础风")
|
||||
# 场景 GLB 真的能下载
|
||||
var glb: Dictionary = await client.get_binary(scene.glb_url)
|
||||
if glb["ok"]:
|
||||
var bytes: PackedByteArray = glb["data"]
|
||||
var magic := bytes.slice(0, 4).get_string_from_ascii()
|
||||
_check(magic == "glTF" and bytes.size() == scene.file_size,
|
||||
"场景 GLB 可下载且魔数正确(%s,%d 字节)" % [magic, bytes.size()])
|
||||
else:
|
||||
_fail("场景 GLB 下载失败:%s" % glb["error"])
|
||||
|
||||
# ---------- 5. ② 动作内部标准跨端闭环 ----------
|
||||
var motion: CatalogTypes.Motion = bundle.find_motion(_expect["motionId"])
|
||||
if motion == null:
|
||||
_fail("目录里找不到动作 #%d" % _expect["motionId"])
|
||||
else:
|
||||
_check(motion.keypoint_format == _expect["keypointFormat"],
|
||||
"动作内部标准 = %s" % motion.keypoint_format)
|
||||
_check(motion.keypoint_frames == _expect["motionFrames"],
|
||||
"服务端帧数 = %d(期望 %d)" % [motion.keypoint_frames, _expect["motionFrames"]])
|
||||
_check(motion.is_scorable(), "动作可评分(预处理已完成)")
|
||||
var seq_result: Dictionary = await catalog_store.fetch_sequence(_expect["motionId"])
|
||||
if not seq_result["ok"]:
|
||||
_fail("关键点序列拉取/解析失败:%s" % seq_result.get("error", ""))
|
||||
else:
|
||||
var seq: KeypointSequence.ParsedSequence = seq_result["sequence"]
|
||||
_check(seq.frame_count() == _expect["motionFrames"],
|
||||
"客户端解析帧数 = %d(期望 %d)" % [seq.frame_count(), _expect["motionFrames"]])
|
||||
_check(seq.skipped_lines == 0, "无解析失败行(skipped=%d)" % seq.skipped_lines)
|
||||
_check(seq.frames.size() > 0 and seq.frames[0].points.size() == KeypointFrame.KEYPOINT_COUNT,
|
||||
"首帧点数 = %d" % (seq.frames[0].points.size() if seq.frames.size() > 0 else 0))
|
||||
_check(seq.server_angles.size() == seq.frame_count(), "每帧都带服务端关节角")
|
||||
_check(_near(seq.estimated_fps(), 30.0, 0.5),
|
||||
"时间轴推导帧率 = %.2f fps" % seq.estimated_fps())
|
||||
var duration_s := (seq.times_ms[seq.times_ms.size() - 1] - seq.times_ms[0]) / 1000.0
|
||||
_check(duration_s > 90.0 and duration_s < 100.0, "动作时长 = %.2f s" % duration_s)
|
||||
# 缩略图(③ 预览资产)可下载且为 PNG
|
||||
var thumb: Dictionary = await client.get_binary(motion.thumbnail_url)
|
||||
if thumb["ok"]:
|
||||
var png: PackedByteArray = thumb["data"]
|
||||
_check(png.slice(0, 8).hex_encode() == "89504e470d0a1a0a",
|
||||
"动作缩略图可下载且为 PNG(%d 字节)" % png.size())
|
||||
else:
|
||||
_fail("动作缩略图下载失败:%s" % thumb["error"])
|
||||
|
||||
# ---------- 6. ① 模型资源可下载 ----------
|
||||
var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"])
|
||||
if model == null:
|
||||
_fail("目录里找不到模型 #%d" % _expect["modelId"])
|
||||
else:
|
||||
_check(model.format == "vrm", "模型格式 = %s" % model.format)
|
||||
_check(model.is_primary, "模型为默认模型")
|
||||
var model_bytes: Dictionary = await client.get_binary(model.file_url)
|
||||
if model_bytes["ok"]:
|
||||
var payload: PackedByteArray = model_bytes["data"]
|
||||
_check(payload.slice(0, 4).get_string_from_ascii() == "glTF"
|
||||
and payload.size() == model.file_size,
|
||||
"模型文件可下载且为 glTF 容器(%d 字节)" % payload.size())
|
||||
else:
|
||||
_fail("模型下载失败:%s" % model_bytes["error"])
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if _failed > 0:
|
||||
print("[integration] ✗ 跨端集成探针发现 %d 项不符" % _failed)
|
||||
_exit_code = 1
|
||||
else:
|
||||
print("[integration] ✓ 跨端集成探针全部通过")
|
||||
_exit_code = 0
|
||||
_done = true
|
||||
quit(_exit_code)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dexb1tstsyjuh
|
||||
@@ -0,0 +1,96 @@
|
||||
extends SceneTree
|
||||
|
||||
## 反例服务端(T-WIN-020 的**负例**夹具):故意不遵守 websocket-keypoints v0.2.0。
|
||||
##
|
||||
## 用途:证明 `build/integration/ws-client.mjs` 的断言**真的会失败**。
|
||||
## 一个只跑通过用例的检查器,无法区分「契约被遵守」与「断言写得太松」。
|
||||
##
|
||||
## 故意违反的条款:
|
||||
## * §3.2 welcome **不声明** silentTimeoutMs / heartbeatIntervalMs(v0.2.0 硬性要求);
|
||||
## * §3.2 welcome 不带 sessionId;
|
||||
## * §3.6 收到应用层 ping **不回** pong;
|
||||
## * §5 不做 deviceId 去重(第二个同 deviceId 连接不会踢掉第一个)。
|
||||
##
|
||||
## 用法:与 probe-ws.gd 相同(`--port` / `--duration-ms` / `--report` / `--stop-file`),
|
||||
## 但本文件只记录最小事实。期望结果:ws-client.mjs 出现多项 ✗ 并以非 0 退出。
|
||||
|
||||
const DEFAULT_PORT := 18770
|
||||
const DEFAULT_DURATION_MS := 60_000
|
||||
|
||||
var _tcp := TCPServer.new()
|
||||
var _peers: Array = []
|
||||
var _started := false
|
||||
var _done := false
|
||||
var _start_ms := 0
|
||||
var _port := DEFAULT_PORT
|
||||
var _duration_ms := DEFAULT_DURATION_MS
|
||||
var _report_path := ""
|
||||
var _stop_file := ""
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
match args[i]:
|
||||
"--port":
|
||||
_port = int(args[i + 1]); i += 2
|
||||
"--duration-ms":
|
||||
_duration_ms = int(args[i + 1]); i += 2
|
||||
"--report":
|
||||
_report_path = args[i + 1]; i += 2
|
||||
"--stop-file":
|
||||
_stop_file = args[i + 1]; i += 2
|
||||
_:
|
||||
i += 1
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
if not _started:
|
||||
_started = true
|
||||
_start_ms = Time.get_ticks_msec()
|
||||
var err := _tcp.listen(_port, "127.0.0.1")
|
||||
if err != OK:
|
||||
print("[bad-ws] ✗ listen 失败:%s" % error_string(err))
|
||||
quit(2)
|
||||
return true
|
||||
print("[bad-ws] listening on 127.0.0.1:%d(**故意不合规**的服务端)" % _port)
|
||||
|
||||
while _tcp.is_connection_available():
|
||||
var conn := _tcp.take_connection()
|
||||
if conn == null:
|
||||
continue
|
||||
var peer := WebSocketPeer.new()
|
||||
peer.supported_protocols = PackedStringArray(["opendance.pose.v1"])
|
||||
if peer.accept_stream(conn) == OK:
|
||||
_peers.append(peer)
|
||||
|
||||
for peer in _peers:
|
||||
peer.poll()
|
||||
while peer.get_available_packet_count() > 0:
|
||||
# peer 来自无类型数组,返回值类型推不出来,这里显式标注
|
||||
var packet: PackedByteArray = peer.get_packet()
|
||||
if peer.was_string_packet():
|
||||
var text: String = packet.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if typeof(parsed) == TYPE_DICTIONARY and str(parsed.get("type", "")) == "hello":
|
||||
# 违规 1/2/3:缺保活字段、缺 sessionId、且此后不回 pong
|
||||
peer.send_text(JSON.stringify({
|
||||
"type": "welcome",
|
||||
"protocol": "opendance.pose.v1",
|
||||
"expectedFps": 30,
|
||||
}))
|
||||
|
||||
if _done:
|
||||
return true
|
||||
var elapsed := Time.get_ticks_msec() - _start_ms
|
||||
if elapsed >= _duration_ms or (_stop_file != "" and FileAccess.file_exists(_stop_file)):
|
||||
if _report_path != "":
|
||||
var file := FileAccess.open(_report_path, FileAccess.WRITE)
|
||||
if file != null:
|
||||
file.store_string(JSON.stringify({"server": "bad-ws", "peers": _peers.size()}, " "))
|
||||
file.close()
|
||||
print("[bad-ws] 收尾(连接 %d,未做去重、未回 pong)" % _peers.size())
|
||||
_done = true
|
||||
quit(0)
|
||||
return _done
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpri5w6clu80v
|
||||
@@ -0,0 +1,175 @@
|
||||
extends SceneTree
|
||||
|
||||
## WebSocket 契约一致性探针(T-WIN-020)
|
||||
##
|
||||
## 用法(由 build/accept-ws.sh 调用,也可手工执行):
|
||||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||||
## --script /绝对路径/build/integration/probe-ws.gd \
|
||||
## -- --port 18765 --duration-ms 60000 --report /tmp/ws-report.json
|
||||
##
|
||||
## 为什么需要它:`tests/test_pose_server.gd` 用的是**同引擎内的客户端**
|
||||
## (Godot 自己写的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。
|
||||
## 同引擎内的自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义),
|
||||
## 因此**必须有一个独立实现的客户端**来对照 —— 这就是本探针 + Node 客户端的组合:
|
||||
## * 本探针:把**发布包里的** PoseServer 跑起来(只加载包内代码),
|
||||
## 把收到的连接/控制/帧/断开事件汇总成 JSON 报告;
|
||||
## * `build/integration/ws-client.mjs`:Node 端按契约 v0.2.0 逐条跑场景并断言。
|
||||
##
|
||||
## 探针自身不做判定(只收集事实),判定在 Node 客户端与 accept-ws.sh 里,避免
|
||||
## 「用被测实现来判定被测实现」。
|
||||
|
||||
const DEFAULT_PORT := 18765
|
||||
const DEFAULT_DURATION_MS := 60_000
|
||||
|
||||
var _server: Node = null
|
||||
var _started := false
|
||||
var _done := false
|
||||
var _start_ms := 0
|
||||
var _port := DEFAULT_PORT
|
||||
var _duration_ms := DEFAULT_DURATION_MS
|
||||
var _report_path := "/tmp/opendance-ws-report.json"
|
||||
## 可选:客户端跑完后 touch 这个文件,探针提前收尾(比固定时长更快、更稳)
|
||||
var _stop_file := ""
|
||||
|
||||
var _events: Array = []
|
||||
var _connected: Array = []
|
||||
var _disconnected: Array = []
|
||||
var _replaced: Array = []
|
||||
var _controls := {}
|
||||
var _frames := 0
|
||||
var _last_frame_id := -1
|
||||
var _frame_id_regressions := 0
|
||||
var _first_frame_ms := 0
|
||||
var _last_frame_ms := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
match args[i]:
|
||||
"--port":
|
||||
_port = int(args[i + 1]); i += 2
|
||||
"--duration-ms":
|
||||
_duration_ms = int(args[i + 1]); i += 2
|
||||
"--report":
|
||||
_report_path = args[i + 1]; i += 2
|
||||
"--stop-file":
|
||||
_stop_file = args[i + 1]; i += 2
|
||||
_:
|
||||
i += 1
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
if not _started:
|
||||
_started = true
|
||||
_start_ms = Time.get_ticks_msec()
|
||||
_start_server()
|
||||
if _done:
|
||||
return true
|
||||
var elapsed := Time.get_ticks_msec() - _start_ms
|
||||
if elapsed >= _duration_ms:
|
||||
_finish()
|
||||
elif _stop_file != "" and FileAccess.file_exists(_stop_file):
|
||||
_finish()
|
||||
return _done
|
||||
|
||||
|
||||
func _start_server() -> void:
|
||||
var script: GDScript = load("res://src/net/pose_server.gd")
|
||||
if script == null:
|
||||
print("[ws] ✗ 无法加载 res://src/net/pose_server.gd")
|
||||
_finish(2)
|
||||
return
|
||||
_server = script.new()
|
||||
_server.name = "ProbePoseServer"
|
||||
root.add_child(_server)
|
||||
|
||||
_server.listening.connect(_on_listening)
|
||||
_server.listen_failed.connect(_on_listen_failed)
|
||||
_server.client_connected.connect(_on_client_connected)
|
||||
_server.client_disconnected.connect(_on_client_disconnected)
|
||||
_server.device_replaced.connect(_on_device_replaced)
|
||||
_server.frame_received.connect(_on_frame_received)
|
||||
_server.control_received.connect(_on_control_received)
|
||||
|
||||
var err: int = _server.listen("127.0.0.1", _port)
|
||||
if err != OK:
|
||||
print("[ws] ✗ listen(%d) 失败:%s" % [_port, error_string(err)])
|
||||
_finish(2)
|
||||
|
||||
|
||||
func _on_listening(port: int) -> void:
|
||||
# accept-ws.sh 靠这一行判断「服务端已就绪」
|
||||
print("[ws] listening on 127.0.0.1:%d(契约 §1 端点)" % port)
|
||||
|
||||
|
||||
func _on_listen_failed(port: int, reason: String) -> void:
|
||||
print("[ws] ✗ 监听 %d 失败:%s" % [port, reason])
|
||||
|
||||
|
||||
func _now_ms() -> int:
|
||||
return Time.get_ticks_msec() - _start_ms
|
||||
|
||||
|
||||
func _on_client_connected(client_id: int, device: String) -> void:
|
||||
_connected.append({"id": client_id, "device": device, "atMs": _now_ms()})
|
||||
_events.append({"atMs": _now_ms(), "kind": "connected", "id": client_id, "device": device})
|
||||
|
||||
|
||||
func _on_client_disconnected(client_id: int, reason: String) -> void:
|
||||
_disconnected.append({"id": client_id, "reason": reason, "atMs": _now_ms()})
|
||||
_events.append({"atMs": _now_ms(), "kind": "disconnected", "id": client_id, "reason": reason})
|
||||
|
||||
|
||||
func _on_device_replaced(device_id: String) -> void:
|
||||
_replaced.append(device_id)
|
||||
_events.append({"atMs": _now_ms(), "kind": "device_replaced", "deviceId": device_id})
|
||||
|
||||
|
||||
func _on_frame_received(frame) -> void:
|
||||
_frames += 1
|
||||
if _first_frame_ms == 0:
|
||||
_first_frame_ms = _now_ms()
|
||||
_last_frame_ms = _now_ms()
|
||||
if _last_frame_id >= 0 and frame.frame_id <= _last_frame_id:
|
||||
_frame_id_regressions += 1
|
||||
_last_frame_id = frame.frame_id
|
||||
|
||||
|
||||
func _on_control_received(client_id: int, message: Dictionary) -> void:
|
||||
var type := str(message.get("type", "(无 type)"))
|
||||
_controls[type] = int(_controls.get(type, 0)) + 1
|
||||
_events.append({"atMs": _now_ms(), "kind": "control", "id": client_id, "type": type})
|
||||
|
||||
|
||||
func _finish(code: int = 0) -> void:
|
||||
if _done:
|
||||
return
|
||||
_done = true
|
||||
var report := {
|
||||
"port": _port,
|
||||
"durationMs": _now_ms(),
|
||||
"server": _server.snapshot() if _server != null else {},
|
||||
"connected": _connected,
|
||||
"disconnected": _disconnected,
|
||||
"deviceReplaced": _replaced,
|
||||
"controls": _controls,
|
||||
"framesReceived": _frames,
|
||||
"lastFrameId": _last_frame_id,
|
||||
"frameIdRegressions": _frame_id_regressions,
|
||||
"firstFrameAtMs": _first_frame_ms,
|
||||
"lastFrameAtMs": _last_frame_ms,
|
||||
"events": _events,
|
||||
}
|
||||
if _server != null:
|
||||
_server.stop()
|
||||
var file := FileAccess.open(_report_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
print("[ws] ✗ 无法写入报告 %s" % _report_path)
|
||||
else:
|
||||
file.store_string(JSON.stringify(report, " "))
|
||||
file.close()
|
||||
print("[ws] 报告已写入 %s(连接 %d、帧 %d、控制帧 %s、替换 %d)" % [
|
||||
_report_path, _connected.size(), _frames, JSON.stringify(_controls), _replaced.size()])
|
||||
quit(code)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcw10cvmdqty6
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 为「Windows 客户端 × Web 服务端」跨端集成验收准备一份**真实**目录:
|
||||
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
|
||||
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const API = process.env.API_BASE ?? 'http://127.0.0.1:3100';
|
||||
// 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/)
|
||||
const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR
|
||||
?? new URL('../../../示例文件', import.meta.url).pathname;
|
||||
const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json';
|
||||
const SOURCE = {
|
||||
sourceType: 'original',
|
||||
sourceName: '跨端集成验收素材',
|
||||
sourceUrl: 'https://example.com/opendance',
|
||||
license: 'MIT',
|
||||
author: 'OpenDance QA',
|
||||
};
|
||||
|
||||
async function request(method, path, { json, form } = {}) {
|
||||
const init = { method };
|
||||
if (json !== undefined) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(json); }
|
||||
if (form !== undefined) init.body = form;
|
||||
const res = await fetch(`${API}${path}`, init);
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
function fileForm(fields, filename, content, mime = 'application/octet-stream') {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) form.append(k, String(v));
|
||||
form.append('file', new Blob([content], { type: mime }), filename);
|
||||
return form;
|
||||
}
|
||||
|
||||
const must = (cond, what, extra = '') => {
|
||||
if (!cond) { console.error(`✗ ${what} ${extra}`); process.exit(1); }
|
||||
console.log(` ✓ ${what}`);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
// ---------- 歌曲 ----------
|
||||
const song = await request('POST', '/api/songs', {
|
||||
json: { title: '霜雪千年', artist: '乐正绫 / 洛天依', album: '跨端验收', language: 'zh', durationMs: 96700, bpm: 120, difficulty: 3, ...SOURCE },
|
||||
});
|
||||
must(song.status === 201, '创建曲目', `${song.status} ${JSON.stringify(song.body)}`);
|
||||
const songId = song.body.id;
|
||||
|
||||
// ---------- 动作:真实 VMD ----------
|
||||
const vmd = readFileSync(`${SAMPLES}/【动作】霜雪千年.vmd`);
|
||||
const motion = await request('POST', '/api/motions', {
|
||||
form: fileForm({ name: '霜雪千年(真实 VMD)', songId, autoPreprocess: false, ...SOURCE }, '霜雪千年.vmd', vmd, 'application/octet-stream'),
|
||||
});
|
||||
must(motion.status === 201, '上传真实 VMD 动作', `${motion.status} ${JSON.stringify(motion.body)}`);
|
||||
const motionId = motion.body.id;
|
||||
must(motion.body.format === 'vmd', `识别格式 vmd(实际 ${motion.body.format})`);
|
||||
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
|
||||
must(pre.status === 200, '预处理为内部标准 33 点', `${pre.status} ${JSON.stringify(pre.body)}`);
|
||||
console.log(` 预处理结果:${JSON.stringify(pre.body)}`);
|
||||
|
||||
// ---------- 模型:真实 PMX 压缩包 → VRM ----------
|
||||
const pmxZip = readFileSync(`${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`);
|
||||
const model = await request('POST', '/api/models', {
|
||||
form: fileForm({ name: '初音未来(真实 PMX → VRM)', ...SOURCE }, 'miku.zip', pmxZip, 'application/zip'),
|
||||
});
|
||||
must(model.status === 201, '上传真实 PMX 压缩包', `${model.status} ${JSON.stringify(model.body)}`);
|
||||
const modelId = model.body.id;
|
||||
must(model.body.format === 'vrm', `转换产物格式 vrm(实际 ${model.body.format})`);
|
||||
must(model.body.usable === true, '模型可用(humanoid 达标)', JSON.stringify(model.body.unusableReason));
|
||||
console.log(` 转换说明:${model.body.conversionNote}`);
|
||||
console.log(` humanoid:${model.body.stats?.humanoidMapped}/${model.body.stats?.humanoidTotal},可驱动=${model.body.stats?.drivable}`);
|
||||
|
||||
// ---------- 场景:真实 GLB(复用刚转换出的 VRM 几何)+ 物理参数 ----------
|
||||
const glbRes = await fetch(`${API}${model.body.fileUrl}`);
|
||||
const glbBytes = Buffer.from(await glbRes.arrayBuffer());
|
||||
must(glbBytes.subarray(0, 4).toString('ascii') === 'glTF', `取回模型 GLB(${glbBytes.length} 字节)`);
|
||||
// 作为场景上传时补一个按客户端 colliders:auto 约定命名的碰撞节点不方便,直接用 mesh 模式
|
||||
const sceneConfig = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -3.5, z: 0 },
|
||||
wind: {
|
||||
base: { x: 2, y: 0, z: -1 },
|
||||
gustAmplitude: 4,
|
||||
gustFrequency: 0.5,
|
||||
gustDirection: { x: 1, y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
|
||||
colliders: 'mesh',
|
||||
},
|
||||
environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 },
|
||||
spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }],
|
||||
camera: { mode: 'orbit', distance: 6, height: 2 },
|
||||
};
|
||||
const scene = await request('POST', '/api/scenes', {
|
||||
form: fileForm({ name: '跨端验收场景(真实 GLB)', config: JSON.stringify(sceneConfig), ...SOURCE }, 'stage.glb', glbBytes, 'model/gltf-binary'),
|
||||
});
|
||||
must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`);
|
||||
const sceneId = scene.body.id;
|
||||
|
||||
// ---------- 审核发布 ----------
|
||||
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) {
|
||||
// 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的
|
||||
const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } });
|
||||
must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status));
|
||||
const approve = await request('POST', `/api/reviews/${kind}/${id}/approve`, {
|
||||
json: kind === 'model' ? { reviewer: 'qa', comment: '通过', setPrimary: true } : { reviewer: 'qa', comment: '通过' },
|
||||
});
|
||||
must(approve.status === 200 || approve.status === 201, `审核通过 ${kind}#${id}`, `${approve.status} ${JSON.stringify(approve.body)}`);
|
||||
}
|
||||
|
||||
// ---------- 目录导出 ----------
|
||||
const health = await request('GET', '/health');
|
||||
must(health.status === 200, 'GET /health(取契约版本写进期望值)', String(health.status));
|
||||
|
||||
const catalog = await request('GET', '/api/catalog/export');
|
||||
must(catalog.status === 200, 'GET /api/catalog/export', String(catalog.status));
|
||||
const data = catalog.body;
|
||||
console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`);
|
||||
must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])');
|
||||
const version = await request('GET', '/api/catalog/version');
|
||||
console.log(` 目录指纹:${JSON.stringify(version.body)}`);
|
||||
|
||||
mkdirSync(dirname(EXPECT_PATH), { recursive: true });
|
||||
writeFileSync(EXPECT_PATH, JSON.stringify({
|
||||
songId, motionId, modelId, sceneId,
|
||||
sceneConfig,
|
||||
motionFrames: pre.body?.frameCount ?? null,
|
||||
// 注意:**上传响应**里的 keypointFormat 还是 null(预处理后才写入),
|
||||
// 必须取**预处理响应**的值 —— 这里踩过一次,期望值写成了 null。
|
||||
keypointFormat: pre.body?.keypointFormat ?? null,
|
||||
catalogRevision: version.body?.revision ?? null,
|
||||
contractVersions: health.body?.contractVersion ?? {},
|
||||
}, null, 2));
|
||||
console.log(`\n期望值已写入 ${EXPECT_PATH}`);
|
||||
};
|
||||
|
||||
run().catch((e) => { console.error('ERR', e); process.exit(1); });
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WebSocket 契约一致性客户端(T-WIN-020)
|
||||
*
|
||||
* 独立的 Node 实现,按 contracts/websocket-keypoints.md **v0.2.0** 逐条跑场景,
|
||||
* 对发布包里的 Windows `PoseServer` 做契约一致性验证。
|
||||
*
|
||||
* 为什么不用 Godot 自己的客户端:同引擎内的自测会共享同样的假设
|
||||
* (超时判定、分包、文本/二进制边界、关闭语义、子协议协商)。
|
||||
* 真实对端是 Android(Kotlin/OkHttp),所以必须用**另一套实现**来对照。
|
||||
*
|
||||
* 用法:
|
||||
* node build/integration/ws-client.mjs --url ws://127.0.0.1:18765/pose [--skip-slow]
|
||||
*
|
||||
* 场景(对应契约章节):
|
||||
* §1/§3.2 子协议协商 + hello → welcome(含 silentTimeoutMs / heartbeatIntervalMs)
|
||||
* §1.1 heartbeatIntervalMs 必须 < silentTimeoutMs / 2
|
||||
* §2 239 字节二进制帧(magic/version/flags/frame_id/33×7B),含 flags 位
|
||||
* §3.4 stats 文本帧
|
||||
* §3.6 应用层 ping → pong(sentAtMs 原样回传)
|
||||
* §5 同 deviceId 新连接替换旧连接:旧连接收到 close(1000, "replaced…")
|
||||
* §1.1 只发 ping 时连接**不会**被静默超时踢掉(19 秒对照,--skip-slow 可跳过)
|
||||
* §1.1/§7 完全不发数据 → 服务端 close(1001, "timeout")
|
||||
* §3.5 bye → 服务端 close(1000)
|
||||
* §3.1/§6 协议版本不匹配 → bye(reason=protocol_mismatch) + close(1002)
|
||||
* §2 非法二进制帧 → 服务端不崩且计入 malformed(由探针报告核对)
|
||||
*
|
||||
* 退出码:0 全通过 / 1 有失败 / 2 用法或连接问题。
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function argValue(name, fallback) {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback;
|
||||
}
|
||||
const URL_ = argValue('--url', 'ws://127.0.0.1:18765/pose');
|
||||
const SUBPROTOCOL = 'opendance.pose.v1';
|
||||
const SKIP_SLOW = args.includes('--skip-slow');
|
||||
|
||||
const MAGIC = 0x4f44;
|
||||
const FRAME_BYTES = 239;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
function check(name, ok, detail = '') {
|
||||
if (ok) {
|
||||
passed += 1;
|
||||
console.log(` ✓ ${name}`);
|
||||
} else {
|
||||
failed += 1;
|
||||
failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
|
||||
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 构造契约 §2 的 239 字节关键点帧 */
|
||||
function buildFrame({ frameId = 0, flags = 0, mirrored = false, lowConf = false, upperBodyOnly = false } = {}) {
|
||||
const buf = new ArrayBuffer(FRAME_BYTES);
|
||||
const view = new DataView(buf);
|
||||
view.setUint16(0, MAGIC, true); // magic(小端)
|
||||
view.setUint8(2, 0x01); // version
|
||||
let f = flags;
|
||||
if (mirrored) f |= 0x01;
|
||||
if (lowConf) f |= 0x02;
|
||||
if (upperBodyOnly) f |= 0x04;
|
||||
view.setUint8(3, f);
|
||||
view.setUint32(4, frameId >>> 0, true);
|
||||
for (let i = 0; i < 33; i += 1) {
|
||||
const o = 8 + i * 7;
|
||||
// 造一组确定性的归一化坐标:x 从 -0.9 到 0.9,y 随索引摆动,z 小量
|
||||
const x = Math.round((-0.9 + (1.8 * i) / 32) * 32767);
|
||||
const y = Math.round((0.5 * Math.sin(i) + 0.1) * 32767);
|
||||
const z = Math.round((0.02 * i - 0.3) * 32767);
|
||||
view.setInt16(o, Math.max(-32768, Math.min(32767, x)), true);
|
||||
view.setInt16(o + 2, Math.max(-32768, Math.min(32767, y)), true);
|
||||
view.setInt16(o + 4, Math.max(-32768, Math.min(32767, z)), true);
|
||||
view.setUint8(o + 6, i === 5 ? 100 : 240); // 故意放一个低置信度点(conf<128)
|
||||
}
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/** 打开一条连接,返回 { ws, next(type), closeInfo } */
|
||||
function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SUBPROTOCOL, hello = true } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(URL_, [SUBPROTOCOL]);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const inbox = [];
|
||||
const waiters = [];
|
||||
const state = { closeInfo: null, error: null };
|
||||
|
||||
const push = (msg) => {
|
||||
const w = waiters.shift();
|
||||
if (w) w(msg);
|
||||
else inbox.push(msg);
|
||||
};
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
try {
|
||||
push(JSON.parse(ev.data));
|
||||
} catch {
|
||||
push({ type: '(非 JSON 文本)', raw: ev.data });
|
||||
}
|
||||
} else {
|
||||
push({ type: '(二进制)', byteLength: ev.data.byteLength });
|
||||
}
|
||||
});
|
||||
ws.addEventListener('close', (ev) => {
|
||||
state.closeInfo = { code: ev.code, reason: ev.reason };
|
||||
const w = waiters.shift();
|
||||
if (w) w({ type: '(closed)', ...state.closeInfo });
|
||||
else inbox.push({ type: '(closed)', ...state.closeInfo });
|
||||
});
|
||||
ws.addEventListener('error', (ev) => {
|
||||
state.error = ev?.message ?? 'error';
|
||||
});
|
||||
|
||||
const opened = new Promise((res, rej) => {
|
||||
ws.addEventListener('open', res);
|
||||
const t = setTimeout(() => rej(new Error('连接超时(3s)')), 3000);
|
||||
t.unref?.();
|
||||
});
|
||||
|
||||
opened
|
||||
.then(() => {
|
||||
if (hello) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello', protocol, deviceId, device,
|
||||
appVersion: '0.2.0', cameraFacing: 'front', targetFps: 30, resolution: '640x480',
|
||||
}));
|
||||
}
|
||||
resolve({
|
||||
ws,
|
||||
state,
|
||||
next: (timeoutMs = 3000) => new Promise((res) => {
|
||||
if (inbox.length) return res(inbox.shift());
|
||||
const t = setTimeout(() => res({ type: '(超时)', timeoutMs }), timeoutMs);
|
||||
t.unref?.();
|
||||
waiters.push((m) => {
|
||||
clearTimeout(t);
|
||||
res(m);
|
||||
});
|
||||
}),
|
||||
async closed(timeoutMs = 3000) {
|
||||
if (state.closeInfo) return state.closeInfo;
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (state.closeInfo) return state.closeInfo;
|
||||
await sleep(50);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function badFrame() {
|
||||
// 契约要求恰好 239 字节;这里故意发 100 字节
|
||||
return new Uint8Array(100).fill(0x7f);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n== WebSocket 契约一致性(websocket-keypoints v0.2.0)@ ${URL_} ==\n`);
|
||||
|
||||
// ---------- §1/§3.2 握手 ----------
|
||||
console.log('[1] 子协议协商 + hello → welcome(§1 / §3.1 / §3.2)');
|
||||
const a = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
||||
const welcome = await a.next(3000);
|
||||
check('收到 welcome', welcome?.type === 'welcome', JSON.stringify(welcome));
|
||||
check('welcome.protocol 与契约一致', welcome?.protocol === SUBPROTOCOL, String(welcome?.protocol));
|
||||
check('welcome.sessionId 非空', typeof welcome?.sessionId === 'string' && welcome.sessionId.length > 0);
|
||||
check('welcome.expectedFps = 30', welcome?.expectedFps === 30, String(welcome?.expectedFps));
|
||||
const silent = Number(welcome?.silentTimeoutMs);
|
||||
const heartbeat = Number(welcome?.heartbeatIntervalMs);
|
||||
check('welcome 声明 silentTimeoutMs', Number.isFinite(silent) && silent > 0, String(welcome?.silentTimeoutMs));
|
||||
check('welcome 声明 heartbeatIntervalMs', Number.isFinite(heartbeat) && heartbeat > 0, String(welcome?.heartbeatIntervalMs));
|
||||
check('heartbeatIntervalMs < silentTimeoutMs / 2(§1.1 硬性)',
|
||||
heartbeat < silent / 2, `心跳 ${heartbeat} ms,超时 ${silent} ms`);
|
||||
check('子协议已协商成功(未回落到空串)', a.ws.protocol === SUBPROTOCOL, `实际 '${a.ws.protocol}'`);
|
||||
|
||||
// ---------- §2 二进制帧 ----------
|
||||
console.log('\n[2] 239 字节二进制帧(§2)');
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
a.ws.send(buildFrame({ frameId: i, mirrored: i % 2 === 0, lowConf: i % 5 === 0, upperBodyOnly: false }));
|
||||
await sleep(8);
|
||||
}
|
||||
// 故意跳号(丢包检测:§8 应进入 degraded 并可自动恢复)
|
||||
for (let i = 63; i < 75; i += 1) {
|
||||
a.ws.send(buildFrame({ frameId: i }));
|
||||
await sleep(8);
|
||||
}
|
||||
check('已发送 72 帧(含跳号 60→63)', true);
|
||||
a.ws.send(badFrame());
|
||||
check('已发送 1 个非法长度帧(100 字节,服务端应计入 malformed 且不崩)', true);
|
||||
await sleep(200);
|
||||
|
||||
// ---------- §3.4 / §3.6 ----------
|
||||
console.log('\n[3] stats 与 ping/pong(§3.4 / §3.6)');
|
||||
a.ws.send(JSON.stringify({ type: 'stats', fps: 29.6, dropped: 3, latencyMs: 42, confidenceAvg: 0.93 }));
|
||||
const sentAt = 1730000000000;
|
||||
a.ws.send(JSON.stringify({ type: 'ping', sentAtMs: sentAt }));
|
||||
const pong = await a.next(3000);
|
||||
check('收到 pong', pong?.type === 'pong', JSON.stringify(pong));
|
||||
check('pong.sentAtMs 原样回传', pong?.sentAtMs === sentAt, String(pong?.sentAtMs));
|
||||
check('pong.serverAtMs 已填写', Number.isFinite(Number(pong?.serverAtMs)) && Number(pong?.serverAtMs) > 0,
|
||||
String(pong?.serverAtMs));
|
||||
|
||||
// ---------- §5 设备去重 ----------
|
||||
console.log('\n[4] 同 deviceId 替换旧连接(§5)');
|
||||
const a2 = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
||||
const a2welcome = await a2.next(3000);
|
||||
check('新连接收到 welcome', a2welcome?.type === 'welcome', JSON.stringify(a2welcome));
|
||||
const oldClose = await a.closed(4000);
|
||||
check('旧连接被服务端关闭', oldClose !== null, '旧连接仍在打开');
|
||||
check('旧连接关闭码为 1000(契约 §5)', oldClose?.code === 1000, String(oldClose?.code));
|
||||
check('旧连接关闭原因为 replaced by new connection',
|
||||
String(oldClose?.reason ?? '').includes('replaced'), String(oldClose?.reason));
|
||||
check('新连接保持打开', a2.ws.readyState === WebSocket.OPEN, String(a2.ws.readyState));
|
||||
|
||||
// ---------- §1.1 保活:只发 ping 不会被踢 ----------
|
||||
console.log('\n[5] 保活:只发应用层 ping,连接不应被静默超时踢掉(§1.1)');
|
||||
if (SKIP_SLOW) {
|
||||
console.log(' · --skip-slow:跳过 19 秒对照');
|
||||
} else {
|
||||
const k = await connect({ deviceId: 'bbbb000000000002', device: 'Keepalive' });
|
||||
const kw = await k.next(3000);
|
||||
check('保活连接已握手', kw?.type === 'welcome', JSON.stringify(kw));
|
||||
const deadline = Date.now() + (silent + 4000); // 超过静默超时 4 秒
|
||||
let pings = 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (k.ws.readyState !== WebSocket.OPEN) break;
|
||||
k.ws.send(JSON.stringify({ type: 'ping', sentAtMs: Date.now() }));
|
||||
pings += 1;
|
||||
await sleep(Math.max(500, Math.floor(heartbeat / 2)));
|
||||
}
|
||||
check(`持续 ${Math.round((silent + 4000) / 100) / 10}s 发 ping(${pings} 次)后连接仍打开`,
|
||||
k.ws.readyState === WebSocket.OPEN,
|
||||
`readyState=${k.ws.readyState}(静默超时 ${silent} ms,期望心跳 ${heartbeat} ms)`);
|
||||
k.ws.close(1000, 'done');
|
||||
await k.closed(2000);
|
||||
}
|
||||
|
||||
// ---------- §1.1 / §7 静默超时 ----------
|
||||
console.log('\n[6] 完全不发数据 → close(1001, "timeout")(§1.1 / §7)');
|
||||
if (SKIP_SLOW) {
|
||||
console.log(' · --skip-slow:跳过静默超时对照');
|
||||
} else {
|
||||
const s = await connect({ deviceId: 'cccc000000000003', device: 'Silent' });
|
||||
const sw = await s.next(3000);
|
||||
check('静默连接已握手', sw?.type === 'welcome', JSON.stringify(sw));
|
||||
const t0 = Date.now();
|
||||
const closeInfo = await s.closed(silent + 6000);
|
||||
const elapsed = Date.now() - t0;
|
||||
check('服务端关闭了静默连接', closeInfo !== null, `${elapsed} ms 内未关闭`);
|
||||
check('关闭码为 1001(going away)', closeInfo?.code === 1001, String(closeInfo?.code));
|
||||
check('关闭原因为 timeout', String(closeInfo?.reason ?? '').includes('timeout'), String(closeInfo?.reason));
|
||||
check(`关闭时机接近 silentTimeoutMs(实测 ${elapsed} ms,阈值 ${silent} ms)`,
|
||||
closeInfo !== null && elapsed >= silent * 0.8 && elapsed <= silent + 4000, `${elapsed} ms`);
|
||||
}
|
||||
|
||||
// ---------- §3.5 bye ----------
|
||||
console.log('\n[7] bye → 服务端 close(1000)(§3.5)');
|
||||
const b = await connect({ deviceId: 'dddd000000000004', device: 'Bye' });
|
||||
await b.next(3000);
|
||||
b.ws.send(JSON.stringify({ type: 'bye', reason: 'user_stop' }));
|
||||
const byeClose = await b.closed(4000);
|
||||
check('服务端关闭了连接', byeClose !== null);
|
||||
check('关闭码为 1000', byeClose?.code === 1000, String(byeClose?.code));
|
||||
|
||||
// ---------- §3.1 / §6 协议不匹配 ----------
|
||||
console.log('\n[8] 协议版本不匹配 → bye + close(1002)(§3.1 / §6)');
|
||||
const c = await connect({ deviceId: 'eeee000000000005', device: 'Old', protocol: 'opendance.pose.v0' });
|
||||
const byeMsg = await c.next(3000);
|
||||
check('收到 bye', byeMsg?.type === 'bye', JSON.stringify(byeMsg));
|
||||
check('bye.reason 说明协议不匹配', String(byeMsg?.reason ?? '').includes('protocol_mismatch'), String(byeMsg?.reason));
|
||||
const mismatchClose = await c.closed(4000);
|
||||
check('关闭码为 1002(协议错误)', mismatchClose?.code === 1002, String(mismatchClose?.code));
|
||||
|
||||
// ---------- 收尾 ----------
|
||||
console.log('\n[9] 收尾');
|
||||
a2.ws.close(1000, 'done');
|
||||
await a2.closed(2000);
|
||||
await sleep(300);
|
||||
|
||||
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
|
||||
if (failed > 0) {
|
||||
console.log('失败项:');
|
||||
for (const f of failures) console.log(` - ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('全部通过 ✅');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('✗ 客户端异常:', err?.message ?? err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -178,3 +178,9 @@ fi
|
||||
echo ""
|
||||
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
||||
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
|
||||
echo ""
|
||||
echo "下一步(文档 §7.5 / §7.6:发布前必须做的三条产物级验收):"
|
||||
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证"
|
||||
echo " GODOT_BIN=/path/to/godot bash build/accept-ws.sh $ZIP # WebSocket 契约一致性"
|
||||
echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性"
|
||||
echo " API_BASE=http://127.0.0.1:3000 GODOT_BIN=... bash build/accept-integration.sh $ZIP # 跨端(需已部署服务端)"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
extends SceneTree
|
||||
|
||||
## 发布产物验收探针(T-WIN-018,由 build/accept-release.sh 调用)
|
||||
##
|
||||
## 用法:
|
||||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||||
## --script /绝对路径/build/probe-release.gd \
|
||||
## -- --expect-version 0.2.2 --snapshot /绝对路径/contracts/VERSION
|
||||
##
|
||||
## 为什么需要它:`build/verify.sh` 验证的是**仓库源码**(`godot --path .`),
|
||||
## 而交付给用户的是 zip 里的 `OpenDance.pck`。差别全在「导出」这一步:
|
||||
## 导出预设漏了资源、pck 没重新生成、版本号没同步、契约快照没更新……
|
||||
## 都只在包里才暴露。本探针**只读包内资源**(`--main-pack` + `res://`),
|
||||
## 所以它给出的版本号、契约表、autoload、主场景就是用户装到机器上的那一份。
|
||||
##
|
||||
## 断言(任一不满足即 quit(1)):
|
||||
## 1. ProjectSettings 的 application/config/version == 期望版本
|
||||
## (界面上「关于」清单与 .pck 都源自它 —— 文档 §11.1);
|
||||
## 2. AboutScreen.app_version() == 期望版本(界面真正会显示的值);
|
||||
## 3. 主场景非空且可加载,四个 autoload 都在场景树里;
|
||||
## 4. 关键资源(UI / 模型 / 场景 / 网络 / 评分)在包内都可加载;
|
||||
## 5. 包内 Credits.CONTRACT_VERSIONS 与**包内** contracts/VERSION 快照逐项一致
|
||||
## —— 抓「pck 用旧契约构建、快照却拷了新的」这类漂移。
|
||||
|
||||
const KEY_RESOURCES := [
|
||||
"res://src/ui/credits.gd",
|
||||
"res://src/ui/about_screen.gd",
|
||||
"res://src/ui/main.gd",
|
||||
"res://src/ui/main_menu.gd",
|
||||
"res://src/ui/scene_panel.gd",
|
||||
"res://src/ui/source_panel.gd",
|
||||
"res://src/model/humanoid_map.gd",
|
||||
"res://src/model/vrm_loader.gd",
|
||||
"res://src/scene/scene_runtime.gd",
|
||||
"res://src/net/api_client.gd",
|
||||
"res://src/net/catalog_types.gd",
|
||||
"res://src/net/pose_server.gd",
|
||||
"res://src/net/keypoint_frame.gd",
|
||||
"res://src/score/dtw.gd",
|
||||
"res://src/score/scoring.gd",
|
||||
]
|
||||
|
||||
const EXPECTED_AUTOLOADS := ["AppConfig", "CatalogStore", "PoseReceiver", "ScoreService"]
|
||||
|
||||
var _failed := 0
|
||||
|
||||
|
||||
func _check(ok: bool, text: String) -> void:
|
||||
print(" %s %s" % ["✓" if ok else "✗", text])
|
||||
if not ok:
|
||||
_failed += 1
|
||||
|
||||
|
||||
## 解析 contracts/VERSION(`key: value`,`#` 注释)
|
||||
func _read_snapshot(path: String) -> Dictionary:
|
||||
var out := {}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
return out
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return out
|
||||
while not file.eof_reached():
|
||||
var line := file.get_line().strip_edges()
|
||||
if line.is_empty() or line.begins_with("#"):
|
||||
continue
|
||||
var idx := line.find(":")
|
||||
if idx > 0:
|
||||
out[line.substr(0, idx).strip_edges()] = line.substr(idx + 1).strip_edges()
|
||||
file.close()
|
||||
return out
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var expect_version := ""
|
||||
var snapshot_path := ""
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
if args[i] == "--expect-version" and i + 1 < args.size():
|
||||
expect_version = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--snapshot" and i + 1 < args.size():
|
||||
snapshot_path = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
print("[accept] 包内运行时探针(--main-pack OpenDance.pck)")
|
||||
var project_version := str(ProjectSettings.get_setting("application/config/version", ""))
|
||||
print(" engine=%s project=%s main_scene=%s" % [
|
||||
str(Engine.get_version_info().get("string", "?")),
|
||||
project_version,
|
||||
str(ProjectSettings.get_setting("application/run/main_scene", "")),
|
||||
])
|
||||
|
||||
var about = load("res://src/ui/about_screen.gd")
|
||||
var about_version := str(about.app_version()) if about != null else ""
|
||||
var credits = load("res://src/ui/credits.gd")
|
||||
var contract_versions: Dictionary = credits.CONTRACT_VERSIONS if credits != null else {}
|
||||
|
||||
if not expect_version.is_empty():
|
||||
_check(project_version == expect_version,
|
||||
"包内 application/config/version = %s(期望 %s)" % [project_version, expect_version])
|
||||
_check(about_version == expect_version,
|
||||
"AboutScreen.app_version() = %s(「关于」清单会显示这个值)" % about_version)
|
||||
else:
|
||||
print(" · 未指定 --expect-version,跳过版本比对(实际 %s)" % project_version)
|
||||
|
||||
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||||
_check(not main_scene.is_empty() and load(main_scene) != null, "主场景可加载:%s" % main_scene)
|
||||
|
||||
var autoloads := []
|
||||
for child in root.get_children():
|
||||
autoloads.append(str(child.name))
|
||||
var missing := []
|
||||
for name in EXPECTED_AUTOLOADS:
|
||||
if not autoloads.has(name):
|
||||
missing.append(name)
|
||||
_check(missing.is_empty(), "autoload 齐备 %s(实际 %s)" % [str(EXPECTED_AUTOLOADS), str(autoloads)])
|
||||
|
||||
var unloadable := []
|
||||
for path in KEY_RESOURCES:
|
||||
if load(path) == null:
|
||||
unloadable.append(path)
|
||||
_check(unloadable.is_empty(), "关键资源全部可加载(%d 项)%s" % [
|
||||
KEY_RESOURCES.size(), "" if unloadable.is_empty() else " 缺失:" + str(unloadable)])
|
||||
|
||||
var snapshot := _read_snapshot(snapshot_path)
|
||||
if snapshot.is_empty():
|
||||
print(" · 未提供 contracts/VERSION 快照(%s),跳过契约比对" % snapshot_path)
|
||||
else:
|
||||
print(" [contracts] 包内 Credits = ", JSON.stringify(contract_versions))
|
||||
print(" [contracts] 包内快照文件 = ", JSON.stringify(snapshot))
|
||||
var mismatch := []
|
||||
for key in snapshot.keys():
|
||||
if not contract_versions.has(key):
|
||||
mismatch.append("%s 缺失" % key)
|
||||
elif str(contract_versions[key]) != str(snapshot[key]):
|
||||
mismatch.append("%s: pck=%s 快照=%s" % [key, contract_versions[key], snapshot[key]])
|
||||
for key in contract_versions.keys():
|
||||
if not snapshot.has(key):
|
||||
mismatch.append("%s 只存在于 pck" % key)
|
||||
_check(mismatch.is_empty(), "pck 内契约表与包内 contracts/VERSION 快照一致%s" % [
|
||||
"" if mismatch.is_empty() else ":" + str(mismatch)])
|
||||
|
||||
if _failed > 0:
|
||||
print("[accept] ✗ 探针发现 %d 项不符" % _failed)
|
||||
quit(1)
|
||||
return
|
||||
print("[accept] ✓ 包内运行时探针全部通过")
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb1l1hl8fpavq
|
||||
+3
-3
@@ -8,7 +8,7 @@ dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter="_scratch/*, tests/*, *.md"
|
||||
exclude_filter="_scratch/*, tests/*, build/*, *.md"
|
||||
export_path="build/windows/OpenDance.exe"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
@@ -37,8 +37,8 @@ application/modify_resources=true
|
||||
application/icon=""
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version="0.2.2"
|
||||
application/product_version="0.2.2"
|
||||
application/file_version="0.2.4"
|
||||
application/product_version="0.2.4"
|
||||
application/company_name="OpenDance Studio"
|
||||
application/product_name="OpenDance Studio"
|
||||
application/file_description="OpenDance Studio 摄像头跳舞评分客户端"
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ config_version=5
|
||||
|
||||
config/name="OpenDance Studio"
|
||||
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
|
||||
config/version="0.2.2"
|
||||
config/version="0.2.4"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
+26
-2
@@ -1,5 +1,5 @@
|
||||
project: opendance-windows-client
|
||||
version: 0.2.2
|
||||
version: 0.2.4
|
||||
repository: opendance-windows
|
||||
default_branch: main
|
||||
engine: Godot 4.7.2
|
||||
@@ -94,9 +94,33 @@ tasks:
|
||||
file: tasks/017-pe-version.md
|
||||
scope: 发布包 exe 的 PE 版本资源(Windows 属性页显示的文件/产品版本)必须等于发布版本
|
||||
status: done
|
||||
- id: T-WIN-018
|
||||
file: tasks/018-release-acceptance.md
|
||||
scope: 发布包级端到端验收(读包内 pck:版本号 / 界面显示值 / 契约表 / 资源可加载)
|
||||
status: done
|
||||
- id: T-WIN-019
|
||||
file: tasks/019-cross-end-integration.md
|
||||
scope: 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 补 Motion.thumbnailUrl 契约字段
|
||||
status: done
|
||||
- id: T-WIN-020
|
||||
file: tasks/020-ws-contract-conformance.md
|
||||
scope: WebSocket 契约一致性验收(Node 独立客户端 × 发布包内 PoseServer)+ 故意不合规的负例服务端
|
||||
status: done
|
||||
- id: T-WIN-021
|
||||
file: tasks/021-frame-vector-conformance.md
|
||||
scope: 关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器,12 用例 / 396 点逐值比对)
|
||||
status: done
|
||||
- id: T-WIN-022
|
||||
file: tasks/022-encode-rounding.md
|
||||
scope: 编码取整与契约参考实现(JS Math.round)对齐,编码差异收紧为 0
|
||||
status: done
|
||||
- id: T-WIN-023
|
||||
file: tasks/023-pck-dev-leftovers.md
|
||||
scope: 发布包内不得混入开发/验收工具(export exclude build/*)+ 产物级残留检查
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-windows-v0.2.2.zip
|
||||
artifact: opendance-windows-v0.2.4.zip
|
||||
packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底)
|
||||
release_rules: # 文档 §5.7 硬性要求
|
||||
- 单个完整包,禁止 .partN 分卷
|
||||
|
||||
+10
-2
@@ -107,9 +107,16 @@ func _request(method: int, path: String, body: Dictionary = {}, as_text: bool =
|
||||
request_completed.emit(path, false, status)
|
||||
return outcome
|
||||
|
||||
var text := raw.get_string_from_utf8()
|
||||
# 只有**需要文本**时才做 UTF-8 解码。
|
||||
# 原来这里是无条件 `raw.get_string_from_utf8()`,对二进制响应(模型 VRM、场景 GLB、
|
||||
# 缩略图 PNG)有两个后果:
|
||||
# 1. 每下载一个文件都会打印一条
|
||||
# 「Unicode parsing error … Invalid UTF-8 leading byte (N)」噪声 ——
|
||||
# 跨端集成探针里第一次注意到它;
|
||||
# 2. 白白把整个文件(模型可达数十 MB)解码成一份 String 拷贝后才丢弃。
|
||||
# 二进制路径返回的一直是 `raw`,所以数据本身没错,但代价与噪声都是白付的。
|
||||
if status < 200 or status >= 300:
|
||||
outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(text)]
|
||||
outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(raw.get_string_from_utf8())]
|
||||
request_completed.emit(path, false, status)
|
||||
return outcome
|
||||
|
||||
@@ -119,6 +126,7 @@ func _request(method: int, path: String, body: Dictionary = {}, as_text: bool =
|
||||
request_completed.emit(path, true, status)
|
||||
return outcome
|
||||
|
||||
var text := raw.get_string_from_utf8()
|
||||
if as_text:
|
||||
outcome["ok"] = true
|
||||
outcome["data"] = text
|
||||
|
||||
@@ -94,6 +94,8 @@ class Motion extends RefCounted:
|
||||
var keypoint_frames: int = 0
|
||||
var preprocess_state: String = "none"
|
||||
var preprocess_error: String = ""
|
||||
## 首帧骨架缩略图(契约 data-models 0.6.0 起 Motion 也有该字段)
|
||||
var thumbnail_url: String = ""
|
||||
var source: SourceInfo = SourceInfo.new()
|
||||
var status: String = ""
|
||||
|
||||
@@ -113,6 +115,7 @@ class Motion extends RefCounted:
|
||||
out.keypoint_frames = int(d.get("keypointFrames", 0) if d.get("keypointFrames") != null else 0)
|
||||
out.preprocess_state = str(d.get("preprocessState", "none"))
|
||||
out.preprocess_error = CatalogTypes._nullable_string(d.get("preprocessError"))
|
||||
out.thumbnail_url = CatalogTypes._nullable_string(d.get("thumbnailUrl"))
|
||||
out.source = SourceInfo.from_dict(d)
|
||||
out.status = str(d.get("status", ""))
|
||||
return out
|
||||
|
||||
@@ -152,6 +152,20 @@ static func decode(buf: PackedByteArray) -> PoseFrame:
|
||||
return frame
|
||||
|
||||
|
||||
## 量化取整:与契约参考实现(opendance-web 的 `Math.round`)**逐位一致**
|
||||
##
|
||||
## JS 的 `Math.round` 对「恰好 .5」是**朝 +∞** 取整(`Math.round(-2.5) === -2`),
|
||||
## 而 GDScript 的 `roundi` 是**半数远离零**(`roundi(-2.5) == -3`)——
|
||||
## 两者在 `-X.5` 上会差 1 个 LSB。Android 的 Kotlin 实现用的正是 `Math.round`,
|
||||
## 所以本端若用 `roundi`,与参考实现/发送端就不一致(实测:某一用例 33 字节不同)。
|
||||
## 这里显式实现同一套规则:`floor(x + 0.5)`。
|
||||
##
|
||||
## 注:本端是接收方,`encode()` 只用于自测与协议回归;但对齐之后,
|
||||
## `build/accept-frame-vectors.sh` 才能把「编码器字节差异 = 0」作为硬性断言。
|
||||
static func _round_like_js(value: float) -> int:
|
||||
return int(floor(value + 0.5))
|
||||
|
||||
|
||||
## 编码为 239 字节帧(本端为接收方,此函数用于自测与协议回归)
|
||||
static func encode(frame_id: int, points: PackedVector3Array, confidence: PackedFloat32Array, flags: int = 0) -> PackedByteArray:
|
||||
assert(points.size() == KEYPOINT_COUNT, "points 必须为 33 项")
|
||||
@@ -167,10 +181,10 @@ static func encode(frame_id: int, points: PackedVector3Array, confidence: Packed
|
||||
for i in KEYPOINT_COUNT:
|
||||
var offset := HEADER_SIZE + i * POINT_BYTES
|
||||
var p := points[i]
|
||||
buf.encode_s16(offset, clampi(roundi(p.x * SCALE), -32768, 32767))
|
||||
buf.encode_s16(offset + 2, clampi(roundi(p.y * SCALE), -32768, 32767))
|
||||
buf.encode_s16(offset + 4, clampi(roundi(p.z * SCALE), -32768, 32767))
|
||||
buf.encode_u8(offset + 6, clampi(roundi(confidence[i] * 255.0), 0, 255))
|
||||
buf.encode_s16(offset, clampi(_round_like_js(p.x * SCALE), -32768, 32767))
|
||||
buf.encode_s16(offset + 2, clampi(_round_like_js(p.y * SCALE), -32768, 32767))
|
||||
buf.encode_s16(offset + 4, clampi(_round_like_js(p.z * SCALE), -32768, 32767))
|
||||
buf.encode_u8(offset + 6, clampi(_round_like_js(confidence[i] * 255.0), 0, 255))
|
||||
|
||||
return buf
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# T-WIN-018:发布包级端到端验收(读包内 pck,不是读源码)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-014(整包发布)/ T-WIN-017(PE 版本元数据)
|
||||
- **文档**: v1.6 §5.7 / §7.5 / §11
|
||||
- **契约**: 不涉及(验收流程)
|
||||
|
||||
## 为什么需要
|
||||
|
||||
`build/verify.sh` 跑的是**仓库源码**(`godot --path .`),而交付给用户的是 zip 里的
|
||||
`OpenDance.pck` + `OpenDance.exe`。差别全在「导出」这一步:
|
||||
|
||||
| 只在导出后才可能出现的问题 | 源码侧能发现吗 |
|
||||
|--|--|
|
||||
| 导出预设漏了资源 / 资源没导入 | ❌ |
|
||||
| pck 没重新生成(用了上一次的) | ❌ |
|
||||
| 版本号没同步进预设 → PE 资源与 pck 里都是旧值 | ❌(v0.2.1 就这么翻的车) |
|
||||
| 契约快照文件拷了新的、pck 却是旧契约构建的 | ❌ |
|
||||
|
||||
本项目已在两端因此翻车(本仓 v0.2.1 的 PE 版本元数据、Web v0.7.4 的实体注册),
|
||||
所以三端统一要求:**每次发布都要有一条读产物本身的验收**(文档 §7.5)。
|
||||
|
||||
## 实现
|
||||
|
||||
| 文件 | 作用 |
|
||||
|--|--|
|
||||
| `build/probe-release.gd`(新增) | **包内运行时探针**:用 `godot --headless --main-pack <pck> --script <探针>` 只读包内资源,断言并 `quit(0/1)` |
|
||||
| `build/accept-release.sh`(新增) | 对一个 zip(或已解压目录)跑完整验收 |
|
||||
|
||||
### 探针断言(5 类)
|
||||
|
||||
1. `ProjectSettings` 的 `application/config/version` == 期望版本
|
||||
—— 这是「关于」清单与 pck 的**唯一来源**(§11.1);
|
||||
2. `AboutScreen.app_version()` == 期望版本 —— 界面真正会显示的值;
|
||||
3. 主场景非空且可加载;四个 autoload(`AppConfig` / `CatalogStore` /
|
||||
`PoseReceiver` / `ScoreService`)都在场景树里;
|
||||
4. 15 个关键资源(UI / 模型 / 场景 / 网络 / 评分)在包内都可加载;
|
||||
5. 包内 `Credits.CONTRACT_VERSIONS` 与**包内** `contracts/VERSION` 快照逐项一致
|
||||
—— 抓「pck 用旧契约构建、快照却拷了新的」。
|
||||
|
||||
### 验收脚本步骤
|
||||
|
||||
1. sha256 校验(有 `SHA256SUMS` 时);
|
||||
2. 解压(deflate 用 `unzip`,LZMA 用 `7z`)+ 文件清单完整性
|
||||
(`OpenDance.exe` / `OpenDance.pck` / `README.txt` / `THIRD_PARTY.md` / `contracts/VERSION`);
|
||||
顺带检查**不允许有 `.partN` 分卷残留**;
|
||||
3. exe 的 **PE 版本资源** = 发布版本(复用 `build/check-pe-version.sh`);
|
||||
4. 用 Godot 直接加载**包内 pck** 跑 180 帧,断言无 `SCRIPT ERROR`;
|
||||
5. 执行包内运行时探针。
|
||||
|
||||
## 实测(对**已发布**的 v0.2.2 资产,重新下载后跑)
|
||||
|
||||
```
|
||||
[accept] sha256 校验:opendance-windows-v0.2.2.zip: OK
|
||||
✓ OpenDance.exe / OpenDance.pck / README.txt / THIRD_PARTY.md / contracts/VERSION
|
||||
✓ PE 版本资源一致:FileVersion 0.2.2.0 / 资源字符串 0.2.2
|
||||
✓ 包内 pck 启动 180 帧无错误(退出码 0)
|
||||
✓ 包内 application/config/version = 0.2.2(期望 0.2.2)
|
||||
✓ AboutScreen.app_version() = 0.2.2(「关于」清单会显示这个值)
|
||||
✓ 主场景可加载:res://scenes/main.tscn
|
||||
✓ autoload 齐备 ["AppConfig", "CatalogStore", "PoseReceiver", "ScoreService"]
|
||||
✓ 关键资源全部可加载(15 项)
|
||||
✓ pck 内契约表与包内 contracts/VERSION 快照一致
|
||||
发布包级验收全部通过
|
||||
```
|
||||
|
||||
## 反向验证
|
||||
|
||||
| 负例 | 结果 |
|
||||
|--|--|
|
||||
| 把包内 `contracts/VERSION` 的 `rest-api` 改成 `0.4.0`(快照与 pck 不一致) | ✗ `pck 内契约表与包内 contracts/VERSION 快照一致:["rest-api: pck=0.5.0 快照=0.4.0"]`,退出码 1 |
|
||||
| 期望版本写错(如 `0.2.1` 对 v0.2.2 的包) | ✗ 三处版本断言全失败,退出码 1 |
|
||||
|
||||
> 首次运行还暴露了脚本自身的一个缺陷:`SHA256SUMS` 用相对路径 + `cd` 到包目录后
|
||||
> 被二次拼接成 `dist-release/dist-release/SHA256SUMS`,导致 sha256 校验假失败
|
||||
> (而其余检查全绿,很容易被忽略)。已改为绝对路径,并把这条记在脚本注释里。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2.zip
|
||||
# 或对已解压目录:bash build/accept-release.sh dist-release/opendance-windows-v0.2.2
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# T-WIN-019:跨端集成验收(包内客户端 × 真实 Web 服务端)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-013(场景选择)/ T-WIN-018(发布包级验收)/ T-WEB-015(场景管理)
|
||||
- **文档**: v1.6 §5.6 / §7.5
|
||||
- **契约**: `rest-api 0.5.0`、`data-models 0.7.0`、`skeleton-topology 0.2.0`
|
||||
|
||||
## 为什么需要
|
||||
|
||||
本仓的单测用的是**假 HTTP 夹具**(`tests/test_api_integration.gd` 打本地小服务),
|
||||
Web 端的测试只验自己那一侧。**两端各自绿灯 ≠ 契约字段真的对得上**:
|
||||
字段名、嵌套层级、null 与缺字段、单位(米/度/秒)、归一化范围,
|
||||
都可能在一侧对、另一侧错。契约 0.5.0 新增的 `scenes[]` 与物理参数尤其如此。
|
||||
|
||||
所以本任务是:**拿发布包里的客户端(包内 pck)打真实服务端**。
|
||||
|
||||
## 发现并修复的缺陷
|
||||
|
||||
探针首次运行就抓到一个真实缺口:
|
||||
|
||||
```
|
||||
服务端 GET /api/catalog/export 的 motion 条目(契约 data-models 0.6.0 起):
|
||||
...,"keypointUrl":"...","thumbnailUrl":"/static/motion-thumbnail/.../x.png","keypointFrames":2901,...
|
||||
客户端的 CatalogTypes.Motion:
|
||||
✗ 没有 thumbnail_url 字段(服务端一直在发,客户端默默丢掉)
|
||||
而同一文件的 Model / Scene 都有 thumbnail_url。
|
||||
```
|
||||
|
||||
**修复**:`src/net/catalog_types.gd` 的 `Motion` 增加 `thumbnail_url` 并解析
|
||||
`thumbnailUrl`(缺失时为 `""`,兼容旧服务端);新增
|
||||
`tests/test_catalog_types.gd`(4 项 / 20 条断言)用**服务端真实报文形状**锁住
|
||||
「三个资源类型都消费 `thumbnailUrl`」,并显式记录一处**刻意不消费**的差异:
|
||||
`Model.usable` / `unusableReason` —— 它们是 Web 端审核用的,
|
||||
而 `catalog/export` 只下发**可用**模型(目录里恒为 true),
|
||||
不可用模型的可见性由 Web 管理端负责。
|
||||
|
||||
## 实现
|
||||
|
||||
| 文件 | 作用 |
|
||||
|--|--|
|
||||
| `build/integration/probe-integration.gd` | 跨端集成探针:只加载**包内**代码(`--main-pack` + `res://`),打真实服务端并逐项断言 |
|
||||
| `build/integration/seed-catalog.mjs` | 通过真实 Web API 播种一份**真实**目录(真 VMD → 内部标准;真 PMX → VRM;场景 GLB + 物理参数),并写出期望值 JSON |
|
||||
| `build/accept-integration.sh` | 编排:检查服务端可达 → 播种(可跳过)→ 对包内 pck 跑探针 → 汇报 |
|
||||
|
||||
### 探针断言(对应需求编号)
|
||||
|
||||
| 需求 | 断言 |
|
||||
|--|--|
|
||||
| ⑤ API 升级 | `/health` 连通 + 六个契约版本与 Web 一致;`/api/catalog/version` 指纹非空;`catalog/export` 四类资源齐备;`defaultModelId` 解析为默认模型 id |
|
||||
| ⑤ 场景选择 + 物理参数落地 | **逐项**比对类型化后的场景参数:重力 y、基础风 x/z、阵风幅度/频率、风坐标系、地面摩擦/坡度、碰撞体模式、出生点 yaw、相机 mode/distance/height、环境光与雾;另外校验 `Wind.sample(0)` 等于基础风;场景 GLB **可下载**且魔数与 `fileSize` 一致 |
|
||||
| ② 动作内部标准 | `keypointFormat == mediapipe33@0.1.0`、服务端帧数、`is_scorable()`;拉取 NDJSON 后**客户端解析帧数 == 服务端帧数**、`skipped_lines == 0`、首帧 33 点、每帧带服务端关节角、时间轴推导帧率 ≈ 30 fps、时长 ≈ 96.7 s |
|
||||
| ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG |
|
||||
| ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 |
|
||||
|
||||
## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL)
|
||||
|
||||
```
|
||||
[integration] Windows 包内客户端 × 真实 Web 服务端:http://127.0.0.1:3100
|
||||
✓ GET /health 200(服务端版本 0.7.4);契约 6 项全部一致
|
||||
✓ 目录指纹非空;目录内嵌契约版本与 /health 一致
|
||||
✓ 目录四类资源齐备:songs=1 motions=1 models=1 scenes=1
|
||||
✓ defaultModelId 解析为 1
|
||||
✓ 场景 glbUrl/fileSize;重力 y=-3.50;基础风 (2.0,0.0,-1.0);阵风 4.0 m/s @ 0.50 Hz
|
||||
✓ 风坐标系 world;地面摩擦/坡度 0.80/30°;碰撞体 mesh;出生点 yaw 90°
|
||||
✓ 相机 orbit/6.0/2.0;环境光 2.0/雾 0.20;风采样 t=0 等于基础风
|
||||
✓ 场景 GLB 可下载且魔数正确(glTF,1153244 字节)
|
||||
✓ 动作内部标准 mediapipe33@0.1.0;客户端解析帧数 2901 == 服务端 2901
|
||||
✓ 无解析失败行;首帧 33 点;每帧带服务端关节角;30.00 fps;96.67 s
|
||||
✓ 动作缩略图可下载且为 PNG
|
||||
✓ 模型格式 vrm、默认模型、文件可下载且 glTF 容器
|
||||
[integration] ✓ 跨端集成探针全部通过
|
||||
```
|
||||
|
||||
### 真实素材
|
||||
|
||||
播种用的都是仓库根 `示例文件/` 下的真实素材(不是编造的):
|
||||
`【动作】霜雪千年.vmd`(827,973 B → 2901 帧内部标准)、
|
||||
`模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`(1.0 MB → VRM,humanoid 19/22、`drivable`)。
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
1. **autoload 单例在 `--script` 模式下不是全局标识符**:
|
||||
`AppConfig` / `CatalogStore` 直接当名字用会 `Compile Error: Identifier not found`
|
||||
(脚本来自项目外,编译期不认这两个 autoload 名)。改为
|
||||
`root.get_node_or_null("AppConfig")` 取节点后动态调用。
|
||||
2. **协程里一处运行时错误会让进程永不退出**:`_finish()` 不会被调用,
|
||||
表现为「无输出卡住」,很难定位。已加 120 秒**看门狗**:超时判失败并退出。
|
||||
3. **输出缓冲**:`print()` 管道输出在进程被 SIGTERM 杀死时会丢,
|
||||
调试这类脚本要重定向到文件而不是 `| head`。
|
||||
@@ -0,0 +1,110 @@
|
||||
# T-WIN-020:WebSocket 契约一致性验收(独立实现 × 发布包内的 PoseServer)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-015(保活 / 去重 / 状态机)/ T-WIN-018(产物级验收)
|
||||
- **文档**: v1.6 §6.5 / §7.6
|
||||
- **契约**: `websocket-keypoints 0.2.0`
|
||||
|
||||
## 为什么需要
|
||||
|
||||
`tests/test_pose_server.gd` 用的是**同引擎内的客户端**(Godot 自己的
|
||||
`WebSocketPeer`),而真实对端是 **Android(Kotlin/OkHttp)**。
|
||||
同引擎自测会共享同样的假设 —— 超时判定、分包、文本/二进制边界、关闭语义、
|
||||
子协议协商、`ping` 是否真的续命 —— 于是「测试全绿」并不等于「Android 连得上」。
|
||||
|
||||
契约第 7 节也明确写了:**静默超时必须由保活避免,而不是靠重连掩盖**。
|
||||
这句话只有用一个独立实现的客户端实测才算验过。
|
||||
|
||||
## 实现(三个文件)
|
||||
|
||||
| 文件 | 角色 |
|
||||
|--|--|
|
||||
| `build/integration/probe-ws.gd` | 把**发布包里**的 `PoseServer` 跑起来(`--main-pack` + 只加载包内代码),把连接 / 控制帧 / 帧 / 断开事件汇总成 JSON 报告。**自身不做判定**,避免「用被测实现判定被测实现」 |
|
||||
| `build/integration/ws-client.mjs` | Node 实现的**契约一致性客户端**(用 Node 内置 `WebSocket`,无依赖),逐条跑场景并断言,退出码即结论 |
|
||||
| `build/accept-ws.sh` | 编排:解包 → 起探针 → 等监听 → 跑客户端 → 收报告 → **核对客户端看不到的那一半**(服务端侧计数) |
|
||||
|
||||
### 客户端场景(契约章节 → 断言)
|
||||
|
||||
| 场景 | 断言要点 |
|
||||
|--|--|
|
||||
| §1 / §3.1 / §3.2 握手 | 子协议协商成功;`welcome.protocol` / `sessionId` / `expectedFps`;**声明** `silentTimeoutMs` 与 `heartbeatIntervalMs` |
|
||||
| §1.1 保活参数 | `heartbeatIntervalMs < silentTimeoutMs / 2`(硬性) |
|
||||
| §2 二进制帧 | 239 字节(magic `0x4F44` / version 1 / flags 位 / u32 frame_id / 33×7B 小端),发 72 帧含**故意跳号**与**故意非法长度帧** |
|
||||
| §3.4 stats | 文本 `stats` 计入活动 |
|
||||
| §3.6 ping/pong | 收到 `pong`,`sentAtMs` **原样回传**,`serverAtMs` 已填 |
|
||||
| §5 设备去重 | 同 `deviceId` 新连接 → 旧连接 `close(1000, "replaced by new connection")`,新连接保持打开 |
|
||||
| §1.1 保活对照 | 只发应用层 `ping` 持续 **19 秒**(> 静默超时 15 秒)连接**仍打开** |
|
||||
| §1.1 / §7 静默超时 | 完全不发数据 → `close(1001, "timeout")`,且时机 ≈ `silentTimeoutMs` |
|
||||
| §3.5 bye | `bye` → 服务端 `close(1000)` |
|
||||
| §3.1 / §6 协议不匹配 | `bye.reason` 含 `protocol_mismatch` + `close(1002)` |
|
||||
|
||||
### 服务端侧事实核对(`accept-ws.sh`)
|
||||
|
||||
客户端只能看到自己那一半;服务端的计数与原因由探针报告核对:
|
||||
`frames_dropped ≥ 3`(来自故意的 60→63 跳号)、`malformed_frames == 1`(来自 100 字节帧)、
|
||||
`frame_id` 无回退、控制帧分类计数、`device_replaced` 事件、
|
||||
断开原因含「同设备替换 / 静默超时 / `user_stop` / `code=1002`」、
|
||||
且静默超时消息**带实测毫秒数与阈值**(可核对)。
|
||||
|
||||
## 实测(**已发布**的 v0.2.3 资产重新下载后跑)
|
||||
|
||||
```
|
||||
== WebSocket 契约一致性(websocket-keypoints v0.2.0)@ ws://127.0.0.1:18765/pose ==
|
||||
[1] ✓ 收到 welcome ✓ protocol ✓ sessionId ✓ expectedFps=30 ✓ 声明两个保活参数
|
||||
✓ heartbeat(5000) < silentTimeout(15000)/2 ✓ 子协议已协商
|
||||
[2] ✓ 已发送 72 帧(含跳号 60→63)✓ 已发送 1 个非法长度帧
|
||||
[3] ✓ 收到 pong ✓ sentAtMs 原样回传 ✓ serverAtMs 已填写
|
||||
[4] ✓ 旧连接关闭码 1000 ✓ 原因 replaced by new connection ✓ 新连接保持打开
|
||||
[5] ✓ 持续 19s 发 ping 后连接仍打开
|
||||
[6] ✓ 关闭码 1001 ✓ 原因 timeout ✓ 实测 15011 ms(阈值 15000 ms)
|
||||
[7] ✓ bye → 关闭码 1000
|
||||
[8] ✓ bye.reason=protocol_mismatch ✓ 关闭码 1002
|
||||
== 结果:30 通过 / 0 失败 ==
|
||||
服务端侧:✓ silentTimeoutMs=15000/heartbeatIntervalMs=5000 ✓ 6 次连接 ✓ 72 帧
|
||||
✓ frame_id 最大 74 无回退 ✓ frames_dropped=3 ✓ malformed_frames=1
|
||||
✓ hello=6 ping=9 stats=1 bye=1 ✓ device_replaced=["aaaa000000000001"]
|
||||
✓ 断开原因:替换 / user_stop / code=1002 / 静默超时 15001 ms(阈值 15000 ms)
|
||||
WebSocket 契约一致性验收全部通过
|
||||
```
|
||||
|
||||
> 探针日志里出现的两条 `WARNING` 是**故意的负例**产生的(非法帧、协议不匹配),
|
||||
> 属预期;脚本只把 `SCRIPT ERROR` 判为失败。
|
||||
|
||||
## 反向验证(证明断言真的会失败)
|
||||
|
||||
只跑通过用例的检查器无法区分「契约被遵守」与「断言写得太松」。
|
||||
因此配了一个**故意不合规的服务端** `build/integration/probe-ws-bad.gd`:
|
||||
`welcome` 不声明保活字段、不带 `sessionId`、收到 `ping` 不回 `pong`、不做 `deviceId` 去重。
|
||||
|
||||
对它的运行结果(`ws-client.mjs`):
|
||||
|
||||
```
|
||||
✗ welcome.sessionId 非空
|
||||
✗ welcome 声明 silentTimeoutMs — undefined
|
||||
✗ welcome 声明 heartbeatIntervalMs — undefined
|
||||
✗ heartbeatIntervalMs < silentTimeoutMs / 2 — 心跳 NaN ms,超时 NaN ms
|
||||
✗ 收到 pong — {"type":"(超时)"} ✗ pong.sentAtMs 原样回传
|
||||
✗ 旧连接被服务端关闭 ✗ 旧连接关闭码为 1000 ✗ 原因 replaced…
|
||||
✗ 收到 bye(拿到的是 welcome) ✗ bye.reason 说明协议不匹配
|
||||
客户端退出码=1
|
||||
```
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
1. **`--script` 模式下的类型推断**:探针从无类型数组里取 `peer`,`peer.get_packet()`
|
||||
的返回类型推不出来,`var packet := …` 直接 **Parse Error**(且在 `--headless`
|
||||
下只表现为「脚本加载失败 + 退出码 1」,没有别的线索)。显式标注类型即可。
|
||||
2. **不要写否定式断言**:核对静默超时消息时第一版写成
|
||||
`say(!reasons.includes("静默超时 1"), …)`,本意是「消息里带 15001 ms」,
|
||||
实际把**正确行为判成了失败**。断言要写成正向的模式匹配
|
||||
(`/静默超时 \d+ ms(阈值 \d+ ms/`)。
|
||||
3. **固定时长 vs 提前收尾**:探针一开始只能按 `--duration-ms` 跑满,
|
||||
编排脚本就得猜时间。改成「客户端跑完 touch 一个 stop 文件,探针提前收尾」,
|
||||
既快又不依赖机器速度。
|
||||
|
||||
## 未覆盖(诚实声明)
|
||||
|
||||
- **Android 端自身**的重连状态机与退避(§7.1 的 0.5s→1s→2s→4s→8s→15s ±20% 抖动)
|
||||
在本端无法执行:它在 Kotlin 里,由 Android 仓的 JVM 测试覆盖(本任务验的是
|
||||
**它对端的行为**是否与契约一致 —— 也就是「重连之后对面会怎么反应」);
|
||||
- 真机 Wi-Fi 抖动 / 切网 / 进程被杀这些真实断线场景,仍需设备实测。
|
||||
@@ -0,0 +1,86 @@
|
||||
# T-WIN-021:关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-004(关键点帧解码)/ T-WEB-006(动作内部标准)
|
||||
- **文档**: v1.6 §7.6.2
|
||||
- **契约**: `websocket-keypoints 0.2.0` §2
|
||||
|
||||
## 为什么需要
|
||||
|
||||
`opendance-web/web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的**参考实现**
|
||||
(它的 `encodeBinaryFrame` 注释就是「Android 端参考实现」,
|
||||
`decodeBinaryFrame` 注释是「Windows 端参考实现(T-WIN-004)」):
|
||||
|
||||
* **Android ↔ Web** 已经用「金标准字节向量」对齐(`KeypointFrameTest.goldenVectorMatchesWebImplementation`);
|
||||
* **Windows 的解码器此前只跟自己比过** —— 自己编码、自己解码,
|
||||
两个方向用同一份错误假设也能一直绿。这条边是本轮补的。
|
||||
|
||||
## 实现
|
||||
|
||||
| 文件 | 角色 |
|
||||
|--|--|
|
||||
| `opendance-web/web-server/scripts/gen-frame-vectors.mts`(新增) | 用**参考实现**生成向量:12 个用例的字节(`hex`)+参考解码结果(`points`,float64)+ float32 输入基准(`f32InputPoints` / `f32Hex`) |
|
||||
| `opendance-web/web-server/test/keypoint-frame-codec.spec.ts`(新增,7 项) | 把参考实现对其他两端的**承诺显式钉住**:i16 最小值边界、**取整规则**、置信度阈值 128、u32 frame_id、flags 掩码、非法帧拒绝、随机输入往返 |
|
||||
| `opendance-windows/build/integration/frame-vectors.json`(锁定) | 由参考实现生成并入库的向量(比对基准) |
|
||||
| `opendance-windows/build/integration/probe-frame-vectors.gd`(新增) | 用**包内** `KeypointFrame` 解码同一批字节并逐值比对,产出 JSON 报告 |
|
||||
| `opendance-windows/build/accept-frame-vectors.sh`(新增) | 编排:**重新生成并比对向量** → 解包 → 包内解码比对 → 报告核对 |
|
||||
|
||||
### 比对口径(关键)
|
||||
|
||||
* `flags` / `frame_id`:**精确相等**;
|
||||
* 坐标与置信度:本端点存进 `PackedVector3Array`、置信度存进 `PackedFloat32Array`,
|
||||
即 **float32**;参考实现是 float64。因此先把期望值收敛到 float32
|
||||
(GDScript 里借 `Vector3(...)` / `PackedFloat32Array([...])` 天然完成)再比**精确相等**
|
||||
—— 比用容差更严格,并把差异限定在「表示精度」这一点上。
|
||||
|
||||
## 实测(已发布的 v0.2.3 资产重新下载后跑)
|
||||
|
||||
```
|
||||
[accept-fv] 用参考实现重新生成向量并比对…
|
||||
✓ 锁定的向量与参考实现重新生成的结果逐字节一致
|
||||
[accept-fv] 包内解码器 × 参考向量…
|
||||
[frame-vectors] 用例 12 个;解码用例 12/12,比对点 396 个
|
||||
[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)
|
||||
✓ 解码差异 0 项(必须为 0)
|
||||
✓ 逐点比对 396 个(期望 396)
|
||||
✓ 编码器差异 1 项,全部属于「量化前恰好 .5」的取整边界
|
||||
关键点帧跨实现一致性验收全部通过
|
||||
```
|
||||
|
||||
覆盖的边界:`i16` 最小值 `-32768`(`-32768/32767`,必须是 `0x8000` 而不是被夹成 `-32767`)、
|
||||
`i16` 最大值、`±1` 全量程、置信度 `0 / 128 / 255`、`flags` 低 3 位组合、
|
||||
`frame_id` 的 `0xFFFFFFFF` 与 `0x80000000`、以及取整边界。
|
||||
|
||||
## 一处**已测量并归类**的差异(非缺陷)
|
||||
|
||||
本端 `KeypointFrame.encode()` 与参考编码器在**一个**用例上差 33 字节:
|
||||
|
||||
| | 取整方式 | `-X.5` 的行为 |
|
||||
|--|--|--|
|
||||
| JS 参考实现(`Math.round`) | 半数朝 **+∞** | `-2.5 → -2` |
|
||||
| **Android(生产发送方,`Math.round`)** | 同上 | `-2.5 → -2` ✅ 与参考一致 |
|
||||
| Windows `KeypointFrame.encode()`(`roundi`,半数远离零) | | `-2.5 → -3` ❌ 差 1 LSB |
|
||||
|
||||
**影响**:Windows 是**接收方**,生产链路里不调用 `encode()`(它只用于本仓自测与协议回归),
|
||||
因此对产品无影响。`accept-frame-vectors.sh` 因此把编码差异记为「归类记录」,
|
||||
但**硬性要求差异只能出现在「量化前恰好落在 .5」的用例上** —— 若哪天出现在别处,
|
||||
就说明还有别的问题。若将来 Windows 需要成为发送方,把 `roundi` 换成
|
||||
`floor(x + 0.5)` 即可与参考实现完全一致。
|
||||
|
||||
## 踩坑记录(两处"看起来通过、其实没测到")
|
||||
|
||||
1. **期望值写成了输入值**:第一版生成器把「喂进编码器的原始点」当成 `points` 写进向量,
|
||||
于是比对变成了「比编码量化」而不是「比两个解码器是否一致」。
|
||||
靠抽查数值(`-2.5/32767` 的原值出现在解码结果里)才发现。
|
||||
→ 向量里现在同时有 `inputPoints`(原始)、`f32InputPoints` / `f32Hex`(同输入基准)
|
||||
与 `points`(**参考解码结果**),并各自注明用途。
|
||||
2. **拿不同输入的字节做比较**:编码器比对一开始用 `hex`(float64 输入编出来的)当基准,
|
||||
于是「正数恰好 .5」整例 99 字节不同 —— 实际只是输入从 float64 收敛到 float32 的量化差,
|
||||
与取整规则无关。改用 `f32Hex`(**同输入**基准)后,差异收敛到唯一一个真正的取整边界用例。
|
||||
→ 教训:跨实现比对必须先统一**输入**,否则差异无法归因。
|
||||
|
||||
## 未覆盖
|
||||
|
||||
* 三端矩阵的另外两条边已有证据:Android ↔ Web(金标准向量,Android 仓 JVM 测试)、
|
||||
Android → Windows(T-WIN-020 的实时链路验收,真实 WebSocket 发 239 字节帧);
|
||||
* 真机端到端(相机 → MediaPipe → 编码 → Wi-Fi → Windows 解码 → 驱动模型)仍需设备实测。
|
||||
@@ -0,0 +1,66 @@
|
||||
# T-WIN-022:编码取整与契约参考实现对齐(编码差异收紧为 0)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-021(跨实现一致性向量)
|
||||
- **文档**: v1.6 §7.6.2
|
||||
- **契约**: `websocket-keypoints 0.2.0` §2
|
||||
|
||||
## 缺陷
|
||||
|
||||
`KeypointFrame.encode()` 用 GDScript 的 `roundi` 取整,而契约参考实现
|
||||
(`opendance-web` 的 `keypoint-codec.ts`)与**生产发送方 Android**(Kotlin `Math.round`)
|
||||
都用「半数朝 +∞」:
|
||||
|
||||
| | `-2.5` | `2.5` | `-0.5` |
|
||||
|--|--|--|--|
|
||||
| JS `Math.round` / Kotlin `Math.round` | **-2** | 3 | 0 |
|
||||
| GDScript `roundi`(半数远离零) | **-3** | 3 | -1 |
|
||||
|
||||
实测差异:T-WIN-021 的 12 个向量里有 1 个用例(坐标含 `-0.5`)**33 字节不同**。
|
||||
|
||||
**影响**:本端是**接收方**,`encode()` 只用于自测与协议回归,因此对产品无影响 ——
|
||||
但它让「跨实现一致性」这条断言只能是宽松口径(「差异仅限取整边界」),
|
||||
而宽松口径在实践中会退化成「永远没人看的已知差异」。
|
||||
|
||||
## 修复
|
||||
|
||||
```gdscript
|
||||
## 量化取整:与契约参考实现(JS Math.round)逐位一致
|
||||
static func _round_like_js(value: float) -> int:
|
||||
return int(floor(value + 0.5))
|
||||
```
|
||||
|
||||
`encode()` 的 4 处取整全部改用它(坐标 ×3 + 置信度)。
|
||||
夹紧顺序(先取整后 `clampi`)与参考实现的「先夹后取整」对任意实数等价
|
||||
(`32767.4 → 32767`;`-32768.4 → -32768`),已在任务里逐值核对。
|
||||
|
||||
## 验证
|
||||
|
||||
1. **单测**(`tests/test_keypoint_frame.gd`,+11 条断言):
|
||||
* `_round_like_js` 的 5 个取值(含 `-2.5 → -2`、`-0.5 → 0`);
|
||||
* 端到端 `-0.5/32767 → 0`(`roundi` 会给 -1,是有效的判别用例);
|
||||
* `test_reference_vectors_decode_and_encode_exactly`:直接读**锁定的参考向量**
|
||||
(`build/integration/frame-vectors.json`),逐值解码比对 + 用 `f32InputPoints`
|
||||
重编码与 `f32Hex` **逐字节**比对 —— 这一步让 `verify.sh`(只跑源码,无需 pck)
|
||||
也覆盖了「Web ↔ Windows」这条边。
|
||||
2. **产物级**(`build/accept-frame-vectors.sh`,已收紧口径):
|
||||
原先允许「编码差异仅限 `.5` 取整边界」,现在**硬性要求 0 项**。
|
||||
|
||||
实测(v0.2.4 发布包):
|
||||
|
||||
```
|
||||
[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)
|
||||
[frame-vectors] ✓ 本端 encode() 与参考字节完全一致
|
||||
✓ 解码差异 0 项(必须为 0)
|
||||
✓ 编码器字节与参考实现完全一致(0 项差异)
|
||||
```
|
||||
|
||||
`verify.sh`:**785 通过 / 0 失败**(原 774)。
|
||||
|
||||
## 写测试时踩的坑
|
||||
|
||||
第一版断言写成「`2.5/32767` 编码为 3」,**失败**。原因不是编码错了,而是
|
||||
`PackedVector3Array` 把该值存成 **float32**,`float32(2.5/32767) × 32767` 略小于 2.5,
|
||||
`floor(x+0.5)` 得 2 —— 与 JS 对同一 float32 输入的行为**完全一致**。
|
||||
即:**断言必须基于实际存储精度**,或改用「规则本身 + 有效判别用例」
|
||||
(`-0.5/32767`)而不是「理想有理数」。这与 T-WIN-021 的两处踩坑同源。
|
||||
@@ -0,0 +1,57 @@
|
||||
# T-WIN-023:发布包内不得混入开发/验收工具
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-018(发布包级验收)
|
||||
- **文档**: v1.6 §5.3 / §7.5
|
||||
- **契约**: 不涉及(打包)
|
||||
|
||||
## 缺陷(实测)
|
||||
|
||||
`export_presets.cfg` 用的是 `export_filter="all_resources"` + `exclude_filter="_scratch/*, tests/*, *.md"`。
|
||||
`build/` 不在排除列表里 —— 于是**每次新增一个构建/验收脚本,它就被编译进产物**:
|
||||
|
||||
```
|
||||
res://build/probe-release.gdc
|
||||
res://build/integration/probe-integration.gdc
|
||||
res://build/integration/probe-ws.gdc
|
||||
res://build/integration/probe-ws-bad.gdc ← **故意不合规**的服务端夹具
|
||||
res://build/integration/probe-frame-vectors.gdc
|
||||
res://build/integration/frame-vectors.json
|
||||
```
|
||||
|
||||
体积代价:`OpenDance.pck` **315,464 B → 145,400 B(排除后)**,
|
||||
即 **54% 的 pck 是测试夹具**。已发布的 v0.2.3 同样带着这些残留(其中包含
|
||||
那个刻意违反协议的坏服务端),虽然不影响功能,但既不该进产物,也不该被用户拿到。
|
||||
|
||||
## 修复
|
||||
|
||||
1. `export_presets.cfg` 的 `exclude_filter` 增加 `build/*`:
|
||||
```ini
|
||||
exclude_filter="_scratch/*, tests/*, build/*, *.md"
|
||||
```
|
||||
2. **新增产物级防线**(否则下次加一个 `tools/` 目录又会重演):
|
||||
* `build/integration/list-pck.gd`:用 `--main-pack` 遍历 `res://`,列出全部条目并
|
||||
标出开发残留(`res://build/`、`res://tests/`、`res://_scratch/`、`res://dist-release/`、`*.md`);
|
||||
有残留即退出码 1,支持 `--json --out <path>`。
|
||||
* `build/accept-release.sh` 新增步骤:跑该脚本并断言「无开发残留」,同时打印条目总数。
|
||||
|
||||
## 验证
|
||||
|
||||
```
|
||||
# 修复前
|
||||
[pck] ✗ 产物里混入了开发残留 12 项:res://build/…(pck 315,464 B)
|
||||
|
||||
# 修复后(v0.2.4)
|
||||
✓ pck 共 58 项,无开发残留(build/ tests/ _scratch/ *.md)
|
||||
(pck 145,400 B;zip 26,631,908 B,仍在 30 MB 上传上限内)
|
||||
```
|
||||
|
||||
`accept-release.sh` 其余步骤(PE 版本资源、pck 启动 180 帧、运行时探针、契约表比对)
|
||||
在排除 `build/*` 后依旧全绿 —— 验收脚本本身是**从仓库路径**加载探针的,
|
||||
不依赖包内是否有它们的副本。
|
||||
|
||||
## 顺带确认(不是问题)
|
||||
|
||||
排查时特意确认了 pck **没有**把上一次导出的 pck 自嵌套进去
|
||||
(`file_count=112`、条目里没有 `res://build/windows/OpenDance.pck`):
|
||||
Godot 在扫描资源时排除了自己的导出产物目录内容。这条也由 `list-pck.gd` 长期盯着。
|
||||
@@ -21,6 +21,12 @@ var _available := false
|
||||
var _unavailable_reason := ""
|
||||
|
||||
|
||||
## 服务端基地址:默认契约里的 127.0.0.1:3000,可用 OPENDANCE_API_BASE 覆盖
|
||||
func _api_base() -> String:
|
||||
var from_env := OS.get_environment("OPENDANCE_API_BASE").strip_edges()
|
||||
return from_env if from_env != "" else "http://127.0.0.1:3000"
|
||||
|
||||
|
||||
func _ensure_client() -> bool:
|
||||
if _client != null:
|
||||
# 每次都要显式 skip:否则后续测试会静默 return,
|
||||
@@ -30,7 +36,7 @@ func _ensure_client() -> bool:
|
||||
return _available
|
||||
|
||||
var tree := Engine.get_main_loop() as SceneTree
|
||||
_client = ApiClient.new("http://127.0.0.1:3000")
|
||||
_client = ApiClient.new(_api_base())
|
||||
_client.timeout_seconds = 8.0
|
||||
tree.root.add_child(_client)
|
||||
|
||||
@@ -45,15 +51,37 @@ func _ensure_client() -> bool:
|
||||
return _available
|
||||
|
||||
|
||||
## 契约版本**不能写死在测试里**:写死过一次(0.1.0),而这条用例只在
|
||||
## 「服务端可达」时才跑,平时一直是 skip,于是契约升到 0.5.0 后没有任何地方发现它。
|
||||
## 现在改成从仓库的 `contracts/VERSION`(三端逐字节一致的单一来源)读期望值,
|
||||
## 并与服务端 `/health` 报的逐一比对 —— 顺带成为「客户端契约快照 vs 服务端」的跨端校验。
|
||||
func test_health_reports_contract_version() -> void:
|
||||
if not await _ensure_client():
|
||||
return
|
||||
var result: Dictionary = await _client.health()
|
||||
is_true(result["ok"], "GET /health 成功")
|
||||
var version: Dictionary = result["data"].get("contractVersion", {})
|
||||
eq(str(version.get("rest-api", "")), "0.1.0", "契约 rest-api 版本为 0.1.0")
|
||||
eq(str(version.get("websocket-keypoints", "")), "0.1.0", "契约 websocket-keypoints 版本为 0.1.0")
|
||||
eq(str(version.get("skeleton-topology", "")), "0.1.0", "契约 skeleton-topology 版本为 0.1.0")
|
||||
var expected := _read_contract_snapshot()
|
||||
ok(expected.size() > 0, "可读取仓库 contracts/VERSION(%d 项)" % expected.size())
|
||||
for key in expected.keys():
|
||||
eq(str(version.get(key, "")), str(expected[key]), "契约 %s 与仓库快照一致" % key)
|
||||
|
||||
|
||||
## 解析 res://contracts/VERSION(`key: value`,`#` 注释)
|
||||
func _read_contract_snapshot() -> Dictionary:
|
||||
var out := {}
|
||||
if not FileAccess.file_exists("res://contracts/VERSION"):
|
||||
return out
|
||||
var file := FileAccess.open("res://contracts/VERSION", FileAccess.READ)
|
||||
if file == null:
|
||||
return out
|
||||
while not file.eof_reached():
|
||||
var line := file.get_line().strip_edges()
|
||||
if not line.is_empty() and not line.begins_with("#") and line.contains(":"):
|
||||
var idx := line.find(":")
|
||||
out[line.substr(0, idx).strip_edges()] = line.substr(idx + 1).strip_edges()
|
||||
file.close()
|
||||
return out
|
||||
|
||||
|
||||
func test_catalog_version_returns_revision() -> void:
|
||||
@@ -159,3 +187,48 @@ func test_unknown_route_returns_structured_error() -> void:
|
||||
is_false(result["ok"], "不存在的资源返回失败")
|
||||
eq(int(result["status"]), 404, "状态码为 404")
|
||||
ok(str(result["error"]).length() > 0, "错误信息非空", str(result["error"]))
|
||||
|
||||
|
||||
## T-WIN-019 回归:**二进制响应不得经过 UTF-8 字符串往返**
|
||||
##
|
||||
## 发现经过:跨端集成探针拉动作缩略图(PNG)时,控制台出现
|
||||
## 「Unicode parsing error … Invalid UTF-8 leading byte (89)」。
|
||||
## 根因是 `ApiClient._request()` 对所有响应都无条件执行
|
||||
## `raw.get_string_from_utf8()`:数据本身仍返回原始字节(所以之前没出错),
|
||||
## 但每个文件下载都白做一份 String 拷贝并刷一条噪声日志。
|
||||
## 本用例把「必须原样返回字节」钉住:对含非法 UTF-8 的载荷,
|
||||
## 字符串往返会改变长度,而我们的返回值必须与服务端声明的大小完全一致。
|
||||
func test_binary_download_returns_exact_bytes() -> void:
|
||||
if not await _ensure_client():
|
||||
return
|
||||
|
||||
var export_result: Dictionary = await _client.catalog_export()
|
||||
if not export_result["ok"]:
|
||||
skip("二进制下载", "catalog/export 不可用")
|
||||
return
|
||||
var bundle := CatalogTypes.CatalogBundle.from_dict(export_result["data"])
|
||||
|
||||
var url := ""
|
||||
var expected_size := 0
|
||||
if bundle.scenes.size() > 0:
|
||||
url = bundle.scenes[0].glb_url
|
||||
expected_size = bundle.scenes[0].file_size
|
||||
elif bundle.models.size() > 0:
|
||||
url = bundle.models[0].file_url
|
||||
expected_size = bundle.models[0].file_size
|
||||
if url == "":
|
||||
skip("二进制下载", "目录里没有可下载的模型/场景")
|
||||
return
|
||||
|
||||
var result: Dictionary = await _client.get_binary(url)
|
||||
is_true(result["ok"], "二进制下载成功:%s" % url)
|
||||
if not result["ok"]:
|
||||
return
|
||||
var payload: PackedByteArray = result["data"]
|
||||
eq(typeof(payload), TYPE_PACKED_BYTE_ARRAY, "返回类型是 PackedByteArray")
|
||||
eq(payload.size(), expected_size, "字节数与服务端声明的 fileSize 一致")
|
||||
|
||||
# 证明「字符串往返会损坏数据」:这些字节里含非法 UTF-8 序列
|
||||
var round_tripped := payload.get_string_from_utf8().to_utf8_buffer()
|
||||
ok(round_tripped.size() != payload.size() or round_tripped != payload,
|
||||
"该载荷经 UTF-8 往返会失真(说明不能拿字符串通道传二进制)")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
extends TestCase
|
||||
|
||||
## T-WIN-019:目录类型的**契约字段覆盖**
|
||||
##
|
||||
## 来源:跨端集成探针(`build/integration/probe-integration.gd`)拿**发布包里的**
|
||||
## 客户端打真实 Web 服务端时,用真实 `catalog/export` 报文逐字段比对,
|
||||
## 发现 `Motion.thumbnailUrl`(契约 data-models 0.6.0 起就有)在客户端类型里**缺失** ——
|
||||
## 服务端一直在发,客户端默默丢掉。本文件把这个缺口钉住。
|
||||
##
|
||||
## 这里同时用**服务端真实报文形状**(键名与 `GET /api/catalog/export` 一致)做解析测试,
|
||||
## 而不是自己编一份更"理想"的样例 —— 否则测试会跟着客户端的假设一起漂移。
|
||||
|
||||
const CatalogTypes = preload("res://src/net/catalog_types.gd")
|
||||
|
||||
## 取自真实服务端报文的动作条目(截取与本端相关的字段,键名保持原样)
|
||||
func _motion_payload() -> Dictionary:
|
||||
return {
|
||||
"id": 1,
|
||||
"songId": 1,
|
||||
"name": "霜雪千年(真实 VMD)",
|
||||
"format": "vmd",
|
||||
"fileUrl": "/static/motion/2026/09/a8/x.vmd",
|
||||
"fileSize": 827973,
|
||||
"fps": 30,
|
||||
"frameCount": 2901,
|
||||
"durationMs": 96700,
|
||||
"keypointFormat": "mediapipe33@0.1.0",
|
||||
"keypointUrl": "/static/keypoints/2026/09/7d/y.ndjson",
|
||||
"thumbnailUrl": "/static/motion-thumbnail/2026/09/32/z.png",
|
||||
"keypointFrames": 2901,
|
||||
"preprocessState": "done",
|
||||
"preprocessError": null,
|
||||
"sourceType": "original",
|
||||
"sourceName": "素材",
|
||||
"sourceUrl": "https://example.com",
|
||||
"license": "MIT",
|
||||
"author": "QA",
|
||||
"status": "published",
|
||||
}
|
||||
|
||||
|
||||
func test_motion_parses_all_contract_fields() -> void:
|
||||
var m := CatalogTypes.Motion.from_dict(_motion_payload())
|
||||
eq(m.id, 1, "id")
|
||||
eq(m.song_id, 1, "songId → song_id")
|
||||
eq(m.format, "vmd", "format")
|
||||
eq(m.fps, 30.0, "fps")
|
||||
eq(m.frame_count, 2901, "frameCount → frame_count")
|
||||
eq(m.duration_ms, 96700, "durationMs → duration_ms")
|
||||
eq(m.keypoint_format, "mediapipe33@0.1.0", "keypointFormat → keypoint_format")
|
||||
eq(m.keypoint_frames, 2901, "keypointFrames → keypoint_frames")
|
||||
eq(m.preprocess_state, "done", "preprocessState → preprocess_state")
|
||||
is_true(m.is_scorable(), "预处理完成 → 可评分")
|
||||
# 缺口回归:这一条在集成探针发现前是失败的(客户端类型里没有这个字段)
|
||||
eq(m.thumbnail_url, "/static/motion-thumbnail/2026/09/32/z.png",
|
||||
"thumbnailUrl → thumbnail_url(T-WIN-019 缺字段回归)")
|
||||
|
||||
|
||||
func test_motion_thumbnail_missing_is_empty_not_crash() -> void:
|
||||
# 旧服务端(data-models < 0.6.0)不发 thumbnailUrl:必须是空串而不是解析失败
|
||||
var payload := _motion_payload()
|
||||
payload.erase("thumbnailUrl")
|
||||
var m := CatalogTypes.Motion.from_dict(payload)
|
||||
eq(m.thumbnail_url, "", "缺 thumbnailUrl 时为空串")
|
||||
eq(m.keypoint_frames, 2901, "其余字段仍正常解析")
|
||||
|
||||
|
||||
func test_model_and_scene_thumbnail_fields_present() -> void:
|
||||
# 三个资源类型现在都消费 thumbnailUrl(模型/场景本来就有,动作是本轮补的)
|
||||
var model := CatalogTypes.Model.from_dict({
|
||||
"id": 1, "name": "m", "format": "vrm", "vrmVersion": "1.0", "isPrimary": true,
|
||||
"fileUrl": "/static/model/x.vrm", "fileSize": 10, "thumbnailUrl": "/static/thumbnail/a.png",
|
||||
})
|
||||
eq(model.thumbnail_url, "/static/thumbnail/a.png", "Model.thumbnail_url")
|
||||
eq(model.vrm_version, "1.0", "Model.vrm_version")
|
||||
is_true(model.is_primary, "Model.is_primary")
|
||||
|
||||
var scene := CatalogTypes.Scene.from_dict({
|
||||
"id": 1, "name": "s", "glbUrl": "/static/model/x.glb",
|
||||
"thumbnailUrl": "/static/thumbnail/b.png", "fileSize": 20, "config": {},
|
||||
})
|
||||
eq(scene.thumbnail_url, "/static/thumbnail/b.png", "Scene.thumbnail_url")
|
||||
eq(scene.glb_url, "/static/model/x.glb", "Scene.glb_url")
|
||||
|
||||
|
||||
func test_intentionally_unconsumed_model_fields() -> void:
|
||||
# 记录一处**刻意不消费**的差异,避免以后被当成漏字段:
|
||||
# `usable` / `unusableReason` 是 Web 端审核用的(humanoid 不达标的模型「入库但标记不可用」),
|
||||
# 而 `GET /api/catalog/export` 只下发**可用**模型,所以目录里它们恒为 true。
|
||||
# 客户端的 CatalogStore 只消费目录,故不解析;不可用模型的可见性由 Web 管理端负责。
|
||||
var payload := {
|
||||
"id": 1, "name": "m", "format": "vrm", "vrmVersion": "1.0", "isPrimary": false,
|
||||
"fileUrl": "/static/model/x.vrm", "fileSize": 10, "thumbnailUrl": "/static/thumbnail/a.png",
|
||||
"usable": true, "unusableReason": null,
|
||||
}
|
||||
var model := CatalogTypes.Model.from_dict(payload)
|
||||
eq(model.id, 1, "usable/unusableReason 不影响其他字段解析")
|
||||
is_false(model.is_primary, "isPrimary 仍按报文解析")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmk7mkmy7dwqf
|
||||
@@ -159,3 +159,97 @@ func test_json_debug_frame() -> void:
|
||||
|
||||
var bad := KeypointFrame.from_json_dict({"frameId": 1, "points": []})
|
||||
is_null(bad, "点数不足的 JSON 帧被拒绝")
|
||||
|
||||
|
||||
## T-WIN-021:编码/解码必须与契约参考实现(JS)**逐位一致**
|
||||
##
|
||||
## JS 的 `Math.round` 对「恰好 .5」朝 +∞ 取整(`Math.round(-2.5) === -2`),
|
||||
## 而 GDScript 的 `roundi` 是半数远离零(`roundi(-2.5) == -3`)——
|
||||
## 两者在 `-X.5` 上差 1 个 LSB。Android 的 Kotlin 实现用的正是 `Math.round`,
|
||||
## 所以本端必须显式实现同一规则(`_round_like_js` = `floor(x + 0.5)`)。
|
||||
func test_rounding_rule_matches_js_math_round() -> void:
|
||||
eq(KeypointFrame._round_like_js(-2.5), -2, "取整 -2.5 → -2(半数朝 +∞,与 JS 一致)")
|
||||
eq(KeypointFrame._round_like_js(2.5), 3, "取整 2.5 → 3")
|
||||
eq(KeypointFrame._round_like_js(-0.5), 0, "取整 -0.5 → 0(roundi 会给 -1)")
|
||||
eq(KeypointFrame._round_like_js(0.5), 1, "取整 0.5 → 1")
|
||||
eq(KeypointFrame._round_like_js(-1.5), -1, "取整 -1.5 → -1")
|
||||
# 端到端:-0.5/32767 经 float32 后在量化前恰好落在 -.5 上
|
||||
var points := PackedVector3Array()
|
||||
var conf := PackedFloat32Array()
|
||||
points.resize(33)
|
||||
conf.resize(33)
|
||||
for i in 33:
|
||||
points[i] = Vector3(-0.5 / 32767.0, 0.0, 0.0)
|
||||
conf[i] = 128.0 / 255.0
|
||||
var buf := KeypointFrame.encode(0, points, conf, 0)
|
||||
eq(buf.decode_s16(8), 0, "坐标 -0.5/32767 编码为 0(roundi 会给 -1)")
|
||||
eq(buf.decode_u8(14), 128, "置信度 128/255 编码为 128")
|
||||
|
||||
|
||||
## 用 Web 参考实现(opendance-web 的 keypoint-codec.ts)生成的**锁定向量**做逐值比对。
|
||||
## 这一步让 `verify.sh`(只跑源码,不需要 pck)也能覆盖「Web ↔ Windows」这条边;
|
||||
## 发布包层面的同一比对由 build/accept-frame-vectors.sh 负责。
|
||||
func test_reference_vectors_decode_and_encode_exactly() -> void:
|
||||
var path := "res://build/integration/frame-vectors.json"
|
||||
if not FileAccess.file_exists(path):
|
||||
skip("参考向量", "找不到 %s" % path)
|
||||
return
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
not_null(parsed, "参考向量可解析")
|
||||
var cases: Array = parsed.get("cases", [])
|
||||
eq(cases.size(), 12, "参考向量用例数")
|
||||
|
||||
var mismatches: Array = []
|
||||
var points_compared := 0
|
||||
for c in cases:
|
||||
var name := str(c.get("name", "?"))
|
||||
var bytes := PackedByteArray()
|
||||
var hex := str(c.get("hex", ""))
|
||||
bytes.resize(hex.length() / 2)
|
||||
for i in bytes.size():
|
||||
bytes[i] = hex.substr(i * 2, 2).hex_to_int()
|
||||
if KeypointFrame.validate(bytes) != "":
|
||||
mismatches.append("%s: validate 失败" % name)
|
||||
continue
|
||||
var frame = KeypointFrame.decode(bytes)
|
||||
if frame == null:
|
||||
mismatches.append("%s: decode 为 null" % name)
|
||||
continue
|
||||
if int(frame.frame_id) != int(c.get("frameId", 0)):
|
||||
mismatches.append("%s: frame_id" % name)
|
||||
if int(frame.flags) != int(c.get("flags", 0)):
|
||||
mismatches.append("%s: flags" % name)
|
||||
|
||||
var expect_points: Array = c.get("points", [])
|
||||
for i in expect_points.size():
|
||||
points_compared += 1
|
||||
var e: Array = expect_points[i]
|
||||
# 本端点存 PackedVector3Array(float32),参考实现是 float64:
|
||||
# 先把期望值收敛到 float32(Vector3 的分量天然是 float32)再比精确相等
|
||||
var want := Vector3(float(e[0]), float(e[1]), float(e[2]))
|
||||
var want_conf := PackedFloat32Array([float(e[3])])[0]
|
||||
if frame.points[i] != want:
|
||||
mismatches.append("%s: 点 %d 坐标 %s != %s" % [name, i, str(frame.points[i]), str(want)])
|
||||
elif frame.confidence[i] != want_conf:
|
||||
mismatches.append("%s: 点 %d 置信度" % [name, i])
|
||||
|
||||
# 编码:用同输入基准 f32InputPoints,与 f32Hex 逐字节比对
|
||||
var f32_pts: Array = c.get("f32InputPoints", [])
|
||||
var f32_hex := str(c.get("f32Hex", ""))
|
||||
if f32_pts.size() == 33 and f32_hex != "":
|
||||
var pts := PackedVector3Array()
|
||||
var cfs := PackedFloat32Array()
|
||||
pts.resize(33)
|
||||
cfs.resize(33)
|
||||
for i in 33:
|
||||
var p: Array = f32_pts[i]
|
||||
pts[i] = Vector3(float(p[0]), float(p[1]), float(p[2]))
|
||||
cfs[i] = float(p[3])
|
||||
var encoded := KeypointFrame.encode(int(c.get("frameId", 0)), pts, cfs, int(c.get("flags", 0)))
|
||||
if encoded.hex_encode() != f32_hex:
|
||||
mismatches.append("%s: 编码字节与参考不一致" % name)
|
||||
|
||||
eq(points_compared, cases.size() * 33, "逐点比对数量")
|
||||
eq(mismatches.slice(0, 5), [], "参考向量逐值/逐字节一致(差异 %d 项)" % mismatches.size())
|
||||
|
||||
Reference in New Issue
Block a user