Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78a536476 | ||
|
|
e4e534f2a2 | ||
|
|
ba988f6ca6 | ||
|
|
14ee5f7ec9 |
@@ -14,7 +14,7 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 脚本编译 | OK 全部通过(无 SCRIPT ERROR) |
|
||||
| 无头测试 | OK **687 项全部通过**(10 个测试套件) |
|
||||
| 无头测试 | OK **751 项全部通过**(10 个测试套件) |
|
||||
| 应用启动 | OK 180 帧无运行时错误 |
|
||||
| Windows 导出 | OK 生成 `OpenDance.exe`(104 MB, PE 头校验通过)+ `OpenDance.pck` |
|
||||
| 真实 VRM 验证 | OK 154 骨骼 VRoid 模型,humanoid 覆盖率 **100%** |
|
||||
@@ -22,8 +22,82 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。
|
||||
| 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** |
|
||||
| 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 |
|
||||
| 连接保活与设备去重 | OK welcome/config 声明静默超时与心跳;应用层 ping/pong;按 deviceId 去重 |
|
||||
| 发布整包 | OK 单个 `opendance-windows-v0.2.0.zip` + `SHA256SUMS`(不拆分卷) |
|
||||
| 版本号展示 | OK 「关于」清单显示 `v0.2.0`,来源唯一(`project.godot`) |
|
||||
| 发布整包 | OK 单个 `opendance-windows-v0.2.3.zip` + `SHA256SUMS`(不拆分卷) |
|
||||
| 版本号展示 | OK 「关于」清单显示 `v0.2.2`;`project.godot` / `project.yaml` / `export_presets.cfg` / exe 的 PE 版本资源 / 产物名 / tag 全部一致 |
|
||||
| 场景物理与保活 | OK 新增真实 GLB 端到端用例 + 17 秒保活对照用例(751 项断言全过) |
|
||||
|
||||
## v0.2.3 修订(跨端集成验收 + 两处真实缺陷)
|
||||
|
||||
**缺陷 1(契约字段缺失)**:`CatalogTypes.Motion` 没有 `thumbnail_url` 字段,
|
||||
而服务端 `catalog/export` 从 `data-models 0.6.0` 起一直在发 `thumbnailUrl`
|
||||
(同一文件的 `Model` / `Scene` 都有)。客户端默默丢掉该字段。
|
||||
**修复**:补字段与解析(缺失时为 `""`,兼容旧服务端),并新增
|
||||
`tests/test_catalog_types.gd`(4 项)用**服务端真实报文形状**锁住三个资源类型的
|
||||
`thumbnailUrl` 覆盖;同时显式记录一处**刻意不消费**的差异
|
||||
(`Model.usable`/`unusableReason` —— 目录只下发可用模型,恒为 true)。
|
||||
|
||||
**缺陷 2(二进制响应白做 UTF-8 解码)**:`ApiClient._request()` 对**所有**响应
|
||||
无条件执行 `raw.get_string_from_utf8()`。数据本身没错(二进制路径返回的是原始字节),
|
||||
但每下载一个文件都会打印一条
|
||||
`Unicode parsing error … Invalid UTF-8 leading byte (N)` 噪声,
|
||||
并把整个文件(模型可达数十 MB)解码成一份 String 拷贝后丢弃。
|
||||
**修复**:只在需要文本时解码。跨端集成探针第一次注意到这条噪声。
|
||||
|
||||
**新增跨端集成验收**(`build/accept-integration.sh`):用**发布包里的客户端**
|
||||
(`--main-pack` 加载包内 pck)打**真实 Web 服务端**,逐项比对契约与物理参数,
|
||||
并跑通「真 VMD → 内部标准 33 点 → 客户端解析 2901 帧」的完整闭环。
|
||||
详见 [`tasks/019-cross-end-integration.md`](tasks/019-cross-end-integration.md)。
|
||||
|
||||
此外修掉 `tests/test_api_integration.gd` 里一处**写死的契约版本**(`0.1.0`):
|
||||
该用例只在服务端可达时才跑,平时一直 skip,于是契约升到 0.5.0 后
|
||||
没有任何地方发现它。现在改为从仓库 `contracts/VERSION` 读期望值并与服务端 `/health` 比对。
|
||||
|
||||
## v0.2.2 修订(发布包 exe 的版本元数据)
|
||||
|
||||
**缺陷**:v0.2.1 的包在 Windows 上「右键 → 属性 → 详细信息」显示
|
||||
**文件版本 0.1.0 / 产品版本 0.1.0**,而产物名与 Git tag 都是 v0.2.1。
|
||||
|
||||
**根因**:`export_presets.cfg` 的 `application/file_version` /
|
||||
`application/product_version` 被硬编码在 `0.1.0`,而它们**不会**跟随
|
||||
`project.godot` 的 `application/config/version` 自动更新;`check-version.sh`
|
||||
当时只比 `project.godot` ↔ `project.yaml`(两处一致),所以静态校验全绿、
|
||||
产物却是错的 —— 漂移发生在没人看的第三个位置。
|
||||
|
||||
**修复**:
|
||||
|
||||
| 环节 | 做法 |
|
||||
|--|--|
|
||||
| 同步 | `build/sync-export-version.sh`(新增):导出前把版本号写进导出预设(幂等,拒绝非 `X.Y.Z`) |
|
||||
| 静态防线 | `build/check-version.sh`:新增对导出预设两个键的校验 |
|
||||
| 产物防线 | `build/check-pe-version.sh`(新增):直接读 exe 的 PE 版本资源 —— `VS_FIXEDFILEINFO`(签名 `0xFEEF04BD` 后 16 字节)+ `StringFileInfo`(`strings -el`,属性页显示的就是它)。两处都查:本机实测固定信息里的 `ProductVersion` 恒为 `0.0.0.0` |
|
||||
| 打包 | `build/package-release.sh`:打包前校验源 exe、解包后再校验包内 exe |
|
||||
| 一键验证 | `build/verify.sh`:新增步骤 1/5(未导出则跳过) |
|
||||
| 单元测试 | `tests/test_about.gd`:预设两个键必须等于版本号唯一来源 |
|
||||
|
||||
实测修复后产物:`VS_FIXEDFILEINFO FileVersion = 0.2.2.0`、
|
||||
`StringFileInfo FileVersion/ProductVersion = 0.2.2`。详见
|
||||
[`tasks/017-pe-version.md`](tasks/017-pe-version.md)。
|
||||
|
||||
## v0.2.1 修订(两处修复 + 一批"真机等价"测试)
|
||||
|
||||
1. **场景包围盒不再用 `global_transform`**:`SceneRuntime._compute_aabb()` 原本在
|
||||
`_assemble()` 里调用,而那时运行时节点**还没入树** —— Godot 会打一行
|
||||
`ERROR: Condition "!is_inside_tree()" is true`,并且算出来的只是各网格的
|
||||
**局部** AABB。后果:把网格摆在非原点位置的场景,重力/风区域与兜底地面会算得
|
||||
偏小或偏位(**风力可能作用不到物体上**)。现改为沿树累乘 `Node3D.transform`,
|
||||
与是否入树无关,结果与运行时挂在单位变换父节点下完全一致;
|
||||
2. **新增 21 项"真机等价"测试**(无头即可跑,不需要设备):
|
||||
* `tests/test_scene_config.gd`:在代码里手写合法 GLB(三角形 + BIN chunk)后
|
||||
**真实加载**,核对三种 `colliders` 取值的碰撞体、重力/风 Area3D 数值、
|
||||
以及**带位移网格**的包围盒是否跟着走(这一条正是上述缺陷的回归测试);
|
||||
* `tests/test_pose_server.gd`:**时间维度**验证保活 —— 一个只发应用层 `ping`、
|
||||
不发关键点帧的客户端在 17 秒(> 15 秒静默超时)后**仍保持连接**,
|
||||
而完全不发数据的客户端被 `close(1001)` 断开。这正是"画面里没人就掉线、
|
||||
然后疯狂重连"的根因对照;
|
||||
3. **新增版本号一致性校验** `build/check-version.sh`:强制
|
||||
`project.godot` 的 `application/config/version`(「关于」清单与 .pck 的来源)
|
||||
与 `project.yaml`、发布版本号三者一致;已接入 `build/package-release.sh`
|
||||
(打包前)与 `build/verify.sh`(0/4 步),并做过反向验证。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -35,11 +109,23 @@ 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.0
|
||||
bash build/package-release.sh 0.2.3
|
||||
|
||||
# 发布包级验收(对**发布包**跑,而不是对源码;文档 §7.5)
|
||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.3.zip
|
||||
```
|
||||
|
||||
当前版本:**v0.2.0**(`project.godot` 的 `application/config/version`,
|
||||
与 `project.yaml`、导出产物名、Git tag、「关于」清单展示一致)。
|
||||
`accept-release.sh` 校验 sha256 → 解压 → 文件清单(并检查没有 `.partN` 残留)→
|
||||
exe 的 PE 版本资源 → **用 Godot 直接加载包内 pck 跑 180 帧** → 包内运行时探针
|
||||
(`build/probe-release.gd`:包内版本号、`AboutScreen.app_version()`、主场景、autoload、
|
||||
15 个关键资源、以及**包内 `Credits.CONTRACT_VERSIONS` 与 `contracts/VERSION` 快照是否一致**)。
|
||||
它读的全是包内资源,因此结论就是「用户装到机器上的那一份能不能用」。
|
||||
已对**已发布的 v0.2.2 资产**(重新下载)跑通,并用「篡改包内 contracts/VERSION」与
|
||||
「期望版本写错」两个负例反向验证过。
|
||||
|
||||
当前版本:**v0.2.3**(`project.godot` 的 `application/config/version`,
|
||||
与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、
|
||||
以及 exe 的 PE 版本资源全部一致)。
|
||||
|
||||
详细使用说明见 [`README.txt`](README.txt),工程约束见 [`DSH.md`](DSH.md)。
|
||||
|
||||
@@ -120,7 +206,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.0
|
||||
bash build/package-release.sh 0.2.3
|
||||
```
|
||||
|
||||
| 产物 | 位置 | 说明 |
|
||||
@@ -128,7 +214,7 @@ bash build/package-release.sh 0.2.0
|
||||
| `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.0.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
|
||||
| `opendance-windows-v0.2.3.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
|
||||
| `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 |
|
||||
|
||||
导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。
|
||||
@@ -225,12 +311,21 @@ src/
|
||||
`zip.part0/1/2`(无法直接解压)。现改为**只上传单个完整包**,超限时打包脚本
|
||||
直接告警并要求运维放宽上限,不再回退分卷;做法见
|
||||
`opendance-web/deploy/proxy/README.md` 第九节。
|
||||
6. **v0.2.0 的 zip 用的是 LZMA 压缩**(`ZIP_METHOD=lzma` 打出来的),因为发布时
|
||||
IIS 上限尚未放开:整包从约 38 MB 降到约 26 MB 才能传上去。
|
||||
**代价:Windows 10 的资源管理器不支持 LZMA(method 14) 的 zip,必须用
|
||||
7-Zip / WinRAR / PeaZip 解压**(Win11 24H2+ 起资源管理器原生支持 7z 等格式,
|
||||
但本项目未在 Windows 上实测)。若 IIS 上限已放开,请改用默认的 deflate 包
|
||||
(`bash build/package-release.sh 0.2.0`,产物约 38 MB,双击即可解压)。
|
||||
6. **v0.2.0 / v0.2.1 / v0.2.2 的 zip 用的是 LZMA 压缩**(`ZIP_METHOD=lzma`
|
||||
打出来的),因为这几次发布时 IIS 上限都尚未放开:整包从约 38 MB 降到约 26 MB
|
||||
才能传上去。**代价:Windows 10 的资源管理器不支持 LZMA(method 14) 的 zip,
|
||||
必须用 7-Zip / WinRAR / PeaZip 解压**(Win11 24H2+ 起资源管理器原生支持 7z
|
||||
等格式,但本项目未在 Windows 上实测)。
|
||||
若 IIS 上限已放开、或你能把包放到别处分发(例如 Web 服务端的 `/static`,
|
||||
不受 IIS 那个限制约束),请改用资源管理器可直接双击解压的 deflate 包:
|
||||
`ZIP_METHOD=deflate bash build/package-release.sh 0.2.3`(约 38 MB)。
|
||||
7. **发布包 exe 的版本元数据**(T-WIN-017):v0.2.1 之前在 Windows
|
||||
「属性 → 详细信息」里显示的是 `0.1.0`,因为 `export_presets.cfg` 的
|
||||
`application/file_version` / `application/product_version` 被硬编码且无人校验。
|
||||
v0.2.2 起由 `build/sync-export-version.sh` 在导出前同步、由
|
||||
`build/check-pe-version.sh` 直接读 exe 的 PE 版本资源复核
|
||||
(`VS_FIXEDFILEINFO` + `StringFileInfo` 两处都查),
|
||||
`check-version.sh` / `package-release.sh` / `verify.sh` / `test_about.gd` 均已接入。
|
||||
|
||||
## 第三方开源声明
|
||||
|
||||
|
||||
+62
-10
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Windows 客户端 v0.2.0 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
|
||||
OpenDance Studio -- Windows 客户端 v0.2.3 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
|
||||
================================================================
|
||||
|
||||
摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket
|
||||
@@ -8,6 +8,43 @@ OpenDance Studio -- Windows 客户端 v0.2.0 使用说明(zip 为 LZMA 压缩
|
||||
|
||||
本客户端只接收关键点坐标,不接收、不存储任何视频画面。
|
||||
|
||||
v0.2.3 修订
|
||||
1. 补 Motion.thumbnail_url: 服务端 catalog/export 从 data-models 0.6.0 起一直
|
||||
在发 thumbnailUrl,客户端类型里却缺这个字段(Model/Scene 都有),等于默默丢掉。
|
||||
2. ApiClient 不再对所有响应无条件做 UTF-8 解码: 原来下载每个文件(模型 / GLB /
|
||||
缩略图)都会打印一条 "Unicode parsing error ... Invalid UTF-8 leading byte"
|
||||
噪声,并白做一份 String 拷贝;二进制路径现在直接返回原始字节。
|
||||
3. 新增跨端集成验收 build/accept-integration.sh: 用发布包里的客户端打真实 Web
|
||||
服务端,逐项比对契约与场景物理参数,并跑通真实 VMD → 内部标准 33 点 → 客户端
|
||||
解析 2901 帧的闭环。
|
||||
4. 测试规模 820 项断言(含真实服务端在线的 API 集成组)。
|
||||
|
||||
v0.2.2 修订
|
||||
1. 修复发布包 exe 的版本元数据: v0.2.1 的包在 Windows「右键 -> 属性 ->
|
||||
详细信息」里显示的是 文件版本 0.1.0 / 产品版本 0.1.0, 而产物名与 Git tag
|
||||
都是 v0.2.1。根因是 export_presets.cfg 的 application/file_version 与
|
||||
application/product_version 被硬编码成 0.1.0, 而它们不会跟随 project.godot
|
||||
的 application/config/version 自动更新。
|
||||
2. 新增 build/sync-export-version.sh(导出前同步版本号到预设)与
|
||||
build/check-pe-version.sh(直接读 exe 的 PE 版本资源复核: VS_FIXEDFILEINFO
|
||||
的 FileVersion 与 StringFileInfo 的 FileVersion/ProductVersion 两处都查)。
|
||||
check-version.sh 增加预设校验, package-release.sh 在打包前与解包后各校验一次,
|
||||
verify.sh 增加步骤 1/5, tests/test_about.gd 增加对应单测。
|
||||
3. 现在本包显示: 文件版本 0.2.2 / 产品版本 0.2.2。
|
||||
|
||||
v0.2.1 修订
|
||||
1. 场景包围盒不再用 global_transform: SceneRuntime._compute_aabb() 原先在
|
||||
_assemble() 里调用, 而那时节点还没入树 -> Godot 会打一行 ERROR
|
||||
(Condition "!is_inside_tree()" is true), 且算出来只是各网格的局部 AABB,
|
||||
导致重力/风区域与兜底地面算偏位(风力可能作用不到物体). 现改为沿树累乘
|
||||
Node3D.transform。
|
||||
2. 新增 21 项无头"真机等价"测试: 手写合法 GLB 真实加载(三种 colliders 取值、
|
||||
重力/风数值、带位移网格的包围盒) + 17 秒保活对照(只发 ping 的客户端不被
|
||||
断开、完全不发的客户端被 1001 断开)。
|
||||
3. 新增 build/check-version.sh: 强制 project.godot / project.yaml / 发布版本
|
||||
三者一致(已接入打包与验证脚本, 并做过反向验证)。
|
||||
4. 无头测试规模 751 项断言全部通过。
|
||||
|
||||
--
|
||||
一、快速开始
|
||||
--
|
||||
@@ -103,31 +140,46 @@ OpenDance.exe 的用户看第一节即可。
|
||||
|
||||
打成发布包:
|
||||
|
||||
bash build/package-release.sh 0.2.0
|
||||
bash build/package-release.sh 0.2.3
|
||||
|
||||
得到 dist-release/ 下两个资产:
|
||||
|
||||
opendance-windows-v0.2.0.zip <- 单个完整包,禁止 .partN 分卷
|
||||
opendance-windows-v0.2.3.zip <- 单个完整包,禁止 .partN 分卷
|
||||
SHA256SUMS <- 裸文件名,可直接 sha256sum -c
|
||||
|
||||
包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、
|
||||
README.md、contracts/。打包脚本会解压自检(exe + pck 必须同目录、
|
||||
exe 的 PE 头为 MZ、pck 魔数为 GDPC)并打印包体与上传上限的对比。
|
||||
exe 的 PE 头为 MZ、pck 魔数为 GDPC、exe 的 PE 版本资源 = 发布版本)
|
||||
并打印包体与上传上限的对比。
|
||||
|
||||
注意:本项目 Gitea 前置 IIS 的默认上传上限是 30,000,000 字节
|
||||
(约 28.6 MiB),超过会返回 HTTP 413。这时不要改成分卷,
|
||||
请让运维放宽上限(做法见 opendance-web/deploy/proxy/README.md 第九节)。
|
||||
发布 v0.2.0 时该上限尚未放开,整包(约 38 MB)改用 LZMA 压缩到约 26 MB
|
||||
才传上去(打包时用 ZIP_METHOD=lzma)。**因此 v0.2.0 的 zip 需要
|
||||
7-Zip / WinRAR / PeaZip 解压 —— Windows 10 资源管理器不支持 LZMA 的 zip**;
|
||||
若上限已放开,请改用默认 deflate 包(双击即可解压)。
|
||||
v0.2.0/v0.2.1/v0.2.2 该上限都尚未放开,整包(deflate 约 38 MB)改用
|
||||
LZMA 压缩到约 26 MB 才传上去(打包时用 ZIP_METHOD=lzma)。
|
||||
**因此这几个版本的 zip 需要 7-Zip / WinRAR / PeaZip 解压 ——
|
||||
Windows 10 资源管理器不支持 LZMA 的 zip**。
|
||||
|
||||
如果你能把包放到别处分发(例如 Web 服务端的 /static,不受 IIS 那个
|
||||
上限约束),建议改用资源管理器可直接双击解压的 deflate 包:
|
||||
|
||||
ZIP_METHOD=deflate bash build/package-release.sh 0.2.3 # 约 38 MB
|
||||
|
||||
上限一旦放开,Release 资产也应换回 deflate 包。
|
||||
|
||||
7. 自检清单
|
||||
|
||||
1) 一键验证:GODOT_BIN=/path/to/godot bash build/verify.sh
|
||||
0) 发布包级验收(对**发布包**而不是源码;文档 §7.5)
|
||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.3.zip
|
||||
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
|
||||
读 exe 的 PE 版本资源、用 Godot 直接加载包内 pck 跑 180 帧、并跑包内运行时
|
||||
探针(包内版本号 / 「关于」清单显示值 / 主场景 / autoload / 关键资源 /
|
||||
包内契约表与 contracts/VERSION 快照是否一致)。
|
||||
|
||||
1) 源码一键验证:GODOT_BIN=/path/to/godot bash build/verify.sh
|
||||
预期末行「验证全部通过」。
|
||||
2) 无头测试:$GODOT --headless --path . --script res://tests/run_tests.gd
|
||||
预期「== 结果:687 通过 / 0 失败 ==」与「全部通过」(缺 VRM 素材或
|
||||
预期「== 结果:751 通过 / 0 失败 ==」与「全部通过」(缺 VRM 素材或
|
||||
服务端不可达时相关用例跳过,数字会减少)。
|
||||
3) 启动冒烟:$GODOT --headless --path . --quit-after 180
|
||||
预期无 SCRIPT ERROR / ERROR:,退出码 0。
|
||||
|
||||
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# OpenDance Studio Windows 客户端 —— **跨端集成验收**(T-WIN-019)
|
||||
#
|
||||
# Windows 发布包里的客户端(包内 pck) × 真实 Web 服务端
|
||||
#
|
||||
# 为什么需要它:本仓单测打的是**假 HTTP 夹具**,Web 端测试只验自己那一侧。
|
||||
# 两端各自绿灯 ≠ 契约字段真的对得上(字段名/嵌套/null/单位/范围都可能一侧对一侧错)。
|
||||
# 契约 0.5.0 新增的 `scenes[]` 与重力/风向参数尤其如此。
|
||||
#
|
||||
# 本脚本:
|
||||
# 1. 要求一个**已部署好的** Web 服务端(不做部署,与项目边界一致);
|
||||
# 2. 用真实素材播种一份目录(真 VMD → 内部标准;真 PMX → VRM;场景 GLB + 物理参数),
|
||||
# 并写出期望值 JSON(`--skip-seed` 可跳过,复用已有目录);
|
||||
# 3. 用 Godot 加载**发布包里的 pck**(`--main-pack`)跑
|
||||
# `build/integration/probe-integration.gd`,逐项断言并给出退出码。
|
||||
#
|
||||
# 用法:
|
||||
# GODOT_BIN=/path/to/godot API_BASE=http://127.0.0.1:3000 \
|
||||
# bash build/accept-integration.sh dist-release/opendance-windows-v0.2.3.zip
|
||||
#
|
||||
# # 已播种过、只重跑探针:
|
||||
# GODOT_BIN=... API_BASE=... bash build/accept-integration.sh <zip> --skip-seed
|
||||
#
|
||||
# 环境变量:
|
||||
# API_BASE Web 服务端基地址(默认 http://127.0.0.1:3000)
|
||||
# OPENDANCE_SAMPLES_DIR 真实素材目录(默认 仓库根/示例文件)
|
||||
# EXPECT_PATH 期望值 JSON 输出路径(默认 $WORK_DIR/catalog-expect.json)
|
||||
# WORK_DIR 解包目录(默认 mktemp -d)
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
TARGET="${1:-}"
|
||||
SKIP_SEED=0
|
||||
for arg in "$@"; do [ "$arg" = "--skip-seed" ] && SKIP_SEED=1; done
|
||||
|
||||
API_BASE="${API_BASE:-http://127.0.0.1:3000}"
|
||||
SAMPLES_DIR="${OPENDANCE_SAMPLES_DIR:-$PROJECT_DIR/../示例文件}"
|
||||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||||
EXPECT_PATH="${EXPECT_PATH:-$WORK_DIR/catalog-expect.json}"
|
||||
|
||||
FAILED=0
|
||||
log() { printf '\033[1;34m[accept-int]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[accept-int] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||||
|
||||
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
|
||||
echo "用法:GODOT_BIN=... API_BASE=... bash build/accept-integration.sh <发布包 zip 或已解压目录> [--skip-seed]" >&2
|
||||
exit 2
|
||||
fi
|
||||
# 必须**先**建目录:期望值 JSON 默认就落在 $WORK_DIR 下,播种阶段就要写它
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# ---- 0. 服务端可达性 ----
|
||||
log "服务端:$API_BASE"
|
||||
HEALTH="$(curl -sf -m 10 "$API_BASE/health" || true)"
|
||||
if [ -z "$HEALTH" ]; then
|
||||
fail "GET $API_BASE/health 不可达 —— 请先部署 Web 服务端(本项目不部署它)"
|
||||
exit 1
|
||||
fi
|
||||
echo "$HEALTH" | node -e '
|
||||
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
||||
const j = JSON.parse(s);
|
||||
console.log(` ✓ 服务端版本 ${j.version}|数据库 ${j.database?.connected ? "已连接" : "未连接"}|scenes.available=${j.scenes?.available}`);
|
||||
});'
|
||||
|
||||
# ---- 1. 播种目录 ----
|
||||
if [ "$SKIP_SEED" = "1" ]; then
|
||||
log "跳过播种(--skip-seed);期望值文件:$EXPECT_PATH"
|
||||
else
|
||||
log "播种真实目录(真 VMD / 真 PMX / 场景 GLB + 物理参数)…"
|
||||
if [ ! -d "$SAMPLES_DIR" ]; then
|
||||
fail "找不到素材目录 $SAMPLES_DIR(用 OPENDANCE_SAMPLES_DIR 指定)"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
fail "播种需要 node(Web 端已依赖 node,本机应已安装)"
|
||||
exit 1
|
||||
fi
|
||||
API_BASE="$API_BASE" OPENDANCE_SAMPLES_DIR="$SAMPLES_DIR" EXPECT_PATH="$EXPECT_PATH" \
|
||||
node "$PROJECT_DIR/build/integration/seed-catalog.mjs" || { fail "播种失败"; exit 1; }
|
||||
fi
|
||||
if [ ! -f "$EXPECT_PATH" ]; then
|
||||
fail "缺少期望值文件 $EXPECT_PATH(首次运行不要加 --skip-seed)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 2. 取包内 pck ----
|
||||
if [ -f "$TARGET" ]; then
|
||||
log "解压发布包…"
|
||||
if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
|
||||
command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; }
|
||||
fi
|
||||
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
else
|
||||
PACKAGE_DIR="$TARGET"
|
||||
fi
|
||||
PCK="$PACKAGE_DIR/OpenDance.pck"
|
||||
[ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; }
|
||||
log "包内 pck:$PCK($(stat -c%s "$PCK") 字节)"
|
||||
|
||||
# ---- 3. Godot ----
|
||||
GODOT_BIN="${GODOT_BIN:-}"
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
for candidate in godot godot4 Godot; do
|
||||
command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; }
|
||||
done
|
||||
fi
|
||||
[ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需与本项目同版本 4.7.x)"; exit 1; }
|
||||
|
||||
# ---- 4. 跨端探针 ----
|
||||
log "跨端集成探针(包内客户端 × $API_BASE)…"
|
||||
PROBE_LOG="$WORK_DIR/probe-integration.log"
|
||||
if timeout 600 "$GODOT_BIN" --headless --main-pack "$PCK" \
|
||||
--script "$PROJECT_DIR/build/integration/probe-integration.gd" \
|
||||
-- --api-base "$API_BASE" --expect "$EXPECT_PATH" >"$PROBE_LOG" 2>&1; then
|
||||
PROBE_OK=1
|
||||
else
|
||||
PROBE_OK=0
|
||||
fi
|
||||
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
|
||||
[ "$PROBE_OK" = 1 ] || fail "跨端集成探针未通过"
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "============================================================"
|
||||
echo " 跨端集成验收失败(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================================"
|
||||
echo " 跨端集成验收全部通过(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# OpenDance Studio Windows 客户端 —— **发布包级端到端验收**(T-WIN-018)
|
||||
#
|
||||
# 为什么需要它:`build/verify.sh` 跑的是**仓库源码**(`godot --path .`),
|
||||
# 而交付给用户的是 zip 里的 `OpenDance.pck` + `OpenDance.exe`。
|
||||
# 差别全在「导出」这一步 —— 导出预设漏资源、pck 没重新生成、版本号没同步、
|
||||
# 契约快照没更新,都只在包里才暴露。本项目已经因此翻过一次车:
|
||||
# v0.2.1 的包解开后 exe 属性页显示 0.1.0(配置文件却全一致)。
|
||||
#
|
||||
# 本脚本对一个 zip(或已解压目录)跑完:
|
||||
# 1. sha256 校验(有 SHA256SUMS 时);
|
||||
# 2. 解压 + 文件清单完整性(exe / pck / README / THIRD_PARTY / contracts);
|
||||
# 3. exe 的 **PE 版本资源** = 本次发布版本(build/check-pe-version.sh);
|
||||
# 4. 用 Godot 直接加载**包内 pck** 跑 180 帧,断言无 SCRIPT ERROR;
|
||||
# 5. 包内运行时探针(build/probe-release.gd):版本号、AboutScreen 显示值、
|
||||
# 主场景、autoload、关键资源可加载、**pck 内契约表与包内 contracts/VERSION 一致**。
|
||||
#
|
||||
# 需要与本项目同版本的 Godot 4.7.x(导出模板要求的版本)。
|
||||
#
|
||||
# 用法:
|
||||
# GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2.zip
|
||||
# GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2
|
||||
# GODOT_BIN=... bash build/accept-release.sh <zip> 0.2.2 # 显式指定期望版本
|
||||
#
|
||||
# 可选环境变量:
|
||||
# WORK_DIR 解压目录(默认 mktemp -d)
|
||||
# BOOT_FRAMES 启动检查帧数(默认 180)
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
TARGET="${1:-}"
|
||||
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
|
||||
EXPECTED="${2:-$YAML_VERSION}"
|
||||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||||
BOOT_FRAMES="${BOOT_FRAMES:-180}"
|
||||
|
||||
FAILED=0
|
||||
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||||
|
||||
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
|
||||
echo "用法:GODOT_BIN=/path/to/godot bash build/accept-release.sh <发布包 zip 或已解压目录> [期望版本]" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -z "$EXPECTED" ]; then
|
||||
echo "✗ 无法确定期望版本(project.yaml 里没有 version,也没有显式传入)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "目标:$TARGET"
|
||||
log "期望版本:$EXPECTED"
|
||||
log "工作目录:$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# ---- 1. sha256 + 解压 ----
|
||||
if [ -f "$TARGET" ]; then
|
||||
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
|
||||
# 但 sums 文件本身必须用绝对路径 —— 否则 cd 之后相对路径会被二次拼接成
|
||||
# dist-release/dist-release/SHA256SUMS(这个 bug 在本脚本首跑时就命中了)。
|
||||
TARGET_DIR_ABS="$(cd "$(dirname "$TARGET")" && pwd)"
|
||||
SUMS="$TARGET_DIR_ABS/SHA256SUMS"
|
||||
if [ -f "$SUMS" ]; then
|
||||
log "sha256 校验($SUMS):"
|
||||
if ( cd "$TARGET_DIR_ABS" && sha256sum -c "$SUMS" --ignore-missing ); then
|
||||
:
|
||||
else
|
||||
fail "sha256 校验失败"
|
||||
fi
|
||||
else
|
||||
log "未找到 $SUMS,跳过 sha256 校验"
|
||||
fi
|
||||
|
||||
log "解压…"
|
||||
case "$TARGET" in
|
||||
*.zip)
|
||||
if unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
|
||||
log " 用 unzip 解压成功(deflate 包)"
|
||||
elif command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null; then
|
||||
log " 用 7z 解压成功(LZMA 包 —— 注意 Windows 10 资源管理器不支持它)"
|
||||
else
|
||||
fail "解压失败(既不是可 unzip 的 deflate 包,也没有 7z 处理 LZMA)"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "只支持 .zip 或已解压目录"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
else
|
||||
PACKAGE_DIR="$TARGET"
|
||||
fi
|
||||
[ -n "$PACKAGE_DIR" ] && [ -d "$PACKAGE_DIR" ] || { fail "找不到解压后的包目录"; exit 1; }
|
||||
log "包目录:$PACKAGE_DIR"
|
||||
|
||||
# ---- 2. 文件清单 ----
|
||||
log "文件清单:"
|
||||
for required in OpenDance.exe OpenDance.pck README.txt THIRD_PARTY.md contracts/VERSION; do
|
||||
if [ -f "$PACKAGE_DIR/$required" ]; then
|
||||
printf ' ✓ %s\n' "$required"
|
||||
else
|
||||
fail "缺少 $required"
|
||||
fi
|
||||
done
|
||||
# exe 与 pck 必须同名同目录,否则 Godot 找不到资源
|
||||
if [ "$(basename "$PACKAGE_DIR/OpenDance.exe" .exe)" != "$(basename "$PACKAGE_DIR/OpenDance.pck" .pck)" ]; then
|
||||
fail "exe 与 pck 主文件名不一致"
|
||||
fi
|
||||
# 不允许出现历史分卷残留
|
||||
if find "$PACKAGE_DIR" -maxdepth 1 -name "*.part[0-9]*" | grep -q .; then
|
||||
fail "包内出现 .partN 分卷残留(发布规则要求单个完整包)"
|
||||
fi
|
||||
|
||||
# ---- 3. exe 的 PE 版本资源 ----
|
||||
log "产物 exe 的 PE 版本资源:"
|
||||
if [ -f "$PACKAGE_DIR/OpenDance.exe" ]; then
|
||||
bash "$PROJECT_DIR/build/check-pe-version.sh" "$PACKAGE_DIR/OpenDance.exe" "$EXPECTED" \
|
||||
|| fail "exe 的 PE 版本资源不是 v$EXPECTED(Windows 属性页会显示错版本)"
|
||||
fi
|
||||
|
||||
# ---- 4/5. 用 Godot 加载包内 pck ----
|
||||
GODOT_BIN="${GODOT_BIN:-}"
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
for candidate in godot godot4 Godot; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then GODOT_BIN="$candidate"; break; fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$GODOT_BIN" ]; then
|
||||
fail "未找到 Godot(本端验收需要与本项目同版本的 Godot 4.7.x;用 GODOT_BIN=<路径> 指定)"
|
||||
echo "(其余检查已完成,见上)" >&2
|
||||
exit 1
|
||||
fi
|
||||
log "Godot:$GODOT_BIN($("$GODOT_BIN" --version 2>/dev/null | head -1))"
|
||||
|
||||
PCK="$PACKAGE_DIR/OpenDance.pck"
|
||||
if [ -f "$PCK" ]; then
|
||||
log "包内 pck 启动检查($BOOT_FRAMES 帧,无头)…"
|
||||
BOOT_LOG="$WORK_DIR/pck-boot.log"
|
||||
timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" --quit-after "$BOOT_FRAMES" >"$BOOT_LOG" 2>&1
|
||||
BOOT_STATUS=$?
|
||||
if [ "$BOOT_STATUS" -ne 0 ]; then
|
||||
fail "包内 pck 启动退出码 $BOOT_STATUS"
|
||||
tail -20 "$BOOT_LOG" | sed 's/^/ /'
|
||||
elif grep -qE "SCRIPT ERROR|Parse Error|Compile Error" "$BOOT_LOG"; then
|
||||
fail "包内 pck 启动出现脚本错误"
|
||||
grep -E "SCRIPT ERROR|Parse Error|Compile Error" "$BOOT_LOG" | head -10 | sed 's/^/ /'
|
||||
else
|
||||
echo " ✓ 包内 pck 启动 $BOOT_FRAMES 帧无错误(退出码 0)"
|
||||
fi
|
||||
|
||||
log "包内运行时探针(版本号 / 界面显示值 / 契约表 / 资源可加载)…"
|
||||
PROBE="$PROJECT_DIR/build/probe-release.gd"
|
||||
PROBE_LOG="$WORK_DIR/probe.log"
|
||||
# 探针自己断言并 quit(0/1),因此以**退出码**为准;日志单独落盘再打印,
|
||||
# 避免把「grep 的退出码」误当成「探针的退出码」。
|
||||
if timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" --script "$PROBE" \
|
||||
-- --expect-version "$EXPECTED" --snapshot "$PACKAGE_DIR/contracts/VERSION" \
|
||||
>"$PROBE_LOG" 2>&1; then
|
||||
PROBE_OK=1
|
||||
else
|
||||
PROBE_OK=0
|
||||
fi
|
||||
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
|
||||
[ "$PROBE_OK" = 1 ] || fail "包内运行时探针未通过"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "============================================================"
|
||||
echo " 发布包级验收失败(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================================"
|
||||
echo " 发布包级验收全部通过(工作目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# OpenDance Studio —— Windows 可执行文件的 PE 版本资源校验
|
||||
#
|
||||
# 背景(真实缺陷):发布包里的 `OpenDance.exe` 是 Godot 用
|
||||
# `export_presets.cfg` 的 `application/file_version` / `application/product_version`
|
||||
# 通过 rcedit 写进 PE `VERSIONINFO` 资源的。这两个键**当初被硬编码成 0.1.0 后再没人更新**,
|
||||
# 于是 v0.2.1 的发布包在 Windows 上「右键 → 属性 → 详细信息」显示的是
|
||||
# 文件版本 0.1.0 / 产品版本 0.1.0
|
||||
# —— 产物名与 Gitea tag 是 v0.2.1,二进制元数据却是 0.1.0,正是文档 §11.2
|
||||
# 要禁止的那种「不可核对的发布」。光校验 project.godot / project.yaml 抓不到它:
|
||||
# 那两处当时是一致的,漂移发生在**导出预设**里。
|
||||
#
|
||||
# 因此本脚本直接读产物本身的两种版本元数据:
|
||||
# 1. VS_FIXEDFILEINFO(二进制,权威、机器可读)
|
||||
# FileVersionMS/LS、ProductVersionMS/LS —— 位于签名 0xFEEF04BD 之后 16 字节;
|
||||
# 2. StringFileInfo(UTF-16LE 文本,资源管理器「详细信息」里显示的就是它)
|
||||
# FileVersion / ProductVersion 键值对。
|
||||
#
|
||||
# 用法:
|
||||
# bash build/check-pe-version.sh <exe> [期望版本 X.Y.Z]
|
||||
# 不带期望版本时只打印实际值(排障用),退出码 0;
|
||||
# 带期望版本时任一不一致即退出码 1。
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
EXE="${1:-}"
|
||||
EXPECTED="${2:-}"
|
||||
|
||||
if [ -z "$EXE" ]; then
|
||||
echo "用法:bash build/check-pe-version.sh <exe> [期望版本 X.Y.Z]" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "$EXE" ]; then
|
||||
echo "✗ 找不到文件:$EXE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---- 1. VS_FIXEDFILEINFO ----
|
||||
# 签名 0xFEEF04BD(LE 字节序 bd 04 ef fe)在整个文件的 `VERSIONINFO` 资源里唯一。
|
||||
# 用 grep -abo 直接拿字节偏移(109 MB 文件约 40 ms),再 od 读 4 个 DWORD。
|
||||
SIG_OFFSETS=$(LC_ALL=C grep -abo "$(printf '\xbd\x04\xef\xfe')" "$EXE" 2>/dev/null | cut -d: -f1)
|
||||
SIG_COUNT=$(printf '%s\n' "$SIG_OFFSETS" | grep -c . || true)
|
||||
|
||||
if [ "$SIG_COUNT" -eq 0 ]; then
|
||||
echo "✗ $EXE 里没有 VS_FIXEDFILEINFO 签名(0xFEEF04BD),PE 版本资源缺失" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SIG_COUNT" -gt 1 ]; then
|
||||
echo "⚠ $EXE 里出现 $SIG_COUNT 处签名,取第一处($SIG_COUNT > 1 时结果可能不准)" >&2
|
||||
fi
|
||||
SIG_OFFSET=$(printf '%s\n' "$SIG_OFFSETS" | head -1)
|
||||
|
||||
read -r FV_MS FV_LS PV_MS PV_LS <<<"$(od -An -tu4 -j $((SIG_OFFSET + 16)) -N 16 "$EXE" | tr -s ' ')"
|
||||
fmt_version() { printf '%s.%s.%s.%s' "$(( $1 >> 16 ))" "$(( $1 & 0xFFFF ))" "$(( $2 >> 16 ))" "$(( $2 & 0xFFFF ))"; }
|
||||
FIXED_FILE_VERSION="$(fmt_version "$FV_MS" "$FV_LS")"
|
||||
FIXED_PRODUCT_VERSION="$(fmt_version "$PV_MS" "$PV_LS")"
|
||||
|
||||
# ---- 2. StringFileInfo(UTF-16LE 文本)----
|
||||
# `strings -el` 会把 UTF-16LE 字符串按出现顺序逐行列出,键与值相邻:
|
||||
# FileVersion
|
||||
# 0.1.0
|
||||
# 取键所在行的下一行即为值。缺 strings(binutils) 时降级为只查固定信息。
|
||||
string_value_of() {
|
||||
local key="$1" text="$2"
|
||||
printf '%s\n' "$text" | awk -v k="$key" '$0 == k { getline; print; exit }'
|
||||
}
|
||||
STRING_DUMP=""
|
||||
if command -v strings >/dev/null 2>&1; then
|
||||
STRING_DUMP="$(strings -a -el "$EXE" 2>/dev/null || true)"
|
||||
fi
|
||||
STR_FILE_VERSION="$(string_value_of FileVersion "$STRING_DUMP")"
|
||||
STR_PRODUCT_VERSION="$(string_value_of ProductVersion "$STRING_DUMP")"
|
||||
STR_PRODUCT_NAME="$(string_value_of ProductName "$STRING_DUMP")"
|
||||
STR_COMPANY="$(string_value_of CompanyName "$STRING_DUMP")"
|
||||
|
||||
echo " PE 版本资源($EXE):"
|
||||
echo " VS_FIXEDFILEINFO FileVersion = ${FIXED_FILE_VERSION:-(未读到)}"
|
||||
echo " VS_FIXEDFILEINFO ProductVersion = ${FIXED_PRODUCT_VERSION:-(未读到)}"
|
||||
if [ -n "$STRING_DUMP" ]; then
|
||||
echo " StringFileInfo FileVersion = ${STR_FILE_VERSION:-(未找到)}"
|
||||
echo " StringFileInfo ProductVersion = ${STR_PRODUCT_VERSION:-(未找到)}"
|
||||
echo " StringFileInfo ProductName = ${STR_PRODUCT_NAME:-(未找到)}"
|
||||
echo " StringFileInfo CompanyName = ${STR_COMPANY:-(未找到)}"
|
||||
else
|
||||
echo " StringFileInfo (本机没有 strings/binutils,跳过文本项)"
|
||||
fi
|
||||
|
||||
if [ -z "$EXPECTED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- 3. 与期望版本比对 ----
|
||||
FAILED=0
|
||||
# 固定信息的第 4 段是 revision,rcedit 一律写 0,因此只比前三段
|
||||
fixed_prefix="${FIXED_FILE_VERSION%.*}"
|
||||
if [ "$fixed_prefix" != "$EXPECTED" ]; then
|
||||
echo " ✗ PE FileVersion 为 ${FIXED_FILE_VERSION:-(空)},期望 ${EXPECTED}.x(Windows 属性页会显示错版本)" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
if [ -n "$STRING_DUMP" ]; then
|
||||
if [ "$STR_FILE_VERSION" != "$EXPECTED" ]; then
|
||||
echo " ✗ 资源字符串 FileVersion 为 '${STR_FILE_VERSION:-(空)}',期望 '${EXPECTED}'" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
if [ "$STR_PRODUCT_VERSION" != "$EXPECTED" ]; then
|
||||
echo " ✗ 资源字符串 ProductVersion 为 '${STR_PRODUCT_VERSION:-(空)}',期望 '${EXPECTED}'" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo " ✗ PE 版本资源与期望版本不一致 —— 请先跑 bash build/sync-export-version.sh 并重新导出" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ✓ PE 版本资源一致:FileVersion ${FIXED_FILE_VERSION} / 资源字符串 ${EXPECTED}"
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# OpenDance Studio —— Windows 客户端版本号一致性校验
|
||||
#
|
||||
# 文档 §11.2「单一来源 / 可核对」在本端的落点:
|
||||
# * `project.godot` 的 `application/config/version` 是**运行时唯一来源**
|
||||
# (「关于」清单直接读它,导出后打进 .pck);
|
||||
# * `project.yaml` 的 `version` 是任务清单/发布记录的来源;
|
||||
# * 发布产物文件名 `opendance-windows-vX.Y.Z.zip` 与 Git tag 必须一致。
|
||||
#
|
||||
# 三者一旦漂移,就会出现"界面显示 v0.2.0、产物却叫 v0.2.1"这种不可核对的发布,
|
||||
# 因此打包前强制校验。
|
||||
#
|
||||
# 用法:bash build/check-version.sh [期望版本号]
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
GODOT_VERSION="$(sed -n 's/^config\/version="\(.*\)"$/\1/p' project.godot | head -1)"
|
||||
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
|
||||
PRESET="$PROJECT_DIR/export_presets.cfg"
|
||||
PRESET_FILE_VERSION="$(sed -n 's/^application\/file_version="\(.*\)"$/\1/p' "$PRESET" 2>/dev/null | head -1)"
|
||||
PRESET_PRODUCT_VERSION="$(sed -n 's/^application\/product_version="\(.*\)"$/\1/p' "$PRESET" 2>/dev/null | head -1)"
|
||||
EXPECTED="${1:-$YAML_VERSION}"
|
||||
|
||||
printf ' project.godot application/config/version = %s\n' "${GODOT_VERSION:-(未找到)}"
|
||||
printf ' project.yaml version = %s\n' "${YAML_VERSION:-(未找到)}"
|
||||
printf ' export_presets application/file_version = %s\n' "${PRESET_FILE_VERSION:-(未找到)}"
|
||||
printf ' export_presets application/product_version= %s\n' "${PRESET_PRODUCT_VERSION:-(未找到)}"
|
||||
printf ' 期望版本 = %s\n' "${EXPECTED:-(未指定)}"
|
||||
|
||||
FAILED=0
|
||||
[ -n "$GODOT_VERSION" ] || { echo " ✗ 无法从 project.godot 解析 config/version" >&2; FAILED=1; }
|
||||
[ -n "$YAML_VERSION" ] || { echo " ✗ 无法从 project.yaml 解析 version" >&2; FAILED=1; }
|
||||
# 导出预设的 file_version / product_version 决定 OpenDance.exe 的 PE 版本资源,
|
||||
# 即 Windows「右键 → 属性 → 详细信息」里显示的版本。它们**不会**随 project.godot
|
||||
# 自动更新,曾经因此让 v0.2.1 的发布包显示 0.1.0。这里静态拦住;
|
||||
# 产物那一侧再由 build/check-pe-version.sh 直接读 exe 复核。
|
||||
if [ ! -f "$PRESET" ]; then
|
||||
echo " ✗ 找不到 export_presets.cfg(无法校验 PE 版本资源的来源)" >&2
|
||||
FAILED=1
|
||||
else
|
||||
for PAIR in "file_version:$PRESET_FILE_VERSION" "product_version:$PRESET_PRODUCT_VERSION"; do
|
||||
KEY="${PAIR%%:*}"; VALUE="${PAIR#*:}"
|
||||
if [ -z "$VALUE" ]; then
|
||||
echo " ✗ export_presets.cfg 缺少 application/${KEY}(导出后 PE 版本资源会沿用旧值)" >&2
|
||||
FAILED=1
|
||||
elif [ -n "$GODOT_VERSION" ] && [ "$VALUE" != "$GODOT_VERSION" ]; then
|
||||
echo " ✗ 导出预设 application/${KEY}=${VALUE} 与 project.godot 的 ${GODOT_VERSION} 不一致" >&2
|
||||
echo " (Windows 属性页会显示 ${VALUE};跑 bash build/sync-export-version.sh 后重新导出)" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -n "$EXPECTED" ] && [ "$GODOT_VERSION" != "$EXPECTED" ]; then
|
||||
echo " ✗ project.godot 的版本与期望不一致(界面/About 会显示错版本)" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
if [ -n "$YAML_VERSION" ] && [ "$GODOT_VERSION" != "$YAML_VERSION" ]; then
|
||||
echo " ✗ project.godot 与 project.yaml 版本不一致" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
# 版本号格式 X.Y.Z
|
||||
if [ -n "$GODOT_VERSION" ] && ! printf '%s' "$GODOT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo " ✗ 版本号应为 X.Y.Z(实际 $GODOT_VERSION)" >&2
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo " ✗ 版本一致性校验失败" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ✓ 版本一致:v$GODOT_VERSION(project.godot = project.yaml = export_presets.cfg${EXPECTED:+ = 期望值})"
|
||||
+20
-1
@@ -67,6 +67,17 @@ if [ ! -d "$TEMPLATE_DIR" ] || [ -z "$(ls -A "$TEMPLATE_DIR" 2>/dev/null)" ]; th
|
||||
fi
|
||||
echo "[export] 导出模板:$(ls "$TEMPLATE_DIR" | tr '\n' ' ')"
|
||||
|
||||
# ---- 导出前:把版本号同步进导出预设 ----
|
||||
# export_presets.cfg 的 application/file_version / product_version 决定产物 exe 的
|
||||
# PE 版本资源(Windows「右键 → 属性 → 详细信息」里显示的版本),它们**不会**
|
||||
# 跟随 project.godot 的 application/config/version 自动更新。不在这里同步,
|
||||
# 导出的包就会带着旧版本号 —— v0.2.1 正是因此显示了 0.1.0。
|
||||
echo "[export] 同步版本号到导出预设…"
|
||||
bash "$PROJECT_DIR/build/sync-export-version.sh" || {
|
||||
echo "错误:导出预设版本同步失败。" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- 确保资源已导入(缺失会让导出静默产出不完整的包)----
|
||||
echo "[export] 导入资源…"
|
||||
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --import >/dev/null 2>&1 || true
|
||||
@@ -105,11 +116,19 @@ if [ "$MAGIC" != "4d5a" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 校验 PE 版本资源 = 当前项目版本(文档 §11.2:产物元数据也必须可核对)
|
||||
PROJECT_VERSION="$(sed -n 's/^config\/version="\(.*\)"$/\1/p' "$PROJECT_DIR/project.godot" | head -1)"
|
||||
echo "[export] 校验产物 PE 版本资源(期望 $PROJECT_VERSION)…"
|
||||
if ! bash "$PROJECT_DIR/build/check-pe-version.sh" "$EXE" "$PROJECT_VERSION"; then
|
||||
echo "错误:$EXE 的 PE 版本资源与项目版本不一致。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIZE_EXE=$(stat -c%s "$EXE")
|
||||
SIZE_PCK=$(stat -c%s "$PCK")
|
||||
echo ""
|
||||
echo "[export] 完成:"
|
||||
echo " $EXE ($((SIZE_EXE / 1024)) KiB, PE 头校验通过)"
|
||||
echo " $EXE ($((SIZE_EXE / 1024)) KiB, PE 头与版本资源校验通过)"
|
||||
echo " $PCK ($((SIZE_PCK / 1024)) KiB)"
|
||||
[ -f "$CONSOLE" ] && echo " $CONSOLE ($(( $(stat -c%s "$CONSOLE") / 1024 )) KiB)"
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
extends SceneTree
|
||||
|
||||
## 跨端集成探针(T-WIN-019):**发布包里的 Windows 客户端** × **真实 Web 服务端**
|
||||
##
|
||||
## 用法(由 build/accept-integration.sh 调用,也可手工执行):
|
||||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||||
## --script /绝对路径/build/integration/probe-integration.gd \
|
||||
## -- --api-base http://127.0.0.1:3100 --expect /绝对路径/catalog-expect.json
|
||||
##
|
||||
## 为什么需要它:本仓的单测用的是**假 HTTP 夹具**(`tests/test_api_integration.gd`
|
||||
## 打的是本地起的小服务),Web 端的测试又只验自己那一侧。两端各自绿灯,
|
||||
## 不代表**契约字段真的对得上**:字段名大小写、嵌套层级、null 与缺字段、
|
||||
## 单位(米/度/秒)、归一化坐标范围……都可能在一侧对、另一侧错。
|
||||
## 契约 0.5.0 新增的 `scenes[]` 与物理参数尤其如此。
|
||||
##
|
||||
## 本探针只加载**包内**代码(`--main-pack` + `res://`),打真实服务端:
|
||||
## 1. `/health`:连通性 + 契约版本;
|
||||
## 2. `/api/catalog/version`:目录指纹;
|
||||
## 3. `/api/catalog/export` → `CatalogStore` 解析出的**类型化**目录:
|
||||
## 曲目 / 动作 / 模型 / 场景 + 默认模型;
|
||||
## 4. 场景物理参数逐项比对(重力 / 风 / 地面 / 碰撞体 / 出生点 / 相机 / 环境)
|
||||
## —— ⑤「场景选择与物理参数落地」的跨端证据;
|
||||
## 5. 动作关键点序列(NDJSON)→ `KeypointSequence.parse_ndjson`:
|
||||
## 帧数、33 点、时间轴、服务端关节角 —— ②「动作内部标准」跨端闭环;
|
||||
## 6. 模型 / 场景 / 缩略图资源**可下载**且魔数正确(① ③ 的消费侧)。
|
||||
|
||||
const CatalogTypes = preload("res://src/net/catalog_types.gd")
|
||||
const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
|
||||
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
|
||||
|
||||
var _api_base := ""
|
||||
var _expect_path := ""
|
||||
var _expect: Dictionary = {}
|
||||
var _failed := 0
|
||||
var _started := false
|
||||
var _done := false
|
||||
var _exit_code := 0
|
||||
## 看门狗:协程里一旦出现运行时错误,`_finish()` 就不会被调用,进程会永远不退出
|
||||
## (调试时表现为「无输出卡住」,很难看出问题)。超过时限就自己判失败退出。
|
||||
const WATCHDOG_MS := 120_000
|
||||
var _start_ms := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
if args[i] == "--api-base" and i + 1 < args.size():
|
||||
_api_base = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--expect" and i + 1 < args.size():
|
||||
_expect_path = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
|
||||
## 必须在 _process 里跑:HTTPRequest 需要跨帧等待(同 tests/run_tests.gd)
|
||||
func _process(_delta: float) -> bool:
|
||||
if not _started:
|
||||
_started = true
|
||||
_start_ms = Time.get_ticks_msec()
|
||||
_run()
|
||||
if not _done and Time.get_ticks_msec() - _start_ms > WATCHDOG_MS:
|
||||
print(" ✗ 看门狗超时(%d ms):协程可能因运行时错误中断" % WATCHDOG_MS)
|
||||
_exit_code = 1
|
||||
_done = true
|
||||
quit(_exit_code)
|
||||
return _done
|
||||
|
||||
|
||||
func _check(ok: bool, text: String) -> void:
|
||||
print(" %s %s" % ["✓" if ok else "✗", text])
|
||||
if not ok:
|
||||
_failed += 1
|
||||
|
||||
|
||||
func _near(a: float, b: float, tol := 0.0001) -> bool:
|
||||
return absf(a - b) <= tol
|
||||
|
||||
|
||||
func _load_expect() -> bool:
|
||||
if _expect_path.is_empty() or not FileAccess.file_exists(_expect_path):
|
||||
print(" ✗ 找不到期望值文件:%s" % _expect_path)
|
||||
return false
|
||||
var file := FileAccess.open(_expect_path, FileAccess.READ)
|
||||
if file == null:
|
||||
print(" ✗ 无法读取期望值文件:%s" % _expect_path)
|
||||
return false
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
print(" ✗ 期望值文件不是 JSON 对象")
|
||||
return false
|
||||
_expect = parsed
|
||||
return true
|
||||
|
||||
|
||||
func _fail(text: String) -> void:
|
||||
print(" ✗ %s" % text)
|
||||
_failed += 1
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("[integration] Windows 包内客户端 × 真实 Web 服务端:%s" % _api_base)
|
||||
if _api_base.is_empty() or not _load_expect():
|
||||
_finish()
|
||||
return
|
||||
|
||||
# autoload 单例在 `--script` 模式下不能当全局标识符用(编译期找不到 AppConfig /
|
||||
# CatalogStore 这两个名字),要从场景树里取节点,再用动态调用访问。
|
||||
var app_config = root.get_node_or_null("AppConfig")
|
||||
var catalog_store = root.get_node_or_null("CatalogStore")
|
||||
if app_config == null or catalog_store == null:
|
||||
_fail("场景树里没有 AppConfig / CatalogStore(autoload 未加载)")
|
||||
_finish()
|
||||
return
|
||||
app_config.api_base_url = _api_base
|
||||
var client = catalog_store.client()
|
||||
client.set_base_url(_api_base)
|
||||
|
||||
# ---------- 1. /health ----------
|
||||
var health: Dictionary = await client.health()
|
||||
if not health["ok"]:
|
||||
_fail("GET /health 失败:%s" % health["error"])
|
||||
_finish()
|
||||
return
|
||||
var health_data: Dictionary = health["data"]
|
||||
_check(health["status"] == 200, "GET /health 200(实际 %d)" % health["status"])
|
||||
_check(health_data.get("version", "") != "", "服务端版本 = %s" % health_data.get("version", ""))
|
||||
var contract: Dictionary = health_data.get("contractVersion", {})
|
||||
# 期望值文件缺键时给可读错误,而不是在运行时抛 "Invalid access to property"
|
||||
var want_contract: Dictionary = _expect.get("contractVersions", {})
|
||||
if want_contract.is_empty():
|
||||
_fail("期望值文件缺少 contractVersions(请重新播种,或用本仓 seed-catalog.mjs 生成)")
|
||||
for key in want_contract.keys():
|
||||
_check(contract.get(key, "") == want_contract[key],
|
||||
"契约 %s = %s(服务端 %s)" % [key, want_contract[key], contract.get(key, "缺失")])
|
||||
|
||||
# ---------- 2/3. 目录 ----------
|
||||
var refresh: Dictionary = await catalog_store.refresh()
|
||||
if not refresh["ok"]:
|
||||
_fail("GET /api/catalog/export 失败:%s" % refresh.get("error", ""))
|
||||
_finish()
|
||||
return
|
||||
var bundle: CatalogTypes.CatalogBundle = catalog_store.bundle
|
||||
_check(catalog_store.revision != "", "目录指纹非空:%s" % catalog_store.revision)
|
||||
if not want_contract.is_empty():
|
||||
_check(catalog_store.contract_version.get("rest-api", "") == want_contract.get("rest-api", ""),
|
||||
"目录内嵌契约版本与 /health 一致")
|
||||
|
||||
var songs: Array = catalog_store.songs()
|
||||
var motions: Array = catalog_store.motions()
|
||||
var models: Array = catalog_store.models()
|
||||
var scenes: Array = catalog_store.scenes()
|
||||
_check(songs.size() >= 1 and motions.size() >= 1 and models.size() >= 1 and scenes.size() >= 1,
|
||||
"目录四类资源齐备:songs=%d motions=%d models=%d scenes=%d" % [
|
||||
songs.size(), motions.size(), models.size(), scenes.size()])
|
||||
if not _expect.has("modelId") or not _expect.has("sceneId") or not _expect.has("motionId"):
|
||||
_fail("期望值文件缺少 modelId / sceneId / motionId")
|
||||
_finish()
|
||||
return
|
||||
_check(bundle.default_model_id == _expect["modelId"],
|
||||
"defaultModelId 解析为 %d(期望 %d)" % [bundle.default_model_id, _expect["modelId"]])
|
||||
|
||||
# ---------- 4. ⑤ 场景选择 + 物理参数落地 ----------
|
||||
var scene: CatalogTypes.Scene = bundle.find_scene(_expect["sceneId"])
|
||||
if scene == null:
|
||||
_fail("目录里找不到场景 #%d" % _expect["sceneId"])
|
||||
else:
|
||||
var cfg: CatalogTypes.SceneConfig = scene.config
|
||||
var want: Dictionary = _expect["sceneConfig"]
|
||||
var w_phys: Dictionary = want["physics"]
|
||||
_check(scene.glb_url.begins_with("/static/"), "场景 glbUrl = %s" % scene.glb_url)
|
||||
_check(scene.file_size > 0, "场景 fileSize = %d" % scene.file_size)
|
||||
_check(_near(cfg.physics.gravity.y, w_phys["gravity"]["y"]),
|
||||
"重力 y = %.2f(期望 %.2f)" % [cfg.physics.gravity.y, w_phys["gravity"]["y"]])
|
||||
_check(_near(cfg.physics.wind.base.x, w_phys["wind"]["base"]["x"])
|
||||
and _near(cfg.physics.wind.base.z, w_phys["wind"]["base"]["z"]),
|
||||
"基础风 = (%.1f, %.1f, %.1f)" % [cfg.physics.wind.base.x, cfg.physics.wind.base.y, cfg.physics.wind.base.z])
|
||||
_check(_near(cfg.physics.wind.gust_amplitude, w_phys["wind"]["gustAmplitude"])
|
||||
and _near(cfg.physics.wind.gust_frequency, w_phys["wind"]["gustFrequency"]),
|
||||
"阵风幅度/频率 = %.1f m/s @ %.2f Hz" % [cfg.physics.wind.gust_amplitude, cfg.physics.wind.gust_frequency])
|
||||
_check(cfg.physics.wind.space == w_phys["wind"]["space"],
|
||||
"风坐标系 = %s" % cfg.physics.wind.space)
|
||||
_check(_near(cfg.physics.ground.friction, w_phys["ground"]["friction"])
|
||||
and _near(cfg.physics.ground.slope_limit, w_phys["ground"]["slopeLimit"]),
|
||||
"地面摩擦/坡度 = %.2f / %.0f°" % [cfg.physics.ground.friction, cfg.physics.ground.slope_limit])
|
||||
_check(cfg.physics.colliders == w_phys["colliders"],
|
||||
"碰撞体模式 = %s" % cfg.physics.colliders)
|
||||
_check(cfg.spawn_points.size() >= 1 and _near(cfg.spawn_points[0].yaw, want["spawnPoints"][0]["yaw"]),
|
||||
"出生点 yaw = %.0f°" % (cfg.spawn_points[0].yaw if cfg.spawn_points.size() > 0 else -999.0))
|
||||
_check(cfg.camera.mode == want["camera"]["mode"]
|
||||
and _near(cfg.camera.distance, want["camera"]["distance"])
|
||||
and _near(cfg.camera.height, want["camera"]["height"]),
|
||||
"相机 %s / 距离 %.1f / 高度 %.1f" % [cfg.camera.mode, cfg.camera.distance, cfg.camera.height])
|
||||
_check(_near(cfg.environment.ambient_intensity, want["environment"]["ambientIntensity"])
|
||||
and _near(cfg.environment.fog_density, want["environment"]["fogDensity"]),
|
||||
"环境光强度 %.1f / 雾 %.2f" % [cfg.environment.ambient_intensity, cfg.environment.fog_density])
|
||||
# 阵风采样:t=0 应等于基础风,t=1/(4f) 应叠加一个完整峰值
|
||||
if not cfg.physics.wind.is_calm():
|
||||
var s0: Vector3 = cfg.physics.wind.sample(0.0)
|
||||
_check(s0.is_equal_approx(cfg.physics.wind.base), "风采样 t=0 等于基础风")
|
||||
# 场景 GLB 真的能下载
|
||||
var glb: Dictionary = await client.get_binary(scene.glb_url)
|
||||
if glb["ok"]:
|
||||
var bytes: PackedByteArray = glb["data"]
|
||||
var magic := bytes.slice(0, 4).get_string_from_ascii()
|
||||
_check(magic == "glTF" and bytes.size() == scene.file_size,
|
||||
"场景 GLB 可下载且魔数正确(%s,%d 字节)" % [magic, bytes.size()])
|
||||
else:
|
||||
_fail("场景 GLB 下载失败:%s" % glb["error"])
|
||||
|
||||
# ---------- 5. ② 动作内部标准跨端闭环 ----------
|
||||
var motion: CatalogTypes.Motion = bundle.find_motion(_expect["motionId"])
|
||||
if motion == null:
|
||||
_fail("目录里找不到动作 #%d" % _expect["motionId"])
|
||||
else:
|
||||
_check(motion.keypoint_format == _expect["keypointFormat"],
|
||||
"动作内部标准 = %s" % motion.keypoint_format)
|
||||
_check(motion.keypoint_frames == _expect["motionFrames"],
|
||||
"服务端帧数 = %d(期望 %d)" % [motion.keypoint_frames, _expect["motionFrames"]])
|
||||
_check(motion.is_scorable(), "动作可评分(预处理已完成)")
|
||||
var seq_result: Dictionary = await catalog_store.fetch_sequence(_expect["motionId"])
|
||||
if not seq_result["ok"]:
|
||||
_fail("关键点序列拉取/解析失败:%s" % seq_result.get("error", ""))
|
||||
else:
|
||||
var seq: KeypointSequence.ParsedSequence = seq_result["sequence"]
|
||||
_check(seq.frame_count() == _expect["motionFrames"],
|
||||
"客户端解析帧数 = %d(期望 %d)" % [seq.frame_count(), _expect["motionFrames"]])
|
||||
_check(seq.skipped_lines == 0, "无解析失败行(skipped=%d)" % seq.skipped_lines)
|
||||
_check(seq.frames.size() > 0 and seq.frames[0].points.size() == KeypointFrame.KEYPOINT_COUNT,
|
||||
"首帧点数 = %d" % (seq.frames[0].points.size() if seq.frames.size() > 0 else 0))
|
||||
_check(seq.server_angles.size() == seq.frame_count(), "每帧都带服务端关节角")
|
||||
_check(_near(seq.estimated_fps(), 30.0, 0.5),
|
||||
"时间轴推导帧率 = %.2f fps" % seq.estimated_fps())
|
||||
var duration_s := (seq.times_ms[seq.times_ms.size() - 1] - seq.times_ms[0]) / 1000.0
|
||||
_check(duration_s > 90.0 and duration_s < 100.0, "动作时长 = %.2f s" % duration_s)
|
||||
# 缩略图(③ 预览资产)可下载且为 PNG
|
||||
var thumb: Dictionary = await client.get_binary(motion.thumbnail_url)
|
||||
if thumb["ok"]:
|
||||
var png: PackedByteArray = thumb["data"]
|
||||
_check(png.slice(0, 8).hex_encode() == "89504e470d0a1a0a",
|
||||
"动作缩略图可下载且为 PNG(%d 字节)" % png.size())
|
||||
else:
|
||||
_fail("动作缩略图下载失败:%s" % thumb["error"])
|
||||
|
||||
# ---------- 6. ① 模型资源可下载 ----------
|
||||
var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"])
|
||||
if model == null:
|
||||
_fail("目录里找不到模型 #%d" % _expect["modelId"])
|
||||
else:
|
||||
_check(model.format == "vrm", "模型格式 = %s" % model.format)
|
||||
_check(model.is_primary, "模型为默认模型")
|
||||
var model_bytes: Dictionary = await client.get_binary(model.file_url)
|
||||
if model_bytes["ok"]:
|
||||
var payload: PackedByteArray = model_bytes["data"]
|
||||
_check(payload.slice(0, 4).get_string_from_ascii() == "glTF"
|
||||
and payload.size() == model.file_size,
|
||||
"模型文件可下载且为 glTF 容器(%d 字节)" % payload.size())
|
||||
else:
|
||||
_fail("模型下载失败:%s" % model_bytes["error"])
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if _failed > 0:
|
||||
print("[integration] ✗ 跨端集成探针发现 %d 项不符" % _failed)
|
||||
_exit_code = 1
|
||||
else:
|
||||
print("[integration] ✓ 跨端集成探针全部通过")
|
||||
_exit_code = 0
|
||||
_done = true
|
||||
quit(_exit_code)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dexb1tstsyjuh
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 为「Windows 客户端 × Web 服务端」跨端集成验收准备一份**真实**目录:
|
||||
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
|
||||
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const API = process.env.API_BASE ?? 'http://127.0.0.1:3100';
|
||||
// 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/)
|
||||
const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR
|
||||
?? new URL('../../../示例文件', import.meta.url).pathname;
|
||||
const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json';
|
||||
const SOURCE = {
|
||||
sourceType: 'original',
|
||||
sourceName: '跨端集成验收素材',
|
||||
sourceUrl: 'https://example.com/opendance',
|
||||
license: 'MIT',
|
||||
author: 'OpenDance QA',
|
||||
};
|
||||
|
||||
async function request(method, path, { json, form } = {}) {
|
||||
const init = { method };
|
||||
if (json !== undefined) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(json); }
|
||||
if (form !== undefined) init.body = form;
|
||||
const res = await fetch(`${API}${path}`, init);
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
function fileForm(fields, filename, content, mime = 'application/octet-stream') {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) form.append(k, String(v));
|
||||
form.append('file', new Blob([content], { type: mime }), filename);
|
||||
return form;
|
||||
}
|
||||
|
||||
const must = (cond, what, extra = '') => {
|
||||
if (!cond) { console.error(`✗ ${what} ${extra}`); process.exit(1); }
|
||||
console.log(` ✓ ${what}`);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
// ---------- 歌曲 ----------
|
||||
const song = await request('POST', '/api/songs', {
|
||||
json: { title: '霜雪千年', artist: '乐正绫 / 洛天依', album: '跨端验收', language: 'zh', durationMs: 96700, bpm: 120, difficulty: 3, ...SOURCE },
|
||||
});
|
||||
must(song.status === 201, '创建曲目', `${song.status} ${JSON.stringify(song.body)}`);
|
||||
const songId = song.body.id;
|
||||
|
||||
// ---------- 动作:真实 VMD ----------
|
||||
const vmd = readFileSync(`${SAMPLES}/【动作】霜雪千年.vmd`);
|
||||
const motion = await request('POST', '/api/motions', {
|
||||
form: fileForm({ name: '霜雪千年(真实 VMD)', songId, autoPreprocess: false, ...SOURCE }, '霜雪千年.vmd', vmd, 'application/octet-stream'),
|
||||
});
|
||||
must(motion.status === 201, '上传真实 VMD 动作', `${motion.status} ${JSON.stringify(motion.body)}`);
|
||||
const motionId = motion.body.id;
|
||||
must(motion.body.format === 'vmd', `识别格式 vmd(实际 ${motion.body.format})`);
|
||||
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
|
||||
must(pre.status === 200, '预处理为内部标准 33 点', `${pre.status} ${JSON.stringify(pre.body)}`);
|
||||
console.log(` 预处理结果:${JSON.stringify(pre.body)}`);
|
||||
|
||||
// ---------- 模型:真实 PMX 压缩包 → VRM ----------
|
||||
const pmxZip = readFileSync(`${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`);
|
||||
const model = await request('POST', '/api/models', {
|
||||
form: fileForm({ name: '初音未来(真实 PMX → VRM)', ...SOURCE }, 'miku.zip', pmxZip, 'application/zip'),
|
||||
});
|
||||
must(model.status === 201, '上传真实 PMX 压缩包', `${model.status} ${JSON.stringify(model.body)}`);
|
||||
const modelId = model.body.id;
|
||||
must(model.body.format === 'vrm', `转换产物格式 vrm(实际 ${model.body.format})`);
|
||||
must(model.body.usable === true, '模型可用(humanoid 达标)', JSON.stringify(model.body.unusableReason));
|
||||
console.log(` 转换说明:${model.body.conversionNote}`);
|
||||
console.log(` humanoid:${model.body.stats?.humanoidMapped}/${model.body.stats?.humanoidTotal},可驱动=${model.body.stats?.drivable}`);
|
||||
|
||||
// ---------- 场景:真实 GLB(复用刚转换出的 VRM 几何)+ 物理参数 ----------
|
||||
const glbRes = await fetch(`${API}${model.body.fileUrl}`);
|
||||
const glbBytes = Buffer.from(await glbRes.arrayBuffer());
|
||||
must(glbBytes.subarray(0, 4).toString('ascii') === 'glTF', `取回模型 GLB(${glbBytes.length} 字节)`);
|
||||
// 作为场景上传时补一个按客户端 colliders:auto 约定命名的碰撞节点不方便,直接用 mesh 模式
|
||||
const sceneConfig = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -3.5, z: 0 },
|
||||
wind: {
|
||||
base: { x: 2, y: 0, z: -1 },
|
||||
gustAmplitude: 4,
|
||||
gustFrequency: 0.5,
|
||||
gustDirection: { x: 1, y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
|
||||
colliders: 'mesh',
|
||||
},
|
||||
environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 },
|
||||
spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }],
|
||||
camera: { mode: 'orbit', distance: 6, height: 2 },
|
||||
};
|
||||
const scene = await request('POST', '/api/scenes', {
|
||||
form: fileForm({ name: '跨端验收场景(真实 GLB)', config: JSON.stringify(sceneConfig), ...SOURCE }, 'stage.glb', glbBytes, 'model/gltf-binary'),
|
||||
});
|
||||
must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`);
|
||||
const sceneId = scene.body.id;
|
||||
|
||||
// ---------- 审核发布 ----------
|
||||
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) {
|
||||
// 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的
|
||||
const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } });
|
||||
must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status));
|
||||
const approve = await request('POST', `/api/reviews/${kind}/${id}/approve`, {
|
||||
json: kind === 'model' ? { reviewer: 'qa', comment: '通过', setPrimary: true } : { reviewer: 'qa', comment: '通过' },
|
||||
});
|
||||
must(approve.status === 200 || approve.status === 201, `审核通过 ${kind}#${id}`, `${approve.status} ${JSON.stringify(approve.body)}`);
|
||||
}
|
||||
|
||||
// ---------- 目录导出 ----------
|
||||
const health = await request('GET', '/health');
|
||||
must(health.status === 200, 'GET /health(取契约版本写进期望值)', String(health.status));
|
||||
|
||||
const catalog = await request('GET', '/api/catalog/export');
|
||||
must(catalog.status === 200, 'GET /api/catalog/export', String(catalog.status));
|
||||
const data = catalog.body;
|
||||
console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`);
|
||||
must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])');
|
||||
const version = await request('GET', '/api/catalog/version');
|
||||
console.log(` 目录指纹:${JSON.stringify(version.body)}`);
|
||||
|
||||
mkdirSync(dirname(EXPECT_PATH), { recursive: true });
|
||||
writeFileSync(EXPECT_PATH, JSON.stringify({
|
||||
songId, motionId, modelId, sceneId,
|
||||
sceneConfig,
|
||||
motionFrames: pre.body?.frameCount ?? null,
|
||||
// 注意:**上传响应**里的 keypointFormat 还是 null(预处理后才写入),
|
||||
// 必须取**预处理响应**的值 —— 这里踩过一次,期望值写成了 null。
|
||||
keypointFormat: pre.body?.keypointFormat ?? null,
|
||||
catalogRevision: version.body?.revision ?? null,
|
||||
contractVersions: health.body?.contractVersion ?? {},
|
||||
}, null, 2));
|
||||
console.log(`\n期望值已写入 ${EXPECT_PATH}`);
|
||||
};
|
||||
|
||||
run().catch((e) => { console.error('ERR', e); process.exit(1); });
|
||||
@@ -43,6 +43,9 @@ log() { printf '\033[1;34m[package]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# ---- 0. 版本号一致性(文档 §11.2:产物名 / tag / 界面展示必须一致)----
|
||||
bash "$PROJECT_DIR/build/check-version.sh" "$VERSION" || die "版本一致性校验失败,先统一 project.godot / project.yaml / 发布版本号"
|
||||
|
||||
EXE="$PROJECT_DIR/build/windows/OpenDance.exe"
|
||||
PCK="$PROJECT_DIR/build/windows/OpenDance.pck"
|
||||
|
||||
@@ -56,6 +59,12 @@ PCK_MAGIC=$(head -c 4 "$PCK" | tr -d '\0')
|
||||
[ "$PCK_MAGIC" = "GDPC" ] || die "$PCK 不是合法的 Godot 资源包(头 '$PCK_MAGIC')"
|
||||
log "源产物校验通过:exe PE 头 MZ | pck 头 GDPC"
|
||||
|
||||
# 产物 exe 的 PE 版本资源必须是本次发布版本 —— Windows「属性 → 详细信息」显示的就是它。
|
||||
# v0.2.1 曾带着 0.1.0 发出去(导出预设里是硬编码的旧值,且没有任何环节复核产物)。
|
||||
bash "$PROJECT_DIR/build/check-pe-version.sh" "$EXE" "$VERSION" \
|
||||
|| die "$EXE 的 PE 版本资源不是 v$VERSION;先跑 bash build/sync-export-version.sh $VERSION 再重新导出"
|
||||
log "产物 PE 版本资源校验通过(=$VERSION)"
|
||||
|
||||
# ---- 2. 整理产物 ----
|
||||
log "整理产物到 $STAGE …"
|
||||
rm -rf "$STAGE"
|
||||
@@ -120,6 +129,11 @@ CHECK_PCK=$(head -c 4 "$CHECK_DIR/$NAME/OpenDance.pck" | tr -d '\0')
|
||||
[ "$CHECK_PCK" = "GDPC" ] || die "解压后的 OpenDance.pck 不是合法 Godot 资源包"
|
||||
log "整包完整性自检通过(解压后 exe + pck 同目录,PE/GDPC 头均合法)"
|
||||
|
||||
# 再核一次**解压后**的 exe 版本资源:确认打包/解包环节没有换错文件
|
||||
bash "$PROJECT_DIR/build/check-pe-version.sh" "$CHECK_DIR/$NAME/OpenDance.exe" "$VERSION" \
|
||||
|| die "包内 exe 的 PE 版本资源不是 v$VERSION"
|
||||
log "包内 exe 的 PE 版本资源 = v$VERSION"
|
||||
|
||||
# pck 与 exe 必须同名同目录,否则 Godot 找不到资源
|
||||
[ "$(basename "$CHECK_DIR/$NAME/OpenDance.exe" .exe)" = "$(basename "$CHECK_DIR/$NAME/OpenDance.pck" .pck)" ] \
|
||||
|| die "exe 与 pck 主文件名不一致,导出产物不可运行"
|
||||
@@ -164,3 +178,6 @@ fi
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
extends SceneTree
|
||||
|
||||
## 发布产物验收探针(T-WIN-018,由 build/accept-release.sh 调用)
|
||||
##
|
||||
## 用法:
|
||||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||||
## --script /绝对路径/build/probe-release.gd \
|
||||
## -- --expect-version 0.2.2 --snapshot /绝对路径/contracts/VERSION
|
||||
##
|
||||
## 为什么需要它:`build/verify.sh` 验证的是**仓库源码**(`godot --path .`),
|
||||
## 而交付给用户的是 zip 里的 `OpenDance.pck`。差别全在「导出」这一步:
|
||||
## 导出预设漏了资源、pck 没重新生成、版本号没同步、契约快照没更新……
|
||||
## 都只在包里才暴露。本探针**只读包内资源**(`--main-pack` + `res://`),
|
||||
## 所以它给出的版本号、契约表、autoload、主场景就是用户装到机器上的那一份。
|
||||
##
|
||||
## 断言(任一不满足即 quit(1)):
|
||||
## 1. ProjectSettings 的 application/config/version == 期望版本
|
||||
## (界面上「关于」清单与 .pck 都源自它 —— 文档 §11.1);
|
||||
## 2. AboutScreen.app_version() == 期望版本(界面真正会显示的值);
|
||||
## 3. 主场景非空且可加载,四个 autoload 都在场景树里;
|
||||
## 4. 关键资源(UI / 模型 / 场景 / 网络 / 评分)在包内都可加载;
|
||||
## 5. 包内 Credits.CONTRACT_VERSIONS 与**包内** contracts/VERSION 快照逐项一致
|
||||
## —— 抓「pck 用旧契约构建、快照却拷了新的」这类漂移。
|
||||
|
||||
const KEY_RESOURCES := [
|
||||
"res://src/ui/credits.gd",
|
||||
"res://src/ui/about_screen.gd",
|
||||
"res://src/ui/main.gd",
|
||||
"res://src/ui/main_menu.gd",
|
||||
"res://src/ui/scene_panel.gd",
|
||||
"res://src/ui/source_panel.gd",
|
||||
"res://src/model/humanoid_map.gd",
|
||||
"res://src/model/vrm_loader.gd",
|
||||
"res://src/scene/scene_runtime.gd",
|
||||
"res://src/net/api_client.gd",
|
||||
"res://src/net/catalog_types.gd",
|
||||
"res://src/net/pose_server.gd",
|
||||
"res://src/net/keypoint_frame.gd",
|
||||
"res://src/score/dtw.gd",
|
||||
"res://src/score/scoring.gd",
|
||||
]
|
||||
|
||||
const EXPECTED_AUTOLOADS := ["AppConfig", "CatalogStore", "PoseReceiver", "ScoreService"]
|
||||
|
||||
var _failed := 0
|
||||
|
||||
|
||||
func _check(ok: bool, text: String) -> void:
|
||||
print(" %s %s" % ["✓" if ok else "✗", text])
|
||||
if not ok:
|
||||
_failed += 1
|
||||
|
||||
|
||||
## 解析 contracts/VERSION(`key: value`,`#` 注释)
|
||||
func _read_snapshot(path: String) -> Dictionary:
|
||||
var out := {}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
return out
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return out
|
||||
while not file.eof_reached():
|
||||
var line := file.get_line().strip_edges()
|
||||
if line.is_empty() or line.begins_with("#"):
|
||||
continue
|
||||
var idx := line.find(":")
|
||||
if idx > 0:
|
||||
out[line.substr(0, idx).strip_edges()] = line.substr(idx + 1).strip_edges()
|
||||
file.close()
|
||||
return out
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var expect_version := ""
|
||||
var snapshot_path := ""
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
if args[i] == "--expect-version" and i + 1 < args.size():
|
||||
expect_version = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--snapshot" and i + 1 < args.size():
|
||||
snapshot_path = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
print("[accept] 包内运行时探针(--main-pack OpenDance.pck)")
|
||||
var project_version := str(ProjectSettings.get_setting("application/config/version", ""))
|
||||
print(" engine=%s project=%s main_scene=%s" % [
|
||||
str(Engine.get_version_info().get("string", "?")),
|
||||
project_version,
|
||||
str(ProjectSettings.get_setting("application/run/main_scene", "")),
|
||||
])
|
||||
|
||||
var about = load("res://src/ui/about_screen.gd")
|
||||
var about_version := str(about.app_version()) if about != null else ""
|
||||
var credits = load("res://src/ui/credits.gd")
|
||||
var contract_versions: Dictionary = credits.CONTRACT_VERSIONS if credits != null else {}
|
||||
|
||||
if not expect_version.is_empty():
|
||||
_check(project_version == expect_version,
|
||||
"包内 application/config/version = %s(期望 %s)" % [project_version, expect_version])
|
||||
_check(about_version == expect_version,
|
||||
"AboutScreen.app_version() = %s(「关于」清单会显示这个值)" % about_version)
|
||||
else:
|
||||
print(" · 未指定 --expect-version,跳过版本比对(实际 %s)" % project_version)
|
||||
|
||||
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||||
_check(not main_scene.is_empty() and load(main_scene) != null, "主场景可加载:%s" % main_scene)
|
||||
|
||||
var autoloads := []
|
||||
for child in root.get_children():
|
||||
autoloads.append(str(child.name))
|
||||
var missing := []
|
||||
for name in EXPECTED_AUTOLOADS:
|
||||
if not autoloads.has(name):
|
||||
missing.append(name)
|
||||
_check(missing.is_empty(), "autoload 齐备 %s(实际 %s)" % [str(EXPECTED_AUTOLOADS), str(autoloads)])
|
||||
|
||||
var unloadable := []
|
||||
for path in KEY_RESOURCES:
|
||||
if load(path) == null:
|
||||
unloadable.append(path)
|
||||
_check(unloadable.is_empty(), "关键资源全部可加载(%d 项)%s" % [
|
||||
KEY_RESOURCES.size(), "" if unloadable.is_empty() else " 缺失:" + str(unloadable)])
|
||||
|
||||
var snapshot := _read_snapshot(snapshot_path)
|
||||
if snapshot.is_empty():
|
||||
print(" · 未提供 contracts/VERSION 快照(%s),跳过契约比对" % snapshot_path)
|
||||
else:
|
||||
print(" [contracts] 包内 Credits = ", JSON.stringify(contract_versions))
|
||||
print(" [contracts] 包内快照文件 = ", JSON.stringify(snapshot))
|
||||
var mismatch := []
|
||||
for key in snapshot.keys():
|
||||
if not contract_versions.has(key):
|
||||
mismatch.append("%s 缺失" % key)
|
||||
elif str(contract_versions[key]) != str(snapshot[key]):
|
||||
mismatch.append("%s: pck=%s 快照=%s" % [key, contract_versions[key], snapshot[key]])
|
||||
for key in contract_versions.keys():
|
||||
if not snapshot.has(key):
|
||||
mismatch.append("%s 只存在于 pck" % key)
|
||||
_check(mismatch.is_empty(), "pck 内契约表与包内 contracts/VERSION 快照一致%s" % [
|
||||
"" if mismatch.is_empty() else ":" + str(mismatch)])
|
||||
|
||||
if _failed > 0:
|
||||
print("[accept] ✗ 探针发现 %d 项不符" % _failed)
|
||||
quit(1)
|
||||
return
|
||||
print("[accept] ✓ 包内运行时探针全部通过")
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb1l1hl8fpavq
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# OpenDance Studio —— 把当前版本号同步进 Godot 导出预设
|
||||
#
|
||||
# 为什么需要它:`export_presets.cfg` 的
|
||||
# application/file_version / application/product_version
|
||||
# 决定导出后 `OpenDance.exe` 的 PE `VERSIONINFO` 资源,也就是 Windows 上
|
||||
# 「右键 → 属性 → 详细信息」里显示的文件版本/产品版本。这两个键**不会**
|
||||
# 跟着 `project.godot` 的 `application/config/version` 自动走:
|
||||
# 它们当初被硬编码成 0.1.0 后一直没人改,导致 **v0.2.1 的包显示 0.1.0**。
|
||||
#
|
||||
# 因此发布前必须显式同步,并由 `build/check-pe-version.sh` 在产物上复核。
|
||||
#
|
||||
# 用法:
|
||||
# bash build/sync-export-version.sh [版本号] # 缺省取 project.yaml 的 version
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
PRESET="$PROJECT_DIR/export_presets.cfg"
|
||||
[ -f "$PRESET" ] || { echo "错误:缺少 $PRESET" >&2; exit 1; }
|
||||
|
||||
VERSION="${1:-$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)}"
|
||||
VERSION="${VERSION:-}"
|
||||
|
||||
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "错误:版本号必须形如 X.Y.Z(实际 '$VERSION')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHANGED=0
|
||||
for KEY in file_version product_version; do
|
||||
LINE="application/${KEY}=\"${VERSION}\""
|
||||
if grep -qE "^application/${KEY}=" "$PRESET"; then
|
||||
OLD=$(grep -E "^application/${KEY}=" "$PRESET" | head -1)
|
||||
if [ "$OLD" != "$LINE" ]; then
|
||||
# 用 sed 就地替换;值里只有数字和点,无需转义
|
||||
sed -i "s|^application/${KEY}=.*$|${LINE}|" "$PRESET"
|
||||
echo " application/${KEY}: ${OLD#*=} → \"${VERSION}\""
|
||||
CHANGED=1
|
||||
else
|
||||
echo " application/${KEY}: 已是 \"${VERSION}\""
|
||||
fi
|
||||
else
|
||||
# 预设里没有这一行就追加到 [preset.0.options] 段的末尾(application/ 键都在该段)
|
||||
printf '%s\n' "$LINE" >>"$PRESET"
|
||||
echo " application/${KEY}: 新增 \"${VERSION}\""
|
||||
CHANGED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$CHANGED" -eq 1 ]; then
|
||||
echo "✓ 导出预设已同步到 v${VERSION}(重新导出后 PE 资源才会更新)"
|
||||
else
|
||||
echo "✓ 导出预设已是 v${VERSION},无需改动"
|
||||
fi
|
||||
+31
-3
@@ -26,7 +26,35 @@ fi
|
||||
FAILED=0
|
||||
|
||||
echo "============================================================"
|
||||
echo " 1/3 脚本编译检查"
|
||||
echo " 0/5 版本号一致性(project.godot / project.yaml / export_presets.cfg)"
|
||||
echo "============================================================"
|
||||
if bash "$PROJECT_DIR/build/check-version.sh"; then
|
||||
:
|
||||
else
|
||||
echo "✗ 版本一致性校验失败"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " 1/5 产物 PE 版本资源(Windows 属性页显示的版本)"
|
||||
echo "============================================================"
|
||||
EXPORTED_EXE="$PROJECT_DIR/build/windows/OpenDance.exe"
|
||||
EXPECTED_VERSION="$(sed -n 's/^config\/version="\(.*\)"$/\1/p' "$PROJECT_DIR/project.godot" | head -1)"
|
||||
if [ -f "$EXPORTED_EXE" ]; then
|
||||
if bash "$PROJECT_DIR/build/check-pe-version.sh" "$EXPORTED_EXE" "$EXPECTED_VERSION"; then
|
||||
:
|
||||
else
|
||||
echo "✗ 已导出 exe 的 PE 版本资源与 v$EXPECTED_VERSION 不一致(属性页会显示错版本)"
|
||||
FAILED=1
|
||||
fi
|
||||
else
|
||||
echo "· 尚未导出 build/windows/OpenDance.exe,跳过(打包时 package-release.sh 会强制校验)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " 2/5 脚本编译检查"
|
||||
echo "============================================================"
|
||||
IMPORT_LOG=$(mktemp)
|
||||
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --import >"$IMPORT_LOG" 2>&1
|
||||
@@ -41,7 +69,7 @@ rm -f "$IMPORT_LOG"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " 2/3 无头测试(单元 + 真实 API 集成)"
|
||||
echo " 3/5 无头测试(单元 + 真实 API 集成)"
|
||||
echo "============================================================"
|
||||
TEST_LOG=$(mktemp)
|
||||
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --script res://tests/run_tests.gd >"$TEST_LOG" 2>&1
|
||||
@@ -58,7 +86,7 @@ rm -f "$TEST_LOG"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " 3/3 应用启动检查(180 帧)"
|
||||
echo " 4/5 应用启动检查(180 帧)"
|
||||
echo "============================================================"
|
||||
RUN_LOG=$(mktemp)
|
||||
timeout 120 "$GODOT_BIN" --headless --path "$PROJECT_DIR" --quit-after 180 >"$RUN_LOG" 2>&1
|
||||
|
||||
+2
-2
@@ -37,8 +37,8 @@ application/modify_resources=true
|
||||
application/icon=""
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version="0.1.0"
|
||||
application/product_version="0.1.0"
|
||||
application/file_version="0.2.3"
|
||||
application/product_version="0.2.3"
|
||||
application/company_name="OpenDance Studio"
|
||||
application/product_name="OpenDance Studio"
|
||||
application/file_description="OpenDance Studio 摄像头跳舞评分客户端"
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ config_version=5
|
||||
|
||||
config/name="OpenDance Studio"
|
||||
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
|
||||
config/version="0.2.0"
|
||||
config/version="0.2.3"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
+18
-2
@@ -1,5 +1,5 @@
|
||||
project: opendance-windows-client
|
||||
version: 0.2.0
|
||||
version: 0.2.3
|
||||
repository: opendance-windows
|
||||
default_branch: main
|
||||
engine: Godot 4.7.2
|
||||
@@ -82,13 +82,29 @@ tasks:
|
||||
file: tasks/014-full-package.md
|
||||
scope: 发布整包(单个 zip + SHA256SUMS,不拆分卷)
|
||||
status: done
|
||||
- id: T-WIN-016
|
||||
file: tasks/016-aabb-transforms.md
|
||||
scope: 场景包围盒沿树累乘变换(修复入树前用 global_transform 导致的 ERROR 与范围偏位)
|
||||
status: done
|
||||
- id: T-WIN-015
|
||||
file: tasks/015-keepalive-dedup.md
|
||||
scope: 连接保活 / 设备去重 / 状态机(契约 websocket-keypoints 0.2.0,与 T-AND-011 对齐)
|
||||
status: done
|
||||
- id: T-WIN-017
|
||||
file: tasks/017-pe-version.md
|
||||
scope: 发布包 exe 的 PE 版本资源(Windows 属性页显示的文件/产品版本)必须等于发布版本
|
||||
status: done
|
||||
- id: T-WIN-018
|
||||
file: tasks/018-release-acceptance.md
|
||||
scope: 发布包级端到端验收(读包内 pck:版本号 / 界面显示值 / 契约表 / 资源可加载)
|
||||
status: done
|
||||
- id: T-WIN-019
|
||||
file: tasks/019-cross-end-integration.md
|
||||
scope: 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 补 Motion.thumbnailUrl 契约字段
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-windows-v0.2.0.zip
|
||||
artifact: opendance-windows-v0.2.3.zip
|
||||
packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底)
|
||||
release_rules: # 文档 §5.7 硬性要求
|
||||
- 单个完整包,禁止 .partN 分卷
|
||||
|
||||
+10
-2
@@ -107,9 +107,16 @@ func _request(method: int, path: String, body: Dictionary = {}, as_text: bool =
|
||||
request_completed.emit(path, false, status)
|
||||
return outcome
|
||||
|
||||
var text := raw.get_string_from_utf8()
|
||||
# 只有**需要文本**时才做 UTF-8 解码。
|
||||
# 原来这里是无条件 `raw.get_string_from_utf8()`,对二进制响应(模型 VRM、场景 GLB、
|
||||
# 缩略图 PNG)有两个后果:
|
||||
# 1. 每下载一个文件都会打印一条
|
||||
# 「Unicode parsing error … Invalid UTF-8 leading byte (N)」噪声 ——
|
||||
# 跨端集成探针里第一次注意到它;
|
||||
# 2. 白白把整个文件(模型可达数十 MB)解码成一份 String 拷贝后才丢弃。
|
||||
# 二进制路径返回的一直是 `raw`,所以数据本身没错,但代价与噪声都是白付的。
|
||||
if status < 200 or status >= 300:
|
||||
outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(text)]
|
||||
outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(raw.get_string_from_utf8())]
|
||||
request_completed.emit(path, false, status)
|
||||
return outcome
|
||||
|
||||
@@ -119,6 +126,7 @@ func _request(method: int, path: String, body: Dictionary = {}, as_text: bool =
|
||||
request_completed.emit(path, true, status)
|
||||
return outcome
|
||||
|
||||
var text := raw.get_string_from_utf8()
|
||||
if as_text:
|
||||
outcome["ok"] = true
|
||||
outcome["data"] = text
|
||||
|
||||
@@ -94,6 +94,8 @@ class Motion extends RefCounted:
|
||||
var keypoint_frames: int = 0
|
||||
var preprocess_state: String = "none"
|
||||
var preprocess_error: String = ""
|
||||
## 首帧骨架缩略图(契约 data-models 0.6.0 起 Motion 也有该字段)
|
||||
var thumbnail_url: String = ""
|
||||
var source: SourceInfo = SourceInfo.new()
|
||||
var status: String = ""
|
||||
|
||||
@@ -113,6 +115,7 @@ class Motion extends RefCounted:
|
||||
out.keypoint_frames = int(d.get("keypointFrames", 0) if d.get("keypointFrames") != null else 0)
|
||||
out.preprocess_state = str(d.get("preprocessState", "none"))
|
||||
out.preprocess_error = CatalogTypes._nullable_string(d.get("preprocessError"))
|
||||
out.thumbnail_url = CatalogTypes._nullable_string(d.get("thumbnailUrl"))
|
||||
out.source = SourceInfo.from_dict(d)
|
||||
out.status = str(d.get("status", ""))
|
||||
return out
|
||||
|
||||
@@ -360,16 +360,33 @@ static func _collect_static_bodies(node: Node) -> Array:
|
||||
return out
|
||||
|
||||
|
||||
static func _compute_aabb(node: Node) -> AABB:
|
||||
## 累加包围盒。
|
||||
##
|
||||
## * 累加包围盒。
|
||||
## *
|
||||
## * ⚠️ 必须**自己沿树累乘变换**,不能用 `global_transform`:本函数在 `_assemble()` 里
|
||||
## * 调用,而那时运行时节点**还没被加入场景树**,`global_transform` 会因为
|
||||
## * "!is_inside_tree()" 直接返回单位变换并**打一行 ERROR 日志**(真机上每次载入
|
||||
## * 场景都会刷一条),算出的包围盒也只是各网格的局部 AABB —— 对于把网格摆在
|
||||
## * 非原点位置的场景,重力/风区域与兜底地面会算得偏小或偏位,**风力可能作用不到**。
|
||||
## *
|
||||
## * 这里改成从根往下累乘 `Node3D.transform`:不依赖是否入树,结果与运行时被挂在
|
||||
## * 单位变换的父节点下时完全一致。
|
||||
static func _compute_aabb(node: Node, parent_transform: Transform3D = Transform3D.IDENTITY) -> AABB:
|
||||
var local := Transform3D.IDENTITY
|
||||
if node is Node3D:
|
||||
local = (node as Node3D).transform
|
||||
var here := parent_transform * local
|
||||
|
||||
var result := AABB()
|
||||
var first := true
|
||||
if node is MeshInstance3D:
|
||||
var mesh_node := node as MeshInstance3D
|
||||
if mesh_node.visible:
|
||||
result = mesh_node.global_transform * mesh_node.get_aabb()
|
||||
result = here * mesh_node.get_aabb()
|
||||
first = false
|
||||
for child in node.get_children():
|
||||
var child_aabb := _compute_aabb(child)
|
||||
var child_aabb := _compute_aabb(child, here)
|
||||
if child_aabb.size == Vector3.ZERO:
|
||||
continue
|
||||
if first:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# T-WIN-016 场景包围盒沿树累乘变换
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-013
|
||||
- **文档**: v1.6 §5.6(场景 GLB 正常加载 / 物理参数生效)
|
||||
|
||||
## 缺陷
|
||||
|
||||
`SceneRuntime._compute_aabb()` 用 `MeshInstance3D.global_transform` 计算包围盒,
|
||||
而它在 `_assemble()` 里被调用 —— 那时运行时节点**还没被加入场景树**。两个后果:
|
||||
|
||||
1. Godot 会打一行错误日志(每次载入场景都会刷):
|
||||
|
||||
```
|
||||
ERROR: Condition "!is_inside_tree()" is true. Returning: Transform3D()
|
||||
```
|
||||
|
||||
这与「日志里不应出现 ERROR」的排查约定冲突,真机排查时是噪声;
|
||||
2. `get_global_transform()` 在树外返回单位变换,于是算出来的只是各网格的**局部**
|
||||
AABB。包围盒用于①重力/风 `Area3D` 的碰撞盒尺寸与位置 ②兜底地面的尺寸与位置 ——
|
||||
对于把网格摆在非原点位置的场景(导出 GLB 的常见情况),区域会**偏小或偏位**,
|
||||
**风力/重力可能作用不到物体上**。
|
||||
|
||||
## 修复
|
||||
|
||||
改为从根节点往下**累乘 `Node3D.transform`**:
|
||||
|
||||
```gdscript
|
||||
static func _compute_aabb(node: Node, parent_transform: Transform3D = Transform3D.IDENTITY) -> AABB:
|
||||
var local := Transform3D.IDENTITY
|
||||
if node is Node3D:
|
||||
local = (node as Node3D).transform
|
||||
var here := parent_transform * local
|
||||
...
|
||||
```
|
||||
|
||||
不依赖是否入树;运行时被挂在单位变换的父节点下时,结果与旧的"全局变换"语义一致。
|
||||
|
||||
## 回归测试(`tests/test_scene_config.gd`)
|
||||
|
||||
在代码里**手写一个合法 GLB**(三角形:POSITION 3×VEC3 + indices 3×u16 + BIN chunk)
|
||||
落盘后真实加载,覆盖:
|
||||
|
||||
* 真实 GLB 加载 `warnings` 为空、不使用兜底地面;
|
||||
* `colliders` 三种取值:`mesh` → 按网格生成;`auto` 命中 `*_col` → 生成;
|
||||
`auto` 未命中 → 兜底地面并标记;`none` → 不生成也不兜底;
|
||||
* 重力 `Area3D` 的 `gravity` / `gravity_direction` 与服务端配置一致,
|
||||
且用 `SPACE_OVERRIDE_REPLACE`(不改全局 `ProjectSettings`);
|
||||
* 风力按基础风向量生效(`wind_force_at()` 与 `WIND_FORCE_SCALE` 对齐);
|
||||
* **带位移网格**(节点 `translation [10,0,0]`)时,重力区域碰撞盒与兜底地面
|
||||
都跟着位移(x≈10)—— 这一条正是上述缺陷的回归测试。
|
||||
|
||||
## 顺带补充:保活的**时间维度**验证(`tests/test_pose_server.gd`)
|
||||
|
||||
用真实的 15 秒静默超时跑一次对照:
|
||||
|
||||
* 只发应用层 `ping`、**不发任何关键点帧**的客户端,17 秒后**仍保持连接**
|
||||
(「画面里没人就掉线、然后疯狂重连」的根因对照);
|
||||
* 连上后什么都不发的客户端被 `close(1001, "timeout")` 断开(超时机制仍有效,
|
||||
且按契约 §7 对端不会重连)。
|
||||
|
||||
## 验收
|
||||
|
||||
| 项 | 结果 |
|
||||
|--|--|
|
||||
| 无头测试 | **751 项断言全部通过**(原 720) |
|
||||
| `build/verify.sh` | 全通过(新增第 0 步版本一致性) |
|
||||
| 日志 | 真实 GLB 加载路径不再出现 `!is_inside_tree()` ERROR |
|
||||
| 版本号 | `project.godot` = `project.yaml` = 发布版本 = **0.2.1**(`build/check-version.sh` 强制) |
|
||||
@@ -0,0 +1,92 @@
|
||||
# T-WIN-017:发布包 exe 的 PE 版本资源必须等于发布版本
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-014(打包流程)
|
||||
- **文档**: v1.6 §5.3 / §5.7 / §11.2
|
||||
- **契约**: 不涉及(纯打包元数据)
|
||||
|
||||
## 缺陷(v0.2.1 实测)
|
||||
|
||||
发布包 `opendance-windows-v0.2.1.zip` 里的 `OpenDance.exe`,在 Windows 上
|
||||
「右键 → 属性 → 详细信息」显示的是:
|
||||
|
||||
```
|
||||
文件版本 0.1.0
|
||||
产品版本 0.1.0
|
||||
```
|
||||
|
||||
而产物名、Git tag、README 全都是 **v0.2.1** —— 正是文档 §11.2 要禁止的
|
||||
「不可核对的发布」。
|
||||
|
||||
### 根因
|
||||
|
||||
`export_presets.cfg` 里这两个键被**硬编码在 0.1.0**,且**没有任何环节读写或校验**:
|
||||
|
||||
```ini
|
||||
application/file_version="0.1.0"
|
||||
application/product_version="0.1.0"
|
||||
```
|
||||
|
||||
Godot 导出时用它们(经 rcedit)写进 exe 的 PE `VERSIONINFO` 资源。
|
||||
它们**不会**跟随 `project.godot` 的 `application/config/version` 自动更新 ——
|
||||
于是每次只是改 `project.godot` + `project.yaml` 发版,二进制元数据一直停在 0.1.0。
|
||||
|
||||
`build/check-version.sh` 当时只比 `project.godot` ↔ `project.yaml`,两处一致,
|
||||
所以**静态校验全绿、产物却是错的**:漂移发生在没人看的第三个位置。
|
||||
|
||||
### 证据
|
||||
|
||||
```
|
||||
$ strings -a -el opendance-windows-v0.2.1/OpenDance.exe | sed -n '3147,3151p'
|
||||
FileVersion
|
||||
0.1.0
|
||||
ProductVersion
|
||||
0.1.0
|
||||
|
||||
$ bash build/check-pe-version.sh <exe> 0.2.1
|
||||
PE 版本资源(…/OpenDance.exe):
|
||||
VS_FIXEDFILEINFO FileVersion = 0.1.0.0
|
||||
VS_FIXEDFILEINFO ProductVersion = 0.0.0.0
|
||||
StringFileInfo FileVersion = 0.1.0
|
||||
StringFileInfo ProductVersion = 0.1.0
|
||||
✗ PE FileVersion 为 0.1.0.0,期望 0.2.1.x(Windows 属性页会显示错版本)
|
||||
✗ 资源字符串 FileVersion 为 '0.1.0',期望 '0.2.1'
|
||||
✗ 资源字符串 ProductVersion 为 '0.1.0',期望 '0.2.1'
|
||||
```
|
||||
|
||||
(对照组:官方模板 `windows_release_x86_64.exe` 的 `FileVersion = 4.7.2`,
|
||||
说明该机上的 rcedit 改写**是生效的**,问题纯粹出在预设的值本身。)
|
||||
|
||||
## 修复
|
||||
|
||||
| 环节 | 文件 | 做法 |
|
||||
|--|--|--|
|
||||
| 同步 | `build/sync-export-version.sh`(新增) | 把版本号写进 `export_presets.cfg` 的 `application/file_version` / `application/product_version`;缺行则补行;幂等;拒绝非 `X.Y.Z` |
|
||||
| 导出 | `build/export-windows.sh` | **导出前**先同步;**导出后**用 `check-pe-version.sh` 直接读 exe 复核 |
|
||||
| 静态防线 | `build/check-version.sh` | 新增对 `export_presets.cfg` 两个键的校验(不等于项目版本即失败) |
|
||||
| 产物防线 | `build/check-pe-version.sh`(新增) | 直接读 exe 的 PE 版本资源:`VS_FIXEDFILEINFO`(签名 `0xFEEF04BD` 之后 16 字节,`grep -abo` 取偏移约 40 ms)+ `StringFileInfo`(`strings -el`,资源管理器属性页显示的就是这两项) |
|
||||
| 打包 | `build/package-release.sh` | 打包前校验源 exe、解包后再校验包内 exe(确认打包/解包没换错文件) |
|
||||
| 一键验证 | `build/verify.sh` | 新增步骤 1/5:已导出 exe 的 PE 版本资源;未导出则跳过并提示(打包时会强制) |
|
||||
| 单元测试 | `tests/test_about.gd` | 新增 `test_export_preset_version_matches_project_version`:预设两个键必须等于版本号唯一来源 |
|
||||
|
||||
> 只校验 `VS_FIXEDFILEINFO` 是不够的:本机实测该结构里的
|
||||
> `ProductVersion` 恒为 `0.0.0.0`(rcedit 只填了字符串形式),而属性页显示的是
|
||||
> `StringFileInfo` 的文本值。因此两处都读、都对。
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 产物 exe 的 PE 版本资源 = 发布版本 | ✅ `check-pe-version.sh` 读 v0.2.2 产物通过(FileVersion 0.2.2.0 / 字符串 0.2.2) |
|
||||
| 预设漂移能被拦住 | ✅ 反向验证:把预设改回 `0.1.0`,`check-version.sh` 与 `test_about.gd` 均失败 |
|
||||
| 打包前/解包后都校验 | ✅ `package-release.sh` 两处 `check-pe-version.sh`,失败即 `die` |
|
||||
| 不新增外部依赖 | ✅ 只用 `grep -abo` + `od` + `strings`(binutils),无 python/node 依赖 |
|
||||
|
||||
## 复现命令
|
||||
|
||||
```bash
|
||||
cd opendance-windows
|
||||
bash build/check-pe-version.sh build/windows/OpenDance.exe 0.2.2 # 产物侧
|
||||
bash build/check-version.sh 0.2.2 # 配置侧
|
||||
bash build/sync-export-version.sh 0.2.2 # 需要修时
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# T-WIN-018:发布包级端到端验收(读包内 pck,不是读源码)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-014(整包发布)/ T-WIN-017(PE 版本元数据)
|
||||
- **文档**: v1.6 §5.7 / §7.5 / §11
|
||||
- **契约**: 不涉及(验收流程)
|
||||
|
||||
## 为什么需要
|
||||
|
||||
`build/verify.sh` 跑的是**仓库源码**(`godot --path .`),而交付给用户的是 zip 里的
|
||||
`OpenDance.pck` + `OpenDance.exe`。差别全在「导出」这一步:
|
||||
|
||||
| 只在导出后才可能出现的问题 | 源码侧能发现吗 |
|
||||
|--|--|
|
||||
| 导出预设漏了资源 / 资源没导入 | ❌ |
|
||||
| pck 没重新生成(用了上一次的) | ❌ |
|
||||
| 版本号没同步进预设 → PE 资源与 pck 里都是旧值 | ❌(v0.2.1 就这么翻的车) |
|
||||
| 契约快照文件拷了新的、pck 却是旧契约构建的 | ❌ |
|
||||
|
||||
本项目已在两端因此翻车(本仓 v0.2.1 的 PE 版本元数据、Web v0.7.4 的实体注册),
|
||||
所以三端统一要求:**每次发布都要有一条读产物本身的验收**(文档 §7.5)。
|
||||
|
||||
## 实现
|
||||
|
||||
| 文件 | 作用 |
|
||||
|--|--|
|
||||
| `build/probe-release.gd`(新增) | **包内运行时探针**:用 `godot --headless --main-pack <pck> --script <探针>` 只读包内资源,断言并 `quit(0/1)` |
|
||||
| `build/accept-release.sh`(新增) | 对一个 zip(或已解压目录)跑完整验收 |
|
||||
|
||||
### 探针断言(5 类)
|
||||
|
||||
1. `ProjectSettings` 的 `application/config/version` == 期望版本
|
||||
—— 这是「关于」清单与 pck 的**唯一来源**(§11.1);
|
||||
2. `AboutScreen.app_version()` == 期望版本 —— 界面真正会显示的值;
|
||||
3. 主场景非空且可加载;四个 autoload(`AppConfig` / `CatalogStore` /
|
||||
`PoseReceiver` / `ScoreService`)都在场景树里;
|
||||
4. 15 个关键资源(UI / 模型 / 场景 / 网络 / 评分)在包内都可加载;
|
||||
5. 包内 `Credits.CONTRACT_VERSIONS` 与**包内** `contracts/VERSION` 快照逐项一致
|
||||
—— 抓「pck 用旧契约构建、快照却拷了新的」。
|
||||
|
||||
### 验收脚本步骤
|
||||
|
||||
1. sha256 校验(有 `SHA256SUMS` 时);
|
||||
2. 解压(deflate 用 `unzip`,LZMA 用 `7z`)+ 文件清单完整性
|
||||
(`OpenDance.exe` / `OpenDance.pck` / `README.txt` / `THIRD_PARTY.md` / `contracts/VERSION`);
|
||||
顺带检查**不允许有 `.partN` 分卷残留**;
|
||||
3. exe 的 **PE 版本资源** = 发布版本(复用 `build/check-pe-version.sh`);
|
||||
4. 用 Godot 直接加载**包内 pck** 跑 180 帧,断言无 `SCRIPT ERROR`;
|
||||
5. 执行包内运行时探针。
|
||||
|
||||
## 实测(对**已发布**的 v0.2.2 资产,重新下载后跑)
|
||||
|
||||
```
|
||||
[accept] sha256 校验:opendance-windows-v0.2.2.zip: OK
|
||||
✓ OpenDance.exe / OpenDance.pck / README.txt / THIRD_PARTY.md / contracts/VERSION
|
||||
✓ PE 版本资源一致:FileVersion 0.2.2.0 / 资源字符串 0.2.2
|
||||
✓ 包内 pck 启动 180 帧无错误(退出码 0)
|
||||
✓ 包内 application/config/version = 0.2.2(期望 0.2.2)
|
||||
✓ AboutScreen.app_version() = 0.2.2(「关于」清单会显示这个值)
|
||||
✓ 主场景可加载:res://scenes/main.tscn
|
||||
✓ autoload 齐备 ["AppConfig", "CatalogStore", "PoseReceiver", "ScoreService"]
|
||||
✓ 关键资源全部可加载(15 项)
|
||||
✓ pck 内契约表与包内 contracts/VERSION 快照一致
|
||||
发布包级验收全部通过
|
||||
```
|
||||
|
||||
## 反向验证
|
||||
|
||||
| 负例 | 结果 |
|
||||
|--|--|
|
||||
| 把包内 `contracts/VERSION` 的 `rest-api` 改成 `0.4.0`(快照与 pck 不一致) | ✗ `pck 内契约表与包内 contracts/VERSION 快照一致:["rest-api: pck=0.5.0 快照=0.4.0"]`,退出码 1 |
|
||||
| 期望版本写错(如 `0.2.1` 对 v0.2.2 的包) | ✗ 三处版本断言全失败,退出码 1 |
|
||||
|
||||
> 首次运行还暴露了脚本自身的一个缺陷:`SHA256SUMS` 用相对路径 + `cd` 到包目录后
|
||||
> 被二次拼接成 `dist-release/dist-release/SHA256SUMS`,导致 sha256 校验假失败
|
||||
> (而其余检查全绿,很容易被忽略)。已改为绝对路径,并把这条记在脚本注释里。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2.zip
|
||||
# 或对已解压目录:bash build/accept-release.sh dist-release/opendance-windows-v0.2.2
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# T-WIN-019:跨端集成验收(包内客户端 × 真实 Web 服务端)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-013(场景选择)/ T-WIN-018(发布包级验收)/ T-WEB-015(场景管理)
|
||||
- **文档**: v1.6 §5.6 / §7.5
|
||||
- **契约**: `rest-api 0.5.0`、`data-models 0.7.0`、`skeleton-topology 0.2.0`
|
||||
|
||||
## 为什么需要
|
||||
|
||||
本仓的单测用的是**假 HTTP 夹具**(`tests/test_api_integration.gd` 打本地小服务),
|
||||
Web 端的测试只验自己那一侧。**两端各自绿灯 ≠ 契约字段真的对得上**:
|
||||
字段名、嵌套层级、null 与缺字段、单位(米/度/秒)、归一化范围,
|
||||
都可能在一侧对、另一侧错。契约 0.5.0 新增的 `scenes[]` 与物理参数尤其如此。
|
||||
|
||||
所以本任务是:**拿发布包里的客户端(包内 pck)打真实服务端**。
|
||||
|
||||
## 发现并修复的缺陷
|
||||
|
||||
探针首次运行就抓到一个真实缺口:
|
||||
|
||||
```
|
||||
服务端 GET /api/catalog/export 的 motion 条目(契约 data-models 0.6.0 起):
|
||||
...,"keypointUrl":"...","thumbnailUrl":"/static/motion-thumbnail/.../x.png","keypointFrames":2901,...
|
||||
客户端的 CatalogTypes.Motion:
|
||||
✗ 没有 thumbnail_url 字段(服务端一直在发,客户端默默丢掉)
|
||||
而同一文件的 Model / Scene 都有 thumbnail_url。
|
||||
```
|
||||
|
||||
**修复**:`src/net/catalog_types.gd` 的 `Motion` 增加 `thumbnail_url` 并解析
|
||||
`thumbnailUrl`(缺失时为 `""`,兼容旧服务端);新增
|
||||
`tests/test_catalog_types.gd`(4 项 / 20 条断言)用**服务端真实报文形状**锁住
|
||||
「三个资源类型都消费 `thumbnailUrl`」,并显式记录一处**刻意不消费**的差异:
|
||||
`Model.usable` / `unusableReason` —— 它们是 Web 端审核用的,
|
||||
而 `catalog/export` 只下发**可用**模型(目录里恒为 true),
|
||||
不可用模型的可见性由 Web 管理端负责。
|
||||
|
||||
## 实现
|
||||
|
||||
| 文件 | 作用 |
|
||||
|--|--|
|
||||
| `build/integration/probe-integration.gd` | 跨端集成探针:只加载**包内**代码(`--main-pack` + `res://`),打真实服务端并逐项断言 |
|
||||
| `build/integration/seed-catalog.mjs` | 通过真实 Web API 播种一份**真实**目录(真 VMD → 内部标准;真 PMX → VRM;场景 GLB + 物理参数),并写出期望值 JSON |
|
||||
| `build/accept-integration.sh` | 编排:检查服务端可达 → 播种(可跳过)→ 对包内 pck 跑探针 → 汇报 |
|
||||
|
||||
### 探针断言(对应需求编号)
|
||||
|
||||
| 需求 | 断言 |
|
||||
|--|--|
|
||||
| ⑤ API 升级 | `/health` 连通 + 六个契约版本与 Web 一致;`/api/catalog/version` 指纹非空;`catalog/export` 四类资源齐备;`defaultModelId` 解析为默认模型 id |
|
||||
| ⑤ 场景选择 + 物理参数落地 | **逐项**比对类型化后的场景参数:重力 y、基础风 x/z、阵风幅度/频率、风坐标系、地面摩擦/坡度、碰撞体模式、出生点 yaw、相机 mode/distance/height、环境光与雾;另外校验 `Wind.sample(0)` 等于基础风;场景 GLB **可下载**且魔数与 `fileSize` 一致 |
|
||||
| ② 动作内部标准 | `keypointFormat == mediapipe33@0.1.0`、服务端帧数、`is_scorable()`;拉取 NDJSON 后**客户端解析帧数 == 服务端帧数**、`skipped_lines == 0`、首帧 33 点、每帧带服务端关节角、时间轴推导帧率 ≈ 30 fps、时长 ≈ 96.7 s |
|
||||
| ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG |
|
||||
| ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 |
|
||||
|
||||
## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL)
|
||||
|
||||
```
|
||||
[integration] Windows 包内客户端 × 真实 Web 服务端:http://127.0.0.1:3100
|
||||
✓ GET /health 200(服务端版本 0.7.4);契约 6 项全部一致
|
||||
✓ 目录指纹非空;目录内嵌契约版本与 /health 一致
|
||||
✓ 目录四类资源齐备:songs=1 motions=1 models=1 scenes=1
|
||||
✓ defaultModelId 解析为 1
|
||||
✓ 场景 glbUrl/fileSize;重力 y=-3.50;基础风 (2.0,0.0,-1.0);阵风 4.0 m/s @ 0.50 Hz
|
||||
✓ 风坐标系 world;地面摩擦/坡度 0.80/30°;碰撞体 mesh;出生点 yaw 90°
|
||||
✓ 相机 orbit/6.0/2.0;环境光 2.0/雾 0.20;风采样 t=0 等于基础风
|
||||
✓ 场景 GLB 可下载且魔数正确(glTF,1153244 字节)
|
||||
✓ 动作内部标准 mediapipe33@0.1.0;客户端解析帧数 2901 == 服务端 2901
|
||||
✓ 无解析失败行;首帧 33 点;每帧带服务端关节角;30.00 fps;96.67 s
|
||||
✓ 动作缩略图可下载且为 PNG
|
||||
✓ 模型格式 vrm、默认模型、文件可下载且 glTF 容器
|
||||
[integration] ✓ 跨端集成探针全部通过
|
||||
```
|
||||
|
||||
### 真实素材
|
||||
|
||||
播种用的都是仓库根 `示例文件/` 下的真实素材(不是编造的):
|
||||
`【动作】霜雪千年.vmd`(827,973 B → 2901 帧内部标准)、
|
||||
`模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`(1.0 MB → VRM,humanoid 19/22、`drivable`)。
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
1. **autoload 单例在 `--script` 模式下不是全局标识符**:
|
||||
`AppConfig` / `CatalogStore` 直接当名字用会 `Compile Error: Identifier not found`
|
||||
(脚本来自项目外,编译期不认这两个 autoload 名)。改为
|
||||
`root.get_node_or_null("AppConfig")` 取节点后动态调用。
|
||||
2. **协程里一处运行时错误会让进程永不退出**:`_finish()` 不会被调用,
|
||||
表现为「无输出卡住」,很难定位。已加 120 秒**看门狗**:超时判失败并退出。
|
||||
3. **输出缓冲**:`print()` 管道输出在进程被 SIGTERM 杀死时会丢,
|
||||
调试这类脚本要重定向到文件而不是 `| head`。
|
||||
@@ -62,3 +62,19 @@ func test_entry_scene_version_is_exported_into_build() -> void:
|
||||
var version := AboutScreen.app_version()
|
||||
contains("opendance-windows-v%s.zip" % version, version, "产物文件名包含版本号")
|
||||
ok(Credits.entry_count() >= 10, "开源声明条目仍齐备")
|
||||
|
||||
|
||||
func test_export_preset_version_matches_project_version() -> void:
|
||||
# 缺陷回归:export_presets.cfg 的 application/file_version 与 product_version
|
||||
# 决定导出后 exe 的 PE VERSIONINFO 资源,也就是 Windows「右键 → 属性 →
|
||||
# 详细信息」里显示的文件版本/产品版本。这两个键**不会**跟随 project.godot 的
|
||||
# application/config/version 自动更新,v0.2.1 的发布包因此显示的是 0.1.0
|
||||
# (产物名与 tag 都是 v0.2.1,二进制元数据却是 0.1.0)。
|
||||
# 产物侧由 build/check-pe-version.sh 直接读 exe 复核,这里做静态防线。
|
||||
var version := AboutScreen.app_version()
|
||||
var preset := _read_text("res://export_presets.cfg")
|
||||
ne(preset, "", "可读取 export_presets.cfg")
|
||||
contains(preset, "application/file_version=\"%s\"" % version,
|
||||
"导出预设的 file_version 与版本号唯一来源一致")
|
||||
contains(preset, "application/product_version=\"%s\"" % version,
|
||||
"导出预设的 product_version 与版本号唯一来源一致")
|
||||
|
||||
@@ -21,6 +21,12 @@ var _available := false
|
||||
var _unavailable_reason := ""
|
||||
|
||||
|
||||
## 服务端基地址:默认契约里的 127.0.0.1:3000,可用 OPENDANCE_API_BASE 覆盖
|
||||
func _api_base() -> String:
|
||||
var from_env := OS.get_environment("OPENDANCE_API_BASE").strip_edges()
|
||||
return from_env if from_env != "" else "http://127.0.0.1:3000"
|
||||
|
||||
|
||||
func _ensure_client() -> bool:
|
||||
if _client != null:
|
||||
# 每次都要显式 skip:否则后续测试会静默 return,
|
||||
@@ -30,7 +36,7 @@ func _ensure_client() -> bool:
|
||||
return _available
|
||||
|
||||
var tree := Engine.get_main_loop() as SceneTree
|
||||
_client = ApiClient.new("http://127.0.0.1:3000")
|
||||
_client = ApiClient.new(_api_base())
|
||||
_client.timeout_seconds = 8.0
|
||||
tree.root.add_child(_client)
|
||||
|
||||
@@ -45,15 +51,37 @@ func _ensure_client() -> bool:
|
||||
return _available
|
||||
|
||||
|
||||
## 契约版本**不能写死在测试里**:写死过一次(0.1.0),而这条用例只在
|
||||
## 「服务端可达」时才跑,平时一直是 skip,于是契约升到 0.5.0 后没有任何地方发现它。
|
||||
## 现在改成从仓库的 `contracts/VERSION`(三端逐字节一致的单一来源)读期望值,
|
||||
## 并与服务端 `/health` 报的逐一比对 —— 顺带成为「客户端契约快照 vs 服务端」的跨端校验。
|
||||
func test_health_reports_contract_version() -> void:
|
||||
if not await _ensure_client():
|
||||
return
|
||||
var result: Dictionary = await _client.health()
|
||||
is_true(result["ok"], "GET /health 成功")
|
||||
var version: Dictionary = result["data"].get("contractVersion", {})
|
||||
eq(str(version.get("rest-api", "")), "0.1.0", "契约 rest-api 版本为 0.1.0")
|
||||
eq(str(version.get("websocket-keypoints", "")), "0.1.0", "契约 websocket-keypoints 版本为 0.1.0")
|
||||
eq(str(version.get("skeleton-topology", "")), "0.1.0", "契约 skeleton-topology 版本为 0.1.0")
|
||||
var expected := _read_contract_snapshot()
|
||||
ok(expected.size() > 0, "可读取仓库 contracts/VERSION(%d 项)" % expected.size())
|
||||
for key in expected.keys():
|
||||
eq(str(version.get(key, "")), str(expected[key]), "契约 %s 与仓库快照一致" % key)
|
||||
|
||||
|
||||
## 解析 res://contracts/VERSION(`key: value`,`#` 注释)
|
||||
func _read_contract_snapshot() -> Dictionary:
|
||||
var out := {}
|
||||
if not FileAccess.file_exists("res://contracts/VERSION"):
|
||||
return out
|
||||
var file := FileAccess.open("res://contracts/VERSION", FileAccess.READ)
|
||||
if file == null:
|
||||
return out
|
||||
while not file.eof_reached():
|
||||
var line := file.get_line().strip_edges()
|
||||
if not line.is_empty() and not line.begins_with("#") and line.contains(":"):
|
||||
var idx := line.find(":")
|
||||
out[line.substr(0, idx).strip_edges()] = line.substr(idx + 1).strip_edges()
|
||||
file.close()
|
||||
return out
|
||||
|
||||
|
||||
func test_catalog_version_returns_revision() -> void:
|
||||
@@ -159,3 +187,48 @@ func test_unknown_route_returns_structured_error() -> void:
|
||||
is_false(result["ok"], "不存在的资源返回失败")
|
||||
eq(int(result["status"]), 404, "状态码为 404")
|
||||
ok(str(result["error"]).length() > 0, "错误信息非空", str(result["error"]))
|
||||
|
||||
|
||||
## T-WIN-019 回归:**二进制响应不得经过 UTF-8 字符串往返**
|
||||
##
|
||||
## 发现经过:跨端集成探针拉动作缩略图(PNG)时,控制台出现
|
||||
## 「Unicode parsing error … Invalid UTF-8 leading byte (89)」。
|
||||
## 根因是 `ApiClient._request()` 对所有响应都无条件执行
|
||||
## `raw.get_string_from_utf8()`:数据本身仍返回原始字节(所以之前没出错),
|
||||
## 但每个文件下载都白做一份 String 拷贝并刷一条噪声日志。
|
||||
## 本用例把「必须原样返回字节」钉住:对含非法 UTF-8 的载荷,
|
||||
## 字符串往返会改变长度,而我们的返回值必须与服务端声明的大小完全一致。
|
||||
func test_binary_download_returns_exact_bytes() -> void:
|
||||
if not await _ensure_client():
|
||||
return
|
||||
|
||||
var export_result: Dictionary = await _client.catalog_export()
|
||||
if not export_result["ok"]:
|
||||
skip("二进制下载", "catalog/export 不可用")
|
||||
return
|
||||
var bundle := CatalogTypes.CatalogBundle.from_dict(export_result["data"])
|
||||
|
||||
var url := ""
|
||||
var expected_size := 0
|
||||
if bundle.scenes.size() > 0:
|
||||
url = bundle.scenes[0].glb_url
|
||||
expected_size = bundle.scenes[0].file_size
|
||||
elif bundle.models.size() > 0:
|
||||
url = bundle.models[0].file_url
|
||||
expected_size = bundle.models[0].file_size
|
||||
if url == "":
|
||||
skip("二进制下载", "目录里没有可下载的模型/场景")
|
||||
return
|
||||
|
||||
var result: Dictionary = await _client.get_binary(url)
|
||||
is_true(result["ok"], "二进制下载成功:%s" % url)
|
||||
if not result["ok"]:
|
||||
return
|
||||
var payload: PackedByteArray = result["data"]
|
||||
eq(typeof(payload), TYPE_PACKED_BYTE_ARRAY, "返回类型是 PackedByteArray")
|
||||
eq(payload.size(), expected_size, "字节数与服务端声明的 fileSize 一致")
|
||||
|
||||
# 证明「字符串往返会损坏数据」:这些字节里含非法 UTF-8 序列
|
||||
var round_tripped := payload.get_string_from_utf8().to_utf8_buffer()
|
||||
ok(round_tripped.size() != payload.size() or round_tripped != payload,
|
||||
"该载荷经 UTF-8 往返会失真(说明不能拿字符串通道传二进制)")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
extends TestCase
|
||||
|
||||
## T-WIN-019:目录类型的**契约字段覆盖**
|
||||
##
|
||||
## 来源:跨端集成探针(`build/integration/probe-integration.gd`)拿**发布包里的**
|
||||
## 客户端打真实 Web 服务端时,用真实 `catalog/export` 报文逐字段比对,
|
||||
## 发现 `Motion.thumbnailUrl`(契约 data-models 0.6.0 起就有)在客户端类型里**缺失** ——
|
||||
## 服务端一直在发,客户端默默丢掉。本文件把这个缺口钉住。
|
||||
##
|
||||
## 这里同时用**服务端真实报文形状**(键名与 `GET /api/catalog/export` 一致)做解析测试,
|
||||
## 而不是自己编一份更"理想"的样例 —— 否则测试会跟着客户端的假设一起漂移。
|
||||
|
||||
const CatalogTypes = preload("res://src/net/catalog_types.gd")
|
||||
|
||||
## 取自真实服务端报文的动作条目(截取与本端相关的字段,键名保持原样)
|
||||
func _motion_payload() -> Dictionary:
|
||||
return {
|
||||
"id": 1,
|
||||
"songId": 1,
|
||||
"name": "霜雪千年(真实 VMD)",
|
||||
"format": "vmd",
|
||||
"fileUrl": "/static/motion/2026/09/a8/x.vmd",
|
||||
"fileSize": 827973,
|
||||
"fps": 30,
|
||||
"frameCount": 2901,
|
||||
"durationMs": 96700,
|
||||
"keypointFormat": "mediapipe33@0.1.0",
|
||||
"keypointUrl": "/static/keypoints/2026/09/7d/y.ndjson",
|
||||
"thumbnailUrl": "/static/motion-thumbnail/2026/09/32/z.png",
|
||||
"keypointFrames": 2901,
|
||||
"preprocessState": "done",
|
||||
"preprocessError": null,
|
||||
"sourceType": "original",
|
||||
"sourceName": "素材",
|
||||
"sourceUrl": "https://example.com",
|
||||
"license": "MIT",
|
||||
"author": "QA",
|
||||
"status": "published",
|
||||
}
|
||||
|
||||
|
||||
func test_motion_parses_all_contract_fields() -> void:
|
||||
var m := CatalogTypes.Motion.from_dict(_motion_payload())
|
||||
eq(m.id, 1, "id")
|
||||
eq(m.song_id, 1, "songId → song_id")
|
||||
eq(m.format, "vmd", "format")
|
||||
eq(m.fps, 30.0, "fps")
|
||||
eq(m.frame_count, 2901, "frameCount → frame_count")
|
||||
eq(m.duration_ms, 96700, "durationMs → duration_ms")
|
||||
eq(m.keypoint_format, "mediapipe33@0.1.0", "keypointFormat → keypoint_format")
|
||||
eq(m.keypoint_frames, 2901, "keypointFrames → keypoint_frames")
|
||||
eq(m.preprocess_state, "done", "preprocessState → preprocess_state")
|
||||
is_true(m.is_scorable(), "预处理完成 → 可评分")
|
||||
# 缺口回归:这一条在集成探针发现前是失败的(客户端类型里没有这个字段)
|
||||
eq(m.thumbnail_url, "/static/motion-thumbnail/2026/09/32/z.png",
|
||||
"thumbnailUrl → thumbnail_url(T-WIN-019 缺字段回归)")
|
||||
|
||||
|
||||
func test_motion_thumbnail_missing_is_empty_not_crash() -> void:
|
||||
# 旧服务端(data-models < 0.6.0)不发 thumbnailUrl:必须是空串而不是解析失败
|
||||
var payload := _motion_payload()
|
||||
payload.erase("thumbnailUrl")
|
||||
var m := CatalogTypes.Motion.from_dict(payload)
|
||||
eq(m.thumbnail_url, "", "缺 thumbnailUrl 时为空串")
|
||||
eq(m.keypoint_frames, 2901, "其余字段仍正常解析")
|
||||
|
||||
|
||||
func test_model_and_scene_thumbnail_fields_present() -> void:
|
||||
# 三个资源类型现在都消费 thumbnailUrl(模型/场景本来就有,动作是本轮补的)
|
||||
var model := CatalogTypes.Model.from_dict({
|
||||
"id": 1, "name": "m", "format": "vrm", "vrmVersion": "1.0", "isPrimary": true,
|
||||
"fileUrl": "/static/model/x.vrm", "fileSize": 10, "thumbnailUrl": "/static/thumbnail/a.png",
|
||||
})
|
||||
eq(model.thumbnail_url, "/static/thumbnail/a.png", "Model.thumbnail_url")
|
||||
eq(model.vrm_version, "1.0", "Model.vrm_version")
|
||||
is_true(model.is_primary, "Model.is_primary")
|
||||
|
||||
var scene := CatalogTypes.Scene.from_dict({
|
||||
"id": 1, "name": "s", "glbUrl": "/static/model/x.glb",
|
||||
"thumbnailUrl": "/static/thumbnail/b.png", "fileSize": 20, "config": {},
|
||||
})
|
||||
eq(scene.thumbnail_url, "/static/thumbnail/b.png", "Scene.thumbnail_url")
|
||||
eq(scene.glb_url, "/static/model/x.glb", "Scene.glb_url")
|
||||
|
||||
|
||||
func test_intentionally_unconsumed_model_fields() -> void:
|
||||
# 记录一处**刻意不消费**的差异,避免以后被当成漏字段:
|
||||
# `usable` / `unusableReason` 是 Web 端审核用的(humanoid 不达标的模型「入库但标记不可用」),
|
||||
# 而 `GET /api/catalog/export` 只下发**可用**模型,所以目录里它们恒为 true。
|
||||
# 客户端的 CatalogStore 只消费目录,故不解析;不可用模型的可见性由 Web 管理端负责。
|
||||
var payload := {
|
||||
"id": 1, "name": "m", "format": "vrm", "vrmVersion": "1.0", "isPrimary": false,
|
||||
"fileUrl": "/static/model/x.vrm", "fileSize": 10, "thumbnailUrl": "/static/thumbnail/a.png",
|
||||
"usable": true, "unusableReason": null,
|
||||
}
|
||||
var model := CatalogTypes.Model.from_dict(payload)
|
||||
eq(model.id, 1, "usable/unusableReason 不影响其他字段解析")
|
||||
is_false(model.is_primary, "isPrimary 仍按报文解析")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmk7mkmy7dwqf
|
||||
@@ -482,3 +482,57 @@ func test_snapshot_exposes_keepalive_and_device_count() -> void:
|
||||
eq(int(snap["heartbeat_interval_ms"]), PoseServer.HEARTBEAT_INTERVAL_MS, "snapshot 报告心跳间隔")
|
||||
|
||||
await _teardown()
|
||||
|
||||
|
||||
# ---------------- T-AND-011 §6.5:保活的**时间维度**验证 ----------------
|
||||
#
|
||||
# 现有用例只验证了"welcome 声明了保活参数""ping 能换到 pong",但没有验证
|
||||
# **时间维度**上的行为 —— 而"频繁重连"恰恰是时间维度的问题:
|
||||
# 修复前:服务端静默超时 5s,客户端只有协议层 ping(应用层看不到),
|
||||
# 于是"画面里没有人"时每 5 秒被踢一次,再重连,循环往复。
|
||||
# 这里用**真实的 15 秒静默超时**跑一次端到端对照:
|
||||
# A) 只发应用层 ping、不发任何关键点帧 → 必须**不被断开**;
|
||||
# B) 连上后什么都不发 → 必须被静默超时踢掉(close 1001)。
|
||||
# 两个客户端并行跑,总耗时约一个超时周期。
|
||||
func test_keepalive_survives_silence_but_idle_client_is_dropped() -> void:
|
||||
await _setup()
|
||||
await _pump(3)
|
||||
|
||||
# A:只发 ping 的客户端
|
||||
_send_hello(_client, "device-keepalive-only")
|
||||
await _await_control(_client, "welcome")
|
||||
|
||||
# B:连上但什么都不发的客户端
|
||||
var idle := await _open_second_client()
|
||||
_send_hello(idle, "device-idle")
|
||||
await _await_control(idle, "welcome")
|
||||
eq(idle.get_ready_state(), WebSocketPeer.STATE_OPEN, "闲置客户端初始为已连接")
|
||||
|
||||
var timeout_ms := PoseServer.CLIENT_TIMEOUT_MS
|
||||
var heartbeat_ms := PoseServer.HEARTBEAT_INTERVAL_MS
|
||||
# 契约 §1.1:心跳必须小于静默超时的一半(否则必然被误判掉线)
|
||||
ok(heartbeat_ms * 2 < timeout_ms, "心跳间隔小于静默超时的一半",
|
||||
"心跳 %d ms,超时 %d ms" % [heartbeat_ms, timeout_ms])
|
||||
|
||||
# 跑满一个完整的静默超时周期再多一点(真实时钟,不是帧数)
|
||||
var started := Time.get_ticks_msec()
|
||||
var guard := 0
|
||||
while Time.get_ticks_msec() - started < timeout_ms + 2000 and guard < 200000:
|
||||
# A 按服务端声明的心跳节奏发应用层 ping(不发关键点帧)
|
||||
var elapsed := Time.get_ticks_msec() - started
|
||||
if elapsed / heartbeat_ms != (elapsed - 16) / heartbeat_ms:
|
||||
_client.send_text(JSON.stringify({"type": "ping", "sentAtMs": Time.get_ticks_msec()}))
|
||||
_client.poll()
|
||||
idle.poll()
|
||||
await _pump()
|
||||
guard += 1
|
||||
|
||||
var elapsed_ms := Time.get_ticks_msec() - started
|
||||
eq(_client.get_ready_state(), WebSocketPeer.STATE_OPEN,
|
||||
"只发心跳、不发关键点帧的客户端在 %d ms 后仍保持连接(频繁重连的根因已修复)" % elapsed_ms)
|
||||
eq(idle.get_ready_state(), WebSocketPeer.STATE_CLOSED,
|
||||
"完全不发数据的客户端被静默超时断开(超时机制仍然有效)")
|
||||
if idle.get_ready_state() == WebSocketPeer.STATE_CLOSED:
|
||||
eq(idle.get_close_code(), 1001, "静默超时以 1001 关闭(契约 §7:对端不重连)")
|
||||
|
||||
await _teardown()
|
||||
|
||||
@@ -243,3 +243,193 @@ func test_environment_mapping_clamps_godot_fog_density() -> void:
|
||||
is_false(env_off.fog_enabled, "fogDensity = 0 时关闭雾")
|
||||
runtime.free()
|
||||
runtime_off.free()
|
||||
|
||||
|
||||
# ---------------- 真实 GLB 的场景加载(§5.6 验收:GLB 正常加载 + 参数生效) ----------------
|
||||
#
|
||||
# 现有用例覆盖的是参数解析与降级路径,但**没有一条真正加载 GLB**。
|
||||
# 这里在代码里手写一个合法的 GLB(一个三角形:POSITION + indices,带 BIN chunk),
|
||||
# 落盘后用 `SceneRuntime.load_from_file()` 真加载,核对:
|
||||
# * 不再回退内置空场景(warnings 为空、兜底地面未启用);
|
||||
# * 碰撞体按 `colliders` 三种取值分别正确生成;
|
||||
# * 重力/风 Area3D 按服务端参数生效;
|
||||
# * 包围盒**沿树累乘变换**(用带位移的节点验证 —— 曾经这里用 global_transform
|
||||
# 在入树前计算,会打 ERROR 日志且算出的范围偏小/偏位)。
|
||||
|
||||
const TEST_GLB_PATH := "user://test-scene-{0}.glb"
|
||||
|
||||
## 手写合法 GLB:一个三角形(POSITION 3×VEC3 + indices 3×u16),节点可命名/可位移
|
||||
static func _build_triangle_glb(node_name: String, translation: Vector3 = Vector3.ZERO) -> PackedByteArray:
|
||||
var sp := StreamPeerBuffer.new()
|
||||
sp.big_endian = false
|
||||
for v in [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]:
|
||||
sp.put_float(v)
|
||||
while sp.get_position() % 4 != 0:
|
||||
sp.put_u8(0)
|
||||
var index_offset := sp.get_position()
|
||||
for i in [0, 1, 2]:
|
||||
sp.put_u16(i)
|
||||
while sp.get_position() % 4 != 0:
|
||||
sp.put_u8(0)
|
||||
var bin := sp.data_array
|
||||
|
||||
var node := {"mesh": 0, "name": node_name}
|
||||
if translation != Vector3.ZERO:
|
||||
node["translation"] = [translation.x, translation.y, translation.z]
|
||||
var gltf := {
|
||||
"asset": {"version": "2.0", "generator": "OpenDance test"},
|
||||
"scene": 0,
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"nodes": [node],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
|
||||
"buffers": [{"byteLength": bin.size()}],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962},
|
||||
{"buffer": 0, "byteOffset": index_offset, "byteLength": 6, "target": 34963},
|
||||
],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
|
||||
{"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"},
|
||||
],
|
||||
}
|
||||
var json_bytes := JSON.stringify(gltf).to_utf8_buffer()
|
||||
while json_bytes.size() % 4 != 0:
|
||||
json_bytes.append(0x20)
|
||||
|
||||
var out := PackedByteArray()
|
||||
out.append_array(PackedByteArray([0x67, 0x6C, 0x54, 0x46])) # 'glTF'
|
||||
out.resize(12)
|
||||
out.encode_u32(4, 2)
|
||||
var json_header := PackedByteArray()
|
||||
json_header.resize(8)
|
||||
json_header.encode_u32(0, json_bytes.size())
|
||||
json_header.encode_u32(4, 0x4E4F534A) # 'JSON'
|
||||
var bin_header := PackedByteArray()
|
||||
bin_header.resize(8)
|
||||
bin_header.encode_u32(0, bin.size())
|
||||
bin_header.encode_u32(4, 0x004E4942) # 'BIN\0'
|
||||
out.append_array(json_header)
|
||||
out.append_array(json_bytes)
|
||||
out.append_array(bin_header)
|
||||
out.append_array(bin)
|
||||
out.encode_u32(8, out.size())
|
||||
return out
|
||||
|
||||
|
||||
func _write_scene_glb(tag: String, node_name: String, translation: Vector3 = Vector3.ZERO) -> String:
|
||||
var path := TEST_GLB_PATH.format(tag)
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
file.store_buffer(_build_triangle_glb(node_name, translation))
|
||||
file.close()
|
||||
return path
|
||||
|
||||
|
||||
func _scene_cfg(overrides: Dictionary) -> CatalogTypes.SceneConfig:
|
||||
var base := {"physics": {"gravity": {"y": -3.5}, "wind": {"base": {"x": 2.0, "y": 0.0, "z": 0.0}}}}.duplicate(true)
|
||||
for key in overrides:
|
||||
base["physics"][key] = overrides[key]
|
||||
return CatalogTypes.SceneConfig.from_dict(base)
|
||||
|
||||
|
||||
## 取重力区域的碰撞盒(用来核对包围盒是否算对)
|
||||
func _area_box(rt: SceneRuntime, area_name: String) -> CollisionShape3D:
|
||||
var area := rt.get_node_or_null(area_name) as Area3D
|
||||
if area == null:
|
||||
return null
|
||||
for child in area.get_children():
|
||||
if child is CollisionShape3D:
|
||||
return child as CollisionShape3D
|
||||
return null
|
||||
|
||||
|
||||
func test_real_glb_loads_without_fallback_and_colliders_match_mode() -> void:
|
||||
var path := _write_scene_glb("mesh", "Floor")
|
||||
|
||||
# mesh 模式:所有网格都生成碰撞体
|
||||
var rt_mesh: SceneRuntime = SceneRuntime.load_from_file(path, _scene_cfg({"colliders": "mesh"}), "真实场景")
|
||||
eq(rt_mesh.warnings.size(), 0, "真实 GLB 加载无降级告警(warnings 为空)")
|
||||
not_null(rt_mesh.root, "场景根节点已生成")
|
||||
eq(rt_mesh.collider_count, 1, "mesh 模式为 1 个网格生成 1 个碰撞体")
|
||||
is_false(rt_mesh.used_fallback_ground, "有网格时不再生成兜底地面")
|
||||
contains(rt_mesh.describe(), "碰撞体 1 个", "摘要报告碰撞体数量")
|
||||
|
||||
# auto 模式:只认命名约定(Floor_col 命中,Floor 不命中)
|
||||
var rt_auto_hit: SceneRuntime = SceneRuntime.load_from_file(
|
||||
_write_scene_glb("auto-hit", "Floor_col"), _scene_cfg({"colliders": "auto"}), "命名命中"
|
||||
)
|
||||
eq(rt_auto_hit.collider_count, 1, "auto 模式按 *_col 命名约定生成碰撞体")
|
||||
|
||||
var rt_auto_miss: SceneRuntime = SceneRuntime.load_from_file(
|
||||
_write_scene_glb("auto-miss", "Floor"), _scene_cfg({"colliders": "auto"}), "命名未命中"
|
||||
)
|
||||
eq(rt_auto_miss.collider_count, 1, "auto 未命中时为兜底地面生成 1 个碰撞体")
|
||||
is_true(rt_auto_miss.used_fallback_ground, "auto 未命中时标记使用了兜底地面")
|
||||
|
||||
# none 模式:不生成碰撞体,也不兜底
|
||||
var rt_none: SceneRuntime = SceneRuntime.load_from_file(path, _scene_cfg({"colliders": "none"}), "无碰撞")
|
||||
eq(rt_none.collider_count, 0, "none 模式不生成任何碰撞体")
|
||||
is_false(rt_none.used_fallback_ground, "none 模式不兜底")
|
||||
|
||||
rt_mesh.free()
|
||||
rt_auto_hit.free()
|
||||
rt_auto_miss.free()
|
||||
rt_none.free()
|
||||
|
||||
|
||||
func test_real_glb_gravity_and_wind_match_config() -> void:
|
||||
var rt: SceneRuntime = SceneRuntime.load_from_file(
|
||||
_write_scene_glb("gravity", "Floor"), _scene_cfg({"colliders": "mesh"}), "物理参数"
|
||||
)
|
||||
|
||||
var gravity_area := rt.get_node_or_null("SceneGravityArea") as Area3D
|
||||
not_null(gravity_area, "重力的 Area3D 已创建")
|
||||
if gravity_area != null:
|
||||
almost(gravity_area.gravity, 3.5, 1e-4, "重力大小按服务端配置生效")
|
||||
almost(gravity_area.gravity_direction.y, -1.0, 1e-5, "重力方向向下")
|
||||
eq(gravity_area.gravity_space_override, Area3D.SPACE_OVERRIDE_REPLACE,
|
||||
"场景级重力用 REPLACE 覆盖(不改全局 ProjectSettings)")
|
||||
|
||||
not_null(rt.get_node_or_null("SceneWindArea") as Area3D, "风的 Area3D 已创建")
|
||||
almost(rt.wind_force_at(0.0).x, 2.0 * SceneRuntime.WIND_FORCE_SCALE, 1e-3,
|
||||
"风力按服务端基础风向量生效")
|
||||
contains(rt.describe(), "重力 3.5", "日志摘要含重力数值(可核对)")
|
||||
contains(rt.describe(), "2.0,0.0,0.0", "日志摘要含基础风向量")
|
||||
|
||||
rt.free()
|
||||
|
||||
|
||||
func test_aabb_accumulates_transforms_for_offset_meshes() -> void:
|
||||
# 网格整体位移到 x=10:包围盒必须跟着走,否则重力/风区域会算偏位(风力作用不到)
|
||||
# 用 mesh 模式:只验证"包围盒是否跟着位移走",不引入 auto 未命中导致的兜底告警
|
||||
var rt: SceneRuntime = SceneRuntime.load_from_file(
|
||||
_write_scene_glb("offset", "Floor", Vector3(10, 0, 0)),
|
||||
_scene_cfg({"colliders": "mesh"}),
|
||||
"位移场景",
|
||||
)
|
||||
eq(rt.warnings.size(), 0, "带位移的真实 GLB 加载无降级告警")
|
||||
eq(rt.collider_count, 1, "位移场景按 mesh 模式生成 1 个碰撞体")
|
||||
|
||||
var box := _area_box(rt, "SceneGravityArea")
|
||||
not_null(box, "重力区域的碰撞盒存在")
|
||||
if box != null:
|
||||
ok(box.position.x > 9.5 and box.position.x < 11.5,
|
||||
"包围盒沿树累乘变换:区域中心跟着网格位移(x≈10)", "实际 x=%.2f" % box.position.x)
|
||||
var box_shape := box.shape as BoxShape3D
|
||||
not_null(box_shape, "碰撞盒是 BoxShape3D")
|
||||
if box_shape != null:
|
||||
ok(box_shape.size.x > 10.0, "区域水平尺寸覆盖到位移后的场景", "size.x=%.1f" % box_shape.size.x)
|
||||
|
||||
# auto 未命中 → 兜底地面也应落在位移后的位置
|
||||
var rt_fallback: SceneRuntime = SceneRuntime.load_from_file(
|
||||
_write_scene_glb("offset-plain", "FloorPlain", Vector3(10, 0, 0)),
|
||||
_scene_cfg({"colliders": "auto"}),
|
||||
"位移兜底",
|
||||
)
|
||||
var ground := rt_fallback.get_node_or_null("FallbackGround") as StaticBody3D
|
||||
not_null(ground, "兜底地面已生成")
|
||||
if ground != null:
|
||||
ok(ground.position.x > 9.5 and ground.position.x < 11.5,
|
||||
"兜底地面跟着包围盒位移(x≈10)", "实际 x=%.2f" % ground.position.x)
|
||||
|
||||
rt.free()
|
||||
rt_fallback.free()
|
||||
|
||||
Reference in New Issue
Block a user