Commit Graph
19 Commits
Author SHA1 Message Date
zijunchiang cc8b6ba729 test(integration): 跨端验收补两类契约边界(不可用模型 / 场景 config 缺字段)
正常路径之外的**字段边界**同样只在跨端跑起来才暴露。配合 Web v0.7.5 增加:

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

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

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

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

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

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

实测(v0.2.5 发布包,四条验收全绿):
  * accept-release.sh:pck 无开发残留 / PE 版本资源 0.2.5 / pck 启动 180 帧 / 运行时探针
  * accept-ws.sh:客户端 40 通过 / 0 失败 + 服务端侧全部事实
    (10 次连接、111 帧、frames_dropped=3、malformed=1、device_replaced 仅 1 次、
     v0.1.0 风格对端未被静默超时踢掉)
  * accept-frame-vectors.sh:解码 12/12、396 点 0 差异、编码器 0 差异
  * verify.sh:785 通过 / 0 失败
2026-09-18 02:47:00 +08:00
zijunchiang 6ea2c93d66 fix(pack): 编码取整与参考实现对齐 + 产物不再夹带测试夹具(v0.2.4)
两个缺陷,都由上一轮的验收机制暴露:

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

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

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

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

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

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

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

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

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

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

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

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

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

顺带:package-release.sh 结尾把三条发布前验收(产物自证 / WS 契约一致性 / 跨端集成)
一次列全。不影响已发布的 v0.2.3 产物,故不新发版本。
2026-09-17 05:32:24 +08:00
zijunchiang a78a536476 feat(integration): 跨端集成验收(包内客户端 × 真实 Web 服务端)+ 修两个真实缺陷(v0.2.3)
单测打的是假 HTTP 夹具,Web 端测试只验自己那一侧 —— 两端各自绿灯不代表契约字段
真的对得上。本版加了跨端集成验收,并在首次运行就抓到两个真实缺陷。

一、跨端集成验收(T-WIN-019)
  * build/integration/probe-integration.gd:只加载**包内**代码
    (`godot --main-pack <pck> --script <探针>` + res://),打真实服务端并逐项断言:
    /health 六个契约版本、catalog/version 指纹、catalog/export 解析出的四类资源与
    defaultModelId;**场景物理参数逐项比对**(重力/基础风/阵风/风坐标系/地面摩擦坡度/
    碰撞体/出生点/相机/环境)+ Wind.sample(0) + 场景 GLB 可下载;
    动作 NDJSON → 客户端解析帧数 == 服务端帧数、33 点、每帧服务端关节角、
    推导帧率 30 fps、时长 96.67 s;缩略图 PNG;模型 VRM 可下载。
  * build/integration/seed-catalog.mjs:用**真实素材**播种目录
    (示例文件/ 下的真 VMD → 内部标准 33 点;真 PMX → VRM;场景 GLB + 物理参数),
    并把期望值(含从 /health 取的契约版本)写成 JSON。
  * build/accept-integration.sh:编排「服务端可达 → 播种 → 包内探针 → 汇报」。
  实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL):全部通过。

二、探针抓到的两个真实缺陷
  1. `CatalogTypes.Motion` 缺 `thumbnail_url`:服务端 catalog/export 从
     data-models 0.6.0 起一直在发 thumbnailUrl(Model/Scene 都有),客户端默默丢掉。
     已补字段与解析(缺失为 ""),新增 tests/test_catalog_types.gd(4 项 20 断言)
     用**服务端真实报文形状**锁住三个资源类型的覆盖,并显式记录一处刻意不消费的差异
     (Model.usable/unusableReason —— 目录只下发可用模型,恒为 true)。
  2. `ApiClient._request()` 对**所有**响应无条件 `raw.get_string_from_utf8()`:
     数据本身没错(二进制路径返回原始字节),但每下载一个文件都会打印
     「Unicode parsing error … Invalid UTF-8 leading byte (N)」噪声,并把整个文件
     (模型可达数十 MB)解码成 String 拷贝后丢弃。改为只在需要文本时解码。

