5 Commits
Author SHA1 Message Date
zijunchiang c1311cf3c1 chore(project): 登记 T-WIN-025(上一提交漏了 project.yaml 条目) 2026-09-18 03:10:27 +08:00
zijunchiang 20c6682202 test(release): 逐文件核对「发布包 == 打标签的树」(T-WIN-025)
「发布包 == 打标签的树」此前只抽查几个文件,必然会漏。新增
build/check-tracked-release.py(纯标准库,直接读 LZMA zip):对包内**每一个**文件
按路径决定基准 —— OpenDance.exe / OpenDance.pck 比工作树 build/windows/ 的导出结果,
其余文件比 git show <tag>:<路径>;内容不一致 / 包内多余 / 构建产物缺失都会列出并退出 1。
已接入 build/accept-release.sh(第 0 步)与 build/package-release.sh 的提示。

实测(已发布的 v0.2.5 资产重新下载后):
  包内 13 个文件:源码/文档 11(与 tag v0.2.5 比)+ 构建产物 2(与工作树导出比)
  ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
反向验证:把包内 README.txt 追加一行后用 7z 重打成 LZMA zip →
  ✗ 内容不一致 1 处(README.txt),退出码 1。

踩坑:脚本第一版把 BUILD_OUTPUTS 的键一律当目录前缀(rel[len(prefix):]),
而本仓产物是单文件,拼出 build/windows/OpenDance.exe/ 导致误报「工作树缺失」;
现在区分「以 / 结尾的目录前缀」与「精确文件名」两种键。
2026-09-18 03:10:15 +08:00
zijunchiang cc8b6ba729 test(integration): 跨端验收补两类契约边界(不可用模型 / 场景 config 缺字段)
正常路径之外的**字段边界**同样只在跨端跑起来才暴露。配合 Web v0.7.5 增加:

1. 不可用模型:用 Assimp 把真实 PMX 转成 GLB 后入库(等价"管线只产出 GLB"),
   Web 侧断言 usable=false、原因准确(骨架达标时不得说"humanoid 不达标")、
   不在 catalog/export、defaultModelId 不变;Windows 侧(包内探针)断言
   bundle.models 里没有任何非 VRM 模型、按 id 找不到该模型、defaultModelId 仍指向可用 VRM。
2. 场景 config 只给 {physics:{gravity:{y:-9.8}}}:Web 侧断言服务端补齐默认
   (碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条);
   Windows 侧断言客户端解析出的**同一组默认值**逐项一致。

实测(Web v0.7.5 × Windows v0.2.5 包内 pck,真实 MySQL):
  ✓ usable=false 且原因为「未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标)…」
  ✓ 目录里的模型全是 VRM/不可用模型 #2 不在目录里/defaultModelId 仍为 1
  ✓ 最简配置:重力 -9.8 保留,碰撞体 auto、地面 0.6/45°、静风、相机 follow、
    环境光 1.0、出生点 1 条,两端逐项一致
跨端集成验收全部通过。

顺带修掉播种脚本自身的顺序错误(把依赖 catalog 的断言插到了 catalog 取回之前),
以及把断言改成「先取目录再断言」。不影响发布产物(build/ 已排除出 pck)。
2026-09-18 03:05:51 +08:00
zijunchiang e0ba4ea833 fix(ws): config 改为变更驱动(原每 2 秒无条件广播)+ 契约「可选/兜底」语义定向验收(v0.2.5)
一、缺陷:config 被当成心跳
_maybe_push_config() 原来每 CONFIG_PUSH_INTERVAL_MS(2 秒)无条件广播一次 config。
契约 §3.3 的语义是「运行中**调整**时下发」,不是心跳 —— 一次 30 分钟推流就是 900 条
没有任何信息量的消息;更糟的是它让「收到 config」无法被当作「配置变了」的信号。
实测 v0.2.4 产物:17 秒内收到 9 条。
修复:
  * 抽出 config_payload() 作为唯一载荷来源(同时用作「是否变化」的指纹);
  * hello 应答顺序固定为「先 welcome、再 config」(若靠周期 tick 补发,
    新对端第一条消息可能变成 config,只认握手应答的实现会困惑);
  * _maybe_push_config() 仅在载荷真的变化时广播,并用 CONFIG_PUSH_INTERVAL_MS 去抖;
  * 显式 broadcast_config()(UI 改设置)同步更新指纹,避免下一 tick 重发。
