2 Commits
Author SHA1 Message Date
zijunchiang ba988f6ca6 fix(packaging): 发布包 exe 的 PE 版本资源必须等于发布版本(v0.2.2)
缺陷(v0.2.1 实测):把 opendance-windows-v0.2.1.zip 解开、在 Windows 上
「右键 → 属性 → 详细信息」,显示的是

    文件版本    0.1.0
    产品版本    0.1.0

而产物名、Git tag、README 全是 v0.2.1 —— 正是文档 §11.2 要禁止的
「不可核对的发布」。

根因:export_presets.cfg 的 application/file_version /
application/product_version 被硬编码在 0.1.0,而它们**不会**跟随
project.godot 的 application/config/version 自动更新;check-version.sh 当时
只比 project.godot ↔ project.yaml(两处一致),所以静态校验全绿、产物却是错的。
漂移发生在没人看的第三个位置 —— 校验位置少一个,等于没校验。

证据:
  strings -a -el OpenDance.exe  ->  FileVersion 0.1.0 / ProductVersion 0.1.0
  VS_FIXEDFILEINFO @0x67b976c   ->  FileVersion 0.1.0.0 / ProductVersion 0.0.0.0
对照组:官方模板 windows_release_x86_64.exe 的 FileVersion = 4.7.2,
说明本机 rcedit 改写是生效的,问题纯粹在预设的值本身。

修复:
  * build/sync-export-version.sh(新增):导出前把版本号写进导出预设,
    幂等、拒绝非 X.Y.Z;
  * build/check-pe-version.sh(新增):直接读 exe 的 PE 版本资源 ——
    VS_FIXEDFILEINFO(签名 0xFEEF04BD 后 16 字节,grep -abo 取偏移约 40 ms)
    与 StringFileInfo(strings -el,资源管理器属性页显示的就是它)。
    两处都查:实测固定信息里的 ProductVersion 恒为 0.0.0.0;
  * build/check-version.sh:新增导出预设两个键的校验;
  * build/export-windows.sh:导出前同步、导出后直接复核产物;
  * build/package-release.sh:打包前校验源 exe、解包后再校验包内 exe;
  * build/verify.sh:新增步骤 1/5「产物 PE 版本资源」(未导出则跳过);
  * tests/test_about.gd:新增 test_export_preset_version_matches_project_version。

反向验证:把预设 file_version 改回 0.1.0 →
  check-version.sh 退出码 1(报出两处不一致);
  无头测试 753 通过 / 1 失败,失败项正是
  「导出预设的 file_version 与版本号唯一来源一致」。

实测 v0.2.2 产物:PE FileVersion 0.2.2.0 / StringFileInfo 0.2.2,
版本号在 project.godot / project.yaml / export_presets.cfg / exe 元数据 /
产物名 / tag 六处一致。verify.sh 全绿(754 通过 / 0 失败 / 5 跳过)。

同时把 LZMA 压缩的取舍写清楚:deflate 包 38,248,818 B 仍超 IIS
30,000,000 B 上限,故 Release 资产仍是 LZMA 整包(26,628,529 B,不拆分卷);
若上限放开或改由 Web /static 分发,用 ZIP_METHOD=deflate 重打包即可得到
资源管理器可直接双击解压的包。
2026-09-17 04:22:57 +08:00
zijunchiang 14ee5f7ec9 fix(scene): 包围盒沿树累乘变换(修掉入树前的 ERROR 与范围偏位)+ 21 项"真机等价"测试
缺陷:SceneRuntime._compute_aabb() 用 MeshInstance3D.global_transform 计算包围盒,
而它在 _assemble() 里调用 —— 那时运行时节点**还没入树**。两个后果:
- Godot 打一行 `ERROR: Condition "!is_inside_tree()" is true. Returning: Transform3D()`
  (真机每次载入场景都刷一条,与"日志里不应有 ERROR"的排查约定冲突);
- 树外的 global_transform 返回单位变换,算出来的只是各网格的**局部** AABB,
  于是重力/风 Area3D 的碰撞盒与兜底地面会偏小或偏位 —— 对把网格摆在非原点位置的
  场景(导出 GLB 的常见情况)**风力可能作用不到物体上**。

修复:改为从根往下累乘 `Node3D.transform`,不依赖是否入树;挂在单位变换父节点下时
结果与旧语义一致。

新增 21 项无头"真机等价"测试(不需要设备):
- tests/test_scene_config.gd:在代码里手写合法 GLB(三角形 + BIN chunk)落盘后
  **真实加载**:warnings 为空、按 colliders 三种取值分别核对碰撞体(mesh / auto 命中
  *_col / auto 未命中的兜底地面 / none)、重力 Area3D 的 gravity 与方向、
  SPACE_OVERRIDE_REPLACE、风力与 WIND_FORCE_SCALE 对齐,以及**带位移网格**
  (translation [10,0,0])时重力区域与兜底地面都跟着位移(该缺陷的回归测试);