三、顺带修掉测试里的写死契约版本
  tests/test_api_integration.gd 断言 rest-api/websocket-keypoints/skeleton-topology
  均为 "0.1.0"。该用例只在服务端可达时才跑,平时一直 skip,所以契约升到 0.5.0 后
  没有任何地方发现它。改为从仓库 contracts/VERSION 读期望值并与 /health 比对
  (顺带成为「客户端契约快照 vs 服务端」的跨端校验)。

证据:verify.sh 全绿(820 通过 / 0 失败,比上版 +46,因为服务端在线时 API 集成组
真的跑了);包内 pck 跨端集成探针全部通过;check-version/PE 版本资源 = 0.2.3。
2026-09-17 05:18:03 +08:00
zijunchiang e4e534f2a2 test(accept): 发布包级端到端验收(读包内 pck 而非源码)+ T-WIN-018
`build/verify.sh` 跑的是仓库源码(`godot --path .`),交付给用户的是 zip 里的
OpenDance.pck + OpenDance.exe,差别全在「导出」这一步:导出预设漏资源、pck 没
重新生成、版本号没同步、契约快照与 pck 不一致 —— 都只在包里才暴露。
本仓 v0.2.1 的 PE 版本元数据就是这么翻的车。

新增:
  * build/probe-release.gd —— 包内运行时探针。用
    `godot --headless --main-pack <pck> --script <探针>` 只读包内资源,断言并
    quit(0/1):
      1) application/config/version == 期望版本(界面版本唯一来源,§11.1);
      2) AboutScreen.app_version() == 期望版本(「关于」清单真正显示的值);
      3) 主场景可加载、四个 autoload 都在场景树里;
      4) 15 个关键资源(UI/模型/场景/网络/评分)在包内都可加载;
      5) 包内 Credits.CONTRACT_VERSIONS 与包内 contracts/VERSION 快照逐项一致
         (抓「pck 用旧契约构建、快照却拷了新的」)。
  * build/accept-release.sh —— 一个 zip(或已解压目录)跑完:sha256 → 解压
    (deflate 用 unzip / LZMA 用 7z,并检查没有 .partN 残留)→ 文件清单 →
    check-pe-version.sh → 包内 pck 启动 180 帧无 SCRIPT ERROR → 探针。
  * package-release.sh 结尾打印下一步验收命令;README 补用法。

实测(对**已发布**的 v0.2.2 资产重新下载后跑):
  sha256 OK;PE 版本资源 0.2.2;包内 pck 启动 180 帧无错误;
  包内 version=0.2.2;AboutScreen.app_version()=0.2.2;主场景
  res://scenes/main.tscn;autoload 齐备;15 项关键资源可加载;
  pck 内契约表与包内 contracts/VERSION 一致 → 发布包级验收全部通过。

反向验证:
  * 篡改包内 contracts/VERSION 的 rest-api=0.4.0 →
    ✗ ["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 校验假失败(其余检查仍全绿,
很容易被忽略)。首跑时正是它把「全部通过」变成了失败。

verify.sh 仍全绿(754 通过 / 0 失败 / 5 跳过)。
2026-09-17 04:50:40 +08:00
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
zijunchiang 86e5decd10 build(release): 增加 ZIP_METHOD=lzma 选项(37 MB 整包被 IIS 30 MB 上限挡住时用)
Windows v0.2.0 发布时,Gitea 站点 IIS 的 maxAllowedContentLength 仍是默认的
30,000,000 字节:16.4 MB 通过,而 38,245,602 B 的整包用**正确**的 multipart 写法
(字段名 attachment)复测依旧 HTTP 413(仅发出 917 KB 即被拒)。为不阻塞发布,
本次给打包脚本增加压缩方式开关:

  ZIP_METHOD=deflate   (默认)约 38 MB,Windows 资源管理器双击即可解压
  ZIP_METHOD=lzma      约 26 MB,可绕过 30 MB 闸;**代价**是 Windows 10 资源管理器
                       不支持 LZMA(method 14) 的 zip,必须用 7-Zip / WinRAR / PeaZip

脚本细节:
- lzma 走 `7z a -tzip -mm=LZMA -mx=9`(缺 7z 时报错并给出安装提示);
- lzma 包额外做 `7z t` 完整性测试,并改用 `7z x` 做解压自检(unzip 对 method 14
  支持不一致,不能用来验证);
