10 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
zijunchiang 6ea2c93d66 fix(pack): 编码取整与参考实现对齐 + 产物不再夹带测试夹具(v0.2.4)
两个缺陷,都由上一轮的验收机制暴露:

1) 编码取整与契约参考实现不一致(T-WIN-022)
   KeypointFrame.encode() 用 GDScript 的 roundi(半数远离零),而契约参考实现
   (Web 的 keypoint-codec.ts)与**生产发送方 Android**(Kotlin Math.round)都是
   「半数朝 +∞」:-2.5 分别是 -3 与 -2。实测在 12 个跨实现向量里 1 例 33 字节不同。
   本端是接收方、encode() 只用于自测与协议回归,对产品无影响;但它让「跨实现一致性」
   只能停留在宽松口径("差异仅限 .5 取整边界"),而宽松口径会退化成没人看的已知差异。
   修复:新增 _round_like_js(v) = floor(v + 0.5),用于 4 处取整(坐标 ×3 + 置信度);
   夹紧顺序对任意实数与参考实现等价。
   收紧:accept-frame-vectors.sh 的编码口径改为「必须为 0 项差异」。
   新增单测(+11 断言):_round_like_js 的 5 个取值、端到端 -0.5/32767 → 0(roundi
   会给 -1,是有效判别用例),以及 test_reference_vectors_decode_and_encode_exactly
   —— 直接读锁定的参考向量逐值解码 + 用 f32InputPoints 重编码与 f32Hex 逐字节比对,
   让 verify.sh(只跑源码、无需 pck)也覆盖「Web ↔ Windows」这条边。