- tests/test_pose_server.gd:**时间维度**验证保活 —— 只发应用层 ping、不发关键点帧的
  客户端在 17 秒(> 15 秒静默超时)后仍保持连接("画面里没人就掉线、疯狂重连"的根因
  对照),而完全不发数据的客户端被 close(1001) 断开。

新增 build/check-version.sh:强制 project.godot 的 application/config/version
(「关于」清单与 .pck 的来源)与 project.yaml、发布版本号三者一致;已接入
build/package-release.sh(打包前)与 build/verify.sh(第 0/4 步),并做过反向验证。
此前没有任何机制保证这两个文件同步 —— 界面显示的版本可能和产物名不一致。

版本升至 0.2.1(project.godot / project.yaml / README / 产物名 / tag 全部一致),
About 清单与 .pck 内版本号均为 0.2.1(已用 strings 核对)。

验证:build/verify.sh 四步全通过(版本一致性 → 编译 → 751 项断言 → 180 帧启动无错误);
产物 opendance-windows-v0.2.1.zip(LZMA,26,627,191 B)
sha256 d49885327047f5a52de6c732431ea080cd1a030ae1c581a8bc0ec0d99bfb792c。
2026-09-17 03:59:29 +08:00
17 changed files with 884 additions and 35 deletions
+72 -14
View File
@@ -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,56 @@ 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.2.zip` + `SHA256SUMS`(不拆分卷) |
| 版本号展示 | OK 「关于」清单显示 `v0.2.2`;`project.godot` / `project.yaml` / `export_presets.cfg` / exe 的 PE 版本资源 / 产物名 / tag 全部一致 |
| 场景物理与保活 | OK 新增真实 GLB 端到端用例 + 17 秒保活对照用例(751 项断言全过) |
## 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 +83,12 @@ 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.2
```
当前版本:**v0.2.0**(`project.godot` 的 `application/config/version`,
与 `project.yaml`、导出产物名、Git tag、「关于」清单展示一致)。
当前版本:**v0.2.2**(`project.godot` 的 `application/config/version`,
与 `project.yaml`、`export_presets.cfg`、导出产物名、Git tag、「关于」清单展示、
以及 exe 的 PE 版本资源全部一致)。
详细使用说明见 [`README.txt`](README.txt),工程约束见 [`DSH.md`](DSH.md)。
@@ -120,7 +169,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.2
```
| 产物 | 位置 | 说明 |
@@ -128,7 +177,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.2.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
| `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 |
导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。
@@ -225,12 +274,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.2`(约 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` 均已接入。
## 第三方开源声明
+43 -9
View File
@@ -1,5 +1,5 @@
================================================================
OpenDance Studio -- Windows 客户端 v0.2.0 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
OpenDance Studio -- Windows 客户端 v0.2.2 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压)
================================================================
摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket
@@ -8,6 +8,32 @@ OpenDance Studio -- Windows 客户端 v0.2.0 使用说明(zip 为 LZMA 压缩
本客户端只接收关键点坐标,不接收、不存储任何视频画面。
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 +129,39 @@ OpenDance.exe 的用户看第一节即可。
打成发布包:
bash build/package-release.sh 0.2.0
bash build/package-release.sh 0.2.2
得到 dist-release/ 下两个资产:
opendance-windows-v0.2.0.zip <- 单个完整包,禁止 .partN 分卷
opendance-windows-v0.2.2.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.2 # 约 38 MB
上限一旦放开,Release 资产也应换回 deflate 包。
7. 自检清单
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。
+116
View File
@@ -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}"
+76
View File
@@ -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
View File
@@ -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 ""
+14
View File
@@ -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 主文件名不一致,导出产物不可运行"
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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.2"
application/product_version="0.2.2"
application/company_name="OpenDance Studio"
application/product_name="OpenDance Studio"
application/file_description="OpenDance Studio 摄像头跳舞评分客户端"
+1 -1
View File
@@ -9,7 +9,7 @@ config_version=5
config/name="OpenDance Studio"
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
config/version="0.2.0"
config/version="0.2.2"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://icon.svg"
+10 -2
View File
@@ -1,5 +1,5 @@
project: opendance-windows-client
version: 0.2.0
version: 0.2.2
repository: opendance-windows
default_branch: main
engine: Godot 4.7.2
@@ -82,13 +82,21 @@ 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
deliverable:
artifact: opendance-windows-v0.2.0.zip
artifact: opendance-windows-v0.2.2.zip
packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底)
release_rules: # 文档 §5.7 硬性要求
- 单个完整包,禁止 .partN 分卷
+20 -3
View File
@@ -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:
+69
View File
@@ -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` 强制) |
+92
View File
@@ -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 # 需要修时
```
+16
View File
@@ -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 与版本号唯一来源一致")
+54
View File
@@ -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()
+190
View File
@@ -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()