- 超限告警里补上「可用 ZIP_METHOD=lzma 绕过」的提示。

README.md / README.txt 同时写明:v0.2.0 的 zip 是 LZMA,需要 7-Zip/WinRAR 解压;
若 IIS 上限已放开,请改用默认 deflate 包。
2026-09-17 03:42:05 +08:00
zijunchiang d9229fae5f docs: 补齐部署必需的 Gitea 域名与属主;重新打包 v0.2.0(含修正后的说明文档)
域名与仓库属主属于「部署必需且非隐私」的信息,此前写成 `<Gitea 地址>/<owner>`
会让读者无法照抄。补成真实值:gitea.kasugano.cn / zijunchiang
(口令与访问令牌仍为占位符)。

- README.md / README.txt:clone 地址与跨仓库链接补成真实地址;
- README.md / README.txt:说明 `<服务器地址>` 是你们自己部署的 Web 服务端地址
  (部署方决定,不是被脱敏的域名),示例里的 192.0.2.10 为 RFC 5737 文档用 IP。

因本仓库 v0.2.0 的整包**尚未上传**(Gitea 附件通道故障),本次一并按修正后的
文档重新打包,避免发布出一个域名仍是占位符的包;新包的 sha256 会更新到
Release 说明里。
2026-09-17 03:07:24 +08:00
zijunchiang c3a227c6df feat(net): 连接保活 / 设备去重 / 连接状态机(T-WIN-015,契约 websocket-keypoints 0.2.0)
与 Android 端 T-AND-011「两端一起改」:契约先在 opendance-web 修改并提升到
websocket-keypoints 0.2.0(向后兼容:子协议名与二进制帧 version 均不变,
只新增可选控制字段与一种新控制类型),再同步三端快照。

修掉本端的 3 处根因/放大器:
1. 静默超时 5s 过短且未声明,客户端无从得知。OkHttp 的协议层 ping 由底层库
   自动应答、**不暴露给应用层**,无法刷新 last_seen_ms,于是"画面里没人"时
   每 5 秒必然踢一次连接 -> 客户端重连 -> 再被踢。
   - CLIENT_TIMEOUT_MS 5s -> 15s,新增 HEARTBEAT_INTERVAL_MS = 5s;
   - welcome_payload() 在 welcome 中声明 silentTimeoutMs / heartbeatIntervalMs,
     config 广播同样带上(契约 §1.1:心跳必须 < 静默超时的一半,5000*2 < 15000);
   - 处理应用层 ping 并回 pong(回显 sentAtMs + serverAtMs,供对端算 RTT)。
2. 反复重连被计成多台设备。按契约 §5 用 hello.deviceId 去重:同一设备只保留
   最新连接,旧连接以 close(1000) 关闭——刻意用 1000,因为对端按契约 §7
   收到 1000 不会重连,否则会形成"挤掉-重连-再挤掉"的循环;
   新增 device_count() 与 device_replaced 信号,界面一律显示去重后的设备数。
3. 只有 handshaken 布尔量,日志看不出卡在哪一步。按契约 §8 逐客户端维护与
   Android 端同名的状态(connecting -> handshaking -> streaming -> degraded,
   主动停止/超时/被替换为 stopped),并记录 close code/reason、协商子协议、
   连接/握手/首帧时间;degraded 保持时长做成可覆盖字段便于测试。

验收测试(tests/test_pose_server.gd 新增 7 项,总计 720 通过 / 0 失败):
welcome 声明保活参数、ping->pong 回显、同 deviceId 去重(旧连接 1000 关闭且
设备数仍为 1)、8 个状态名与契约一致、状态随握手/首帧推进到 streaming、
丢帧进入 degraded 后恢复、snapshot 暴露设备数与保活参数。

同时更新 README/README.txt/tasks 与 Credits 的契约版本(0.2.0)。
2026-09-17 01:11:48 +08:00
zijunchiang 5581cbe05d feat(scene): 场景选择与物理落地 + 整包发布(T-WIN-012/013/014)
T-WIN-013:API 升级与场景选择
- 契约快照同步到 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0
- catalog_types.gd 新增 SceneConfig(物理/环境/出生点/相机)与 Scene;
  CatalogBundle 增加 scenes[];from_dict 按契约 7.1 再收敛一次
  (缺项默认、越界夹紧、非法枚举回退),与服务端 normalizeSceneConfig 同规则