实测 v0.2.5:17 秒内 1 条。

二、契约「可选/兜底」语义的定向验收(沿用 Android 侧的教训:
   契约里可选的字段必须用**不提供**它的对端来测,否则等于没测)
新增 3 个场景(客户端断言 30 → 40 项):
  * v0.1.0 风格对端:只发二进制帧(每 500ms)、从不 ping/stats、hello 不带 deviceId,
    持续 17 秒(> 静默超时 15 秒)→ 连接必须保持(契约 §1.1 的向后兼容承诺);
  * 两条都不带 deviceId 的连接同时在线 → 不做去重、按连接计数(§5),
    device_replaced 只应有那一次真正重复 deviceId 的事件;
  * 完全不发 hello、只发 5 帧的对端 → 不断开、不崩、帧仍计入。

三、验收脚本自身修掉两处误判
  * 「跨连接的 frame_id 回退」被当成缺陷:frame_id 是**每条连接各自从 0 递增**,
    新连接重新开始完全合规 —— 指标用错了。已改名并标注「仅参考」,
    真正相关的判据是逐连接的 frames_dropped == 3;
  * 「静默超时次数」被当成固定值:套件里本来就有"完全不发数据"的对端,
    空闲的替换连接也会被正常超时。已改为**定向断言**:由 hello 记录建立
    client_id → device 映射(探针新增 hellos[]),只检查 v0.1.0 风格对端没被误踢。

四、测试客户端改为顺序无关
固定「welcome 之后紧跟 config」后,"第一条消息就是 welcome"的假设立刻暴露。
契约没规定消息顺序,客户端必须顺序无关 —— 已加 nextOfType(type) 按类型取消息
并跳过其它类型(同时计入 counts)。

实测(v0.2.5 发布包,四条验收全绿):
  * accept-release.sh:pck 无开发残留 / PE 版本资源 0.2.5 / pck 启动 180 帧 / 运行时探针
  * accept-ws.sh:客户端 40 通过 / 0 失败 + 服务端侧全部事实
    (10 次连接、111 帧、frames_dropped=3、malformed=1、device_replaced 仅 1 次、
     v0.1.0 风格对端未被静默超时踢掉)
  * accept-frame-vectors.sh:解码 12/12、396 点 0 差异、编码器 0 差异
  * verify.sh:785 通过 / 0 失败
