Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ba4ea833 | ||
|
|
8faaa2a8a5 |
@@ -22,10 +22,31 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。
|
|||||||
| 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** |
|
| 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** |
|
||||||
| 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 |
|
| 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 |
|
||||||
| 连接保活与设备去重 | OK welcome/config 声明静默超时与心跳;应用层 ping/pong;按 deviceId 去重 |
|
| 连接保活与设备去重 | OK welcome/config 声明静默超时与心跳;应用层 ping/pong;按 deviceId 去重 |
|
||||||
| 发布整包 | OK 单个 `opendance-windows-v0.2.4.zip` + `SHA256SUMS`(不拆分卷) |
|
| 发布整包 | OK 单个 `opendance-windows-v0.2.5.zip` + `SHA256SUMS`(不拆分卷) |
|
||||||
| 版本号展示 | OK 「关于」清单显示 `v0.2.2`;`project.godot` / `project.yaml` / `export_presets.cfg` / exe 的 PE 版本资源 / 产物名 / tag 全部一致 |
|
| 版本号展示 | OK 「关于」清单显示 `v0.2.2`;`project.godot` / `project.yaml` / `export_presets.cfg` / exe 的 PE 版本资源 / 产物名 / tag 全部一致 |
|
||||||
| 场景物理与保活 | OK 新增真实 GLB 端到端用例 + 17 秒保活对照用例(751 项断言全过) |
|
| 场景物理与保活 | OK 新增真实 GLB 端到端用例 + 17 秒保活对照用例(751 项断言全过) |
|
||||||
|
|
||||||
|
## v0.2.5 修订(config 改为变更驱动 + 契约「可选/兜底」语义定向验收)
|
||||||
|
|
||||||
|
**缺陷**:`config` 被当成心跳 —— 每 2 秒**无条件**广播一次(契约 §3.3 的语义是
|
||||||
|
"运行中**调整**时下发")。一次 30 分钟推流 = 900 条无信息量的消息,而且让
|
||||||
|
「收到 config」无法被当成「配置变了」的信号。实测 v0.2.4:17 秒内 9 条 → v0.2.5:**1 条**。
|
||||||
|
修复:抽出 `config_payload()` 作为唯一载荷与指纹来源;`hello` 应答固定为
|
||||||
|
**先 `welcome` 再 `config`**(避免新对端第一条消息变成 config);之后仅当载荷变化才广播,
|
||||||
|
并用 `CONFIG_PUSH_INTERVAL_MS` 去抖。
|
||||||
|
|
||||||
|
**新增 3 个契约语义场景**(`accept-ws.sh` / `ws-client.mjs`,总 30 → **40 项断言**):
|
||||||
|
* **v0.1.0 风格对端**:只发二进制帧(每 500ms 一帧)、**从不 ping**、`hello` 不带 `deviceId`,
|
||||||
|
持续 17 秒(> 静默超时)→ 连接必须保持(契约 §1.1 的向后兼容承诺);
|
||||||
|
* **两条都不带 `deviceId`** 的连接同时在线 → 不做去重,按连接计数(§5);
|
||||||
|
* **完全不发 `hello`**、只发 5 帧的对端 → 不断开、不崩、帧仍计入。
|
||||||
|
|
||||||
|
顺带修掉验收脚本自身两处**误判**:把「跨连接的 frame_id 回退」当缺陷(`frame_id` 是
|
||||||
|
每条连接各自从 0 递增,新连接重新开始是合规的)、把「静默超时次数」当固定值
|
||||||
|
(空闲连接本来就会被正常超时);现改为**定向断言**:由 hello 记录建立
|
||||||
|
`client_id → device` 映射,只检查 v0.1.0 风格对端没有被误踢。
|
||||||
|
测试客户端也改为**按类型取消息**(`nextOfType`)—— 契约没规定消息顺序,客户端必须顺序无关。
|
||||||
|
|
||||||
## v0.2.4 修订(编码取整对齐 + 产物不再夹带测试夹具)
|
## v0.2.4 修订(编码取整对齐 + 产物不再夹带测试夹具)
|
||||||
|
|
||||||
**缺陷 1(编码取整与参考实现不一致)**:`KeypointFrame.encode()` 用 GDScript 的
|
**缺陷 1(编码取整与参考实现不一致)**:`KeypointFrame.encode()` 用 GDScript 的
|
||||||
@@ -127,10 +148,10 @@ GODOT_BIN=/path/to/godot bash build/verify.sh
|
|||||||
GODOT_BIN=/path/to/godot bash build/export-windows.sh release
|
GODOT_BIN=/path/to/godot bash build/export-windows.sh release
|
||||||
|
|
||||||
# 打包发布产物(单个完整 zip + SHA256SUMS)
|
# 打包发布产物(单个完整 zip + SHA256SUMS)
|
||||||
bash build/package-release.sh 0.2.4
|
bash build/package-release.sh 0.2.5
|
||||||
|
|
||||||
# 发布包级验收(对**发布包**跑,而不是对源码;文档 §7.5)
|
# 发布包级验收(对**发布包**跑,而不是对源码;文档 §7.5)
|
||||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.4.zip
|
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.5.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
`accept-release.sh` 校验 sha256 → 解压 → 文件清单(并检查没有 `.partN` 残留)→
|
`accept-release.sh` 校验 sha256 → 解压 → 文件清单(并检查没有 `.partN` 残留)→
|
||||||
@@ -149,7 +170,7 @@ Android 已用「金标准字节向量」与它对齐;而**本端的解码器
|
|||||||
(自己编码、自己解码)。这条边由下列脚本补上:
|
(自己编码、自己解码)。这条边由下列脚本补上:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.4.zip
|
GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.5.zip
|
||||||
# 没有 web 仓 / 未装 node_modules 时:... --skip-regen
|
# 没有 web 仓 / 未装 node_modules 时:... --skip-regen
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -172,7 +193,7 @@ GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendan
|
|||||||
文本/二进制边界、关闭语义、子协议协商)。因此另配一套**独立实现**的客户端:
|
文本/二进制边界、关闭语义、子协议协商)。因此另配一套**独立实现**的客户端:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.4.zip
|
GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.5.zip
|
||||||
# 跳过耗时的两个场景(保活 19s / 静默超时 15s):
|
# 跳过耗时的两个场景(保活 19s / 静默超时 15s):
|
||||||
GODOT_BIN=... bash build/accept-ws.sh <zip> --skip-slow
|
GODOT_BIN=... bash build/accept-ws.sh <zip> --skip-slow
|
||||||
```
|
```
|
||||||
@@ -190,7 +211,7 @@ GODOT_BIN=... bash build/accept-ws.sh <zip> --skip-slow
|
|||||||
保活字段、不回 pong、不做去重)跑同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。
|
保活字段、不回 pong、不做去重)跑同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。
|
||||||
详见 [`tasks/020-ws-contract-conformance.md`](tasks/020-ws-contract-conformance.md)。
|
详见 [`tasks/020-ws-contract-conformance.md`](tasks/020-ws-contract-conformance.md)。
|
||||||
|
|
||||||
当前版本:**v0.2.4**(`project.godot` 的 `application/config/version`,
|
当前版本:**v0.2.5**(`project.godot` 的 `application/config/version`,
|
||||||
与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、
|
与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、
|
||||||
以及 exe 的 PE 版本资源全部一致)。
|
以及 exe 的 PE 版本资源全部一致)。
|
||||||
|
|
||||||
@@ -273,7 +294,7 @@ GODOT_BIN=/path/to/godot bash build/export-windows.sh release
|
|||||||
powershell -ExecutionPolicy Bypass -File build\export-windows.ps1
|
powershell -ExecutionPolicy Bypass -File build\export-windows.ps1
|
||||||
|
|
||||||
# 打成可分发的 zip(单个完整包 + SHA256SUMS)
|
# 打成可分发的 zip(单个完整包 + SHA256SUMS)
|
||||||
bash build/package-release.sh 0.2.4
|
bash build/package-release.sh 0.2.5
|
||||||
```
|
```
|
||||||
|
|
||||||
| 产物 | 位置 | 说明 |
|
| 产物 | 位置 | 说明 |
|
||||||
@@ -281,7 +302,7 @@ bash build/package-release.sh 0.2.4
|
|||||||
| `OpenDance.exe` | `build/windows/` | 主程序,约 104 MB;脚本校验前 2 字节为 `MZ`(合法 PE) |
|
| `OpenDance.exe` | `build/windows/` | 主程序,约 104 MB;脚本校验前 2 字节为 `MZ`(合法 PE) |
|
||||||
| `OpenDance.pck` | `build/windows/` | 资源包,约 107 KB; **必须与 exe 同目录** |
|
| `OpenDance.pck` | `build/windows/` | 资源包,约 107 KB; **必须与 exe 同目录** |
|
||||||
| `OpenDance.console.exe` | `build/windows/` | 控制台包装器,可能生成,非必需 |
|
| `OpenDance.console.exe` | `build/windows/` | 控制台包装器,可能生成,非必需 |
|
||||||
| `opendance-windows-v0.2.4.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
|
| `opendance-windows-v0.2.5.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
|
||||||
| `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 |
|
| `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 |
|
||||||
|
|
||||||
导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。
|
导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。
|
||||||
@@ -385,7 +406,7 @@ src/
|
|||||||
等格式,但本项目未在 Windows 上实测)。
|
等格式,但本项目未在 Windows 上实测)。
|
||||||
若 IIS 上限已放开、或你能把包放到别处分发(例如 Web 服务端的 `/static`,
|
若 IIS 上限已放开、或你能把包放到别处分发(例如 Web 服务端的 `/static`,
|
||||||
不受 IIS 那个限制约束),请改用资源管理器可直接双击解压的 deflate 包:
|
不受 IIS 那个限制约束),请改用资源管理器可直接双击解压的 deflate 包:
|
||||||
`ZIP_METHOD=deflate bash build/package-release.sh 0.2.4`(约 38 MB)。
|
`ZIP_METHOD=deflate bash build/package-release.sh 0.2.5`(约 38 MB)。
|
||||||
7. **发布包 exe 的版本元数据**(T-WIN-017):v0.2.1 之前在 Windows
|
7. **发布包 exe 的版本元数据**(T-WIN-017):v0.2.1 之前在 Windows
|
||||||
「属性 → 详细信息」里显示的是 `0.1.0`,因为 `export_presets.cfg` 的
|
「属性 → 详细信息」里显示的是 `0.1.0`,因为 `export_presets.cfg` 的
|
||||||
`application/file_version` / `application/product_version` 被硬编码且无人校验。
|
`application/file_version` / `application/product_version` 被硬编码且无人校验。
|
||||||
|
|||||||
+15
-5
@@ -1,5 +1,5 @@
|
|||||||
================================================================
|
================================================================
|
||||||
OpenDance Studio -- Windows 客户端 v0.2.4 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
|
OpenDance Studio -- Windows 客户端 v0.2.5 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
|
||||||
================================================================
|
================================================================
|
||||||
|
|
||||||
摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket
|
摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket
|
||||||
@@ -8,6 +8,16 @@ OpenDance Studio -- Windows 客户端 v0.2.4 使用说明(zip 为 LZMA 压缩
|
|||||||
|
|
||||||
本客户端只接收关键点坐标,不接收、不存储任何视频画面。
|
本客户端只接收关键点坐标,不接收、不存储任何视频画面。
|
||||||
|
|
||||||
|
v0.2.5 修订
|
||||||
|
1. config 不再被当成心跳: 原来每 2 秒无条件广播一次(契约 §3.3 的语义是"运行中
|
||||||
|
调整时下发"),一次 30 分钟推流会产生 900 条无信息量的消息。现改为
|
||||||
|
"hello 应答后先 welcome 再 config,之后仅当配置真的变化才广播(带去抖)"。
|
||||||
|
实测 17 秒内 config 由 9 条降到 1 条。
|
||||||
|
2. 新增 3 个契约语义场景(WebSocket 验收断言 30 → 40 项):
|
||||||
|
v0.1.0 风格对端(只发帧、从不 ping、不带 deviceId)持续 17 秒不被踢;
|
||||||
|
两条都不带 deviceId 的连接不做去重;完全不发 hello 的对端不崩不断。
|
||||||
|
3. 测试规模 785 项断言。
|
||||||
|
|
||||||
v0.2.4 修订
|
v0.2.4 修订
|
||||||
1. 编码取整与契约参考实现对齐: 本端 encode() 原用 GDScript roundi(半数远离零),
|
1. 编码取整与契约参考实现对齐: 本端 encode() 原用 GDScript roundi(半数远离零),
|
||||||
而参考实现与生产发送方(Android 的 Math.round)是「半数朝 +∞」—— -2.5 分别是
|
而参考实现与生产发送方(Android 的 Math.round)是「半数朝 +∞」—— -2.5 分别是
|
||||||
@@ -152,11 +162,11 @@ OpenDance.exe 的用户看第一节即可。
|
|||||||
|
|
||||||
打成发布包:
|
打成发布包:
|
||||||
|
|
||||||
bash build/package-release.sh 0.2.4
|
bash build/package-release.sh 0.2.5
|
||||||
|
|
||||||
得到 dist-release/ 下两个资产:
|
得到 dist-release/ 下两个资产:
|
||||||
|
|
||||||
opendance-windows-v0.2.4.zip <- 单个完整包,禁止 .partN 分卷
|
opendance-windows-v0.2.5.zip <- 单个完整包,禁止 .partN 分卷
|
||||||
SHA256SUMS <- 裸文件名,可直接 sha256sum -c
|
SHA256SUMS <- 裸文件名,可直接 sha256sum -c
|
||||||
|
|
||||||
包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、
|
包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、
|
||||||
@@ -175,14 +185,14 @@ OpenDance.exe 的用户看第一节即可。
|
|||||||
如果你能把包放到别处分发(例如 Web 服务端的 /static,不受 IIS 那个
|
如果你能把包放到别处分发(例如 Web 服务端的 /static,不受 IIS 那个
|
||||||
上限约束),建议改用资源管理器可直接双击解压的 deflate 包:
|
上限约束),建议改用资源管理器可直接双击解压的 deflate 包:
|
||||||
|
|
||||||
ZIP_METHOD=deflate bash build/package-release.sh 0.2.4 # 约 38 MB
|
ZIP_METHOD=deflate bash build/package-release.sh 0.2.5 # 约 38 MB
|
||||||
|
|
||||||
上限一旦放开,Release 资产也应换回 deflate 包。
|
上限一旦放开,Release 资产也应换回 deflate 包。
|
||||||
|
|
||||||
7. 自检清单
|
7. 自检清单
|
||||||
|
|
||||||
0) 发布包级验收(对**发布包**而不是源码;文档 §7.5)
|
0) 发布包级验收(对**发布包**而不是源码;文档 §7.5)
|
||||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.4.zip
|
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.5.zip
|
||||||
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
|
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
|
||||||
读 exe 的 PE 版本资源、用 Godot 直接加载包内 pck 跑 180 帧、并跑包内运行时
|
读 exe 的 PE 版本资源、用 Godot 直接加载包内 pck 跑 180 帧、并跑包内运行时
|
||||||
探针(包内版本号 / 「关于」清单显示值 / 主场景 / autoload / 关键资源 /
|
探针(包内版本号 / 「关于」清单显示值 / 主场景 / autoload / 关键资源 /
|
||||||
|
|||||||
+24
-5
@@ -136,10 +136,12 @@ const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (
|
|||||||
|
|
||||||
say(r.server.silent_timeout_ms === 15000 && r.server.heartbeat_interval_ms === 5000,
|
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}`);
|
`服务端声明 silentTimeoutMs=${r.server.silent_timeout_ms} / heartbeatIntervalMs=${r.server.heartbeat_interval_ms}`);
|
||||||
say(r.connected.length >= 6, `收到 ${r.connected.length} 次连接(握手完成)`);
|
say(r.connected.length >= 10, `收到 ${r.connected.length} 次连接(含 v0.1.0 风格、无 deviceId、无 hello 的对端)`);
|
||||||
say(r.framesReceived >= 72, `收到 ${r.framesReceived} 个关键点帧`);
|
say(r.framesReceived >= 100, `收到 ${r.framesReceived} 个关键点帧(基础 72 + v0.1.0 风格对端 34 + 无 hello 对端 5)`);
|
||||||
say(r.lastFrameId >= 74, `最大 frame_id = ${r.lastFrameId}`);
|
say(r.lastFrameId >= 74, `最大 frame_id = ${r.lastFrameId}`);
|
||||||
say(r.frameIdRegressions === 0, `frame_id 无回退(回退 ${r.frameIdRegressions} 次)`);
|
// frame_id 是「每条连接各自从 0 递增」,新连接重新从 0 开始是合规的,
|
||||||
|
// 因此这里只打印跨连接计数作参考,不用它做判定(第一版误当成缺陷)。
|
||||||
|
console.log(` · 跨连接 frame_id 回退计数 ${r.frameIdRegressionsAcrossConnections ?? "?"}(仅参考)`);
|
||||||
// 客户端故意跳号 60→63,服务端应按 §8 计入丢帧
|
// 客户端故意跳号 60→63,服务端应按 §8 计入丢帧
|
||||||
say(r.server.frames_dropped >= 3, `丢帧检测:frames_dropped=${r.server.frames_dropped}(期望 ≥3,来自故意的 60→63 跳号)`);
|
say(r.server.frames_dropped >= 3, `丢帧检测:frames_dropped=${r.server.frames_dropped}(期望 ≥3,来自故意的 60→63 跳号)`);
|
||||||
// 客户端故意发 100 字节非法帧,服务端应计数且不崩
|
// 客户端故意发 100 字节非法帧,服务端应计数且不崩
|
||||||
@@ -149,14 +151,31 @@ 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.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((c.ping ?? 0) >= (skipSlow ? 1 : 8), `应用层 ping 计数=${c.ping ?? 0}(保活场景)`);
|
||||||
// 去重事件
|
// 去重事件
|
||||||
say(Array.isArray(r.deviceReplaced) && r.deviceReplaced.includes("aaaa000000000001"),
|
say(Array.isArray(r.deviceReplaced) && r.deviceReplaced.length === 1
|
||||||
`device_replaced 事件:${JSON.stringify(r.deviceReplaced)}`);
|
&& r.deviceReplaced[0] === "aaaa000000000001",
|
||||||
|
`device_replaced 只应发生一次且只针对重复的 deviceId(实际 ${JSON.stringify(r.deviceReplaced)}):`
|
||||||
|
+ "契约 §5 要求缺 deviceId 的连接**不做去重**、按连接计数");
|
||||||
// 断开原因:替换(1000) / 静默超时 / bye(1000) / 协议错误(1002)
|
// 断开原因:替换(1000) / 静默超时 / bye(1000) / 协议错误(1002)
|
||||||
const reasons = r.disconnected.map((x) => x.reason).join(" | ");
|
const reasons = r.disconnected.map((x) => x.reason).join(" | ");
|
||||||
say(reasons.includes("被同一设备的新连接替换"), "服务端记录了「同设备替换」断开原因");
|
say(reasons.includes("被同一设备的新连接替换"), "服务端记录了「同设备替换」断开原因");
|
||||||
say(reasons.includes("user_stop"), "服务端记录了 bye 的 reason");
|
say(reasons.includes("user_stop"), "服务端记录了 bye 的 reason");
|
||||||
say(reasons.includes("code=1002"), "服务端记录了协议不匹配的 1002 关闭");
|
say(reasons.includes("code=1002"), "服务端记录了协议不匹配的 1002 关闭");
|
||||||
if (!skipSlow) {
|
if (!skipSlow) {
|
||||||
|
// 只有"专门完全不发数据"的那条连接该被静默超时踢掉。
|
||||||
|
// v0.1.0 风格的对端只发二进制帧、从不 ping —— 契约 §1.1 明确"每帧同样算活动",
|
||||||
|
// 若它也被踢掉,静默超时计数就会 >1(这是"契约可选字段/兜底路径"最容易漏的一处)。
|
||||||
|
// **定向**断言:契约 §1.1 承诺"只发帧、不发 ping 的 v0.1.0 风格对端也能正常互通"。
|
||||||
|
// 报告里的断开原因没有设备名,因此靠 hello 记录建立 client_id → device 映射,
|
||||||
|
// 再检查该连接有没有被静默超时踢掉(全局计数会混入"空闲的连接"这类正常超时)。
|
||||||
|
const legacyIds = (r.hellos ?? []).filter((h) => String(h.device).includes("Legacy")).map((h) => h.id);
|
||||||
|
say(legacyIds.length === 1, `报告里应能识别出 v0.1.0 风格对端(client_id=${JSON.stringify(legacyIds)})`);
|
||||||
|
const legacyTimedOut = r.disconnected.some(
|
||||||
|
(x) => legacyIds.includes(x.id) && String(x.reason).includes("静默超时"),
|
||||||
|
);
|
||||||
|
say(!legacyTimedOut,
|
||||||
|
"只发二进制帧、从不 ping 的对端**没有**被静默超时踢掉(契约 §1.1:每帧同样算活动)");
|
||||||
|
const silentTimeouts = r.disconnected.filter((x) => String(x.reason).includes("静默超时")).length;
|
||||||
|
console.log(` · 静默超时共 ${silentTimeouts} 次(含专门"完全不发数据"的对端与空闲连接,属预期)`);
|
||||||
say(reasons.includes("静默超时"), "服务端记录了静默超时踢人");
|
say(reasons.includes("静默超时"), "服务端记录了静默超时踢人");
|
||||||
// 写这条时第一版写成了 !reasons.includes("静默超时 1") —— 恰好把「消息里带 15001 ms」
|
// 写这条时第一版写成了 !reasons.includes("静默超时 1") —— 恰好把「消息里带 15001 ms」
|
||||||
// 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。
|
// 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ var _connected: Array = []
|
|||||||
var _disconnected: Array = []
|
var _disconnected: Array = []
|
||||||
var _replaced: Array = []
|
var _replaced: Array = []
|
||||||
var _controls := {}
|
var _controls := {}
|
||||||
|
## 每条连接的 hello 标识(client_id → {device, deviceId}),
|
||||||
|
## 用来在报告核对阶段回答「某类对端有没有被误踢」——断开原因里没有设备名,
|
||||||
|
## 只有 client_id 可用,所以必须在这里建立映射。
|
||||||
|
var _hellos: Array = []
|
||||||
var _frames := 0
|
var _frames := 0
|
||||||
var _last_frame_id := -1
|
var _last_frame_id := -1
|
||||||
var _frame_id_regressions := 0
|
var _frame_id_regressions := 0
|
||||||
@@ -141,6 +145,13 @@ func _on_control_received(client_id: int, message: Dictionary) -> void:
|
|||||||
var type := str(message.get("type", "(无 type)"))
|
var type := str(message.get("type", "(无 type)"))
|
||||||
_controls[type] = int(_controls.get(type, 0)) + 1
|
_controls[type] = int(_controls.get(type, 0)) + 1
|
||||||
_events.append({"atMs": _now_ms(), "kind": "control", "id": client_id, "type": type})
|
_events.append({"atMs": _now_ms(), "kind": "control", "id": client_id, "type": type})
|
||||||
|
if type == "hello":
|
||||||
|
_hellos.append({
|
||||||
|
"id": client_id,
|
||||||
|
"device": str(message.get("device", "")),
|
||||||
|
"deviceId": str(message.get("deviceId", "")),
|
||||||
|
"atMs": _now_ms(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
func _finish(code: int = 0) -> void:
|
func _finish(code: int = 0) -> void:
|
||||||
@@ -155,9 +166,12 @@ func _finish(code: int = 0) -> void:
|
|||||||
"disconnected": _disconnected,
|
"disconnected": _disconnected,
|
||||||
"deviceReplaced": _replaced,
|
"deviceReplaced": _replaced,
|
||||||
"controls": _controls,
|
"controls": _controls,
|
||||||
|
"hellos": _hellos,
|
||||||
"framesReceived": _frames,
|
"framesReceived": _frames,
|
||||||
"lastFrameId": _last_frame_id,
|
"lastFrameId": _last_frame_id,
|
||||||
"frameIdRegressions": _frame_id_regressions,
|
# 注意:这是**跨连接**的全局计数。frame_id 是「每条连接各自从 0 单调递增」,
|
||||||
|
# 新连接重新从 0 开始完全合规,因此该字段只作参考,不能用来判定丢帧/回退。
|
||||||
|
"frameIdRegressionsAcrossConnections": _frame_id_regressions,
|
||||||
"firstFrameAtMs": _first_frame_ms,
|
"firstFrameAtMs": _first_frame_ms,
|
||||||
"lastFrameAtMs": _last_frame_ms,
|
"lastFrameAtMs": _last_frame_ms,
|
||||||
"events": _events,
|
"events": _events,
|
||||||
|
|||||||
+106
-13
@@ -83,16 +83,32 @@ function buildFrame({ frameId = 0, flags = 0, mirrored = false, lowConf = false,
|
|||||||
return new Uint8Array(buf);
|
return new Uint8Array(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开一条连接,返回 { ws, next(type), closeInfo } */
|
/**
|
||||||
function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SUBPROTOCOL, hello = true } = {}) {
|
* 打开一条连接,返回 { ws, state, next(), closed(), counts }
|
||||||
|
*
|
||||||
|
* @param omitDeviceId 为 true 时 `hello` **不带 deviceId 字段**(契约 §3.1 标注为可选,
|
||||||
|
* §5 要求此时接收端"不做去重,按连接计数")
|
||||||
|
* @param hello 为 false 时连 `hello` 都不发(测接收端对"只发二进制帧"的对端是否宽容)
|
||||||
|
*/
|
||||||
|
function connect({
|
||||||
|
deviceId = '',
|
||||||
|
device = 'Node 契约客户端',
|
||||||
|
protocol = SUBPROTOCOL,
|
||||||
|
hello = true,
|
||||||
|
omitDeviceId = false,
|
||||||
|
} = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(URL_, [SUBPROTOCOL]);
|
const ws = new WebSocket(URL_, [SUBPROTOCOL]);
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
const inbox = [];
|
const inbox = [];
|
||||||
const waiters = [];
|
const waiters = [];
|
||||||
const state = { closeInfo: null, error: null };
|
const state = { closeInfo: null, error: null };
|
||||||
|
/** 收到的消息类型计数(用于断言"config 不是周期性刷屏"这类事实) */
|
||||||
|
const counts = {};
|
||||||
|
|
||||||
const push = (msg) => {
|
const push = (msg) => {
|
||||||
|
const t = msg?.type ?? '(未知)';
|
||||||
|
counts[t] = (counts[t] ?? 0) + 1;
|
||||||
const w = waiters.shift();
|
const w = waiters.shift();
|
||||||
if (w) w(msg);
|
if (w) w(msg);
|
||||||
else inbox.push(msg);
|
else inbox.push(msg);
|
||||||
@@ -128,14 +144,18 @@ function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SU
|
|||||||
opened
|
opened
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (hello) {
|
if (hello) {
|
||||||
ws.send(JSON.stringify({
|
const payload = {
|
||||||
type: 'hello', protocol, deviceId, device,
|
type: 'hello', protocol, device,
|
||||||
appVersion: '0.2.0', cameraFacing: 'front', targetFps: 30, resolution: '640x480',
|
appVersion: '0.2.0', cameraFacing: 'front', targetFps: 30, resolution: '640x480',
|
||||||
}));
|
};
|
||||||
|
// 契约 §3.1:deviceId 可选 —— 这里刻意支持"整个字段都不出现"的合规对端
|
||||||
|
if (!omitDeviceId) payload.deviceId = deviceId;
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
ws,
|
ws,
|
||||||
state,
|
state,
|
||||||
|
counts,
|
||||||
next: (timeoutMs = 3000) => new Promise((res) => {
|
next: (timeoutMs = 3000) => new Promise((res) => {
|
||||||
if (inbox.length) return res(inbox.shift());
|
if (inbox.length) return res(inbox.shift());
|
||||||
const t = setTimeout(() => res({ type: '(超时)', timeoutMs }), timeoutMs);
|
const t = setTimeout(() => res({ type: '(超时)', timeoutMs }), timeoutMs);
|
||||||
@@ -145,6 +165,22 @@ function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SU
|
|||||||
res(m);
|
res(m);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* 按**类型**取消息,跳过其它类型(并把它们计入 counts)。
|
||||||
|
* 契约没有规定消息顺序,客户端必须顺序无关 —— 尤其是接收端现在会在
|
||||||
|
* `welcome` 之后立刻补一条 `config`,"第一条消息是 welcome" 不再是保证。
|
||||||
|
*/
|
||||||
|
async nextOfType(type, timeoutMs = 3000) {
|
||||||
|
const skipped = [];
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const msg = await this.next(Math.max(50, deadline - Date.now()));
|
||||||
|
if (msg?.type === type) return msg;
|
||||||
|
skipped.push(msg?.type);
|
||||||
|
if (msg?.type === '(closed)') return msg;
|
||||||
|
}
|
||||||
|
return { type: '(超时)', expected: type, skipped };
|
||||||
|
},
|
||||||
async closed(timeoutMs = 3000) {
|
async closed(timeoutMs = 3000) {
|
||||||
if (state.closeInfo) return state.closeInfo;
|
if (state.closeInfo) return state.closeInfo;
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
@@ -171,7 +207,7 @@ async function main() {
|
|||||||
// ---------- §1/§3.2 握手 ----------
|
// ---------- §1/§3.2 握手 ----------
|
||||||
console.log('[1] 子协议协商 + hello → welcome(§1 / §3.1 / §3.2)');
|
console.log('[1] 子协议协商 + hello → welcome(§1 / §3.1 / §3.2)');
|
||||||
const a = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
const a = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
||||||
const welcome = await a.next(3000);
|
const welcome = await a.nextOfType('welcome');
|
||||||
check('收到 welcome', welcome?.type === 'welcome', JSON.stringify(welcome));
|
check('收到 welcome', welcome?.type === 'welcome', JSON.stringify(welcome));
|
||||||
check('welcome.protocol 与契约一致', welcome?.protocol === SUBPROTOCOL, String(welcome?.protocol));
|
check('welcome.protocol 与契约一致', welcome?.protocol === SUBPROTOCOL, String(welcome?.protocol));
|
||||||
check('welcome.sessionId 非空', typeof welcome?.sessionId === 'string' && welcome.sessionId.length > 0);
|
check('welcome.sessionId 非空', typeof welcome?.sessionId === 'string' && welcome.sessionId.length > 0);
|
||||||
@@ -205,7 +241,7 @@ async function main() {
|
|||||||
a.ws.send(JSON.stringify({ type: 'stats', fps: 29.6, dropped: 3, latencyMs: 42, confidenceAvg: 0.93 }));
|
a.ws.send(JSON.stringify({ type: 'stats', fps: 29.6, dropped: 3, latencyMs: 42, confidenceAvg: 0.93 }));
|
||||||
const sentAt = 1730000000000;
|
const sentAt = 1730000000000;
|
||||||
a.ws.send(JSON.stringify({ type: 'ping', sentAtMs: sentAt }));
|
a.ws.send(JSON.stringify({ type: 'ping', sentAtMs: sentAt }));
|
||||||
const pong = await a.next(3000);
|
const pong = await a.nextOfType('pong');
|
||||||
check('收到 pong', pong?.type === 'pong', JSON.stringify(pong));
|
check('收到 pong', pong?.type === 'pong', JSON.stringify(pong));
|
||||||
check('pong.sentAtMs 原样回传', pong?.sentAtMs === sentAt, String(pong?.sentAtMs));
|
check('pong.sentAtMs 原样回传', pong?.sentAtMs === sentAt, String(pong?.sentAtMs));
|
||||||
check('pong.serverAtMs 已填写', Number.isFinite(Number(pong?.serverAtMs)) && Number(pong?.serverAtMs) > 0,
|
check('pong.serverAtMs 已填写', Number.isFinite(Number(pong?.serverAtMs)) && Number(pong?.serverAtMs) > 0,
|
||||||
@@ -214,7 +250,7 @@ async function main() {
|
|||||||
// ---------- §5 设备去重 ----------
|
// ---------- §5 设备去重 ----------
|
||||||
console.log('\n[4] 同 deviceId 替换旧连接(§5)');
|
console.log('\n[4] 同 deviceId 替换旧连接(§5)');
|
||||||
const a2 = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
const a2 = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
||||||
const a2welcome = await a2.next(3000);
|
const a2welcome = await a2.nextOfType('welcome');
|
||||||
check('新连接收到 welcome', a2welcome?.type === 'welcome', JSON.stringify(a2welcome));
|
check('新连接收到 welcome', a2welcome?.type === 'welcome', JSON.stringify(a2welcome));
|
||||||
const oldClose = await a.closed(4000);
|
const oldClose = await a.closed(4000);
|
||||||
check('旧连接被服务端关闭', oldClose !== null, '旧连接仍在打开');
|
check('旧连接被服务端关闭', oldClose !== null, '旧连接仍在打开');
|
||||||
@@ -229,7 +265,7 @@ async function main() {
|
|||||||
console.log(' · --skip-slow:跳过 19 秒对照');
|
console.log(' · --skip-slow:跳过 19 秒对照');
|
||||||
} else {
|
} else {
|
||||||
const k = await connect({ deviceId: 'bbbb000000000002', device: 'Keepalive' });
|
const k = await connect({ deviceId: 'bbbb000000000002', device: 'Keepalive' });
|
||||||
const kw = await k.next(3000);
|
const kw = await k.nextOfType('welcome');
|
||||||
check('保活连接已握手', kw?.type === 'welcome', JSON.stringify(kw));
|
check('保活连接已握手', kw?.type === 'welcome', JSON.stringify(kw));
|
||||||
const deadline = Date.now() + (silent + 4000); // 超过静默超时 4 秒
|
const deadline = Date.now() + (silent + 4000); // 超过静默超时 4 秒
|
||||||
let pings = 0;
|
let pings = 0;
|
||||||
@@ -252,7 +288,7 @@ async function main() {
|
|||||||
console.log(' · --skip-slow:跳过静默超时对照');
|
console.log(' · --skip-slow:跳过静默超时对照');
|
||||||
} else {
|
} else {
|
||||||
const s = await connect({ deviceId: 'cccc000000000003', device: 'Silent' });
|
const s = await connect({ deviceId: 'cccc000000000003', device: 'Silent' });
|
||||||
const sw = await s.next(3000);
|
const sw = await s.nextOfType('welcome');
|
||||||
check('静默连接已握手', sw?.type === 'welcome', JSON.stringify(sw));
|
check('静默连接已握手', sw?.type === 'welcome', JSON.stringify(sw));
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const closeInfo = await s.closed(silent + 6000);
|
const closeInfo = await s.closed(silent + 6000);
|
||||||
@@ -267,7 +303,7 @@ async function main() {
|
|||||||
// ---------- §3.5 bye ----------
|
// ---------- §3.5 bye ----------
|
||||||
console.log('\n[7] bye → 服务端 close(1000)(§3.5)');
|
console.log('\n[7] bye → 服务端 close(1000)(§3.5)');
|
||||||
const b = await connect({ deviceId: 'dddd000000000004', device: 'Bye' });
|
const b = await connect({ deviceId: 'dddd000000000004', device: 'Bye' });
|
||||||
await b.next(3000);
|
await b.nextOfType('welcome');
|
||||||
b.ws.send(JSON.stringify({ type: 'bye', reason: 'user_stop' }));
|
b.ws.send(JSON.stringify({ type: 'bye', reason: 'user_stop' }));
|
||||||
const byeClose = await b.closed(4000);
|
const byeClose = await b.closed(4000);
|
||||||
check('服务端关闭了连接', byeClose !== null);
|
check('服务端关闭了连接', byeClose !== null);
|
||||||
@@ -276,14 +312,71 @@ async function main() {
|
|||||||
// ---------- §3.1 / §6 协议不匹配 ----------
|
// ---------- §3.1 / §6 协议不匹配 ----------
|
||||||
console.log('\n[8] 协议版本不匹配 → bye + close(1002)(§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 c = await connect({ deviceId: 'eeee000000000005', device: 'Old', protocol: 'opendance.pose.v0' });
|
||||||
const byeMsg = await c.next(3000);
|
const byeMsg = await c.nextOfType('bye');
|
||||||
check('收到 bye', byeMsg?.type === 'bye', JSON.stringify(byeMsg));
|
check('收到 bye', byeMsg?.type === 'bye', JSON.stringify(byeMsg));
|
||||||
check('bye.reason 说明协议不匹配', String(byeMsg?.reason ?? '').includes('protocol_mismatch'), String(byeMsg?.reason));
|
check('bye.reason 说明协议不匹配', String(byeMsg?.reason ?? '').includes('protocol_mismatch'), String(byeMsg?.reason));
|
||||||
const mismatchClose = await c.closed(4000);
|
const mismatchClose = await c.closed(4000);
|
||||||
check('关闭码为 1002(协议错误)', mismatchClose?.code === 1002, String(mismatchClose?.code));
|
check('关闭码为 1002(协议错误)', mismatchClose?.code === 1002, String(mismatchClose?.code));
|
||||||
|
|
||||||
|
// ---------- §1.1 向后兼容:v0.1.0 风格对端(只发帧,从不 ping)----------
|
||||||
|
console.log('\n[9] v0.1.0 风格对端:只发二进制帧、不发 ping / stats、hello 不带 deviceId(§1.1 / §3.1)');
|
||||||
|
if (SKIP_SLOW) {
|
||||||
|
console.log(' · --skip-slow:跳过 17 秒帧保活对照');
|
||||||
|
} else {
|
||||||
|
const legacy = await connect({ omitDeviceId: true, device: 'Legacy v0.1.0' });
|
||||||
|
const lw = await legacy.nextOfType('welcome');
|
||||||
|
check('v0.1.0 风格对端也收到 welcome', lw?.type === 'welcome', JSON.stringify(lw));
|
||||||
|
const legacyFrames = Math.ceil((silent + 2000) / 500); // 每 500ms 一帧
|
||||||
|
for (let i = 0; i < legacyFrames; i += 1) {
|
||||||
|
if (legacy.ws.readyState !== WebSocket.OPEN) break;
|
||||||
|
legacy.ws.send(buildFrame({ frameId: i }));
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
check(`只发帧(每 500ms 一帧、共 ${legacyFrames} 帧)持续 ${((silent + 2000) / 1000).toFixed(1)}s 后连接仍打开`,
|
||||||
|
legacy.ws.readyState === WebSocket.OPEN,
|
||||||
|
`readyState=${legacy.ws.readyState}(契约 §1.1:每帧都算活动,ping 只是兜底)`);
|
||||||
|
check('没有收到过 pong 也能正常推流(本场景从不发 ping)',
|
||||||
|
(legacy.counts.pong ?? 0) === 0, JSON.stringify(legacy.counts));
|
||||||
|
// §3.3 的语义是"运行中调整时下发",不是心跳:17 秒内不应出现周期性刷屏
|
||||||
|
const cfgCount = legacy.counts.config ?? 0;
|
||||||
|
check(`17 秒内 config 条数应在合理范围(收到 ${cfgCount} 条;周期广播会是 ~8 条)`,
|
||||||
|
cfgCount <= 3, `实际 ${cfgCount} 条 —— config 应为变更驱动,而非心跳`);
|
||||||
|
legacy.ws.close(1000, 'done');
|
||||||
|
await legacy.closed(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- §5 缺 deviceId → 不做去重 ----------
|
||||||
|
console.log('\n[10] 两个都不带 deviceId 的连接不应互相顶掉(§5)');
|
||||||
|
const n1 = await connect({ omitDeviceId: true, device: 'No-Id A' });
|
||||||
|
const n1w = await n1.nextOfType('welcome');
|
||||||
|
check('第一条无 deviceId 连接握手成功', n1w?.type === 'welcome', JSON.stringify(n1w));
|
||||||
|
const n2 = await connect({ omitDeviceId: true, device: 'No-Id B' });
|
||||||
|
const n2w = await n2.nextOfType('welcome');
|
||||||
|
check('第二条无 deviceId 连接握手成功', n2w?.type === 'welcome', JSON.stringify(n2w));
|
||||||
|
await sleep(500);
|
||||||
|
check('第一条仍在打开(未被去重顶掉)', n1.ws.readyState === WebSocket.OPEN, String(n1.ws.readyState));
|
||||||
|
check('第二条仍在打开', n2.ws.readyState === WebSocket.OPEN, String(n2.ws.readyState));
|
||||||
|
n1.ws.close(1000, 'done');
|
||||||
|
n2.ws.close(1000, 'done');
|
||||||
|
await n1.closed(2000);
|
||||||
|
await n2.closed(2000);
|
||||||
|
|
||||||
|
// ---------- 不打招呼的对端(连 hello 都不发)----------
|
||||||
|
console.log('\n[11] 完全不发 hello、只发二进制帧的对端应被宽容对待');
|
||||||
|
const raw = await connect({ hello: false });
|
||||||
|
const rawFrames = 5;
|
||||||
|
for (let i = 0; i < rawFrames; i += 1) {
|
||||||
|
raw.ws.send(buildFrame({ frameId: 1000 + i }));
|
||||||
|
await sleep(30);
|
||||||
|
}
|
||||||
|
await sleep(300);
|
||||||
|
check('接收端没有因此断开连接', raw.ws.readyState === WebSocket.OPEN, String(raw.ws.readyState));
|
||||||
|
check('一个控制帧都没发过', Object.keys(raw.counts).length === 0, JSON.stringify(raw.counts));
|
||||||
|
raw.ws.close(1000, 'done');
|
||||||
|
await raw.closed(2000);
|
||||||
|
|
||||||
// ---------- 收尾 ----------
|
// ---------- 收尾 ----------
|
||||||
console.log('\n[9] 收尾');
|
console.log('\n[12] 收尾');
|
||||||
a2.ws.close(1000, 'done');
|
a2.ws.close(1000, 'done');
|
||||||
await a2.closed(2000);
|
await a2.closed(2000);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|||||||
+2
-2
@@ -37,8 +37,8 @@ application/modify_resources=true
|
|||||||
application/icon=""
|
application/icon=""
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="0.2.4"
|
application/file_version="0.2.5"
|
||||||
application/product_version="0.2.4"
|
application/product_version="0.2.5"
|
||||||
application/company_name="OpenDance Studio"
|
application/company_name="OpenDance Studio"
|
||||||
application/product_name="OpenDance Studio"
|
application/product_name="OpenDance Studio"
|
||||||
application/file_description="OpenDance Studio 摄像头跳舞评分客户端"
|
application/file_description="OpenDance Studio 摄像头跳舞评分客户端"
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="OpenDance Studio"
|
config/name="OpenDance Studio"
|
||||||
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
|
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
|
||||||
config/version="0.2.4"
|
config/version="0.2.5"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|||||||
+6
-2
@@ -1,5 +1,5 @@
|
|||||||
project: opendance-windows-client
|
project: opendance-windows-client
|
||||||
version: 0.2.4
|
version: 0.2.5
|
||||||
repository: opendance-windows
|
repository: opendance-windows
|
||||||
default_branch: main
|
default_branch: main
|
||||||
engine: Godot 4.7.2
|
engine: Godot 4.7.2
|
||||||
@@ -118,9 +118,13 @@ tasks:
|
|||||||
file: tasks/023-pck-dev-leftovers.md
|
file: tasks/023-pck-dev-leftovers.md
|
||||||
scope: 发布包内不得混入开发/验收工具(export exclude build/*)+ 产物级残留检查
|
scope: 发布包内不得混入开发/验收工具(export exclude build/*)+ 产物级残留检查
|
||||||
status: done
|
status: done
|
||||||
|
- id: T-WIN-024
|
||||||
|
file: tasks/024-config-push-and-legacy-peer.md
|
||||||
|
scope: config 改为变更驱动(原每 2 秒无条件广播)+ v0.1.0 风格对端/缺 deviceId/无 hello 的契约语义验收
|
||||||
|
status: done
|
||||||
|
|
||||||
deliverable:
|
deliverable:
|
||||||
artifact: opendance-windows-v0.2.4.zip
|
artifact: opendance-windows-v0.2.5.zip
|
||||||
packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底)
|
packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底)
|
||||||
release_rules: # 文档 §5.7 硬性要求
|
release_rules: # 文档 §5.7 硬性要求
|
||||||
- 单个完整包,禁止 .partN 分卷
|
- 单个完整包,禁止 .partN 分卷
|
||||||
|
|||||||
+40
-12
@@ -76,6 +76,8 @@ var _next_client_id: int = 1
|
|||||||
var _port: int = 0
|
var _port: int = 0
|
||||||
var _running: bool = false
|
var _running: bool = false
|
||||||
var _last_config_push_ms: int = 0
|
var _last_config_push_ms: int = 0
|
||||||
|
## 最近一次广播出去的配置指纹(用于「配置是否变化」判断)
|
||||||
|
var _last_config_signature: String = ""
|
||||||
## 见 DEGRADED_HOLD_MS;运行期可调(测试用)
|
## 见 DEGRADED_HOLD_MS;运行期可调(测试用)
|
||||||
var degraded_hold_ms: int = DEGRADED_HOLD_MS
|
var degraded_hold_ms: int = DEGRADED_HOLD_MS
|
||||||
|
|
||||||
@@ -372,7 +374,11 @@ func _handle_control(entry: Dictionary, text: String) -> void:
|
|||||||
if message.has("targetFps"):
|
if message.has("targetFps"):
|
||||||
expected_fps = int(message["targetFps"])
|
expected_fps = int(message["targetFps"])
|
||||||
entry["state"] = ClientState.STREAMING if int(entry["first_frame_at_ms"]) > 0 else ClientState.HANDSHAKING
|
entry["state"] = ClientState.STREAMING if int(entry["first_frame_at_ms"]) > 0 else ClientState.HANDSHAKING
|
||||||
|
# 顺序固定:先 welcome(对 hello 的应答),再 config(当前镜像/帧率/保活参数)。
|
||||||
|
# 也可以等周期 tick 广播,但那样"新连接的第一条消息"可能变成 config,
|
||||||
|
# 让只认"握手应答"的实现困惑 —— 契约没规定顺序,但顺序自然更好排障。
|
||||||
_send_json(entry, welcome_payload())
|
_send_json(entry, welcome_payload())
|
||||||
|
_send_json(entry, config_payload())
|
||||||
"ping":
|
"ping":
|
||||||
# 契约 §3.6:应用层心跳(协议层 ping 对应用层不可见,无法续命)
|
# 契约 §3.6:应用层心跳(协议层 ping 对应用层不可见,无法续命)
|
||||||
_send_json(entry, {
|
_send_json(entry, {
|
||||||
@@ -401,18 +407,9 @@ func welcome_payload() -> Dictionary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func _maybe_push_config() -> void:
|
## 契约 §3.3 的 config 载荷(同时用作「配置是否变化」的指纹来源)
|
||||||
if CONFIG_PUSH_INTERVAL_MS <= 0:
|
func config_payload() -> Dictionary:
|
||||||
return
|
return {
|
||||||
var now := Time.get_ticks_msec()
|
|
||||||
if now - _last_config_push_ms < CONFIG_PUSH_INTERVAL_MS:
|
|
||||||
return
|
|
||||||
_last_config_push_ms = now
|
|
||||||
broadcast_config()
|
|
||||||
|
|
||||||
|
|
||||||
func broadcast_config() -> void:
|
|
||||||
var payload := {
|
|
||||||
"type": "config",
|
"type": "config",
|
||||||
"mirror": mirror,
|
"mirror": mirror,
|
||||||
"targetFps": expected_fps,
|
"targetFps": expected_fps,
|
||||||
@@ -422,6 +419,37 @@ func broadcast_config() -> void:
|
|||||||
"silentTimeoutMs": CLIENT_TIMEOUT_MS,
|
"silentTimeoutMs": CLIENT_TIMEOUT_MS,
|
||||||
"heartbeatIntervalMs": HEARTBEAT_INTERVAL_MS,
|
"heartbeatIntervalMs": HEARTBEAT_INTERVAL_MS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## 契约 §3.3 的语义是「**运行中调整**时下发」,不是心跳。
|
||||||
|
##
|
||||||
|
## 原实现每 `CONFIG_PUSH_INTERVAL_MS`(2 秒)**无条件**广播一次 config ——
|
||||||
|
## 对一次 30 分钟的推流是 900 条没有任何信息量的消息,也让「收到 config」
|
||||||
|
## 无法被当成「配置变了」的信号(发送端想据此做点什么就无从判断)。
|
||||||
|
## 现在改为:
|
||||||
|
## 1. 有新客户端完成握手 → 立刻补发一次(它需要知道镜像/目标帧率);
|
||||||
|
## 2. 之后仅当**载荷真的变化**才发,并用 `CONFIG_PUSH_INTERVAL_MS` 做去抖
|
||||||
|
## (连续拖动滑块时不会每个中间值都发一条)。
|
||||||
|
func _maybe_push_config() -> void:
|
||||||
|
if CONFIG_PUSH_INTERVAL_MS <= 0:
|
||||||
|
return
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
var signature := JSON.stringify(config_payload())
|
||||||
|
|
||||||
|
if signature == _last_config_signature:
|
||||||
|
return
|
||||||
|
if now - _last_config_push_ms < CONFIG_PUSH_INTERVAL_MS:
|
||||||
|
return
|
||||||
|
_last_config_signature = signature
|
||||||
|
_last_config_push_ms = now
|
||||||
|
broadcast_config()
|
||||||
|
|
||||||
|
|
||||||
|
func broadcast_config() -> void:
|
||||||
|
var payload := config_payload()
|
||||||
|
# 显式广播(UI 改了镜像/帧率等)也要更新指纹,否则下一个 tick 会再发一遍同样的内容
|
||||||
|
_last_config_signature = JSON.stringify(payload)
|
||||||
|
_last_config_push_ms = Time.get_ticks_msec()
|
||||||
for entry in _clients:
|
for entry in _clients:
|
||||||
if entry["handshaken"]:
|
if entry["handshaken"]:
|
||||||
_send_json(entry, payload)
|
_send_json(entry, payload)
|
||||||
|
|||||||
@@ -51,7 +51,12 @@
|
|||||||
`i16` 最大值、`±1` 全量程、置信度 `0 / 128 / 255`、`flags` 低 3 位组合、
|
`i16` 最大值、`±1` 全量程、置信度 `0 / 128 / 255`、`flags` 低 3 位组合、
|
||||||
`frame_id` 的 `0xFFFFFFFF` 与 `0x80000000`、以及取整边界。
|
`frame_id` 的 `0xFFFFFFFF` 与 `0x80000000`、以及取整边界。
|
||||||
|
|
||||||
## 一处**已测量并归类**的差异(非缺陷)
|
## 一处差异(**已在 v0.2.4 修复**,见 T-WIN-022)
|
||||||
|
|
||||||
|
> 下面记录的差异已修复:本端改为 `_round_like_js(v) = floor(v + 0.5)`,
|
||||||
|
> 与参考实现的 `Math.round`(半数朝 +∞)一致。`accept-frame-vectors.sh` 的编码口径
|
||||||
|
> 也从「差异仅限取整边界」**收紧为「必须为 0」**,实测 0 项差异。
|
||||||
|
> 保留此节是因为「为什么会有这个差异、为什么一开始只敢记不敢修」本身是有效的记录。
|
||||||
|
|
||||||
本端 `KeypointFrame.encode()` 与参考编码器在**一个**用例上差 33 字节:
|
本端 `KeypointFrame.encode()` 与参考编码器在**一个**用例上差 33 字节:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# T-WIN-024:config 改为变更驱动 + 契约「可选/兜底」语义的定向验收
|
||||||
|
|
||||||
|
- **状态**: done
|
||||||
|
- **依赖**: T-WIN-020(WS 契约一致性验收)
|
||||||
|
- **文档**: v1.6 §6.5 / §7.6.1
|
||||||
|
- **契约**: `websocket-keypoints 0.2.0` §1.1 / §3.1 / §3.3 / §5
|
||||||
|
|
||||||
|
## 一、缺陷:config 被当成心跳周期性广播
|
||||||
|
|
||||||
|
`_maybe_push_config()` 原来每 `CONFIG_PUSH_INTERVAL_MS`(2 秒)**无条件**广播一次 `config`:
|
||||||
|
|
||||||
|
* 契约 §3.3 的语义是「**运行中调整**时下发」,不是心跳。一次 30 分钟推流 = **900 条**
|
||||||
|
没有任何信息量的消息;
|
||||||
|
* 更糟的是它让「收到 config」无法被当作「配置变了」的信号 —— 发送端想据此做点什么
|
||||||
|
就无从判断。实测(v0.2.4 产物):17 秒内收到 **9 条** config。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
1. 抽出 `config_payload()` 作为唯一载荷来源(同时用作「是否变化」的指纹);
|
||||||
|
2. `hello` 应答时**顺序固定**为「先 `welcome`、再 `config`」—— 契约没规定顺序,
|
||||||
|
但若让周期 tick 补发,新对端的第一条消息可能变成 `config`,只认握手应答的实现会困惑;
|
||||||
|
3. `_maybe_push_config()` 只在**载荷真的变化**时广播,并用 `CONFIG_PUSH_INTERVAL_MS`
|
||||||
|
做去抖(连续拖动滑块不会每个中间值都发一条);
|
||||||
|
4. 显式 `broadcast_config()`(UI 改镜像/帧率时调用)也同步更新指纹,避免下一 tick 重发。
|
||||||
|
|
||||||
|
实测(v0.2.5 产物):17 秒内 **1 条**。
|
||||||
|
|
||||||
|
## 二、契约「可选/兜底」语义的定向验收(新增 3 个场景)
|
||||||
|
|
||||||
|
上一轮 Android 侧的教训是「契约里**可选**的字段必须用**不提供**它的对端来测,
|
||||||
|
否则等于没测」(自家两端总是互相迎合)。这轮把它用到接收端:
|
||||||
|
|
||||||
|
| 场景 | 契约条款 | 断言 |
|
||||||
|
|--|--|--|
|
||||||
|
| **v0.1.0 风格对端**:只发二进制帧(每 500ms 一帧)、**从不发 `ping`/`stats`**、`hello` **不带 `deviceId`**,持续 17 秒(> 静默超时 15 秒) | §1.1「仅实现 v0.1.0 的一端仍可正常互通(它不发 ping、不读 silentTimeoutMs,但每帧与每秒 stats 已足够保活)」 | 连接**保持打开**;服务端**没有**把它静默超时踢掉(靠 hello 记录建立 `client_id → device` 映射后定向核对) |
|
||||||
|
| **两条都不带 `deviceId`** 的连接同时在线 | §5「`deviceId` 缺失或为空 → 不做去重,按连接计数」 | 两条都保持打开,`device_replaced` **只有**那次真正重复 `deviceId` 的事件 |
|
||||||
|
| **完全不发 `hello`**、只发 5 帧的对端 | 契约未规定(不该崩、不该断) | 服务端不断开、不崩,帧仍被计入 |
|
||||||
|
|
||||||
|
## 三、验收脚本自身修掉的两处误判
|
||||||
|
|
||||||
|
1. **把"跨连接的 frame_id 回退"当成缺陷**:`frame_id` 是**每条连接各自从 0 递增**,
|
||||||
|
新连接重新从 0 开始完全合规。探针原来做的是全局计数,属**指标用错**。
|
||||||
|
已改名为 `frameIdRegressionsAcrossConnections` 并标明「仅参考」,
|
||||||
|
不再作为判定依据(真正相关的判据是逐连接的 `frames_dropped == 3`)。
|
||||||
|
2. **把"静默超时次数"当成固定值**:套件里本来就有一条专门"完全不发数据"的对端,
|
||||||
|
另外那条空闲的替换连接也会被正常超时 —— 全局计数会混入这些**预期**超时。
|
||||||
|
已改为**定向断言**:先由 hello 记录找出 v0.1.0 风格对端的 `client_id`,
|
||||||
|
再检查它**没有**被静默超时踢掉。
|
||||||
|
|
||||||
|
## 四、测试客户端也要"顺序无关"
|
||||||
|
|
||||||
|
固定「先 welcome 再 config」之后,`ws-client.mjs` 里"取到的第一条消息就是 welcome"
|
||||||
|
的假设暴露了:接收端在 `welcome` 之后紧跟 `config`,断言就会拿到 config。
|
||||||
|
契约没有规定消息顺序,**客户端必须顺序无关** —— 已加 `nextOfType(type)`,
|
||||||
|
按类型取消息并跳过其它类型(同时计入 `counts`)。
|
||||||
|
|
||||||
|
## 五、实测
|
||||||
|
|
||||||
|
```
|
||||||
|
== 结果:40 通过 / 0 失败 == # 原 30 项,新增 10 项
|
||||||
|
✓ 只发帧(每 500ms 一帧、共 34 帧)持续 17.0s 后连接仍打开
|
||||||
|
✓ 17 秒内 config 条数应在合理范围(收到 1 条;周期广播会是 ~8 条)
|
||||||
|
✓ 报告里应能识别出 v0.1.0 风格对端(client_id=[7])
|
||||||
|
✓ 只发二进制帧、从不 ping 的对端没有被静默超时踢掉(契约 §1.1:每帧同样算活动)
|
||||||
|
✓ 第一条仍在打开(未被去重顶掉)/✓ 第二条仍在打开
|
||||||
|
✓ 一个控制帧都没发过
|
||||||
|
服务端侧:10 次连接 / 111 帧 / frames_dropped=3 / malformed_frames=1 / device_replaced 仅 1 次
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user