- 新增 src/scene/scene_runtime.gd:GLB 载入 -> 碰撞体(mesh 三角网 /
  auto 命名约定 *_col、*_colonly / none 不兜底)-> 场景级重力(Area3D
  SPACE_OVERRIDE_REPLACE,不改全局 ProjectSettings)-> 风(每物理帧
  apply_central_force,阵风正弦叠加,支持 local 坐标系)-> 环境光与雾
  -> 出生点与相机(follow/fixed/orbit);无碰撞体时兜底地面
- CatalogStore 增加 scenes/default_scene/find_scene/ensure_scene_cached/
  fetch_thumbnail(场景缓存带 sha256 指纹,避免换包后仍用旧 GLB)
- 主界面改为 2x2 四个选择器(曲目/动作/模型/场景)+ 场景参数面板
  (缩略图 + 重力/摩擦/弹性/碰撞体/风摘要)
- 游戏界面载入场景并落地物理,角色落在出生点;场景缺失/下载失败/
  解析失败一律回退内置空场景并提示,不阻塞开始跟练

T-WIN-012:「关于」清单展示当前版本号
- 版本唯一来源为 project.godot 的 application/config/version(v0.2.0)
- about_manifest() 输出版本/引擎/契约/协议/地址/隐私清单;
  无头测试无 autoload,读取 AppConfig 走 get_node_or_null 容错
- Credits 的单个 CONTRACT_VERSION 常量改为 CONTRACT_VERSIONS 字典
  (六项契约各自独立递增),并双向核对 contracts/VERSION

T-WIN-014:发布必须是单个完整包
- build/package-release.sh 重写:只产出单个 zip + SHA256SUMS,
  清理历史 .partN,超限时告警而非回退分卷;新增源产物与解压后完整性
  自检(PE 头 MZ / pck 魔数 GDPC / exe+pck 同目录同名)与体积-上限对比
- deploy/proxy 相关的 Gitea IIS 256MB 上限说明见 opendance-web

测试:tests/test_scene_config.gd(12 例)+ tests/test_about.gd(4 例);
无头测试 687 通过 / 0 失败(10 个套件),verify.sh 全部通过
2026-09-17 00:45:23 +08:00
opendance 4913760a8b docs: 消除 Gitea 模棱两可 Unicode 告警(全角标点紧邻 ASCII 时改为半角)
依据 Gitea v1.27.3 modules/charset/escape_stream.go 的判定规则
(analyzeDetectResults:仅当左右相邻字符都不是非 ASCII 时才标记,
且 _common 表恒定生效),把说明文档中会被标记的全角标点
(,:;?!() 等)改为半角,并保证成对括号宽度一致。
中文语境中的全角标点(左右均为中文)保持原样,不动代码与契约语义。
2026-09-15 21:45:51 +08:00
zijunchiang 8c1d24865c docs: 全角 | / ? 改 ASCII(Gitea 混淆字符检测的剩余触发项) 2026-09-15 21:36:41 +08:00
zijunchiang 68a5ca0a87 docs: 其余易混淆字符(x/>=/<=/~/OK 取代 × − ≥ ≤ ≈ ✓) 2026-09-15 21:34:50 +08:00
zijunchiang 9f7b926a9a docs: 新增「初次部署(从零跑起来)」;去混淆字符与真实内网信息
- README.md / README.txt 新增初次部署章节:前置条件、获取代码、首次运行、
  配置服务端地址(API 3000 / 管理端 3001 分离)、与 Android 的 WebSocket 联通、
  打包导出、自检清单、常见问题
- 三库同源契约:':=' 写法修回、去掉真实 MariaDB 版本串
- 全文去除易被 Gitea 判为「模棱两可 Unicode」的字符(- - ... -> 取代 — – … § →)
- 真实内网地址与库名改占位符
2026-09-15 21:33:27 +08:00
zijunchiang aef5f88083 docs(win): 用实现说明替换阶段二占位 README 2026-09-14 20:05:40 +08:00
zijunchiang 20f27271a8 docs: 初始化占位说明与契约依赖 2026-09-14 19:02:38 +08:00