2026-09-18 02:47:00 +08:00
zijunchiang 8faaa2a8a5 docs: T-WIN-021 记录收尾(取整差异已在 v0.2.4 修复,口径收紧为 0) 2026-09-18 02:23:21 +08:00
18 changed files with 747 additions and 56 deletions
+36 -10
View File
@@ -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
``` ```
@@ -183,14 +204,19 @@ GODOT_BIN=... bash build/accept-ws.sh <zip> --skip-slow
* `build/accept-ws.sh`:编排两者,并核对**客户端看不到的那一半**(服务端侧 * `build/accept-ws.sh`:编排两者,并核对**客户端看不到的那一半**(服务端侧
`frames_dropped` / `malformed_frames` / 控制帧计数 / 去重事件 / 关闭原因)。 `frames_dropped` / `malformed_frames` / 控制帧计数 / 去重事件 / 关闭原因)。
实测(已发布的 v0.2.3 资产):客户端 **30 通过 / 0 失败** + 服务端侧 16 项事实全过, **契约边界场景**(配合 Web v0.7.5):① **不可用模型**(真实 PMX 经 Assimp 转 GLB 后入库)
必须 `usable=false`、**不进入** `catalog/export`、不影响 `defaultModelId`,且包内客户端
`bundle.models` 里没有任何非 VRM 模型;② 场景 `config` **只给最少字段**时,服务端补齐的默认值
与客户端解析出的默认值**逐项一致**(碰撞体 `auto`、地面 `0.6/45°`、静风、相机 `follow`…)。
实测(已发布的 v0.2.5 资产):客户端 **40 通过 / 0 失败** + 服务端侧 16 项事实全过,
其中最关键的是**保活对照**——只发应用层 `ping` 持续 19 秒(> 静默超时 15 秒)连接 其中最关键的是**保活对照**——只发应用层 `ping` 持续 19 秒(> 静默超时 15 秒)连接
**仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。 **仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。
反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明 反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明
保活字段、不回 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 +299,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 +307,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 +411,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
View File
@@ -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 / 关键资源 /
+16
View File
@@ -56,6 +56,22 @@ log "期望版本:$EXPECTED"
log "工作目录:$WORK_DIR" log "工作目录:$WORK_DIR"
mkdir -p "$WORK_DIR" mkdir -p "$WORK_DIR"
# ---- 0. 逐文件核对:发布包 == 打标签的树(T-WIN-025)----
# 「发布包里的东西必须等于打标签的那棵树」此前只靠抽查几个文件,必然会漏。
# 本步骤对包内**每一个**文件核对:源码/文档比 tag,构建产物(exe/pck)比工作树导出结果。
CHECKER="$PROJECT_DIR/build/check-tracked-release.py"
if [ -f "$CHECKER" ] && command -v git >/dev/null 2>&1 && [ -f "$TARGET" ]; then
TAG="v$(basename "$TARGET" .zip | sed 's/^opendance-windows-v//')"
if git -C "$PROJECT_DIR" rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then
log "逐文件核对:发布包 == tag $TAG"
python3 "$CHECKER" "$TARGET" --tag "$TAG" || fail "发布包与 tag $TAG 不一致"
else
log "跳过逐文件核对(仓库里没有 tag $TAG;打标签后可手工跑 $CHECKER)"
fi
else
log "跳过逐文件核对(缺 checker / git / 目标是已解压目录)"
fi
# ---- 1. sha256 + 解压 ---- # ---- 1. sha256 + 解压 ----
if [ -f "$TARGET" ]; then if [ -f "$TARGET" ]; then
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录; # SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
+24 -5
View File
@@ -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」
// 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。 // 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""发布产物 × 打标签的树:**逐文件**核对(T-WEB-018)
## 为什么需要
「发布包里的东西必须等于打标签的那棵树」这条规则此前是靠人手工抽查几个文件
(README、contracts/VERSION…)来确认的 —— 而抽查必然会漏。本轮实测就发现
两个认知错误:
* Web 的发布包**包含** `web-server/test/**`(33 个文件)而我此前以为不含,
于是 `test/**` 的改动是否影响产物从来没被检查过;
* 发布包里另有构建产物(`OpenDance.exe` / `OpenDance.pck`)与 git 无关,
必须与工作树的导出结果比 —— 只比源码文件是不够的。
本脚本把这条规则变成**可重复的全量核对**:
对发布包里的**每一个**文件,按路径决定比较基准:
| 路径 | 比较基准 |
|--|--|
| 构建产物(见 `BUILD_OUTPUTS`) | 工作树里对应的构建输出 |
| 其余文件 | `git show <tag>:<路径>`(打标签的树) |
任何一处不一致 / 包内多余(既不在 git 也不在构建输出)/ 缺失都会列出并以非 0 退出。
## 用法(在仓库根目录执行)
python3 <script> <发布包> --tag v0.7.5 [--json]
Windows 的包是 `.zip`(LZMA 压缩,Python 的 zipfile 能直接读)。
包内通常有**一层顶层目录**(`opendance-windows-v0.2.5/`),脚本会自动剥掉。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tarfile
import zipfile
# ---------------------------------------------------------------- 仓库相关配置
# 各仓库按自己的产物结构改这一段即可;脚本其余部分与仓库无关。
# 包内路径前缀 → 工作树路径(构建产物:不在 git 里,与工作树的构建结果比)
BUILD_OUTPUTS: dict[str, str] = {
'OpenDance.exe': 'build/windows/OpenDance.exe', # Godot 导出产物
'OpenDance.pck': 'build/windows/OpenDance.pck',
}
def read_archive(path: str) -> dict[str, bytes]:
"""读出包内全部文件(剥掉顶层目录)。"""
files: dict[str, bytes] = {}
if path.endswith('.tar.gz') or path.endswith('.tgz'):
with tarfile.open(path, 'r:gz') as tar:
for member in tar.getmembers():
if not member.isfile():
continue
handle = tar.extractfile(member)
if handle is not None:
files[_strip_top(member.name)] = handle.read()
elif path.endswith('.zip'):
with zipfile.ZipFile(path) as zf:
for info in zf.infolist():
if info.is_dir():
continue
files[_strip_top(info.filename)] = zf.read(info)
else:
raise SystemExit(f'✗ 不认识的包格式:{path}(支持 .tar.gz / .zip)')
return {k: v for k, v in files.items() if not k.startswith('/')}
def _strip_top(name: str) -> str:
parts = name.split('/', 1)
return parts[1] if len(parts) == 2 else name
def md5(data: bytes) -> str:
return hashlib.md5(data).hexdigest()
def git_blob(rel_path: str, tag: str) -> bytes | None:
result = subprocess.run(
['git', 'show', f'{tag}:{rel_path}'],
capture_output=True,
)
return result.stdout if result.returncode == 0 else None
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser()
parser.add_argument('archive')
parser.add_argument('--tag', required=True, help='要对比的 Git tag,例如 v0.7.5')
parser.add_argument('--json', action='store_true', help='输出机器可读结果')
args = parser.parse_args(argv[1:])
if not os.path.exists(args.archive):
print(f'✗ 找不到发布包:{args.archive}', file=sys.stderr)
return 2
if subprocess.run(['git', 'rev-parse', '--verify', f'{args.tag}^{{commit}}'],
capture_output=True).returncode != 0:
print(f'✗ 仓库里没有 tag {args.tag}', file=sys.stderr)
return 2
files = read_archive(args.archive)
mismatch: list[str] = []
extra: list[str] = []
missing: list[str] = []
tracked = built = 0
for rel, data in sorted(files.items()):
local_base: str | None = None
for prefix, target in BUILD_OUTPUTS.items():
# 目录前缀(以 / 结尾):把剩余路径拼到目标目录下;
# 精确文件名:目标就是该文件的路径(Windows/Android 的产物是单文件)
if prefix.endswith('/'):
if rel.startswith(prefix):
local_base = os.path.join(target, rel[len(prefix):])
break
elif rel == prefix:
local_base = target
break
if local_base is not None:
built += 1
if not os.path.exists(local_base):
missing.append(f'{rel}(工作树缺 {local_base})')
elif md5(data) != md5(open(local_base, 'rb').read()):
mismatch.append(f'{rel}(与工作树构建 {local_base} 不一致)')
continue
tracked += 1
blob = git_blob(rel, args.tag)
if blob is None:
extra.append(rel)
elif md5(data) != md5(blob):
mismatch.append(f'{rel}(与 {args.tag} 的内容不一致)')
report = {
'archive': args.archive,
'tag': args.tag,
'files': len(files),
'trackedFiles': tracked,
'buildOutputs': built,
'mismatch': mismatch,
'extra': extra,
'missing': missing,
}
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f' 发布包 {args.archive}')
print(f' 对比 tag:{args.tag}')
print(f' 包内 {len(files)} 个文件:源码/文档 {tracked}(与 tag 比)、构建产物 {built}(与工作树构建比)')
if mismatch:
print(f' ✗ 内容不一致 {len(mismatch)} 处:')
for item in mismatch[:20]:
print(f' · {item}')
if extra:
print(f' ✗ 包内有、{args.tag} 里没有 {len(extra)} 处(既不是声明过的构建产物):')
for item in extra[:20]:
print(f' · {item}')
if missing:
print(f' ✗ 构建产物在工作树里找不到 {len(missing)} 处:')
for item in missing[:20]:
print(f' · {item}')
if mismatch or extra or missing:
if not args.json:
print(' ✗ 发布包与打标签的树不一致')
return 1
if not args.json:
print(' ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
+39
View File
@@ -245,6 +245,45 @@ func _run() -> void:
else: else:
_fail("动作缩略图下载失败:%s" % thumb["error"]) _fail("动作缩略图下载失败:%s" % thumb["error"])
# ---------- 5b. ② 目录边界:不可用模型不得出现在客户端目录里(①)----------
var nonVrm: Array = []
for m in models:
if str(m.format) != "vrm":
nonVrm.append("%s(%s)" % [m.name, m.format])
_check(nonVrm.is_empty(),
"目录里的模型全是 VRM(不可用模型不进目录):%s" % ("OK" if nonVrm.is_empty() else str(nonVrm)))
if _expect.has("unusableModelId") and _expect["unusableModelId"] != null:
var unusable_id := int(_expect["unusableModelId"])
_check(bundle.find_model(unusable_id) == null,
"不可用模型 #%d 不在客户端目录里" % unusable_id)
_check(bundle.default_model_id == _expect["modelId"],
"defaultModelId 仍指向可用的 VRM 模型(实际 %d)" % bundle.default_model_id)
# ---------- 5c. ④ 场景 config 缺字段时的默认值(契约 0.5.0 边界)----------
if _expect.has("minimalSceneId") and _expect.has("minimalSceneExpect"):
var minimal: CatalogTypes.Scene = bundle.find_scene(int(_expect["minimalSceneId"]))
if minimal == null:
_fail("目录里找不到最简配置场景 #%d" % int(_expect["minimalSceneId"]))
else:
var want_min: Dictionary = _expect["minimalSceneExpect"]
var cfg2: CatalogTypes.SceneConfig = minimal.config
_check(_near(cfg2.physics.gravity.y, float(want_min["gravityY"])),
"最简配置:重力保留 %.1f" % cfg2.physics.gravity.y)
_check(cfg2.physics.colliders == str(want_min["colliders"]),
"最简配置:碰撞体补默认 %s" % cfg2.physics.colliders)
_check(_near(cfg2.physics.ground.friction, float(want_min["groundFriction"]))
and _near(cfg2.physics.ground.slope_limit, float(want_min["groundSlopeLimit"])),
"最简配置:地面默认 摩擦 %.2f / 坡度 %.0f°" % [
cfg2.physics.ground.friction, cfg2.physics.ground.slope_limit])
_check(cfg2.physics.wind.is_calm() == bool(want_min["windCalm"]),
"最简配置:风默认静风(is_calm=%s)" % str(cfg2.physics.wind.is_calm()))
_check(cfg2.camera.mode == str(want_min["cameraMode"]),
"最简配置:相机默认 %s" % cfg2.camera.mode)
_check(_near(cfg2.environment.ambient_intensity, float(want_min["ambientIntensity"])),
"最简配置:环境光默认 %.1f" % cfg2.environment.ambient_intensity)
_check(cfg2.spawn_points.size() <= 1,
"最简配置:出生点默认最多一条(实际 %d)" % cfg2.spawn_points.size())
# ---------- 6. ① 模型资源可下载 ---------- # ---------- 6. ① 模型资源可下载 ----------
var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"]) var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"])
if model == null: if model == null:
+15 -1
View File
@@ -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,
+102 -3
View File
@@ -3,14 +3,17 @@
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM; * 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。 * 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
*/ */
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path'; import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
const API = process.env.API_BASE ?? 'http://127.0.0.1:3100'; const API = process.env.API_BASE ?? 'http://127.0.0.1:3100';
// 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/) // 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/)
const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR
?? new URL('../../../示例文件', import.meta.url).pathname; ?? new URL('../../../示例文件', import.meta.url).pathname;
const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json'; const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json';
/** 用于把真实 PMX 转成 GLB(模拟"转换只产出 GLB"的入库形态) */
const ASSIMP_BIN = process.env.ASSIMP_BIN ?? '/usr/bin/assimp';
const SOURCE = { const SOURCE = {
sourceType: 'original', sourceType: 'original',
sourceName: '跨端集成验收素材', sourceName: '跨端集成验收素材',
@@ -102,8 +105,67 @@ const run = async () => {
must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`); must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`);
const sceneId = scene.body.id; const sceneId = scene.body.id;
// ---------- 边界 A:不可用模型(只有 GLB、没有 VRM 容器)----------
// 形态等价于"管线只产出了 GLB":契约 model-formats §1.1 要求这类模型
// **可入库、可审核,但 usable=false,且不进入 catalog/export**(① 的硬性要求)。
let unusable = null;
try {
const pmxZip = `${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`;
const entries = execFileSync('unzip', ['-Z1', pmxZip], { encoding: 'utf8' })
.split('\n').map((line) => line.trim())
.filter((line) => line.toLowerCase().endsWith('.pmx'));
if (entries.length === 0) throw new Error('压缩包里没有 .pmx');
const pmxPath = join('/tmp', 'opendance-seed-model.pmx');
const glbPath = join('/tmp', 'opendance-seed-model.glb');
execFileSync('unzip', ['-p', pmxZip, entries[0]], { stdio: ['ignore', openSync(pmxPath, 'w'), 'ignore'] });
execFileSync(ASSIMP_BIN, ['export', pmxPath, glbPath], { stdio: 'ignore' });
const glb = readFileSync(glbPath);
must(glb.subarray(0, 4).toString('ascii') === 'glTF', `Assimp 转出 GLB(${glb.length} 字节)`);
const created = await request('POST', '/api/models', {
form: fileForm({ name: '不可用模型(仅 GLB,未产出 VRM)', ...SOURCE }, 'unusable.glb', glb, 'model/gltf-binary'),
});
must(created.status === 201, '上传仅 GLB 的模型', `${created.status} ${JSON.stringify(created.body)}`);
const id = created.body.id;
must(created.body.format === 'glb', `入库格式为 glb(实际 ${created.body.format})`);
must(created.body.usable === false, '该模型标记为不可用(usable=false)',
JSON.stringify(created.body.unusableReason));
const approve = await request('POST', `/api/reviews/model/${id}/approve`, {
json: { reviewer: 'qa', comment: '通过但不设为默认' },
});
must(approve.status === 200 || approve.status === 201, '审核通过(不设为默认模型)', String(approve.status));
const detail = await request('GET', `/api/models/${id}`);
const reason = String(detail.body?.unusableReason ?? '');
const drivable = detail.body?.stats?.drivable === true;
must(reason.includes('未产出 VRM'), `不可用原因说明"未产出 VRM":${reason}`);
// 归因准确性(T-WEB-017):骨架已达标时**不得**说"humanoid 不达标"
if (drivable) {
must(!reason.includes('humanoid 不达标'),
`骨架达标(drivable=true,humanoid ${detail.body?.stats?.humanoidMapped}/${detail.body?.stats?.humanoidTotal})`
+ `时不得归因于 humanoid:${reason}`);
} else {
console.log(` · 该 GLB 的 drivable=false(humanoid ${detail.body?.stats?.humanoidMapped}/`
+ `${detail.body?.stats?.humanoidTotal}),原因归因于 humanoid 属正确`);
}
unusable = { id, reason, drivable };
rmSync(pmxPath, { force: true });
rmSync(glbPath, { force: true });
} catch (error) {
console.log(` · 跳过不可用模型场景(${error.message})—— 需要 unzip 与 ASSIMP_BIN`);
}
// ---------- 边界 B:场景 config 只给最少字段,服务端必须补齐默认 ----------
const minimalConfig = { physics: { gravity: { y: -9.8 } } };
const minimalScene = await request('POST', '/api/scenes', {
form: fileForm(
{ name: '最简配置场景(只有 gravity)', config: JSON.stringify(minimalConfig), ...SOURCE },
'minimal.glb', glbBytes, 'model/gltf-binary',
),
});
must(minimalScene.status === 201, '上传最简配置场景', `${minimalScene.status} ${JSON.stringify(minimalScene.body)}`);
const minimalSceneId = minimalScene.body.id;
// ---------- 审核发布 ---------- // ---------- 审核发布 ----------
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) { for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId], ['scene', minimalSceneId]]) {
// 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的 // 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的
const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } }); 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)); must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status));
@@ -122,6 +184,31 @@ const run = async () => {
const data = catalog.body; const data = catalog.body;
console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`); console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`);
must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])'); must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])');
// 不可用模型不得进入客户端目录(①),且默认模型不受影响
const modelsInCatalog = (catalog.body?.models ?? []);
const nonVrm = modelsInCatalog.filter((m) => m.format !== 'vrm');
must(nonVrm.length === 0, '目录里的模型全是 VRM(不可用模型不进目录)', JSON.stringify(nonVrm));
if (unusable) {
must(!modelsInCatalog.some((m) => m.id === unusable.id), `不可用模型 #${unusable.id} 不在目录里`);
must(catalog.body?.defaultModelId === modelId,
`defaultModelId 仍是可用的 VRM 模型(实际 ${catalog.body?.defaultModelId},期望 ${modelId})`);
}
// 最简配置场景:服务端必须补齐默认值(契约 0.5.0 的字段边界)
const minimalEntry = (catalog.body?.scenes ?? []).find((s) => s.id === minimalSceneId);
must(minimalEntry !== undefined, `最简配置场景 #${minimalSceneId} 在目录里`);
const mc = minimalEntry?.config ?? {};
must(mc.physics?.gravity?.y === -9.8, `重力保留(${JSON.stringify(mc.physics?.gravity)})`);
must(mc.physics?.colliders === 'auto', `碰撞体补默认 auto(实际 ${mc.physics?.colliders})`);
must(mc.physics?.ground?.friction === 0.6 && mc.physics?.ground?.slopeLimit === 45,
`地面补默认 0.6/45°(实际 ${JSON.stringify(mc.physics?.ground)})`);
must(mc.physics?.wind?.gustAmplitude === 0 && mc.physics?.wind?.base?.x === 0,
`风补默认(静风):${JSON.stringify(mc.physics?.wind)}`);
must(mc.camera?.mode === 'follow', `相机补默认 follow(实际 ${mc.camera?.mode})`);
must(mc.environment?.ambientIntensity === 1, `环境光补默认 1(实际 ${mc.environment?.ambientIntensity})`);
must(mc.spawnPoints === undefined || mc.spawnPoints.length <= 1,
`出生点补默认(最多一条):${JSON.stringify(mc.spawnPoints)}`);
const version = await request('GET', '/api/catalog/version'); const version = await request('GET', '/api/catalog/version');
console.log(` 目录指纹:${JSON.stringify(version.body)}`); console.log(` 目录指纹:${JSON.stringify(version.body)}`);
@@ -135,6 +222,18 @@ const run = async () => {
keypointFormat: pre.body?.keypointFormat ?? null, keypointFormat: pre.body?.keypointFormat ?? null,
catalogRevision: version.body?.revision ?? null, catalogRevision: version.body?.revision ?? null,
contractVersions: health.body?.contractVersion ?? {}, contractVersions: health.body?.contractVersion ?? {},
unusableModelId: unusable?.id ?? null,
unusableDrivable: unusable?.drivable ?? null,
minimalSceneId,
minimalSceneExpect: {
gravityY: -9.8,
colliders: 'auto',
groundFriction: 0.6,
groundSlopeLimit: 45,
windCalm: true,
cameraMode: 'follow',
ambientIntensity: 1,
},
}, null, 2)); }, null, 2));
console.log(`\n期望值已写入 ${EXPECT_PATH}`); console.log(`\n期望值已写入 ${EXPECT_PATH}`);
}; };
+106 -13
View File
@@ -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 -1
View File
@@ -179,7 +179,8 @@ echo ""
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后" echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。" echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
echo "" echo ""
echo "下一步(文档 §7.5 / §7.6:发布前必须做的三条产物级验收):" echo "下一步(文档 §7.5 / §7.6 / §7.7:发布前必须做的验收,含逐文件核对):"
echo " python3 build/check-tracked-release.py $ZIP --tag v$VERSION # 包内每个文件 vs 打标签的树"
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证" 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-ws.sh $ZIP # WebSocket 契约一致性"
echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性" echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性"
+2 -2
View File
@@ -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
View File
@@ -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"
+10 -2
View File
@@ -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,17 @@ 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
- id: T-WIN-025
file: tasks/025-tracked-release-check.md
scope: 逐文件核对「发布包 == 打标签的树」(源码比 tag、构建产物比工作树导出)
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
View File
@@ -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)
+26
View File
@@ -52,6 +52,32 @@ Web 端的测试只验自己那一侧。**两端各自绿灯 ≠ 契约字段真
| ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG | | ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG |
| ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 | | ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 |
## 契约边界场景(续,配合 Web v0.7.5)
正常路径之外的**字段边界**同样只在跨端跑起来才会暴露。`seed-catalog.mjs` 与
`probe-integration.gd` 因此增加两类场景,**两端都断言**:
| 场景 | Web 侧(播种脚本断言) | Windows 侧(包内探针断言) |
|--|--|--|
| **不可用模型**:真实 PMX 用 Assimp 转 GLB 后入库(等价"管线只产出 GLB") | `usable=false`;原因准确(骨架达标时**不得**说"humanoid 不达标");**不在** `catalog/export`;`defaultModelId` 不变 | `bundle.models` 里没有任何非 VRM 模型;按 id 找不到该模型;`defaultModelId` 仍指向可用的 VRM |
| **场景 config 只给最少字段**(`{physics:{gravity:{y:-9.8}}}`) | 服务端补齐默认(碰撞体 `auto`、地面 `0.6/45°`、静风、相机 `follow`、环境光 `1`、出生点 1 条) | 客户端解析出的**同一组默认值**逐项一致 |
实测(Web v0.7.5 × Windows v0.2.5 包内 pck):
```
✓ 该模型标记为不可用(usable=false)
✓ 不可用原因说明"未产出 VRM":未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标),需重新以 VRM 交付才能进入客户端目录
✓ 骨架达标(drivable=true,humanoid 19/22)时不得归因于 humanoid
✓ 目录里的模型全是 VRM(不可用模型不进目录)/✓ 不可用模型 #2 不在客户端目录里
✓ defaultModelId 仍是可用的 VRM 模型(实际 1,期望 1)
✓ 最简配置:重力保留 -9.8 / 碰撞体补默认 auto / 地面默认 0.60 / 45°
✓ 最简配置:风默认静风(is_calm=true)/ 相机默认 follow / 环境光默认 1.0 / 出生点 1 条
```
> 这两类场景的价值在**同一份契约的两侧解释**:Web 认为"不可用"的模型长什么样、
> 客户端是否真的看不到它;以及"缺字段"时两端的默认值是否逐项一致
> (服务端补齐 vs 客户端收敛,任一侧不一致都会表现为"配置看起来生效了但行为不对")。
## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL) ## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL)
``` ```
+6 -1
View File
@@ -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 字节:
+67
View File
@@ -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 次
```
+58
View File
@@ -0,0 +1,58 @@
# T-WIN-025:逐文件核对「发布包 == 打标签的树」
- **状态**: done
- **依赖**: T-WIN-018(发布包级验收)
- **文档**: v1.6 §5.3 / §7.7
- **契约**: 不涉及
## 为什么需要
「发布包里的东西必须等于打标签的那棵树」此前只靠**抽查**几个文件
(README、`contracts/VERSION`…),而抽查必然会漏。上一轮实测暴露两个认知错误:
* Web 的发布包**包含** `web-server/test/**`(33 个文件),而我此前以为不含;
* 发布包里另有**构建产物**(本仓是 `OpenDance.exe` / `OpenDance.pck`)与 git 无关,
必须与工作树的导出结果比。
## 实现
`build/check-tracked-release.py`(纯标准库,`.zip` 直接读,LZMA 也支持):
对包内**每一个**文件按路径决定比较基准
| 路径 | 比较基准 |
|--|--|
| `OpenDance.exe` / `OpenDance.pck`(`BUILD_OUTPUTS`) | 工作树 `build/windows/` 下的导出产物 |
| 其余全部 | `git show <tag>:<路径>` |
任一**内容不一致** / **包内多余**(既不在 git 也不是声明的构建产物)/
**构建产物在工作树缺失** 都会列出并以非 0 退出。已接入 `build/accept-release.sh`
(第 0 步;目标须是 `.zip` 且仓库里有对应 tag)与 `build/package-release.sh` 的提示。
## 实测(**已发布**的 v0.2.5 资产重新下载后跑)
```
[accept] 逐文件核对:发布包 == tag v0.2.5
包内 13 个文件:源码/文档 11(与 tag 比)、构建产物 2(与工作树构建比)
✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
```
**反向验证**:把包内 `README.txt` 追加一行后用 7z 重新打成 LZMA zip →
```
✗ 内容不一致 1 处:· README.txt(与 v0.2.5 的内容不一致)
✗ 发布包与打标签的树不一致 退出码=1
```
## 踩坑
脚本第一版把 `BUILD_OUTPUTS` 的键一律当**目录前缀**处理(`rel[len(prefix):]`),
而本仓与 Android 的产物是**单文件**(键不带 `/`),于是拼出
`build/windows/OpenDance.exe/` 这种路径,误报「构建产物在工作树缺失」。
现在区分两种键:以 `/` 结尾的是目录前缀,否则是精确文件名。
## 与「产物内开发残留检查」的区别
| 检查 | 回答的问题 |
|--|--|
| `list-pck.gd`(T-WIN-023) | 产物**内部**(pck 里)有没有混进开发/验收工具 |
| 本脚本(T-WIN-025) | 产物**对着仓库**看有没有多、少、被改过的文件 |