2) 产物里夹带开发/验收工具(T-WIN-023)
   export_filter="all_resources" 且 exclude_filter 没有 build/*,于是每次新增
   构建/验收脚本都被编译进 pck —— 包括**故意不合规**的坏服务端夹具 probe-ws-bad.gd
   与 frame-vectors.json。实测 OpenDance.pck 315,464 B →(排除后)145,400 B,
   即 54% 的 pck 是测试夹具。已发布的 v0.2.3 同样带着这些残留。
   修复:exclude_filter 增加 build/*;
   新增产物级防线 build/integration/list-pck.gd(遍历包内条目、标出
   res://build/ tests/ _scratch/ dist-release/ 与 *.md 残留,有残留即退出码 1),
   并接进 accept-release.sh(打印条目总数并断言「无开发残留」)——否则下次加个新
   工具目录又会重演。顺带确认 pck 没有把上一次导出的 pck 自嵌套进去。

实测(v0.2.4 发布包,三条产物级验收全绿):
  * accept-release.sh:pck 58 项无开发残留 / PE 版本资源 0.2.4 / pck 启动 180 帧无错 /
    运行时探针(包内版本号 0.2.4、AboutScreen 显示值、15 项关键资源、契约表比对)
  * accept-ws.sh:客户端 30 通过 / 0 失败 + 服务端侧 16 项事实
  * accept-frame-vectors.sh:解码 12/12、396 点 0 差异,
    **编码器字节与参考实现完全一致(0 项差异)**
  * verify.sh:785 通过 / 0 失败(原 774)

踩坑:给 -0.5/32767 这类值写断言时,第一版按"理想有理数"期望(2.5/32767 → 3)而
失败 —— PackedVector3Array 存的是 float32,乘回 32767 后略小于半整数,与 JS 对同一
float32 输入的行为其实完全一致。断言必须基于实际存储精度,或改用有效判别用例。
2026-09-17 05:59:54 +08:00
zijunchiang 1c5f28cd41 test(codec): 关键点帧跨实现一致性验收——包内 GDScript 解码器 × Web 参考实现(T-WIN-021)
契约 §2 的参考实现是 opendance-web 的 keypoint-codec.ts(其 decodeBinaryFrame
注释就是「Windows 端参考实现」)。Android 已用金标准向量与它对齐;而本端的解码器
此前**只跟自己比过**(自己编码、自己解码),两个方向共用同一份错误假设也能一直绿。

新增:
  * build/integration/frame-vectors.json —— 由参考实现生成并锁定的向量
    (12 用例:i16 最小值 -32768、i16 最大值、±1 全量程、置信度 0/128/255、
    flags 低 3 位组合、frame_id 0xFFFFFFFF 与 0x80000000、取整边界)。
  * build/integration/probe-frame-vectors.gd —— 用**包内** KeypointFrame 解码同一批字节,
    与参考解码结果**逐值精确比对**(坐标/置信度先收敛到 float32 再比精确相等,
    因为本端点存 PackedVector3Array、置信度存 PackedFloat32Array)。
  * build/accept-frame-vectors.sh —— 编排:用参考实现重新生成向量并与锁定文件比对
    (参考实现变了却不同步向量即失败)→ 解包 → 包内解码比对 → 报告核对。

实测(**已发布**的 v0.2.3 资产重新下载后跑):
  ✓ 锁定的向量与参考实现重新生成的结果逐字节一致
  ✓ 解码用例 12/12,逐点比对 396 个,解码差异 0 项
  ✓ 编码器差异 1 项,且全部属于「量化前恰好 .5」的取整边界
  关键点帧跨实现一致性验收全部通过

已测量并归类的一处差异(非缺陷):JS Math.round 与 Kotlin(生产发送方)Math.round
对 -2.5 取 -2(半数朝 +∞),本端 roundi 取 -3(半数远离零)。Windows 是接收方,
encode() 只在自测/协议回归里用,故对产品无影响;脚本硬性要求这类差异只能出现在
取整边界用例上,否则视为新问题。若将来本端要当发送方,把 roundi 换成 floor(x+0.5) 即可。

踩坑(两处「看着通过其实没测到」,都写进任务文档):
  1. 生成器第一版把「喂进编码器的输入」当成 points 写进向量 → 比对变成「比编码量化」,
     靠抽查数值才发现;现在向量同时含 inputPoints / f32InputPoints / f32Hex / points。
  2. 编码器比对一开始用 float64 输入编出的 hex 当基准(正数恰好 .5 那例整例 99 字节不同),
     实际只是输入从 float64 收敛到 float32 的量化差;改用同输入的 f32Hex 基准后,
     差异收敛到唯一一个真正的取整边界用例。教训:跨实现比对必须先统一输入。

package-release.sh 结尾把四条发布前验收一次列全;verify.sh 全绿(774 通过 / 0 失败)。
2026-09-17 05:52:11 +08:00
zijunchiang cb1c293dcf chore(uid): 补探针脚本的 .uid(Godot 首次导入自动生成,与其他脚本一致入库) 2026-09-17 05:33:25 +08:00
zijunchiang 0ffb2d43bb test(ws): WebSocket 契约一致性验收(独立实现 × 包内 PoseServer)+ 故意不合规的负例服务端
tests/test_pose_server.gd 用的是**同引擎内的客户端**(Godot WebSocketPeer),而真实
对端是 Android(Kotlin/OkHttp)——同引擎自测会共享同样的假设(超时判定、分包、
文本/二进制边界、关闭语义、子协议协商),于是「测试全绿」不等于「Android 连得上」。
契约 §7 也写明:静默超时要用保活避免,而不是靠重连掩盖;这句话只有用独立实现的
客户端实测才算验过。

新增三个文件:
  * build/integration/probe-ws.gd —— 把**包内** PoseServer 跑起来(--main-pack +
    只加载包内代码),把连接/控制帧/帧/断开事件汇总成 JSON 报告;自身不做判定
    (避免「用被测实现判定被测实现」)。支持 --stop-file 提前收尾。
  * build/integration/ws-client.mjs —— Node 实现的契约一致性客户端(用内置
    WebSocket,零依赖),逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景。
  * build/accept-ws.sh —— 编排:解包 → 起探针 → 等监听 → 跑客户端 → 收报告 →
    核对客户端看不到的那一半(frames_dropped / malformed_frames / 控制帧计数 /
    device_replaced / 关闭原因 / 静默超时消息带毫秒数与阈值)。

实测(**已发布**的 v0.2.3 资产重新下载后跑):客户端 30 通过 / 0 失败 + 服务端侧
16 项事实全过。关键两条:
  * 保活对照:只发应用层 ping 持续 19 秒(> 静默超时 15 秒)连接**仍保持打开**;
  * 静默超时:完全不发数据 → close(1001,"timeout"),实测 15011 ms(阈值 15000 ms);
另有:子协议协商、welcome 声明保活参数且 heartbeat(5000) < silentTimeout(15000)/2、
pong 原样回传 sentAtMs、同 deviceId 替换旧连接(1000 "replaced by new connection")、
bye → 1000、协议不匹配 → bye + 1002、72 帧含故意跳号(服务端 frames_dropped=3)
与故意 100 字节非法帧(malformed_frames=1)。

反向验证:新增**故意不合规的服务端** build/integration/probe-ws-bad.gd
(welcome 不声明保活字段、不带 sessionId、收到 ping 不回 pong、不做 deviceId 去重),
对同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。

踩坑记录(已写入任务文档):
  1. --script 模式下从无类型数组取 peer,peer.get_packet() 返回类型推不出来,
     var x := … 直接 Parse Error;
  2. 复核静默超时消息时写了否定式断言 !includes("静默超时 1"),把正确行为判成失败 ——
     断言应写成正向模式匹配;
  3. 探针原本只能按固定时长跑,编排脚本得猜时间;改成客户端跑完 touch stop 文件。

顺带:package-release.sh 结尾把三条发布前验收(产物自证 / WS 契约一致性 / 跨端集成)
一次列全。不影响已发布的 v0.2.3 产物,故不新发版本。
2026-09-17 05:32:24 +08:00
zijunchiang 6238873312 chore(accept): accept-integration.sh 拒绝未知参数
首跑时把 --expect <path> 当成位置参数传进来,被静默忽略(期望值路径只认
EXPECT_PATH 环境变量),于是 --skip-seed 时找不到期望值文件才报错 —— 属于
「参数写错却先跑一半」的糟糕体验。现在除 --skip-seed 外一律报错退出。

不影响发布产物(accept-integration.sh 不在 zip 内),故不新发版本。
2026-09-17 05:21:54 +08:00
33 changed files with 9784 additions and 40 deletions
+100 -7
View File
@@ -22,10 +22,49 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。
| 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** |
| 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 |
| 连接保活与设备去重 | OK welcome/config 声明静默超时与心跳;应用层 ping/pong;按 deviceId 去重 |
| 发布整包 | OK 单个 `opendance-windows-v0.2.3.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 新增真实 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 修订(编码取整对齐 + 产物不再夹带测试夹具)
**缺陷 1(编码取整与参考实现不一致)**:`KeypointFrame.encode()` 用 GDScript 的
`roundi`(半数远离零),而契约参考实现(Web 的 `Math.round`)与**生产发送方 Android**
(Kotlin `Math.round`)都是「半数朝 +∞」—— `-2.5` 分别是 `-3` 与 `-2`。
实测在 12 个跨实现向量里有 1 例 **33 字节不同**。本端是接收方、`encode()` 只用于自测,
对产品无影响;但它让「跨实现一致性」只能停留在宽松口径("差异仅限取整边界"),
而宽松口径会退化成永远没人看的已知差异。**修复**:新增 `_round_like_js(v) = floor(v + 0.5)`
并用于 4 处取整;`accept-frame-vectors.sh` 的口径**收紧为「编码差异必须为 0」**。
**缺陷 2(产物里夹带开发/验收工具)**:`export_filter="all_resources"` +
`exclude_filter` 没有 `build/*`,于是每次新增构建/验收脚本都会被编译进 pck ——
包括**故意不合规**的坏服务端夹具 `probe-ws-bad.gd` 与向量文件。
`OpenDance.pck` 因此从 145,400 B 涨到 **315,464 B(54% 是测试夹具)**。
**修复**:`exclude_filter` 增加 `build/*`;并新增**产物级防线**
`build/integration/list-pck.gd`(遍历包内条目、标出开发残留)+ `accept-release.sh`
断言「无残留」,避免下次加个新工具目录又重演。
## v0.2.3 修订(跨端集成验收 + 两处真实缺陷)
**缺陷 1(契约字段缺失)**:`CatalogTypes.Motion` 没有 `thumbnail_url` 字段,
@@ -109,10 +148,10 @@ GODOT_BIN=/path/to/godot bash build/verify.sh
GODOT_BIN=/path/to/godot bash build/export-windows.sh release
# 打包发布产物(单个完整 zip + SHA256SUMS)
bash build/package-release.sh 0.2.3
bash build/package-release.sh 0.2.5
# 发布包级验收(对**发布包**跑,而不是对源码;文档 §7.5)
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.3.zip
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.5.zip
```
`accept-release.sh` 校验 sha256 → 解压 → 文件清单(并检查没有 `.partN` 残留)→
@@ -123,7 +162,61 @@ exe 的 PE 版本资源 → **用 Godot 直接加载包内 pck 跑 180 帧** →
已对**已发布的 v0.2.2 资产**(重新下载)跑通,并用「篡改包内 contracts/VERSION」与
「期望版本写错」两个负例反向验证过。
当前版本:**v0.2.3**(`project.godot` 的 `application/config/version`,
### 关键点帧跨实现一致性验收(T-WIN-021)
`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的**参考实现**
(其 `decodeBinaryFrame` 注释就是「Windows 端参考实现(T-WIN-004)」)。
Android 已用「金标准字节向量」与它对齐;而**本端的解码器此前只跟自己比过**
(自己编码、自己解码)。这条边由下列脚本补上:
```bash
GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.5.zip
# 没有 web 仓 / 未装 node_modules 时:... --skip-regen
```
1. 用**参考实现**(`npm run vectors:frame`)重新生成向量,与仓库锁定的
`build/integration/frame-vectors.json` 比对 —— 参考实现变了却不同步向量,这里就会失败;
2. 用**包内 pck** 解码同一批字节,与参考解码结果**逐值精确比对**
(float32 表示精度内):实测 **12 用例 / 396 点 / 0 差异**;
3. 附带比对两边编码器:本端不是发送方(`encode()` 只用于自测),差异只归类记录,
但**必须只出现在「量化前恰好 .5」的用例上**。
> 已测量并归类的一处差异:JS `Math.round` 与 Kotlin `Math.round` 对 `-2.5` 取 `-2`
> (半数朝 +∞),而本端 `roundi` 取 `-3`(半数远离零)。生产发送方是 Android,
> 与参考实现一致;Windows 是接收方,`encode()` 不在生产链路上。
> 详见 [`tasks/021-frame-vector-conformance.md`](tasks/021-frame-vector-conformance.md)。
### WebSocket 契约一致性验收(T-WIN-020)
`tests/test_pose_server.gd` 用的是**同引擎内的客户端**,而真实对端是
**Android(Kotlin/OkHttp)** —— 同引擎自测会共享同样的假设(超时判定、分包、
文本/二进制边界、关闭语义、子协议协商)。因此另配一套**独立实现**的客户端:
```bash
GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.5.zip
# 跳过耗时的两个场景(保活 19s / 静默超时 15s):
GODOT_BIN=... bash build/accept-ws.sh <zip> --skip-slow
```
* `build/integration/probe-ws.gd`:把**包内** PoseServer 跑起来并汇总事件成 JSON(自身不判定);
* `build/integration/ws-client.mjs`:Node 实现的契约一致性客户端(无依赖,用内置 `WebSocket`),
逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景;
* `build/accept-ws.sh`:编排两者,并核对**客户端看不到的那一半**(服务端侧
`frames_dropped` / `malformed_frames` / 控制帧计数 / 去重事件 / 关闭原因)。
**契约边界场景**(配合 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 秒)连接
**仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。
反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明
保活字段、不回 pong、不做去重)跑同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。
详见 [`tasks/020-ws-contract-conformance.md`](tasks/020-ws-contract-conformance.md)。
当前版本:**v0.2.5**(`project.godot` 的 `application/config/version`,
与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、
以及 exe 的 PE 版本资源全部一致)。
@@ -206,7 +299,7 @@ GODOT_BIN=/path/to/godot bash build/export-windows.sh release
powershell -ExecutionPolicy Bypass -File build\export-windows.ps1
# 打成可分发的 zip(单个完整包 + SHA256SUMS)
bash build/package-release.sh 0.2.3
bash build/package-release.sh 0.2.5
```
| 产物 | 位置 | 说明 |
@@ -214,7 +307,7 @@ bash build/package-release.sh 0.2.3
| `OpenDance.exe` | `build/windows/` | 主程序,约 104 MB;脚本校验前 2 字节为 `MZ`(合法 PE) |
| `OpenDance.pck` | `build/windows/` | 资源包,约 107 KB; **必须与 exe 同目录** |
| `OpenDance.console.exe` | `build/windows/` | 控制台包装器,可能生成,非必需 |
| `opendance-windows-v0.2.3.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` 直接校验 |
导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。
@@ -318,7 +411,7 @@ src/
等格式,但本项目未在 Windows 上实测)。
若 IIS 上限已放开、或你能把包放到别处分发(例如 Web 服务端的 `/static`,
不受 IIS 那个限制约束),请改用资源管理器可直接双击解压的 deflate 包:
`ZIP_METHOD=deflate bash build/package-release.sh 0.2.3`(约 38 MB)。
`ZIP_METHOD=deflate bash build/package-release.sh 0.2.5`(约 38 MB)。
7. **发布包 exe 的版本元数据**(T-WIN-017):v0.2.1 之前在 Windows
「属性 → 详细信息」里显示的是 `0.1.0`,因为 `export_presets.cfg` 的
`application/file_version` / `application/product_version` 被硬编码且无人校验。
+27 -5
View File
@@ -1,5 +1,5 @@
================================================================
OpenDance Studio -- Windows 客户端 v0.2.3 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
OpenDance Studio -- Windows 客户端 v0.2.5 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
================================================================
摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket
@@ -8,6 +8,28 @@ OpenDance Studio -- Windows 客户端 v0.2.3 使用说明(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 修订
1. 编码取整与契约参考实现对齐: 本端 encode() 原用 GDScript roundi(半数远离零),
而参考实现与生产发送方(Android 的 Math.round)是「半数朝 +∞」—— -2.5 分别是
-3 与 -2。本端是接收方(encode 只用于自测),故对产品无影响,但跨实现一致性
只能停留在"差异仅限取整边界"的宽松口径。现改为 floor(x + 0.5),并把验收口径
收紧为「编码差异必须为 0」。
2. 产物不再夹带开发/验收工具: export_filter="all_resources" 且 exclude_filter
没有 build/*,导致探针脚本(含故意不合规的坏服务端夹具)与向量文件被打进 pck ——
pck 从 145,400 B 涨到 315,464 B(54% 是测试夹具)。现已排除 build/*,
并新增产物级残留检查(accept-release.sh 会断言「无开发残留」)。
3. 无头测试 785 项断言(原 774)。
v0.2.3 修订
1. 补 Motion.thumbnail_url: 服务端 catalog/export 从 data-models 0.6.0 起一直
在发 thumbnailUrl,客户端类型里却缺这个字段(Model/Scene 都有),等于默默丢掉。
@@ -140,11 +162,11 @@ OpenDance.exe 的用户看第一节即可。
打成发布包:
bash build/package-release.sh 0.2.3
bash build/package-release.sh 0.2.5
得到 dist-release/ 下两个资产:
opendance-windows-v0.2.3.zip <- 单个完整包,禁止 .partN 分卷
opendance-windows-v0.2.5.zip <- 单个完整包,禁止 .partN 分卷
SHA256SUMS <- 裸文件名,可直接 sha256sum -c
包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、
@@ -163,14 +185,14 @@ OpenDance.exe 的用户看第一节即可。
如果你能把包放到别处分发(例如 Web 服务端的 /static,不受 IIS 那个
上限约束),建议改用资源管理器可直接双击解压的 deflate 包:
ZIP_METHOD=deflate bash build/package-release.sh 0.2.3 # 约 38 MB
ZIP_METHOD=deflate bash build/package-release.sh 0.2.5 # 约 38 MB
上限一旦放开,Release 资产也应换回 deflate 包。
7. 自检清单
0) 发布包级验收(对**发布包**而不是源码;文档 §7.5)
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.3.zip
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.5.zip
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
读 exe 的 PE 版本资源、用 Godot 直接加载包内 pck 跑 180 帧、并跑包内运行时
探针(包内版本号 / 「关于」清单显示值 / 主场景 / autoload / 关键资源 /
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# ============================================================
# OpenDance Studio Windows 客户端 —— **关键点帧跨实现一致性验收**(T-WIN-021)
#
# opendance-web 的 TypeScript 参考实现(契约 §2)× 发布包里的 GDScript 解码器
#
# 为什么需要它:`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的
# **参考实现**(其 `decodeBinaryFrame` 的注释就是「Windows 端参考实现(T-WIN-004)」)。
# Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;而**本端的 GDScript 解码器
# 此前只跟自己比过**(自己编码、自己解码)。本脚本把这条缺失的边补上:
#
# 1. 用**参考实现**重新生成向量,与仓库里锁定的 `build/integration/frame-vectors.json`
# 比对 —— 参考实现一旦变化,这里必须失败(否则"锁定"就没有意义);
# 2. 用**发布包里的 pck** 解码同一批字节,与参考实现的解码结果**逐值精确比对**
# (float32 表示精度内);
# 3. 附带比对两边**编码器**的字节:本端不是发送方(`encode()` 只用于自测),
# 因此只归类记录 —— 但差异必须**只出现在量化前恰好落在 .5 的用例**上。
#
# 用法:
# GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.3.zip
# ... --skip-regen # 不重新生成向量(没有 web 仓 / 没装 node_modules 时)
#
# 环境变量:
# WEB_REPO opendance-web 仓库路径(默认 ../opendance-web)
# WORK_DIR 工作目录(默认 mktemp -d)
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
TARGET="${1:-}"
SKIP_REGEN=""
for arg in "$@"; do
case "$arg" in
--skip-regen) SKIP_REGEN=1 ;;
"$TARGET") : ;;
*) echo "✗ 未知参数:$arg(只支持 --skip-regen)" >&2; exit 2 ;;
esac
done
WEB_REPO="${WEB_REPO:-$PROJECT_DIR/../opendance-web}"
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
mkdir -p "$WORK_DIR"
VECTORS="$PROJECT_DIR/build/integration/frame-vectors.json"
REPORT="$WORK_DIR/frame-vectors-report.json"
FAILED=0
log() { printf '\033[1;34m[accept-fv]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[accept-fv] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
echo "用法:GODOT_BIN=... bash build/accept-frame-vectors.sh <发布包 zip 或已解压目录> [--skip-regen]" >&2
exit 2
fi
[ -f "$VECTORS" ] || { fail "找不到锁定的向量文件:$VECTORS"; exit 1; }
# ---- 1. 用参考实现重新生成并比对(确保向量仍与参考实现一致)----
log "参考实现:$WEB_REPO/web-server/src/preprocess/keypoint-codec.ts"
if [ -n "$SKIP_REGEN" ]; then
log "跳过重新生成(--skip-regen)"
elif [ ! -f "$WEB_REPO/web-server/scripts/gen-frame-vectors.mts" ]; then
fail "找不到生成脚本($WEB_REPO/web-server/scripts/gen-frame-vectors.mts)—— 用 WEB_REPO 指定仓库,或 --skip-regen"
elif [ ! -d "$WEB_REPO/web-server/node_modules" ]; then
fail "web 仓没有 node_modules,无法运行参考实现(先在那边 npm install,或 --skip-regen)"
else
log "用参考实现重新生成向量并比对…"
REGEN="$WORK_DIR/frame-vectors.regen.json"
if ( cd "$WEB_REPO/web-server" && timeout 300 npx vite-node scripts/gen-frame-vectors.mts -- --out "$REGEN" >"$WORK_DIR/regen.log" 2>&1 ); then
if diff -q "$VECTORS" "$REGEN" >/dev/null; then
echo " ✓ 锁定的向量与参考实现重新生成的结果逐字节一致"
else
fail "锁定的向量已与参考实现不一致(参考实现变了却没同步向量)"
diff "$VECTORS" "$REGEN" | head -20 | sed 's/^/ /'
fi
else
fail "参考实现生成失败(见 $WORK_DIR/regen.log)"
tail -10 "$WORK_DIR/regen.log" | sed 's/^/ /'
fi
fi
# ---- 2. 取包内 pck ----
if [ -f "$TARGET" ]; then
log "解压发布包…"
if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; }
fi
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
else
PACKAGE_DIR="$TARGET"
fi
PCK="$PACKAGE_DIR/OpenDance.pck"
[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; }
log "包内 pck:$PCK"
GODOT_BIN="${GODOT_BIN:-}"
if [ -z "$GODOT_BIN" ]; then
for candidate in godot godot4 Godot; do
command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; }
done
fi
[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; }
# ---- 3. 用包内解码器逐值比对 ----
log "包内解码器 × 参考向量…"
PROBE_LOG="$WORK_DIR/probe.log"
rm -f "$REPORT"
if timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" \
--script "$PROJECT_DIR/build/integration/probe-frame-vectors.gd" \
-- --vectors "$VECTORS" --report "$REPORT" >"$PROBE_LOG" 2>&1; then
PROBE_OK=1
else
PROBE_OK=0
fi
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
[ "$PROBE_OK" = 1 ] || fail "解码比对未通过"
# ---- 4. 报告核对 ----
if [ ! -f "$REPORT" ]; then
fail "探针没有产出报告($REPORT)"
exit 1
fi
log "报告核对:"
node -e '
const fs = require("node:fs");
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
let bad = 0;
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
say(r.cases > 0 && r.decodedCases === r.cases, `解码用例 ${r.decodedCases}/${r.cases}`);
say(r.pointsCompared === r.cases * 33, `逐点比对 ${r.pointsCompared} 个(期望 ${r.cases * 33})`);
say(Array.isArray(r.decodeFailures) && r.decodeFailures.length === 0,
`解码差异 ${r.decodeFailures?.length ?? "?"} 项(必须为 0)`);
if (r.decodeFailures?.length) for (const f of r.decodeFailures.slice(0, 5)) console.log(` · ${f.case}:${f.problem}`);
// 编码器差异:v0.2.4 起本端 `_round_like_js`(floor(x+0.5))已与参考实现的
// `Math.round` 对齐,因此**必须为 0**。原先允许「仅限 .5 取整边界」的宽松口径已收紧:
// 宽松口径下,规则性偏差会长期以「已知差异」的身份留在日志里,没人会再看它。
const diffs = r.encodeDifferences ?? [];
say(diffs.length === 0,
diffs.length === 0
? "编码器字节与参考实现完全一致(0 项差异)"
: `编码器差异 ${diffs.length} 项(必须为 0):${diffs.map((d) => d.case + "(" + d.diffBytes + "B," + (d.halfValueInput ? "取整边界" : "非取整边界") + ")").join("、")}`);
process.exit(bad);
' "$REPORT" || fail "报告核对未通过"
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "============================================================"
echo " 关键点帧跨实现一致性验收失败(工作目录:$WORK_DIR)"
echo "============================================================"
exit 1
fi
echo "============================================================"
echo " 关键点帧跨实现一致性验收全部通过(工作目录:$WORK_DIR)"
echo "============================================================"
+8 -1
View File
@@ -35,7 +35,14 @@ cd "$PROJECT_DIR"
TARGET="${1:-}"
SKIP_SEED=0
for arg in "$@"; do [ "$arg" = "--skip-seed" ] && SKIP_SEED=1; done
# 只认 --skip-seed;其余参数一律报错退出,避免「参数写错被静默忽略」
for arg in "$@"; do
case "$arg" in
--skip-seed) SKIP_SEED=1 ;;
"$TARGET") : ;;
*) echo "✗ 未知参数:$arg(只支持 --skip-seed;期望值路径用 EXPECT_PATH 环境变量)" >&2; exit 2 ;;
esac
done
API_BASE="${API_BASE:-http://127.0.0.1:3000}"
SAMPLES_DIR="${OPENDANCE_SAMPLES_DIR:-$PROJECT_DIR/../示例文件}"
+34
View File
@@ -56,6 +56,22 @@ log "期望版本:$EXPECTED"
log "工作目录:$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 + 解压 ----
if [ -f "$TARGET" ]; then
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
@@ -153,6 +169,24 @@ if [ -f "$PCK" ]; then
echo " ✓ 包内 pck 启动 $BOOT_FRAMES 帧无错误(退出码 0)"
fi
# 产物内不得混入开发/验收工具(T-WIN-023):export_filter="all_resources" 只看
# exclude_filter,加了新的工具目录却忘了排除,就会把探针(乃至**故意不合规**的
# 坏服务端夹具)与向量文件打进 pck —— 实测曾占 pck 的 54%(315,464 B → 145,400 B)。
log "产物内开发残留检查(build/ tests/ _scratch/ *.md)…"
LIST_JSON="$WORK_DIR/pck-entries.json"
if timeout 120 "$GODOT_BIN" --headless --main-pack "$PCK" \
--script "$PROJECT_DIR/build/integration/list-pck.gd" \
-- --json --out "$LIST_JSON" >"$WORK_DIR/pck-list.log" 2>&1; then
node -e '
const fs = require("node:fs");
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
console.log(` ✓ pck 共 ${r.count} 项,无开发残留(build/ tests/ _scratch/ *.md)`);
' "$LIST_JSON" || fail "pck 残留清单解析失败"
else
fail "产物里混入了开发/验收残留(见 $WORK_DIR/pck-list.log)"
grep -E "✗|·" "$WORK_DIR/pck-list.log" | head -10 | sed 's/^/ /'
fi
log "包内运行时探针(版本号 / 界面显示值 / 契约表 / 资源可加载)…"
PROBE="$PROJECT_DIR/build/probe-release.gd"
PROBE_LOG="$WORK_DIR/probe.log"
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# ============================================================
# OpenDance Studio Windows 客户端 —— **WebSocket 契约一致性验收**(T-WIN-020)
#
# contracts/websocket-keypoints.md v0.2.0 × 发布包里的 PoseServer
#
# 为什么需要它:本仓的 `tests/test_pose_server.gd` 用的是**同引擎内的客户端**
# (Godot 自己的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。
# 同引擎自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义、
# 子协议协商)——**必须有一个独立实现的客户端来对照**:
# * `build/integration/probe-ws.gd`:把**发布包里**的 PoseServer 跑起来
# (只加载包内代码),把连接/控制/帧/断开事件汇总成 JSON 报告;
# * `build/integration/ws-client.mjs`:Node 实现的契约一致性客户端,
# 逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景并断言;
# * 本脚本:编排两者,并把**服务端侧**的事实(丢帧计数、malformed、去重事件、
# 关闭原因)也一并核对 —— 客户端只能看到自己那一半。
#
# 用法:
# GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3.zip
# GODOT_BIN=... bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3 # 已解压目录
# ... --skip-slow # 跳过「保活 19s」与「静默超时 15s」两个耗时场景
#
# 可选环境变量:
# PORT 监听端口(默认 18765)
# WORK_DIR 工作目录(默认 mktemp -d)
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
TARGET="${1:-}"
SKIP_SLOW=""
for arg in "$@"; do
case "$arg" in
--skip-slow) SKIP_SLOW="--skip-slow" ;;
"$TARGET") : ;;
*) echo "✗ 未知参数:$arg(只支持 --skip-slow)" >&2; exit 2 ;;
esac
done
PORT="${PORT:-18765}"
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
mkdir -p "$WORK_DIR"
REPORT="$WORK_DIR/ws-report.json"
STOP_FILE="$WORK_DIR/ws-stop"
PROBE_LOG="$WORK_DIR/probe-ws.log"
FAILED=0
log() { printf '\033[1;34m[accept-ws]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[accept-ws] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
echo "用法:GODOT_BIN=... bash build/accept-ws.sh <发布包 zip 或已解压目录> [--skip-slow]" >&2
exit 2
fi
# ---- 1. 取包内 pck ----
if [ -f "$TARGET" ]; then
log "解压发布包…"
if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; }
fi
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
else
PACKAGE_DIR="$TARGET"
fi
PCK="$PACKAGE_DIR/OpenDance.pck"
[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; }
log "包内 pck:$PCK"
# ---- 2. 启探针(包内 PoseServer)----
GODOT_BIN="${GODOT_BIN:-}"
if [ -z "$GODOT_BIN" ]; then
for candidate in godot godot4 Godot; do
command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; }
done
fi
[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; }
rm -f "$REPORT" "$STOP_FILE"
log "启动包内 PoseServer 探针(端口 $PORT)…"
nohup "$GODOT_BIN" --headless --main-pack "$PCK" \
--script "$PROJECT_DIR/build/integration/probe-ws.gd" \
-- --port "$PORT" --duration-ms 180000 --report "$REPORT" --stop-file "$STOP_FILE" \
>"$PROBE_LOG" 2>&1 &
PROBE_PID=$!
cleanup() { kill "$PROBE_PID" 2>/dev/null || true; }
trap cleanup EXIT
READY=0
for _ in $(seq 1 60); do
grep -q "\[ws\] listening" "$PROBE_LOG" 2>/dev/null && { READY=1; break; }
kill -0 "$PROBE_PID" 2>/dev/null || break
sleep 0.5
done
if [ "$READY" != 1 ]; then
fail "探针 30 秒内未开始监听"
tail -20 "$PROBE_LOG" | sed 's/^/ /'
exit 1
fi
grep "\[ws\] listening" "$PROBE_LOG" | sed 's/^/ /'
# ---- 3. 跑契约一致性客户端 ----
log "契约一致性客户端(Node 独立实现)…"
CLIENT_LOG="$WORK_DIR/ws-client.log"
if timeout 300 node "$PROJECT_DIR/build/integration/ws-client.mjs" \
--url "ws://127.0.0.1:$PORT/pose" $SKIP_SLOW >"$CLIENT_LOG" 2>&1; then
CLIENT_OK=1
else
CLIENT_OK=0
fi
sed 's/^/ /' "$CLIENT_LOG"
[ "$CLIENT_OK" = 1 ] || fail "契约一致性客户端未全通过"
# ---- 4. 收尾并取服务端侧报告 ----
touch "$STOP_FILE"
for _ in $(seq 1 40); do
kill -0 "$PROBE_PID" 2>/dev/null || break
sleep 0.5
done
if [ ! -f "$REPORT" ]; then
fail "探针没有产出报告($REPORT)"
tail -20 "$PROBE_LOG" | sed 's/^/ /'
exit 1
fi
# ---- 5. 服务端侧事实核对 ----
log "服务端侧事实核对(客户端看不到的那一半):"
node -e '
const fs = require("node:fs");
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const skipSlow = process.argv[2] === "1";
let bad = 0;
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
say(r.server.silent_timeout_ms === 15000 && r.server.heartbeat_interval_ms === 5000,
`服务端声明 silentTimeoutMs=${r.server.silent_timeout_ms} / heartbeatIntervalMs=${r.server.heartbeat_interval_ms}`);
say(r.connected.length >= 10, `收到 ${r.connected.length} 次连接(含 v0.1.0 风格、无 deviceId、无 hello 的对端)`);
say(r.framesReceived >= 100, `收到 ${r.framesReceived} 个关键点帧(基础 72 + v0.1.0 风格对端 34 + 无 hello 对端 5)`);
say(r.lastFrameId >= 74, `最大 frame_id = ${r.lastFrameId}`);
// frame_id 是「每条连接各自从 0 递增」,新连接重新从 0 开始是合规的,
// 因此这里只打印跨连接计数作参考,不用它做判定(第一版误当成缺陷)。
console.log(` · 跨连接 frame_id 回退计数 ${r.frameIdRegressionsAcrossConnections ?? "?"}(仅参考)`);
// 客户端故意跳号 60→63,服务端应按 §8 计入丢帧
say(r.server.frames_dropped >= 3, `丢帧检测:frames_dropped=${r.server.frames_dropped}(期望 ≥3,来自故意的 60→63 跳号)`);
// 客户端故意发 100 字节非法帧,服务端应计数且不崩
say(r.server.malformed_frames === 1, `非法帧计数:malformed_frames=${r.server.malformed_frames}(期望 1)`);
// 控制帧分类计数
const c = r.controls;
say((c.hello ?? 0) >= 6, `hello=${c.hello ?? 0};ping=${c.ping ?? 0};stats=${c.stats ?? 0};bye=${c.bye ?? 0}`);
say((c.ping ?? 0) >= (skipSlow ? 1 : 8), `应用层 ping 计数=${c.ping ?? 0}(保活场景)`);
// 去重事件
say(Array.isArray(r.deviceReplaced) && r.deviceReplaced.length === 1
&& r.deviceReplaced[0] === "aaaa000000000001",
`device_replaced 只应发生一次且只针对重复的 deviceId(实际 ${JSON.stringify(r.deviceReplaced)}):`
+ "契约 §5 要求缺 deviceId 的连接**不做去重**、按连接计数");
// 断开原因:替换(1000) / 静默超时 / bye(1000) / 协议错误(1002)
const reasons = r.disconnected.map((x) => x.reason).join(" | ");
say(reasons.includes("被同一设备的新连接替换"), "服务端记录了「同设备替换」断开原因");
say(reasons.includes("user_stop"), "服务端记录了 bye 的 reason");
say(reasons.includes("code=1002"), "服务端记录了协议不匹配的 1002 关闭");
if (!skipSlow) {
// 只有"专门完全不发数据"的那条连接该被静默超时踢掉。
// 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("静默超时"), "服务端记录了静默超时踢人");
// 写这条时第一版写成了 !reasons.includes("静默超时 1") —— 恰好把「消息里带 15001 ms」
// 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。
say(/静默超时 \d+ ms(阈值 \d+ ms/.test(reasons),
"静默超时消息带实测毫秒数与阈值(可核对):" + (reasons.match(/静默超时 [^|]*/) ?? [""])[0].trim());
}
process.exit(bad);
' "$REPORT" "$([ -n "$SKIP_SLOW" ] && echo 1 || echo 0)" || fail "服务端侧事实核对未通过"
# 探针日志里不允许出现脚本错误(WARNING 是故意的负例产生的,允许)
if grep -qE "SCRIPT ERROR|Parse Error" "$PROBE_LOG"; then
fail "探针日志出现脚本错误"
grep -E "SCRIPT ERROR|Parse Error" "$PROBE_LOG" | head -5 | sed 's/^/ /'
else
echo " ✓ 探针日志无脚本错误(负例产生的 WARNING 属预期)"
fi
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "============================================================"
echo " WebSocket 契约一致性验收失败(工作目录:$WORK_DIR)"
echo "============================================================"
exit 1
fi
echo "============================================================"
echo " WebSocket 契约一致性验收全部通过(工作目录:$WORK_DIR)"
echo "============================================================"
+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))
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
extends SceneTree
## 列出发布包内 pck 的资源清单(T-WIN-023)
##
## 用途:`accept-release.sh` 用它断言**开发/验收工具不会被打进产物** ——
## 起因是一个真实缺陷:`export_filter="all_resources"` + `exclude_filter` 里没有 `build/*`,
## 于是 `build/` 下的探针脚本(含**故意不合规**的 `probe-ws-bad.gd`)与
## `frame-vectors.json` 全被编译打包进 `OpenDance.pck`:
## pck 315,464 B → 加上 exclude 后 145,400 B(**54% 是测试夹具**)。
##
## 用法:
## godot --headless --main-pack <pck> --script <本文件> [-- --json]
const DEV_PREFIXES := ["res://build/", "res://tests/", "res://_scratch/", "res://dist-release/"]
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var as_json := args.has("--json")
var found: Array = []
_walk("res://", found)
found.sort()
var dev_entries: Array = []
for f in found:
for prefix in DEV_PREFIXES:
if f.begins_with(prefix):
dev_entries.append(f)
break
if f.ends_with(".md"):
if not dev_entries.has(f):
dev_entries.append(f)
if as_json:
var report := {
"entries": found,
"count": found.size(),
"devEntries": dev_entries,
}
var path := ""
var i := args.find("--out")
if i >= 0 and i + 1 < args.size():
path = args[i + 1]
if path != "":
var file := FileAccess.open(path, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(report, " "))
file.close()
print("[pck] 清单已写入 %s(%d 项,开发残留 %d 项)" % [path, found.size(), dev_entries.size()])
else:
print(JSON.stringify(report))
quit(0 if dev_entries.is_empty() else 1)
return
print("[pck] 文件数 %d" % found.size())
for f in found:
print(" ", f)
if dev_entries.is_empty():
print("[pck] ✓ 无开发/验收残留(build/ tests/ _scratch/ *.md)")
else:
print("[pck] ✗ 产物里混入了开发残留 %d 项:" % dev_entries.size())
for f in dev_entries:
print(" · ", f)
quit(0 if dev_entries.is_empty() else 1)
func _walk(dir: String, out: Array) -> void:
var d := DirAccess.open(dir)
if d == null: return
d.list_dir_begin()
while true:
var name := d.get_next()
if name == "": break
var path := dir.path_join(name) if dir != "res://" else "res://" + name
if d.current_is_dir():
if name != "." and name != "..": _walk(path, out)
else:
out.append(path)
d.list_dir_end()
+1
View File
@@ -0,0 +1 @@
uid://cp5nggx7y2164
+213
View File
@@ -0,0 +1,213 @@
extends SceneTree
## 关键点帧跨实现一致性探针(T-WIN-021)
##
## 用**发布包里的** `KeypointFrame` 解码由 opendance-web 参考实现(TypeScript)
## 生成的 239 字节向量,逐值比对。用法(由 build/accept-frame-vectors.sh 调用):
##
## godot --headless --main-pack <包内 OpenDance.pck> \
## --script /绝对路径/build/integration/probe-frame-vectors.gd \
## -- --vectors /绝对路径/frame-vectors.json --report /tmp/frame-vectors-report.json
##
## 为什么需要:`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的
## **参考实现**(其 `decodeBinaryFrame` 的注释就是「Windows 端参考实现(T-WIN-004)」),
## Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;而**本端的 GDScript 解码器
## 此前从未与它逐值比对过** —— 只有本端自己的单测(自己编码、自己解码)。
##
## 比对口径(重要):
## * `flags` / `frame_id`:**精确相等**;
## * 坐标与置信度:本端把点存进 `PackedVector3Array`、置信度存进 `PackedFloat32Array`,
## 即 **float32**;参考实现是 float64。因此先把期望值收敛到 float32
## (GDScript 里借 `Vector3(...)` / `PackedFloat32Array([...])` 天然完成)
## 再比**精确相等** —— 这比用容差更严格,且把差异限定在「表示精度」上。
## * 另附:用本端 `encode()` 对向量里的 `f32InputPoints`(float32 同输入基准)
## 重新编码,与 `f32Hex` 逐字节比对。v0.2.4 起本端取整已改为 `floor(x + 0.5)`
## (与 JS `Math.round` 的「半数朝 +∞」一致),因此**差异必须为 0**;
## 收敛前的宽松口径(只允许出现在 .5 取整边界)已废弃 ——
## 规则性偏差留在日志里会变成"永远没人看的已知差异"。
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
var _vectors_path := ""
var _report_path := ""
var _done := false
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
match args[i]:
"--vectors":
_vectors_path = args[i + 1]; i += 2
"--report":
_report_path = args[i + 1]; i += 2
_:
i += 1
_run()
func _process(_delta: float) -> bool:
return _done
func _hex_to_bytes(hex: String) -> PackedByteArray:
var out := PackedByteArray()
out.resize(hex.length() / 2)
for i in out.size():
out[i] = hex.substr(i * 2, 2).hex_to_int()
return out
## 把 float64 收敛成 float32:GDScript 的 Vector3 分量 / PackedFloat32Array 都是 float32
static func _to_f32(value: float) -> float:
return Vector3(value, 0.0, 0.0).x
func _run() -> void:
if _vectors_path == "" or not FileAccess.file_exists(_vectors_path):
print("[frame-vectors] ✗ 找不到向量文件:%s" % _vectors_path)
quit(2)
return
var file := FileAccess.open(_vectors_path, FileAccess.READ)
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if typeof(parsed) != TYPE_DICTIONARY:
print("[frame-vectors] ✗ 向量文件不是 JSON 对象")
quit(2)
return
var cases: Array = parsed.get("cases", [])
print("[frame-vectors] 参考实现:%s" % parsed.get("source", "(未标注)"))
print("[frame-vectors] 用例 %d 个" % cases.size())
var decoded_cases := 0
var decode_failures: Array = []
var encode_differences: Array = []
var total_points := 0
for c in cases:
var name := str(c.get("name", "(未命名)"))
var bytes := _hex_to_bytes(str(c.get("hex", "")))
var expect_frame_id := int(c.get("frameId", 0))
var expect_flags := int(c.get("flags", 0))
var expect_points: Array = c.get("points", [])
# ---- 校验 + 解码 ----
var problem := KeypointFrame.validate(bytes)
if problem != "":
decode_failures.append({"case": name, "problem": "validate: " + problem})
continue
var frame = KeypointFrame.decode(bytes)
if frame == null:
decode_failures.append({"case": name, "problem": "decode 返回 null"})
continue
decoded_cases += 1
if int(frame.frame_id) != expect_frame_id:
decode_failures.append({
"case": name,
"problem": "frame_id 期望 %d,实际 %d" % [expect_frame_id, frame.frame_id],
})
if int(frame.flags) != expect_flags:
decode_failures.append({
"case": name,
"problem": "flags 期望 %d,实际 %d" % [expect_flags, frame.flags],
})
# ---- 逐点比对(float32 精确相等)----
for i in expect_points.size():
total_points += 1
var e: Array = expect_points[i]
var want := Vector3(_to_f32(float(e[0])), _to_f32(float(e[1])), _to_f32(float(e[2])))
var want_conf := PackedFloat32Array([float(e[3])])[0]
var got: Vector3 = frame.points[i]
var got_conf: float = frame.confidence[i]
if got != want:
decode_failures.append({
"case": name,
"problem": "点 %d 坐标不符:期望 %s,实际 %s" % [i, str(want), str(got)],
})
elif got_conf != want_conf:
decode_failures.append({
"case": name,
"problem": "点 %d 置信度不符:期望 %s,实际 %s" % [i, str(want_conf), str(got_conf)],
})
# ---- 附带:本端 encode() 与参考字节的差异(本端不是发送方,记录不判死)----
# 必须用 **f32InputPoints**(已收敛到 float32)来重编码:本端点/置信度就是 float32,
# 若拿 float64 的原始输入比,差异会同时来自「输入精度」与「取整规则」,无法归因。
var input_points: Array = c.get("f32InputPoints", c.get("inputPoints", []))
# 与**同输入**基准比:`f32Hex` 是用 f32InputPoints 编出来的字节。
# 拿 `hex`(float64 输入)比会把「输入精度差异」也算进来(第一版就是这么误判的:
# 「正数恰好 .5」整例 99 字节不同,其实只是输入从 float64 收敛到 float32 后的量化差)。
var baseline_hex := str(c.get("f32Hex", c.get("hex", "")))
var baseline := _hex_to_bytes(baseline_hex)
if input_points.size() == 33:
var pts := PackedVector3Array()
var conf := PackedFloat32Array()
pts.resize(33)
conf.resize(33)
for i in 33:
var p: Array = input_points[i]
pts[i] = Vector3(float(p[0]), float(p[1]), float(p[2]))
conf[i] = float(p[3])
var re_encoded := KeypointFrame.encode(expect_frame_id, pts, conf, expect_flags)
var diff_offsets: Array = []
for i in re_encoded.size():
if re_encoded[i] != baseline[i]:
diff_offsets.append(i)
if diff_offsets.size() > 0:
# 归类:本端 `roundi` 对「.5」的进位方向与 JS `Math.round` 不同
# (-2.5:GDScript → -3,JS → -2)。**同输入**下,这类差异只可能
# 出现在量化前恰好落在 X.5 的用例上 —— 这里直接**算**出来,
# 而不是靠用例名猜(第一版按名字猜,漏判了「置信度 128/255」,
# 因为那一例的 y 也取了 -0.5)。
var has_half := false
for p in input_points:
for value in [p[0], p[1], p[2]]:
var scaled: float = float(value) * 32767.0
var a := absf(scaled)
if absf(a - floorf(a) - 0.5) < 1e-9:
has_half = true
var c_scaled: float = float(p[3]) * 255.0
var ac := absf(c_scaled)
if absf(ac - floorf(ac) - 0.5) < 1e-9:
has_half = true
encode_differences.append({
"case": name,
"diffBytes": diff_offsets.size(),
"halfValueInput": has_half,
})
var report := {
"source": parsed.get("source", ""),
"cases": cases.size(),
"decodedCases": decoded_cases,
"pointsCompared": total_points,
"decodeFailures": decode_failures,
"encodeDifferences": encode_differences,
}
if _report_path != "":
var out := FileAccess.open(_report_path, FileAccess.WRITE)
if out != null:
out.store_string(JSON.stringify(report, " "))
out.close()
print("[frame-vectors] 解码用例 %d/%d,比对点 %d 个" % [decoded_cases, cases.size(), total_points])
if decode_failures.is_empty():
print("[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)")
else:
print("[frame-vectors] ✗ 解码差异 %d 项:" % decode_failures.size())
for f in decode_failures.slice(0, 8):
print(" · %s:%s" % [f["case"], f["problem"]])
if encode_differences.is_empty():
print("[frame-vectors] ✓ 本端 encode() 与参考字节完全一致")
else:
print("[frame-vectors] ✗ 本端 encode() 与参考字节存在差异(取整规则应已对齐,必须为 0):")
for d in encode_differences:
print(" · %s:%d 字节不同(量化前存在 .5 值=%s)" % [
d["case"], d["diffBytes"], str(d.get("halfValueInput", false))])
_done = true
quit(1 if decode_failures.size() > 0 else 0)
@@ -0,0 +1 @@
uid://chtushi1p64ng
+39
View File
@@ -245,6 +245,45 @@ func _run() -> void:
else:
_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. ① 模型资源可下载 ----------
var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"])
if model == null:
+96
View File
@@ -0,0 +1,96 @@
extends SceneTree
## 反例服务端(T-WIN-020 的**负例**夹具):故意不遵守 websocket-keypoints v0.2.0。
##
## 用途:证明 `build/integration/ws-client.mjs` 的断言**真的会失败**。
## 一个只跑通过用例的检查器,无法区分「契约被遵守」与「断言写得太松」。
##
## 故意违反的条款:
## * §3.2 welcome **不声明** silentTimeoutMs / heartbeatIntervalMs(v0.2.0 硬性要求);
## * §3.2 welcome 不带 sessionId;
## * §3.6 收到应用层 ping **不回** pong;
## * §5 不做 deviceId 去重(第二个同 deviceId 连接不会踢掉第一个)。
##
## 用法:与 probe-ws.gd 相同(`--port` / `--duration-ms` / `--report` / `--stop-file`),
## 但本文件只记录最小事实。期望结果:ws-client.mjs 出现多项 ✗ 并以非 0 退出。
const DEFAULT_PORT := 18770
const DEFAULT_DURATION_MS := 60_000
var _tcp := TCPServer.new()
var _peers: Array = []
var _started := false
var _done := false
var _start_ms := 0
var _port := DEFAULT_PORT
var _duration_ms := DEFAULT_DURATION_MS
var _report_path := ""
var _stop_file := ""
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
match args[i]:
"--port":
_port = int(args[i + 1]); i += 2
"--duration-ms":
_duration_ms = int(args[i + 1]); i += 2
"--report":
_report_path = args[i + 1]; i += 2
"--stop-file":
_stop_file = args[i + 1]; i += 2
_:
i += 1
func _process(_delta: float) -> bool:
if not _started:
_started = true
_start_ms = Time.get_ticks_msec()
var err := _tcp.listen(_port, "127.0.0.1")
if err != OK:
print("[bad-ws] ✗ listen 失败:%s" % error_string(err))
quit(2)
return true
print("[bad-ws] listening on 127.0.0.1:%d(**故意不合规**的服务端)" % _port)
while _tcp.is_connection_available():
var conn := _tcp.take_connection()
if conn == null:
continue
var peer := WebSocketPeer.new()
peer.supported_protocols = PackedStringArray(["opendance.pose.v1"])
if peer.accept_stream(conn) == OK:
_peers.append(peer)
for peer in _peers:
peer.poll()
while peer.get_available_packet_count() > 0:
# peer 来自无类型数组,返回值类型推不出来,这里显式标注
var packet: PackedByteArray = peer.get_packet()
if peer.was_string_packet():
var text: String = packet.get_string_from_utf8()
var parsed: Variant = JSON.parse_string(text)
if typeof(parsed) == TYPE_DICTIONARY and str(parsed.get("type", "")) == "hello":
# 违规 1/2/3:缺保活字段、缺 sessionId、且此后不回 pong
peer.send_text(JSON.stringify({
"type": "welcome",
"protocol": "opendance.pose.v1",
"expectedFps": 30,
}))
if _done:
return true
var elapsed := Time.get_ticks_msec() - _start_ms
if elapsed >= _duration_ms or (_stop_file != "" and FileAccess.file_exists(_stop_file)):
if _report_path != "":
var file := FileAccess.open(_report_path, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify({"server": "bad-ws", "peers": _peers.size()}, " "))
file.close()
print("[bad-ws] 收尾(连接 %d,未做去重、未回 pong)" % _peers.size())
_done = true
quit(0)
return _done
+1
View File
@@ -0,0 +1 @@
uid://cpri5w6clu80v
+189
View File
@@ -0,0 +1,189 @@
extends SceneTree
## WebSocket 契约一致性探针(T-WIN-020)
##
## 用法(由 build/accept-ws.sh 调用,也可手工执行):
## godot --headless --main-pack <包内 OpenDance.pck> \
## --script /绝对路径/build/integration/probe-ws.gd \
## -- --port 18765 --duration-ms 60000 --report /tmp/ws-report.json
##
## 为什么需要它:`tests/test_pose_server.gd` 用的是**同引擎内的客户端**
## (Godot 自己写的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。
## 同引擎内的自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义),
## 因此**必须有一个独立实现的客户端**来对照 —— 这就是本探针 + Node 客户端的组合:
## * 本探针:把**发布包里的** PoseServer 跑起来(只加载包内代码),
## 把收到的连接/控制/帧/断开事件汇总成 JSON 报告;
## * `build/integration/ws-client.mjs`:Node 端按契约 v0.2.0 逐条跑场景并断言。
##
## 探针自身不做判定(只收集事实),判定在 Node 客户端与 accept-ws.sh 里,避免
## 「用被测实现来判定被测实现」。
const DEFAULT_PORT := 18765
const DEFAULT_DURATION_MS := 60_000
var _server: Node = null
var _started := false
var _done := false
var _start_ms := 0
var _port := DEFAULT_PORT
var _duration_ms := DEFAULT_DURATION_MS
var _report_path := "/tmp/opendance-ws-report.json"
## 可选:客户端跑完后 touch 这个文件,探针提前收尾(比固定时长更快、更稳)
var _stop_file := ""
var _events: Array = []
var _connected: Array = []
var _disconnected: Array = []
var _replaced: Array = []
var _controls := {}
## 每条连接的 hello 标识(client_id → {device, deviceId}),
## 用来在报告核对阶段回答「某类对端有没有被误踢」——断开原因里没有设备名,
## 只有 client_id 可用,所以必须在这里建立映射。
var _hellos: Array = []
var _frames := 0
var _last_frame_id := -1
var _frame_id_regressions := 0
var _first_frame_ms := 0
var _last_frame_ms := 0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
match args[i]:
"--port":
_port = int(args[i + 1]); i += 2
"--duration-ms":
_duration_ms = int(args[i + 1]); i += 2
"--report":
_report_path = args[i + 1]; i += 2
"--stop-file":
_stop_file = args[i + 1]; i += 2
_:
i += 1
func _process(_delta: float) -> bool:
if not _started:
_started = true
_start_ms = Time.get_ticks_msec()
_start_server()
if _done:
return true
var elapsed := Time.get_ticks_msec() - _start_ms
if elapsed >= _duration_ms:
_finish()
elif _stop_file != "" and FileAccess.file_exists(_stop_file):
_finish()
return _done
func _start_server() -> void:
var script: GDScript = load("res://src/net/pose_server.gd")
if script == null:
print("[ws] ✗ 无法加载 res://src/net/pose_server.gd")
_finish(2)
return
_server = script.new()
_server.name = "ProbePoseServer"
root.add_child(_server)
_server.listening.connect(_on_listening)
_server.listen_failed.connect(_on_listen_failed)
_server.client_connected.connect(_on_client_connected)
_server.client_disconnected.connect(_on_client_disconnected)
_server.device_replaced.connect(_on_device_replaced)
_server.frame_received.connect(_on_frame_received)
_server.control_received.connect(_on_control_received)
var err: int = _server.listen("127.0.0.1", _port)
if err != OK:
print("[ws] ✗ listen(%d) 失败:%s" % [_port, error_string(err)])
_finish(2)
func _on_listening(port: int) -> void:
# accept-ws.sh 靠这一行判断「服务端已就绪」
print("[ws] listening on 127.0.0.1:%d(契约 §1 端点)" % port)
func _on_listen_failed(port: int, reason: String) -> void:
print("[ws] ✗ 监听 %d 失败:%s" % [port, reason])
func _now_ms() -> int:
return Time.get_ticks_msec() - _start_ms
func _on_client_connected(client_id: int, device: String) -> void:
_connected.append({"id": client_id, "device": device, "atMs": _now_ms()})
_events.append({"atMs": _now_ms(), "kind": "connected", "id": client_id, "device": device})
func _on_client_disconnected(client_id: int, reason: String) -> void:
_disconnected.append({"id": client_id, "reason": reason, "atMs": _now_ms()})
_events.append({"atMs": _now_ms(), "kind": "disconnected", "id": client_id, "reason": reason})
func _on_device_replaced(device_id: String) -> void:
_replaced.append(device_id)
_events.append({"atMs": _now_ms(), "kind": "device_replaced", "deviceId": device_id})
func _on_frame_received(frame) -> void:
_frames += 1
if _first_frame_ms == 0:
_first_frame_ms = _now_ms()
_last_frame_ms = _now_ms()
if _last_frame_id >= 0 and frame.frame_id <= _last_frame_id:
_frame_id_regressions += 1
_last_frame_id = frame.frame_id
func _on_control_received(client_id: int, message: Dictionary) -> void:
var type := str(message.get("type", "(无 type)"))
_controls[type] = int(_controls.get(type, 0)) + 1
_events.append({"atMs": _now_ms(), "kind": "control", "id": client_id, "type": type})
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:
if _done:
return
_done = true
var report := {
"port": _port,
"durationMs": _now_ms(),
"server": _server.snapshot() if _server != null else {},
"connected": _connected,
"disconnected": _disconnected,
"deviceReplaced": _replaced,
"controls": _controls,
"hellos": _hellos,
"framesReceived": _frames,
"lastFrameId": _last_frame_id,
# 注意:这是**跨连接**的全局计数。frame_id 是「每条连接各自从 0 单调递增」,
# 新连接重新从 0 开始完全合规,因此该字段只作参考,不能用来判定丢帧/回退。
"frameIdRegressionsAcrossConnections": _frame_id_regressions,
"firstFrameAtMs": _first_frame_ms,
"lastFrameAtMs": _last_frame_ms,
"events": _events,
}
if _server != null:
_server.stop()
var file := FileAccess.open(_report_path, FileAccess.WRITE)
if file == null:
print("[ws] ✗ 无法写入报告 %s" % _report_path)
else:
file.store_string(JSON.stringify(report, " "))
file.close()
print("[ws] 报告已写入 %s(连接 %d、帧 %d、控制帧 %s、替换 %d)" % [
_report_path, _connected.size(), _frames, JSON.stringify(_controls), _replaced.size()])
quit(code)
+1
View File
@@ -0,0 +1 @@
uid://bcw10cvmdqty6
+102 -3
View File
@@ -3,14 +3,17 @@
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
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 SAMPLES = process.env.OPENDANCE_SAMPLES_DIR
?? new URL('../../../示例文件', import.meta.url).pathname;
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 = {
sourceType: 'original',
sourceName: '跨端集成验收素材',
@@ -102,8 +105,67 @@ const run = async () => {
must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`);
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」是正常的
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));
@@ -122,6 +184,31 @@ const run = async () => {
const data = catalog.body;
console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`);
must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])');
// 不可用模型不得进入客户端目录(①),且默认模型不受影响
const 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');
console.log(` 目录指纹:${JSON.stringify(version.body)}`);
@@ -135,6 +222,18 @@ const run = async () => {
keypointFormat: pre.body?.keypointFormat ?? null,
catalogRevision: version.body?.revision ?? null,
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));
console.log(`\n期望值已写入 ${EXPECT_PATH}`);
};
+397
View File
@@ -0,0 +1,397 @@
#!/usr/bin/env node
/**
* WebSocket 契约一致性客户端(T-WIN-020)
*
* 独立的 Node 实现,按 contracts/websocket-keypoints.md **v0.2.0** 逐条跑场景,
* 对发布包里的 Windows `PoseServer` 做契约一致性验证。
*
* 为什么不用 Godot 自己的客户端:同引擎内的自测会共享同样的假设
* (超时判定、分包、文本/二进制边界、关闭语义、子协议协商)。
* 真实对端是 Android(Kotlin/OkHttp),所以必须用**另一套实现**来对照。
*
* 用法:
* node build/integration/ws-client.mjs --url ws://127.0.0.1:18765/pose [--skip-slow]
*
* 场景(对应契约章节):
* §1/§3.2 子协议协商 + hello → welcome(含 silentTimeoutMs / heartbeatIntervalMs)
* §1.1 heartbeatIntervalMs 必须 < silentTimeoutMs / 2
* §2 239 字节二进制帧(magic/version/flags/frame_id/33×7B),含 flags 位
* §3.4 stats 文本帧
* §3.6 应用层 ping → pong(sentAtMs 原样回传)
* §5 同 deviceId 新连接替换旧连接:旧连接收到 close(1000, "replaced…")
* §1.1 只发 ping 时连接**不会**被静默超时踢掉(19 秒对照,--skip-slow 可跳过)
* §1.1/§7 完全不发数据 → 服务端 close(1001, "timeout")
* §3.5 bye → 服务端 close(1000)
* §3.1/§6 协议版本不匹配 → bye(reason=protocol_mismatch) + close(1002)
* §2 非法二进制帧 → 服务端不崩且计入 malformed(由探针报告核对)
*
* 退出码:0 全通过 / 1 有失败 / 2 用法或连接问题。
*/
const args = process.argv.slice(2);
function argValue(name, fallback) {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback;
}
const URL_ = argValue('--url', 'ws://127.0.0.1:18765/pose');
const SUBPROTOCOL = 'opendance.pose.v1';
const SKIP_SLOW = args.includes('--skip-slow');
const MAGIC = 0x4f44;
const FRAME_BYTES = 239;
let passed = 0;
let failed = 0;
const failures = [];
function check(name, ok, detail = '') {
if (ok) {
passed += 1;
console.log(` ✓ ${name}`);
} else {
failed += 1;
failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 构造契约 §2 的 239 字节关键点帧 */
function buildFrame({ frameId = 0, flags = 0, mirrored = false, lowConf = false, upperBodyOnly = false } = {}) {
const buf = new ArrayBuffer(FRAME_BYTES);
const view = new DataView(buf);
view.setUint16(0, MAGIC, true); // magic(小端)
view.setUint8(2, 0x01); // version
let f = flags;
if (mirrored) f |= 0x01;
if (lowConf) f |= 0x02;
if (upperBodyOnly) f |= 0x04;
view.setUint8(3, f);
view.setUint32(4, frameId >>> 0, true);
for (let i = 0; i < 33; i += 1) {
const o = 8 + i * 7;
// 造一组确定性的归一化坐标:x 从 -0.9 到 0.9,y 随索引摆动,z 小量
const x = Math.round((-0.9 + (1.8 * i) / 32) * 32767);
const y = Math.round((0.5 * Math.sin(i) + 0.1) * 32767);
const z = Math.round((0.02 * i - 0.3) * 32767);
view.setInt16(o, Math.max(-32768, Math.min(32767, x)), true);
view.setInt16(o + 2, Math.max(-32768, Math.min(32767, y)), true);
view.setInt16(o + 4, Math.max(-32768, Math.min(32767, z)), true);
view.setUint8(o + 6, i === 5 ? 100 : 240); // 故意放一个低置信度点(conf<128)
}
return new Uint8Array(buf);
}
/**
* 打开一条连接,返回 { ws, 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) => {
const ws = new WebSocket(URL_, [SUBPROTOCOL]);
ws.binaryType = 'arraybuffer';
const inbox = [];
const waiters = [];
const state = { closeInfo: null, error: null };
/** 收到的消息类型计数(用于断言"config 不是周期性刷屏"这类事实) */
const counts = {};
const push = (msg) => {
const t = msg?.type ?? '(未知)';
counts[t] = (counts[t] ?? 0) + 1;
const w = waiters.shift();
if (w) w(msg);
else inbox.push(msg);
};
ws.addEventListener('message', (ev) => {
if (typeof ev.data === 'string') {
try {
push(JSON.parse(ev.data));
} catch {
push({ type: '(非 JSON 文本)', raw: ev.data });
}
} else {
push({ type: '(二进制)', byteLength: ev.data.byteLength });
}
});
ws.addEventListener('close', (ev) => {
state.closeInfo = { code: ev.code, reason: ev.reason };
const w = waiters.shift();
if (w) w({ type: '(closed)', ...state.closeInfo });
else inbox.push({ type: '(closed)', ...state.closeInfo });
});
ws.addEventListener('error', (ev) => {
state.error = ev?.message ?? 'error';
});
const opened = new Promise((res, rej) => {
ws.addEventListener('open', res);
const t = setTimeout(() => rej(new Error('连接超时(3s)')), 3000);
t.unref?.();
});
opened
.then(() => {
if (hello) {
const payload = {
type: 'hello', protocol, device,
appVersion: '0.2.0', cameraFacing: 'front', targetFps: 30, resolution: '640x480',
};
// 契约 §3.1:deviceId 可选 —— 这里刻意支持"整个字段都不出现"的合规对端
if (!omitDeviceId) payload.deviceId = deviceId;
ws.send(JSON.stringify(payload));
}
resolve({
ws,
state,
counts,
next: (timeoutMs = 3000) => new Promise((res) => {
if (inbox.length) return res(inbox.shift());
const t = setTimeout(() => res({ type: '(超时)', timeoutMs }), timeoutMs);
t.unref?.();
waiters.push((m) => {
clearTimeout(t);
res(m);
});
}),
/**
* 按**类型**取消息,跳过其它类型(并把它们计入 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) {
if (state.closeInfo) return state.closeInfo;
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
if (state.closeInfo) return state.closeInfo;
await sleep(50);
}
return null;
},
});
})
.catch(reject);
});
}
function badFrame() {
// 契约要求恰好 239 字节;这里故意发 100 字节
return new Uint8Array(100).fill(0x7f);
}
async function main() {
console.log(`\n== WebSocket 契约一致性(websocket-keypoints v0.2.0)@ ${URL_} ==\n`);
// ---------- §1/§3.2 握手 ----------
console.log('[1] 子协议协商 + hello → welcome(§1 / §3.1 / §3.2)');
const a = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
const welcome = await a.nextOfType('welcome');
check('收到 welcome', welcome?.type === 'welcome', JSON.stringify(welcome));
check('welcome.protocol 与契约一致', welcome?.protocol === SUBPROTOCOL, String(welcome?.protocol));
check('welcome.sessionId 非空', typeof welcome?.sessionId === 'string' && welcome.sessionId.length > 0);
check('welcome.expectedFps = 30', welcome?.expectedFps === 30, String(welcome?.expectedFps));
const silent = Number(welcome?.silentTimeoutMs);
const heartbeat = Number(welcome?.heartbeatIntervalMs);
check('welcome 声明 silentTimeoutMs', Number.isFinite(silent) && silent > 0, String(welcome?.silentTimeoutMs));
check('welcome 声明 heartbeatIntervalMs', Number.isFinite(heartbeat) && heartbeat > 0, String(welcome?.heartbeatIntervalMs));
check('heartbeatIntervalMs < silentTimeoutMs / 2(§1.1 硬性)',
heartbeat < silent / 2, `心跳 ${heartbeat} ms,超时 ${silent} ms`);
check('子协议已协商成功(未回落到空串)', a.ws.protocol === SUBPROTOCOL, `实际 '${a.ws.protocol}'`);
// ---------- §2 二进制帧 ----------
console.log('\n[2] 239 字节二进制帧(§2)');
for (let i = 0; i < 60; i += 1) {
a.ws.send(buildFrame({ frameId: i, mirrored: i % 2 === 0, lowConf: i % 5 === 0, upperBodyOnly: false }));
await sleep(8);
}
// 故意跳号(丢包检测:§8 应进入 degraded 并可自动恢复)
for (let i = 63; i < 75; i += 1) {
a.ws.send(buildFrame({ frameId: i }));
await sleep(8);
}
check('已发送 72 帧(含跳号 60→63)', true);
a.ws.send(badFrame());
check('已发送 1 个非法长度帧(100 字节,服务端应计入 malformed 且不崩)', true);
await sleep(200);
// ---------- §3.4 / §3.6 ----------
console.log('\n[3] stats 与 ping/pong(§3.4 / §3.6)');
a.ws.send(JSON.stringify({ type: 'stats', fps: 29.6, dropped: 3, latencyMs: 42, confidenceAvg: 0.93 }));
const sentAt = 1730000000000;
a.ws.send(JSON.stringify({ type: 'ping', sentAtMs: sentAt }));
const pong = await a.nextOfType('pong');
check('收到 pong', pong?.type === 'pong', JSON.stringify(pong));
check('pong.sentAtMs 原样回传', pong?.sentAtMs === sentAt, String(pong?.sentAtMs));
check('pong.serverAtMs 已填写', Number.isFinite(Number(pong?.serverAtMs)) && Number(pong?.serverAtMs) > 0,
String(pong?.serverAtMs));
// ---------- §5 设备去重 ----------
console.log('\n[4] 同 deviceId 替换旧连接(§5)');
const a2 = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
const a2welcome = await a2.nextOfType('welcome');
check('新连接收到 welcome', a2welcome?.type === 'welcome', JSON.stringify(a2welcome));
const oldClose = await a.closed(4000);
check('旧连接被服务端关闭', oldClose !== null, '旧连接仍在打开');
check('旧连接关闭码为 1000(契约 §5)', oldClose?.code === 1000, String(oldClose?.code));
check('旧连接关闭原因为 replaced by new connection',
String(oldClose?.reason ?? '').includes('replaced'), String(oldClose?.reason));
check('新连接保持打开', a2.ws.readyState === WebSocket.OPEN, String(a2.ws.readyState));
// ---------- §1.1 保活:只发 ping 不会被踢 ----------
console.log('\n[5] 保活:只发应用层 ping,连接不应被静默超时踢掉(§1.1)');
if (SKIP_SLOW) {
console.log(' · --skip-slow:跳过 19 秒对照');
} else {
const k = await connect({ deviceId: 'bbbb000000000002', device: 'Keepalive' });
const kw = await k.nextOfType('welcome');
check('保活连接已握手', kw?.type === 'welcome', JSON.stringify(kw));
const deadline = Date.now() + (silent + 4000); // 超过静默超时 4 秒
let pings = 0;
while (Date.now() < deadline) {
if (k.ws.readyState !== WebSocket.OPEN) break;
k.ws.send(JSON.stringify({ type: 'ping', sentAtMs: Date.now() }));
pings += 1;
await sleep(Math.max(500, Math.floor(heartbeat / 2)));
}
check(`持续 ${Math.round((silent + 4000) / 100) / 10}s 发 ping(${pings} 次)后连接仍打开`,
k.ws.readyState === WebSocket.OPEN,
`readyState=${k.ws.readyState}(静默超时 ${silent} ms,期望心跳 ${heartbeat} ms)`);
k.ws.close(1000, 'done');
await k.closed(2000);
}
// ---------- §1.1 / §7 静默超时 ----------
console.log('\n[6] 完全不发数据 → close(1001, "timeout")(§1.1 / §7)');
if (SKIP_SLOW) {
console.log(' · --skip-slow:跳过静默超时对照');
} else {
const s = await connect({ deviceId: 'cccc000000000003', device: 'Silent' });
const sw = await s.nextOfType('welcome');
check('静默连接已握手', sw?.type === 'welcome', JSON.stringify(sw));
const t0 = Date.now();
const closeInfo = await s.closed(silent + 6000);
const elapsed = Date.now() - t0;
check('服务端关闭了静默连接', closeInfo !== null, `${elapsed} ms 内未关闭`);
check('关闭码为 1001(going away)', closeInfo?.code === 1001, String(closeInfo?.code));
check('关闭原因为 timeout', String(closeInfo?.reason ?? '').includes('timeout'), String(closeInfo?.reason));
check(`关闭时机接近 silentTimeoutMs(实测 ${elapsed} ms,阈值 ${silent} ms)`,
closeInfo !== null && elapsed >= silent * 0.8 && elapsed <= silent + 4000, `${elapsed} ms`);
}
// ---------- §3.5 bye ----------
console.log('\n[7] bye → 服务端 close(1000)(§3.5)');
const b = await connect({ deviceId: 'dddd000000000004', device: 'Bye' });
await b.nextOfType('welcome');
b.ws.send(JSON.stringify({ type: 'bye', reason: 'user_stop' }));
const byeClose = await b.closed(4000);
check('服务端关闭了连接', byeClose !== null);
check('关闭码为 1000', byeClose?.code === 1000, String(byeClose?.code));
// ---------- §3.1 / §6 协议不匹配 ----------
console.log('\n[8] 协议版本不匹配 → bye + close(1002)(§3.1 / §6)');
const c = await connect({ deviceId: 'eeee000000000005', device: 'Old', protocol: 'opendance.pose.v0' });
const byeMsg = await c.nextOfType('bye');
check('收到 bye', byeMsg?.type === 'bye', JSON.stringify(byeMsg));
check('bye.reason 说明协议不匹配', String(byeMsg?.reason ?? '').includes('protocol_mismatch'), String(byeMsg?.reason));
const mismatchClose = await c.closed(4000);
check('关闭码为 1002(协议错误)', mismatchClose?.code === 1002, String(mismatchClose?.code));
// ---------- §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[12] 收尾');
a2.ws.close(1000, 'done');
await a2.closed(2000);
await sleep(300);
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
if (failed > 0) {
console.log('失败项:');
for (const f of failures) console.log(` - ${f}`);
process.exit(1);
}
console.log('全部通过 ✅');
process.exit(0);
}
main().catch((err) => {
console.error('✗ 客户端异常:', err?.message ?? err);
process.exit(2);
});
+6 -2
View File
@@ -179,5 +179,9 @@ echo ""
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
echo ""
echo "下一步(文档 §7.5:每次发布都要有一条读产物本身的验收):"
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP"
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-ws.sh $ZIP # WebSocket 契约一致性"
echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性"
echo " API_BASE=http://127.0.0.1:3000 GODOT_BIN=... bash build/accept-integration.sh $ZIP # 跨端(需已部署服务端)"
+3 -3
View File
@@ -8,7 +8,7 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="_scratch/*, tests/*, *.md"
exclude_filter="_scratch/*, tests/*, build/*, *.md"
export_path="build/windows/OpenDance.exe"
patches=PackedStringArray()
encryption_include_filters=""
@@ -37,8 +37,8 @@ application/modify_resources=true
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.2.3"
application/product_version="0.2.3"
application/file_version="0.2.5"
application/product_version="0.2.5"
application/company_name="OpenDance Studio"
application/product_name="OpenDance Studio"
application/file_description="OpenDance Studio 摄像头跳舞评分客户端"
+1 -1
View File
@@ -9,7 +9,7 @@ config_version=5
config/name="OpenDance Studio"
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
config/version="0.2.3"
config/version="0.2.5"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://icon.svg"
+26 -2
View File
@@ -1,5 +1,5 @@
project: opendance-windows-client
version: 0.2.3
version: 0.2.5
repository: opendance-windows
default_branch: main
engine: Godot 4.7.2
@@ -102,9 +102,33 @@ tasks:
file: tasks/019-cross-end-integration.md
scope: 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 补 Motion.thumbnailUrl 契约字段
status: done
- id: T-WIN-020
file: tasks/020-ws-contract-conformance.md
scope: WebSocket 契约一致性验收(Node 独立客户端 × 发布包内 PoseServer)+ 故意不合规的负例服务端
status: done
- id: T-WIN-021
file: tasks/021-frame-vector-conformance.md
scope: 关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器,12 用例 / 396 点逐值比对)
status: done
- id: T-WIN-022
file: tasks/022-encode-rounding.md
scope: 编码取整与契约参考实现(JS Math.round)对齐,编码差异收紧为 0
status: done
- id: T-WIN-023
file: tasks/023-pck-dev-leftovers.md
scope: 发布包内不得混入开发/验收工具(export exclude build/*)+ 产物级残留检查
status: done
- 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:
artifact: opendance-windows-v0.2.3.zip
artifact: opendance-windows-v0.2.5.zip
packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底)
release_rules: # 文档 §5.7 硬性要求
- 单个完整包,禁止 .partN 分卷
+18 -4
View File
@@ -152,6 +152,20 @@ static func decode(buf: PackedByteArray) -> PoseFrame:
return frame
## 量化取整:与契约参考实现(opendance-web 的 `Math.round`)**逐位一致**
##
## JS 的 `Math.round` 对「恰好 .5」是**朝 +∞** 取整(`Math.round(-2.5) === -2`),
## 而 GDScript 的 `roundi` 是**半数远离零**(`roundi(-2.5) == -3`)——
## 两者在 `-X.5` 上会差 1 个 LSB。Android 的 Kotlin 实现用的正是 `Math.round`,
## 所以本端若用 `roundi`,与参考实现/发送端就不一致(实测:某一用例 33 字节不同)。
## 这里显式实现同一套规则:`floor(x + 0.5)`。
##
## 注:本端是接收方,`encode()` 只用于自测与协议回归;但对齐之后,
## `build/accept-frame-vectors.sh` 才能把「编码器字节差异 = 0」作为硬性断言。
static func _round_like_js(value: float) -> int:
return int(floor(value + 0.5))
## 编码为 239 字节帧(本端为接收方,此函数用于自测与协议回归)
static func encode(frame_id: int, points: PackedVector3Array, confidence: PackedFloat32Array, flags: int = 0) -> PackedByteArray:
assert(points.size() == KEYPOINT_COUNT, "points 必须为 33 项")
@@ -167,10 +181,10 @@ static func encode(frame_id: int, points: PackedVector3Array, confidence: Packed
for i in KEYPOINT_COUNT:
var offset := HEADER_SIZE + i * POINT_BYTES
var p := points[i]
buf.encode_s16(offset, clampi(roundi(p.x * SCALE), -32768, 32767))
buf.encode_s16(offset + 2, clampi(roundi(p.y * SCALE), -32768, 32767))
buf.encode_s16(offset + 4, clampi(roundi(p.z * SCALE), -32768, 32767))
buf.encode_u8(offset + 6, clampi(roundi(confidence[i] * 255.0), 0, 255))
buf.encode_s16(offset, clampi(_round_like_js(p.x * SCALE), -32768, 32767))
buf.encode_s16(offset + 2, clampi(_round_like_js(p.y * SCALE), -32768, 32767))
buf.encode_s16(offset + 4, clampi(_round_like_js(p.z * SCALE), -32768, 32767))
buf.encode_u8(offset + 6, clampi(_round_like_js(confidence[i] * 255.0), 0, 255))
return buf
+40 -12
View File
@@ -76,6 +76,8 @@ var _next_client_id: int = 1
var _port: int = 0
var _running: bool = false
var _last_config_push_ms: int = 0
## 最近一次广播出去的配置指纹(用于「配置是否变化」判断)
var _last_config_signature: String = ""
## 见 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"):
expected_fps = int(message["targetFps"])
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, config_payload())
"ping":
# 契约 §3.6:应用层心跳(协议层 ping 对应用层不可见,无法续命)
_send_json(entry, {
@@ -401,18 +407,9 @@ func welcome_payload() -> Dictionary:
}
func _maybe_push_config() -> void:
if CONFIG_PUSH_INTERVAL_MS <= 0:
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 := {
## 契约 §3.3 的 config 载荷(同时用作「配置是否变化」的指纹来源)
func config_payload() -> Dictionary:
return {
"type": "config",
"mirror": mirror,
"targetFps": expected_fps,
@@ -422,6 +419,37 @@ func broadcast_config() -> void:
"silentTimeoutMs": CLIENT_TIMEOUT_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:
if entry["handshaken"]:
_send_json(entry, payload)
+26
View File
@@ -52,6 +52,32 @@ Web 端的测试只验自己那一侧。**两端各自绿灯 ≠ 契约字段真
| ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG |
| ① 模型交付 | 模型格式 `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)
```
+110
View File
@@ -0,0 +1,110 @@
# T-WIN-020:WebSocket 契约一致性验收(独立实现 × 发布包内的 PoseServer)
- **状态**: done
- **依赖**: T-WIN-015(保活 / 去重 / 状态机)/ T-WIN-018(产物级验收)
- **文档**: v1.6 §6.5 / §7.6
- **契约**: `websocket-keypoints 0.2.0`
## 为什么需要
`tests/test_pose_server.gd` 用的是**同引擎内的客户端**(Godot 自己的
`WebSocketPeer`),而真实对端是 **Android(Kotlin/OkHttp)**。
同引擎自测会共享同样的假设 —— 超时判定、分包、文本/二进制边界、关闭语义、
子协议协商、`ping` 是否真的续命 —— 于是「测试全绿」并不等于「Android 连得上」。
契约第 7 节也明确写了:**静默超时必须由保活避免,而不是靠重连掩盖**。
这句话只有用一个独立实现的客户端实测才算验过。
## 实现(三个文件)
| 文件 | 角色 |
|--|--|
| `build/integration/probe-ws.gd` | 把**发布包里**的 `PoseServer` 跑起来(`--main-pack` + 只加载包内代码),把连接 / 控制帧 / 帧 / 断开事件汇总成 JSON 报告。**自身不做判定**,避免「用被测实现判定被测实现」 |
| `build/integration/ws-client.mjs` | Node 实现的**契约一致性客户端**(用 Node 内置 `WebSocket`,无依赖),逐条跑场景并断言,退出码即结论 |
| `build/accept-ws.sh` | 编排:解包 → 起探针 → 等监听 → 跑客户端 → 收报告 → **核对客户端看不到的那一半**(服务端侧计数) |
### 客户端场景(契约章节 → 断言)
| 场景 | 断言要点 |
|--|--|
| §1 / §3.1 / §3.2 握手 | 子协议协商成功;`welcome.protocol` / `sessionId` / `expectedFps`;**声明** `silentTimeoutMs` 与 `heartbeatIntervalMs` |
| §1.1 保活参数 | `heartbeatIntervalMs < silentTimeoutMs / 2`(硬性) |
| §2 二进制帧 | 239 字节(magic `0x4F44` / version 1 / flags 位 / u32 frame_id / 33×7B 小端),发 72 帧含**故意跳号**与**故意非法长度帧** |
| §3.4 stats | 文本 `stats` 计入活动 |
| §3.6 ping/pong | 收到 `pong`,`sentAtMs` **原样回传**,`serverAtMs` 已填 |
| §5 设备去重 | 同 `deviceId` 新连接 → 旧连接 `close(1000, "replaced by new connection")`,新连接保持打开 |
| §1.1 保活对照 | 只发应用层 `ping` 持续 **19 秒**(> 静默超时 15 秒)连接**仍打开** |
| §1.1 / §7 静默超时 | 完全不发数据 → `close(1001, "timeout")`,且时机 ≈ `silentTimeoutMs` |
| §3.5 bye | `bye` → 服务端 `close(1000)` |
| §3.1 / §6 协议不匹配 | `bye.reason` 含 `protocol_mismatch` + `close(1002)` |
### 服务端侧事实核对(`accept-ws.sh`)
客户端只能看到自己那一半;服务端的计数与原因由探针报告核对:
`frames_dropped ≥ 3`(来自故意的 60→63 跳号)、`malformed_frames == 1`(来自 100 字节帧)、
`frame_id` 无回退、控制帧分类计数、`device_replaced` 事件、
断开原因含「同设备替换 / 静默超时 / `user_stop` / `code=1002`」、
且静默超时消息**带实测毫秒数与阈值**(可核对)。
## 实测(**已发布**的 v0.2.3 资产重新下载后跑)
```
== WebSocket 契约一致性(websocket-keypoints v0.2.0)@ ws://127.0.0.1:18765/pose ==
[1] ✓ 收到 welcome ✓ protocol ✓ sessionId ✓ expectedFps=30 ✓ 声明两个保活参数
✓ heartbeat(5000) < silentTimeout(15000)/2 ✓ 子协议已协商
[2] ✓ 已发送 72 帧(含跳号 60→63)✓ 已发送 1 个非法长度帧
[3] ✓ 收到 pong ✓ sentAtMs 原样回传 ✓ serverAtMs 已填写
[4] ✓ 旧连接关闭码 1000 ✓ 原因 replaced by new connection ✓ 新连接保持打开
[5] ✓ 持续 19s 发 ping 后连接仍打开
[6] ✓ 关闭码 1001 ✓ 原因 timeout ✓ 实测 15011 ms(阈值 15000 ms)
[7] ✓ bye → 关闭码 1000
[8] ✓ bye.reason=protocol_mismatch ✓ 关闭码 1002
== 结果:30 通过 / 0 失败 ==
服务端侧:✓ silentTimeoutMs=15000/heartbeatIntervalMs=5000 ✓ 6 次连接 ✓ 72 帧
✓ frame_id 最大 74 无回退 ✓ frames_dropped=3 ✓ malformed_frames=1
✓ hello=6 ping=9 stats=1 bye=1 ✓ device_replaced=["aaaa000000000001"]
✓ 断开原因:替换 / user_stop / code=1002 / 静默超时 15001 ms(阈值 15000 ms)
WebSocket 契约一致性验收全部通过
```
> 探针日志里出现的两条 `WARNING` 是**故意的负例**产生的(非法帧、协议不匹配),
> 属预期;脚本只把 `SCRIPT ERROR` 判为失败。
## 反向验证(证明断言真的会失败)
只跑通过用例的检查器无法区分「契约被遵守」与「断言写得太松」。
因此配了一个**故意不合规的服务端** `build/integration/probe-ws-bad.gd`:
`welcome` 不声明保活字段、不带 `sessionId`、收到 `ping` 不回 `pong`、不做 `deviceId` 去重。
对它的运行结果(`ws-client.mjs`):
```
✗ welcome.sessionId 非空
✗ welcome 声明 silentTimeoutMs — undefined
✗ welcome 声明 heartbeatIntervalMs — undefined
✗ heartbeatIntervalMs < silentTimeoutMs / 2 — 心跳 NaN ms,超时 NaN ms
✗ 收到 pong — {"type":"(超时)"} ✗ pong.sentAtMs 原样回传
✗ 旧连接被服务端关闭 ✗ 旧连接关闭码为 1000 ✗ 原因 replaced…
✗ 收到 bye(拿到的是 welcome) ✗ bye.reason 说明协议不匹配
客户端退出码=1
```
## 踩坑记录
1. **`--script` 模式下的类型推断**:探针从无类型数组里取 `peer`,`peer.get_packet()`
的返回类型推不出来,`var packet := …` 直接 **Parse Error**(且在 `--headless`
下只表现为「脚本加载失败 + 退出码 1」,没有别的线索)。显式标注类型即可。
2. **不要写否定式断言**:核对静默超时消息时第一版写成
`say(!reasons.includes("静默超时 1"), …)`,本意是「消息里带 15001 ms」,
实际把**正确行为判成了失败**。断言要写成正向的模式匹配
(`/静默超时 \d+ ms(阈值 \d+ ms/`)。
3. **固定时长 vs 提前收尾**:探针一开始只能按 `--duration-ms` 跑满,
编排脚本就得猜时间。改成「客户端跑完 touch 一个 stop 文件,探针提前收尾」,
既快又不依赖机器速度。
## 未覆盖(诚实声明)
- **Android 端自身**的重连状态机与退避(§7.1 的 0.5s→1s→2s→4s→8s→15s ±20% 抖动)
在本端无法执行:它在 Kotlin 里,由 Android 仓的 JVM 测试覆盖(本任务验的是
**它对端的行为**是否与契约一致 —— 也就是「重连之后对面会怎么反应」);
- 真机 Wi-Fi 抖动 / 切网 / 进程被杀这些真实断线场景,仍需设备实测。
+91
View File
@@ -0,0 +1,91 @@
# T-WIN-021:关键点帧跨实现一致性(Web 参考实现 × 包内 GDScript 解码器)
- **状态**: done
- **依赖**: T-WIN-004(关键点帧解码)/ T-WEB-006(动作内部标准)
- **文档**: v1.6 §7.6.2
- **契约**: `websocket-keypoints 0.2.0` §2
## 为什么需要
`opendance-web/web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的**参考实现**
(它的 `encodeBinaryFrame` 注释就是「Android 端参考实现」,
`decodeBinaryFrame` 注释是「Windows 端参考实现(T-WIN-004)」):
* **Android ↔ Web** 已经用「金标准字节向量」对齐(`KeypointFrameTest.goldenVectorMatchesWebImplementation`);
* **Windows 的解码器此前只跟自己比过** —— 自己编码、自己解码,
两个方向用同一份错误假设也能一直绿。这条边是本轮补的。
## 实现
| 文件 | 角色 |
|--|--|
| `opendance-web/web-server/scripts/gen-frame-vectors.mts`(新增) | 用**参考实现**生成向量:12 个用例的字节(`hex`)+参考解码结果(`points`,float64)+ float32 输入基准(`f32InputPoints` / `f32Hex`) |
| `opendance-web/web-server/test/keypoint-frame-codec.spec.ts`(新增,7 项) | 把参考实现对其他两端的**承诺显式钉住**:i16 最小值边界、**取整规则**、置信度阈值 128、u32 frame_id、flags 掩码、非法帧拒绝、随机输入往返 |
| `opendance-windows/build/integration/frame-vectors.json`(锁定) | 由参考实现生成并入库的向量(比对基准) |
| `opendance-windows/build/integration/probe-frame-vectors.gd`(新增) | 用**包内** `KeypointFrame` 解码同一批字节并逐值比对,产出 JSON 报告 |
| `opendance-windows/build/accept-frame-vectors.sh`(新增) | 编排:**重新生成并比对向量** → 解包 → 包内解码比对 → 报告核对 |
### 比对口径(关键)
* `flags` / `frame_id`:**精确相等**;
* 坐标与置信度:本端点存进 `PackedVector3Array`、置信度存进 `PackedFloat32Array`,
即 **float32**;参考实现是 float64。因此先把期望值收敛到 float32
(GDScript 里借 `Vector3(...)` / `PackedFloat32Array([...])` 天然完成)再比**精确相等**
—— 比用容差更严格,并把差异限定在「表示精度」这一点上。
## 实测(已发布的 v0.2.3 资产重新下载后跑)
```
[accept-fv] 用参考实现重新生成向量并比对…
✓ 锁定的向量与参考实现重新生成的结果逐字节一致
[accept-fv] 包内解码器 × 参考向量…
[frame-vectors] 用例 12 个;解码用例 12/12,比对点 396 个
[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)
✓ 解码差异 0 项(必须为 0)
✓ 逐点比对 396 个(期望 396)
✓ 编码器差异 1 项,全部属于「量化前恰好 .5」的取整边界
关键点帧跨实现一致性验收全部通过
```
覆盖的边界:`i16` 最小值 `-32768`(`-32768/32767`,必须是 `0x8000` 而不是被夹成 `-32767`)、
`i16` 最大值、`±1` 全量程、置信度 `0 / 128 / 255`、`flags` 低 3 位组合、
`frame_id` 的 `0xFFFFFFFF` 与 `0x80000000`、以及取整边界。
## 一处差异(**已在 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 字节:
| | 取整方式 | `-X.5` 的行为 |
|--|--|--|
| JS 参考实现(`Math.round`) | 半数朝 **+∞** | `-2.5 → -2` |
| **Android(生产发送方,`Math.round`)** | 同上 | `-2.5 → -2` ✅ 与参考一致 |
| Windows `KeypointFrame.encode()`(`roundi`,半数远离零) | | `-2.5 → -3` ❌ 差 1 LSB |
**影响**:Windows 是**接收方**,生产链路里不调用 `encode()`(它只用于本仓自测与协议回归),
因此对产品无影响。`accept-frame-vectors.sh` 因此把编码差异记为「归类记录」,
但**硬性要求差异只能出现在「量化前恰好落在 .5」的用例上** —— 若哪天出现在别处,
就说明还有别的问题。若将来 Windows 需要成为发送方,把 `roundi` 换成
`floor(x + 0.5)` 即可与参考实现完全一致。
## 踩坑记录(两处"看起来通过、其实没测到")
1. **期望值写成了输入值**:第一版生成器把「喂进编码器的原始点」当成 `points` 写进向量,
于是比对变成了「比编码量化」而不是「比两个解码器是否一致」。
靠抽查数值(`-2.5/32767` 的原值出现在解码结果里)才发现。
→ 向量里现在同时有 `inputPoints`(原始)、`f32InputPoints` / `f32Hex`(同输入基准)
与 `points`(**参考解码结果**),并各自注明用途。
2. **拿不同输入的字节做比较**:编码器比对一开始用 `hex`(float64 输入编出来的)当基准,
于是「正数恰好 .5」整例 99 字节不同 —— 实际只是输入从 float64 收敛到 float32 的量化差,
与取整规则无关。改用 `f32Hex`(**同输入**基准)后,差异收敛到唯一一个真正的取整边界用例。
→ 教训:跨实现比对必须先统一**输入**,否则差异无法归因。
## 未覆盖
* 三端矩阵的另外两条边已有证据:Android ↔ Web(金标准向量,Android 仓 JVM 测试)、
Android → Windows(T-WIN-020 的实时链路验收,真实 WebSocket 发 239 字节帧);
* 真机端到端(相机 → MediaPipe → 编码 → Wi-Fi → Windows 解码 → 驱动模型)仍需设备实测。
+66
View File
@@ -0,0 +1,66 @@
# T-WIN-022:编码取整与契约参考实现对齐(编码差异收紧为 0)
- **状态**: done
- **依赖**: T-WIN-021(跨实现一致性向量)
- **文档**: v1.6 §7.6.2
- **契约**: `websocket-keypoints 0.2.0` §2
## 缺陷
`KeypointFrame.encode()` 用 GDScript 的 `roundi` 取整,而契约参考实现
(`opendance-web` 的 `keypoint-codec.ts`)与**生产发送方 Android**(Kotlin `Math.round`)
都用「半数朝 +∞」:
| | `-2.5` | `2.5` | `-0.5` |
|--|--|--|--|
| JS `Math.round` / Kotlin `Math.round` | **-2** | 3 | 0 |
| GDScript `roundi`(半数远离零) | **-3** | 3 | -1 |
实测差异:T-WIN-021 的 12 个向量里有 1 个用例(坐标含 `-0.5`)**33 字节不同**。
**影响**:本端是**接收方**,`encode()` 只用于自测与协议回归,因此对产品无影响 ——
但它让「跨实现一致性」这条断言只能是宽松口径(「差异仅限取整边界」),
而宽松口径在实践中会退化成「永远没人看的已知差异」。
## 修复
```gdscript
## 量化取整:与契约参考实现(JS Math.round)逐位一致
static func _round_like_js(value: float) -> int:
return int(floor(value + 0.5))
```
`encode()` 的 4 处取整全部改用它(坐标 ×3 + 置信度)。
夹紧顺序(先取整后 `clampi`)与参考实现的「先夹后取整」对任意实数等价
(`32767.4 → 32767`;`-32768.4 → -32768`),已在任务里逐值核对。
## 验证
1. **单测**(`tests/test_keypoint_frame.gd`,+11 条断言):
* `_round_like_js` 的 5 个取值(含 `-2.5 → -2`、`-0.5 → 0`);
* 端到端 `-0.5/32767 → 0`(`roundi` 会给 -1,是有效的判别用例);
* `test_reference_vectors_decode_and_encode_exactly`:直接读**锁定的参考向量**
(`build/integration/frame-vectors.json`),逐值解码比对 + 用 `f32InputPoints`
重编码与 `f32Hex` **逐字节**比对 —— 这一步让 `verify.sh`(只跑源码,无需 pck)
也覆盖了「Web ↔ Windows」这条边。
2. **产物级**(`build/accept-frame-vectors.sh`,已收紧口径):
原先允许「编码差异仅限 `.5` 取整边界」,现在**硬性要求 0 项**。
实测(v0.2.4 发布包):
```
[frame-vectors] ✓ 解码结果与 Web 参考实现逐值一致(float32 精确相等)
[frame-vectors] ✓ 本端 encode() 与参考字节完全一致
✓ 解码差异 0 项(必须为 0)
✓ 编码器字节与参考实现完全一致(0 项差异)
```
`verify.sh`:**785 通过 / 0 失败**(原 774)。
## 写测试时踩的坑
第一版断言写成「`2.5/32767` 编码为 3」,**失败**。原因不是编码错了,而是
`PackedVector3Array` 把该值存成 **float32**,`float32(2.5/32767) × 32767` 略小于 2.5,
`floor(x+0.5)` 得 2 —— 与 JS 对同一 float32 输入的行为**完全一致**。
即:**断言必须基于实际存储精度**,或改用「规则本身 + 有效判别用例」
(`-0.5/32767`)而不是「理想有理数」。这与 T-WIN-021 的两处踩坑同源。
+57
View File
@@ -0,0 +1,57 @@
# T-WIN-023:发布包内不得混入开发/验收工具
- **状态**: done
- **依赖**: T-WIN-018(发布包级验收)
- **文档**: v1.6 §5.3 / §7.5
- **契约**: 不涉及(打包)
## 缺陷(实测)
`export_presets.cfg` 用的是 `export_filter="all_resources"` + `exclude_filter="_scratch/*, tests/*, *.md"`。
`build/` 不在排除列表里 —— 于是**每次新增一个构建/验收脚本,它就被编译进产物**:
```
res://build/probe-release.gdc
res://build/integration/probe-integration.gdc
res://build/integration/probe-ws.gdc
res://build/integration/probe-ws-bad.gdc ← **故意不合规**的服务端夹具
res://build/integration/probe-frame-vectors.gdc
res://build/integration/frame-vectors.json
```
体积代价:`OpenDance.pck` **315,464 B → 145,400 B(排除后)**,
即 **54% 的 pck 是测试夹具**。已发布的 v0.2.3 同样带着这些残留(其中包含
那个刻意违反协议的坏服务端),虽然不影响功能,但既不该进产物,也不该被用户拿到。
## 修复
1. `export_presets.cfg` 的 `exclude_filter` 增加 `build/*`:
```ini
exclude_filter="_scratch/*, tests/*, build/*, *.md"
```
2. **新增产物级防线**(否则下次加一个 `tools/` 目录又会重演):
* `build/integration/list-pck.gd`:用 `--main-pack` 遍历 `res://`,列出全部条目并
标出开发残留(`res://build/`、`res://tests/`、`res://_scratch/`、`res://dist-release/`、`*.md`);
有残留即退出码 1,支持 `--json --out <path>`。
* `build/accept-release.sh` 新增步骤:跑该脚本并断言「无开发残留」,同时打印条目总数。
## 验证
```
# 修复前
[pck] ✗ 产物里混入了开发残留 12 项:res://build/…(pck 315,464 B)
# 修复后(v0.2.4)
✓ pck 共 58 项,无开发残留(build/ tests/ _scratch/ *.md)
(pck 145,400 B;zip 26,631,908 B,仍在 30 MB 上传上限内)
```
`accept-release.sh` 其余步骤(PE 版本资源、pck 启动 180 帧、运行时探针、契约表比对)
在排除 `build/*` 后依旧全绿 —— 验收脚本本身是**从仓库路径**加载探针的,
不依赖包内是否有它们的副本。
## 顺带确认(不是问题)
排查时特意确认了 pck **没有**把上一次导出的 pck 自嵌套进去
(`file_count=112`、条目里没有 `res://build/windows/OpenDance.pck`):
Godot 在扫描资源时排除了自己的导出产物目录内容。这条也由 `list-pck.gd` 长期盯着。
+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) | 产物**对着仓库**看有没有多、少、被改过的文件 |
+94
View File
@@ -159,3 +159,97 @@ func test_json_debug_frame() -> void:
var bad := KeypointFrame.from_json_dict({"frameId": 1, "points": []})
is_null(bad, "点数不足的 JSON 帧被拒绝")
## T-WIN-021:编码/解码必须与契约参考实现(JS)**逐位一致**
##
## JS 的 `Math.round` 对「恰好 .5」朝 +∞ 取整(`Math.round(-2.5) === -2`),
## 而 GDScript 的 `roundi` 是半数远离零(`roundi(-2.5) == -3`)——
## 两者在 `-X.5` 上差 1 个 LSB。Android 的 Kotlin 实现用的正是 `Math.round`,
## 所以本端必须显式实现同一规则(`_round_like_js` = `floor(x + 0.5)`)。
func test_rounding_rule_matches_js_math_round() -> void:
eq(KeypointFrame._round_like_js(-2.5), -2, "取整 -2.5 → -2(半数朝 +∞,与 JS 一致)")
eq(KeypointFrame._round_like_js(2.5), 3, "取整 2.5 → 3")
eq(KeypointFrame._round_like_js(-0.5), 0, "取整 -0.5 → 0(roundi 会给 -1)")
eq(KeypointFrame._round_like_js(0.5), 1, "取整 0.5 → 1")
eq(KeypointFrame._round_like_js(-1.5), -1, "取整 -1.5 → -1")
# 端到端:-0.5/32767 经 float32 后在量化前恰好落在 -.5 上
var points := PackedVector3Array()
var conf := PackedFloat32Array()
points.resize(33)
conf.resize(33)
for i in 33:
points[i] = Vector3(-0.5 / 32767.0, 0.0, 0.0)
conf[i] = 128.0 / 255.0
var buf := KeypointFrame.encode(0, points, conf, 0)
eq(buf.decode_s16(8), 0, "坐标 -0.5/32767 编码为 0(roundi 会给 -1)")
eq(buf.decode_u8(14), 128, "置信度 128/255 编码为 128")
## 用 Web 参考实现(opendance-web 的 keypoint-codec.ts)生成的**锁定向量**做逐值比对。
## 这一步让 `verify.sh`(只跑源码,不需要 pck)也能覆盖「Web ↔ Windows」这条边;
## 发布包层面的同一比对由 build/accept-frame-vectors.sh 负责。
func test_reference_vectors_decode_and_encode_exactly() -> void:
var path := "res://build/integration/frame-vectors.json"
if not FileAccess.file_exists(path):
skip("参考向量", "找不到 %s" % path)
return
var file := FileAccess.open(path, FileAccess.READ)
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
not_null(parsed, "参考向量可解析")
var cases: Array = parsed.get("cases", [])
eq(cases.size(), 12, "参考向量用例数")
var mismatches: Array = []
var points_compared := 0
for c in cases:
var name := str(c.get("name", "?"))
var bytes := PackedByteArray()
var hex := str(c.get("hex", ""))
bytes.resize(hex.length() / 2)
for i in bytes.size():
bytes[i] = hex.substr(i * 2, 2).hex_to_int()
if KeypointFrame.validate(bytes) != "":
mismatches.append("%s: validate 失败" % name)
continue
var frame = KeypointFrame.decode(bytes)
if frame == null:
mismatches.append("%s: decode 为 null" % name)
continue
if int(frame.frame_id) != int(c.get("frameId", 0)):
mismatches.append("%s: frame_id" % name)
if int(frame.flags) != int(c.get("flags", 0)):
mismatches.append("%s: flags" % name)
var expect_points: Array = c.get("points", [])
for i in expect_points.size():
points_compared += 1
var e: Array = expect_points[i]
# 本端点存 PackedVector3Array(float32),参考实现是 float64:
# 先把期望值收敛到 float32(Vector3 的分量天然是 float32)再比精确相等
var want := Vector3(float(e[0]), float(e[1]), float(e[2]))
var want_conf := PackedFloat32Array([float(e[3])])[0]
if frame.points[i] != want:
mismatches.append("%s: 点 %d 坐标 %s != %s" % [name, i, str(frame.points[i]), str(want)])
elif frame.confidence[i] != want_conf:
mismatches.append("%s: 点 %d 置信度" % [name, i])
# 编码:用同输入基准 f32InputPoints,与 f32Hex 逐字节比对
var f32_pts: Array = c.get("f32InputPoints", [])
var f32_hex := str(c.get("f32Hex", ""))
if f32_pts.size() == 33 and f32_hex != "":
var pts := PackedVector3Array()
var cfs := PackedFloat32Array()
pts.resize(33)
cfs.resize(33)
for i in 33:
var p: Array = f32_pts[i]
pts[i] = Vector3(float(p[0]), float(p[1]), float(p[2]))
cfs[i] = float(p[3])
var encoded := KeypointFrame.encode(int(c.get("frameId", 0)), pts, cfs, int(c.get("flags", 0)))
if encoded.hex_encode() != f32_hex:
mismatches.append("%s: 编码字节与参考不一致" % name)
eq(points_compared, cases.size() * 33, "逐点比对数量")
eq(mismatches.slice(0, 5), [], "参考向量逐值/逐字节一致(差异 %d 项)" % mismatches.size())