Files
simba-rmic-2026/docs/desktop/client.md
T
sloray 6a0eb14b28
构建客户端 / 检查(类型 + 单元测试) (push) Waiting to run
构建客户端 / Linux x64(deb + AppImage) (push) Blocked by required conditions
构建客户端 / Windows x64(NSIS 安装包) (push) Blocked by required conditions
构建客户端 / macOS 通用(.app + .dmg) (push) Blocked by required conditions
docs: 整理项目文档并统一界面表述
将项目说明按主题集中到 docs,补齐根目录 README 和 AGENTS,移除弃用方案并保留有效测试记录与未实现提案。同步文档索引、引用及改动日志生成路径,README 增加 GitHub Markdown 导航和 Mermaid 图。

- fix(client): 统一状态与操作文案,录制和截图成功后再显示完成提示
- test(client): 按文件名核对媒体类型,避免依赖时间戳排序

验证:23 份项目文档、144 处本地引用、27 条外链、2 张 Mermaid 图;前端构建和 12 项测试;截图与录制的 4 条异步反馈路径;Windows Rust 编译检查和后端测试。后端 40 项返回成功,其中 3 项 ffmpeg 集成用例因本机缺少工具而未实际执行。
2026-10-19 11:26:26 +08:00

33 KiB
Raw Blame History

RMIC::2026

黑龙江大学 602 实验室 · Simba Robotics 的视野链路桌面端。同一进程提供用户端与裁判端两种界面。

身份 画面 血量 装甲板 绑定
用户端(team) 一路 双方(左上角自己 / 右上角对手) 自己那块 要,改动需密码
裁判端(referee) 两路并排 双方(各自贴画面角上,队名可改) 发现列表 + 槽位 不需要

前端使用 WebView 中的 React,以 canvas 绘制画面;后端使用 Rust。网络接收、重组、发现、配置、密码和录制均已重写为可单测的模块。

显示名是 RMIC::2026;安装包和可执行文件使用 RMIC-2026,因为 Windows 文件名不允许 :。配置、录制与日志沿用 cam-desktop 目录,以保留已有密码哈希与绑定,见“配置”。

本文的路径以仓库根目录为起点,构建命令会注明执行目录。目录树及命令注释中的测试数量是原记录的快照,当前测试结果以实际运行输出为准。

desktop/
  core/           cam-core:不含任何界面与 Tauri 的东西(83 个测试)
  src-tauri/      Tauri 后端:推流、命令、录制(40 个测试)
  src/            React 前端:两套页面 + HUD
  src/lib/        IPC 封装、WebSocket 客户端、状态存储、格式化、准星绘制、界面小节拍
  src/components/ 取景器、装甲条、命中、结算、装甲板表、队名、录制、设置抽屉、启动序列、门、提示
  src/pages/      用户端 / 裁判端 / 固件维护 / 延迟校准
  src/styles/     tokens(令牌)、base(原子)、chrome(外壳)、stage(舞台)、console、settings、maintenance
  public/fonts/   自托管的 HarmonyOS Sans SC 与 JetBrains Mono
  src-tauri/icons/ 队标(由 simba-robotics.com/logo.svg 生成,含光学尺寸分级)

界面:一套终端语言

配色:墨黑蓝打底,只有一支红

角色 值 用在哪
底色 --void #05070d → --panel-3 #172032 全部面积。五级低饱和冷蓝黑,靠 1px 线分层,不靠阴影
文字 --ink #e7ecf5 → --ink-4 #3a4457 四档亮度,第四档用于弱化显示
强调 --primary #e8232b 只有:焦点、选中、录制中、错误、临界血量、结算
状态 --error / --warn #f2b33d / --ok #3fc98a 各有语义,不与强调色混用
队伍 --team-0 #ff6b5e / --team-1 #4a9bff 硬件约束:装甲板 LED 就是红与蓝

红色用于需要注意的状态。开启的开关为白色,滑杆填充为钢灰色,分段控件以底部 2px 线标记选中,分区编号为灰色,顶栏不加装饰强调线。

队红刻意做得比强调红更暖更浅,否则两条血并列时会被误读成"系统在报警"。

形状:矩形,唯一的斜边给装甲条

界面采用矩形,不使用圆角、切角、药丸或阴影卡片。斜边只用于装甲条的平行四边形,使血量与其他控件在形状上保持区别。

动效:严格照 Material 3 的曲线与时长

styles/tokens.css 里直接写着 M3 的七条缓动与 short/medium/long 四组时长:

令牌 曲线 语义
--m3-emphasized cubic-bezier(0.2, 0, 0, 1) 需要存在感的转场
--m3-emphasized-decelerate cubic-bezier(0.05, 0.7, 0.1, 1) 进入(快起慢收)
--m3-emphasized-accelerate cubic-bezier(0.3, 0, 0.8, 0.15) 退出(慢起快走)
--m3-standard / -decelerate / -accelerate (0.2,0,0,1) / (0,0,0,1) / (0.3,0,1,1) 常规位移
--m3-linear (0,0,1,1) 恒速(仅用于跟踪与循环)

旧名 --e-out / --e-in / --d-1..5 保留为指向 M3 的别名,所以没有一条规则落在自造 的贝塞尔上。两个例外是刻意的:steps() 用于录制红点与终端光标(离散闪烁),linear 用于 "没信号"那圈恒速转动的虚线环——M3 的曲线描述的是有始有终的转场,不是无限循环。

动效:每一页只有一个签名动作

各页共用缓动曲线和时长,动效随操作场景区分:

界面 签名动作 它解决什么
启动序列 一行行亮起的自检 + 真实进度 区分加载中与启动失败
首启门 两张模块卡,悬停浮起并露出角括号 提示选择将保存到本机
用户端 逐格熄灭的装甲条、四周泛红、六秒休眠 静止时屏幕上的东西越少越好
裁判端 中缝可拖、鼠标停在哪一路下方就看哪一路 不用再开第三个页面去"看某一边"
设置 常驻实时预览 + 竖向分节导航 改准星/网格/装甲条时立刻看得见结果
固件维护 步骤轨 + 环形进度 + 终端日志 烧录的哪一步、到哪儿了,一眼可读
解锁 一次性的校验扫描 + 错误时的一次抖动 明确"现在这一步是在验证身份"

prefers-reduced-motion 将这些动效时长设为 0,功能保持可用。

字体:中文一套、拉丁一套,逐字回退

字体 承担什么
中文 HarmonyOS Sans SC(400 / 500 / 700) 叙述、标签、按钮文字。字面方正,11px 仍然立得住
拉丁与数字 JetBrains Mono(400 / 500 / 700) 全部读数:帧率、血量、地址、倍率、时间码。等宽,列里的数字跳动时不左右抖

两者都以 woff2 自托管在 public/fonts/,随 frontendDist 进安装包,运行时不碰网络 (CSP 里 default-src 'self' 也不允许)。中文由 cn-font-split 切成 82 个 unicode-range 分片 × 3 个字重,浏览器按需加载,避免启动时加载完整的 14 MB 字体。

合到一句话里靠逐字回退,不做语言判断:--font-ui 把 JetBrains Mono 写在前面,它里面 没有汉字,浏览器就逐字往下找,落到 HarmonyOS。于是"血量 87"里数字是等宽的、汉字是 HarmonyOS 的,一条 font-family 同时满足两个要求。

启动页等待 document.fonts.ready,避免先显示系统字体、两百毫秒后切换到 HarmonyOS 引起布局重排。

队标

应用图标与启动页的标记是队标(https://simba-robotics.com/logo.svg),由它生成整套 src-tauri/icons/。原图是 1024×1024 的画布、标志只占中间一条,所以脚本把 viewBox 收到本体 上再居中;小尺寸用更大的缩放比(光学尺寸:256px 以上 82%、64–128px 88%、48px 以下 97%), 否则 16/24/32 上那些 1px 笔画会糊成一团。icon.ico 是手工拼的多尺寸容器——Pillow 的写入器 只能从一张主图缩出所有档位,而 Windows 任务栏恰恰只取最小的那几档。

笔画在 17px 以下就只剩 1px,所以顶栏只放字标 RMIC::2026(:: 是界面里唯一被染红的两个 字符),完整队标留给启动页的 30px。

每页最顺手的那个操作

  • 用户端通过滚轮缩放、拖动平移、双击复位,无需额外点击按钮。六秒没有输入或受击时,角落标签与代号淡出;出现输入或受击后恢复。
  • 裁判端让控制台跟着鼠标走。 按下左格,下方列表就只显示那一边的装甲板(再按一次回到 全部,键盘 1 / 2 / 0 同样)。中间那条竖线可以拖——哪一路更重要就把它拉宽,双击回到 等宽。画面与下方控制台之间的横线也可拖动,用来调整回放区域的高度。
  • 设置是一块带预览的控制台。 左侧竖向导航,右侧常驻一块实时预览;预览用的是画面里 同一个绘制函数(lib/reticle.ts),使预览与实际画面的绘制逻辑一致。预览血量滑杆用于查看低血阈值与脉动效果。

版本与改动日志:算出来的,不是写出来的

版本号、构建号和改动记录均从 Git 历史计算。提交规则、派生文件和固件版本通告见版本维护。

快速开始

cd desktop
pnpm install
pnpm app          # = tauri dev:起 vite + 编 Rust + 开窗口
pnpm build        # 只构建前端(tsc --noEmit && vite build)
pnpm tauri build  # 默认打包 macOS 的 .app 与 .dmg;Windows 见下文

下面两条是 Rust 侧,用它验证不依赖窗口的部分:

cd desktop/core      && cargo test    # 83
cd desktop/src-tauri && cargo test    # 40,其中几条真的会起 ffmpeg

Windows x64 构建

需要 Node.js 22.12+(或 24)、pnpm 12、Rust stable MSVC,以及 Visual Studio Build Tools 的 C++ 桌面开发组件和 Windows SDK。应用窗口使用 WebView2 Runtime。

在 desktop/ 中执行:

pnpm install --frozen-lockfile
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --ci -- --locked --config 'unstable.build-std=[]'

--target 覆盖本目录原来的 macOS target,--bundles nsis 选择 Windows 安装包;末尾的 Cargo 参数清空从根固件项目继承的 build-std。

  • 程序:src-tauri/target/x86_64-pc-windows-msvc/release/RMIC-2026.exe(名字由 tauri.conf.json 的 productName 决定;直接 cargo build 出来的原始产物仍叫 cam-desktop.exe,那是内部构建物,不是交付物)。
  • 安装包:同一 release 目录下的 bundle/nsis/*-setup.exe。
  • 核心测试:进入 core/ 后运行 cargo +stable test --locked --target x86_64-pc-windows-msvc --config 'unstable.build-std=[]'。

视频录制另需可从 PATH 找到的 ffmpeg,也可以在配置的 [recording] ffmpeg 中指定路径;基础图传显示不依赖它。

设备发现使用板卡的 Hello 广播;客户端除广播订阅外,还会按已发现设备的 IP 定向续订。Windows 开启 VPN / 虚拟网卡时,全局广播可能走虚拟接口;定向续订可避免"能发现并绑定,但始终等待画面"的情况。队伍端的绑定和画面过滤仍然生效。

演示模式:没有板子也能把整条链路跑起来

cd desktop
CAM_DEMO=1 pnpm app

演示模式合成协议数据报,交给 Receiver::datagram 与 ArmorHealthStore::datagram,沿正常接收路径进入界面:

合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈

分片、重组、发现、统计、掉血派生与两项接收限制使用正式代码路径,截图和录制也无需专门分支。界面标注“演示模式”,设备 ID 使用 0x0DE00001 等人工值。演示数据不能作为现场状态。

命令行与环境变量

  --role <team|referee>   身份。默认看配置文件
  --config <路径>          配置文件位置
  --demo                  演示模式
  --list                  打印发现的摄像头与装甲板后退出
  -h, --help              帮助

CAM_DEMO=1              等价于 --demo
CAM_CONFIG=<路径>        等价于 --config <路径>

使用 tauri dev 时,应用参数需要通过两个 -- 分隔符传递,如 tauri dev -- -- --demo。也可通过上面的环境变量设置。

未知参数只记一条 debug 日志,不报错。 macOS 从访达双击 .app 时系统会塞进来一个 -psn_0_123456;把它当错误处理的表现是"双击图标什么都没发生"。

为什么换成 Tauri,代价是什么

旧客户端使用 Rust + Slint 单二进制。改用 Tauri 是为了加快血条、瞄准器、拖拽校准、绑定控件、设置和回放等界面的开发,把更多工作时间留给 UDP、重组、发现与接收限制。

相应代价:

  • 多一层。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
  • 要联网装依赖(前端 npm、Rust crates)。
  • 画面要过一条本地通道才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器, 路径更短。

core/ 不依赖界面,重组、发现、帧率窗口、槽位分配、配置与密码均可单独测试。

视频怎么进 WebView

视频走一个只监听回环地址的 WebSocket,推二进制 JPEG。界面从 stream_info 命令拿到 ws://127.0.0.1:<随机端口>/?token=...。

为什么不是 Tauri 的 IPC

按早期两路各 22 fps、每帧约 22 kB 的场景估算,JPEG 流量约 1 MB/s。JSON IPC 需要序列化和反序列化,base64 还会增加约 1/3 体积。二进制 WebSocket 直接传递原始 JPEG,由浏览器原生解码,后端无需解码。

二进制帧的布局

  0              4              8      10     12             16
  +--------------+--------------+------+------+--------------+---------+
  | sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
  +--------------+--------------+------+------+--------------+---------+

帧头为 16 字节,采用小端编码。server.rs 与 stream.ts 两侧均有布局测试;长度不一致的帧直接丢弃,防止投递不完整 JPEG。

前端侧的两个陷阱

createImageBitmap 异步解码,相邻帧可能乱序完成。每路记录最后绘制的帧号,只采用更新帧,避免画面回退。

旧的 ImageBitmap 必须显式调用 close() 释放像素资源,仅替换引用不够。早期两路 22 fps 场景下,未释放的资源在几分钟内可积累到几百 MB。

防作弊:两道闸门,不是一个

对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。

限制在接收端执行。Hello 广播已包含由 MAC 派生的 sender_id 与 IP;CSS 只能隐藏显示,无法阻止接收与解码。

闸门 管什么 什么时候开
only_sender 帧的重组与投递 用户端始终是绑定的那一块
discovery_all 是否记录其它板子的 Hello 只在密码解锁后的设置界面里打开

旧客户端共用一个开关控制发现和接收,改绑需要手动修改配置文件。现在设置界面可以列出候选摄像头,同时保持接收限制:其他板卡即使向本机端口推流或进入发现列表,其视频帧也不会进入重组器。

only_sender 只在两种情况下放开:裁判端(中立方),以及用户端还没绑定摄像头。debug 构建不会放开它。

遥测按编译模式门控

TEAM_DIAGNOSTICS = cfg!(debug_assertions)。用户端 release 下不产出帧率/丢帧这些 数字,CameraStatsDto::build 将相应字段置零或置空。门控由编译模式决定,避免依赖现场命令行开关。

这挡不住什么

Hello 广播仍包含 IP。固件会向任何发送 Subscribe 的订阅者推流,本机客户端的接收限制无法阻止其他客户端订阅。若要限制无线订阅者,需要在固件侧实现配对 token,例如首次绑定时发放令牌,之后只接受带令牌的订阅;若令牌只保存在内存中,固件重启后需要重新配对。

回环通道令牌只限制本机程序连接:连接方需要先取得令牌。它不验证无线视频订阅者的身份。

密码:它挡的是谁

  • 密码用于限制现场改绑和退出程序。配置只保存 Argon2id 哈希;修改配置中的绑定后,界面仍要求解锁。
  • 拥有本机配置和源码的人可以删除哈希或修改客户端,因此密码无法约束这种操作。限制无线订阅仍需固件侧配对机制。

所以它的定位是防误触与防随手改,不是密码学意义上的对抗。

实现上:

  • Argon2id(argon2 crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希 一起保存在配置中,之后调整参数无需迁移。短密码不应仅使用 SHA-256 等快速哈希保护。
  • 用户端首次启动时必须设置密码(Config::needs_password_setup),最短 4 位,便于现场记忆与输入。
  • 解锁在本次进程内有效,用户端可以手动“上锁”。
  • 裁判端无需密码,便于现场调整槽位和录制。
  • 离开用户端需要密码,避免通过切换到裁判端绕过绑定和配置限制。

Rust 侧的配置修改命令会调用 AppState::require_unlocked。即使从控制台调用 invoke("bind_camera"),也需要通过这项检查。

客户端移除了旧版的 --camera-id 命令行覆盖,避免绕过密码改绑。临时更换板卡时,在设置中解锁后修改,或用 --config 指定另一份配置文件。

忘了密码:删掉配置文件里的 password 一行(或整个文件)。旧客户端的配置会在首次启动时 导入并备份成 .bak。

用户端 HUD

血条:两个上角,平行四边形,一条分 5 格

自己和对手的血条分别位于画面左上角与右上角,便于操作手在取景时对照。血条为平行四边形,分成 5 格,每格 20 点血;较大的分格便于远处辨认。血量减少时,从靠近画面中心的一侧开始熄灭。

血条使用绝对定位,设置 pointer-events: none,让鼠标仍能缩放和拖动画面。倍率与复位徽标放在下沿,为上方血条留出空间。

宽度、数字、队名、低血阈值与脉动均可配置。阈值以内的格子始终使用警示色,提前标出低血区域。

未接入装甲板时显示占位说明,避免将缺失数据误读为血量归零。

血量归零后,血条保留数字 0 和熄灭的格子,比赛结果由中央横幅显示。

胜负横幅

一方归零后,图传中央显示结果:获胜方为“胜利”,落败方为“落败”,双方同时归零为“平局”。用词带上对手的名字,裁判端两格因此也能直接 看出是哪一边赢了。

  • 结果由 Rust 侧计算(snapshot::resolve_outcomes,有单测)。自己归零即可判负,即使对手掉线;判胜则要求对手归零且自己仍有血,避免在本队从未上报时误判获胜。
  • 横幅持续显示到血量重置(长按 BOOT / 下一局开始),供截图、回放和现场核对。
  • 比赛结果放在画面中央;比赛中的命中反馈避开中央瞄准区域。

瞄准器

瞄准器标记相机画面中的一个点,直接绘制在 canvas 上。图像、网格、瞄准器和伤害数字共用同一组变换,避免 DOM 与 canvas 分别计算位置而产生偏移。

尺寸与缩放的关系也是刻意分开的:

跟着缩放走吗 为什么
位置 是 它指的是画面里的一个点
大小 否 像真瞄具一样,放大之后准星覆盖画面里更小的一块
网格 是 它标的是画面里的比例,用来估距离

偏移校准存的是画面比例,不是像素。 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素 偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。

命中反馈

主板只上报血量,不单独发送受击事件。HealthTracker 根据相邻读数还原命中:

  • 血量减少 ⇒ 一次命中,伤害是两个读数之差;
  • 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
  • 第一次见到一块板子不产生命中——没有基线,此时 hp = 0 只是"已经没血了"。

按板子记基线而不是按 (板子, index):主板一次广播四条、hp 相同,按四条各记一份 基线会让一次掉血被报成四次命中。

已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次 打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。

反馈包括画面四周泛红和准星旁的伤害数字,中央画面保持原样。早期版本的数字曾遮住瞄点,因此移到了准星旁。

命中反馈不表示方向。主板每次广播四路相同血量,事件中的 index 只表示哪一路上报先被收到;到达顺序受 UDP 影响,不能据此判断受击方向。

早期版本会在命中时晃动画面和瞄准器。该效果会干扰观察,也容易被误认为车体晃动,因此已移除。

HitFeedbackConfig::shake 这个字段留着但不再生效:配置文件是 deny_unknown_fields 的, 删字段会让所有写过 shake = true 的配置解析失败,而配置解析失败等于程序起不来。设置 面板里也没有这个开关了。

裁判端

队名可以直接改

两格画面各自下面有一个队名输入框(TeamNameEditor),回车或点到别处生效,Esc 取消。 名字是这一场比赛的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完 立刻反映到血条、装甲板表与"等待图传"的占位文字上。

Rust 侧统一处理队名(config::sanitize_team_name,有单测):合并空白,按字符截断到 12 个字,留空时使用「队伍一 / 队伍二」。输入框也限制长度,避免提交后才截断;按字符处理可避免截断中文的字节序列。

set_team_name(team, name) 每次只修改一队。若提交整个队名数组,最多 200 ms 前的旧快照可能覆盖刚修改的另一队名称。命令仍经过配置解锁检查,避免新增不受检查的写配置入口;裁判端的 Role::needs_password 为假,现场编辑无需密码。

名字在快照里以 teamNames(两条,下标就是队伍号)推给界面,两边各自的 TeamHealthDto.name 也是从这里来的:界面各处自己拼 team === 0 ? "队伍一" : "队伍二" 会在改名之后留下一半旧名字, 而且不会报错。

两格等宽

flex: 1 1 0 + min-width: 0,两格的宽度只由容器决定。

旧客户端的 HorizontalLayout 先按内容需求分配宽度,再分配剩余空间。两侧占位文字长度不同时,曾测得 55% / 45% 的宽度。现在占位文字绝对定位,两格使用相同的宽度规则。

槽位分配:修掉了一个真 bug

规则:

  1. 还在线的槽位不换人;
  2. 候选里排除已经被另一个槽位占着的 id;
  3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
  4. 没有候选时保持槽位为空,避免两格重复显示同一块板。

旧实现只排除当前槽位原有的 id。一队掉线后,其槽位可能选中另一队仍在线的板卡,导致两格重复显示。规则 2 修复了这一问题,并有对应测试。

图传板按在线候选自动分配,装甲板按上报的 team 分配。若画面对应关系不符,或装甲板固件的队伍号烧反,可在设置中手动指定槽位。

装甲板发现列表

列表显示板卡数量、自报队伍、血量、距上次广播的时间、四路上报情况和槽位。裁判端未显示血量时,可据此区分板卡未上线、队伍号错误和上报不完整。

这不需要改固件:主板一直用 encode_from(sender_id) 把自己的 MAC 派生 id 打进帧头, 只是旧的解码路径把它丢了。现在 ArmorHealth::decode_from 把它交出来。

截图与录制

  • 截图存 PNG(shots/ 下),文件名带板子 id 与毫秒时间戳。
  • 录制存 mp4,走 ffmpeg 子进程:-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0 喂帧,x264 编码,+faststart。
  • image2pipe 的 JPEG 序列没有时间戳,需要显式向 ffmpeg 提供帧率。客户端优先使用配置值,其次使用实测值;都不可用时使用默认值并记录警告。该值写入日志与 DTO,回放速度异常时应先核对它。
  • 帧经长度为 64 的队列交给独立写盘线程,避免阻塞接收。队列写入失败时丢帧并计数,界面显示该计数。
  • 开始新录制前先结束上一段,并检查文件名是否重复;重名时追加 -2、-3,避免 ffmpeg 的 -y 覆盖已有录制。

界面内回放使用 Tauri 资源协议,只能访问 tauri.conf.json 中 assetProtocol.scope 允许的目录。默认录制目录在范围内;其他目录中的 <video> 加载失败时,可选择“用系统播放器打开”。

配置

~/.config/cam-desktop/config.toml,--config 可改。目录名沿用旧的 cam-desktop(原因见开头)。要干净重来就删掉这个目录。

role = "team"
roleChosen = true
team = 0
teamNames = ["队伍一", "队伍二"]
cameraId = "0x0DE00001"
armorId = "0x0A200001"
mirror = false
password = "$argon2id$v=19$m=19456,t=2,p=1$..."

[recording]
dir = "/Users/you/Movies/cam-desktop"
ffmpeg = "ffmpeg"
crf = 20
preset = "veryfast"

[hud.crosshair]
style = "cross-dot"
size = 0.05
offsetX = 0.0

配置约定:

  • 使用 serde + toml 处理 HUD 嵌套结构、密码哈希和录制参数,替代旧客户端针对四个标量字段的手写解析。
  • 未知键名报错(deny_unknown_fields),避免拼写错误被静默忽略。
  • 磁盘、IPC 与前端 TS 的字段名统一为 camelCase,减少字段转换遗漏。
  • 先写入同目录临时文件再 rename,避免中断写入导致配置或密码哈希不完整。
  • id 写成十六进制字符串("0x3D4D8A9A"),与界面、日志、抓包里看到的完全一致。
  • teamNames 放在配置根层级,供用户端和裁判端共用。
  • roleChosen 单独记录是否选过模式。role 默认是裁判端,仅靠该字段无法判断是否需要显示首次选择页面。

首次启动时,客户端会导入旧版(Slint)配置中可对应的字段,并将旧文件改名为 .bak,供排查时核对。该迁移仅适用于默认路径;用 --config 指定其他路径时不修改常规配置。

浏览器预览(仅开发)

开发时可在浏览器中预览同一套 React 组件,无需启动 Tauri 后端。页面没有 Tauri 注入时,由 src/lib/dev-preview.ts 合成状态与画面。

pnpm dev                                          # 用户端
open 'http://127.0.0.1:1420/?preview=referee'     # 裁判端(密码 1234)
open 'http://127.0.0.1:1420/?preview=first-run'   # 首启的身份选择
open 'http://127.0.0.1:1420/?preview=password'    # 首启的设密码

后两个参数强制显示对应的首次启动页面,便于检查界面,无需删除配置文件。

预览要求 import.meta.env.DEV 为真且没有 __TAURI_INTERNALS__。模块通过 await import(...) 动态加载,正式构建中不包含它。

测试

cd desktop/core      && cargo test    # 83:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
cd desktop/src-tauri && cargo test    # 40:帧头布局、录制、截图、演示画面、命令行
cd protocol          && cargo test    # 23

前端解码队列测试:在 desktop/ 运行 pnpm test:stream,覆盖慢解码时保留最新帧、多路独立处理、关闭清理、坏帧恢复、设备重启和旧帧释放。它不替代实际 WebView 的画面验证。

测试覆盖的关键行为:

  • 录制测试启动 ffmpeg,写入 20 帧并停止,再用 ffprobe 检查帧数和分辨率,避免只检查文件存在而漏掉空容器。
  • 截图测试写入 PNG 后读回尺寸,并检查无效输入会报错且不留下空文件。
  • 帧率窗口测试验证帧间隔平均值的计算结果(约 30 fps),防止算法回退。

CSP 验证需要检查前端能否连接后端。前端先通过 invoke 获取通道地址,再连接 WebSocket;由前端连接触发的快照 clients 从 0 变为 1,可用于确认 JS 加载、IPC、WebSocket 和 React effect 均已执行。手动连接检查如下:

RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1

线程与数据流

线程 做什么 为什么单独
udp-reader 收包、解析、按发送方重组 不能让解码/写盘挤进数据报之间
armor-reader 收 5601 上的血量广播 与视频是两个端口、两种节奏
rec-writer-<id> 把队列里的帧写进 ffmpeg 的 stdin 接收线程绝不能被写盘拖慢
demo-*(仅演示) 合成协议数据报 走同一条真实路径
tokio 任务 WebSocket 服务、5 Hz 快照 异步 IO

帧在接收线程里编码一次(16 字节头 + JPEG),所有界面共用同一份 Bytes。 视频使用每路只保留最新帧的 watch 通道;状态与控制消息使用有序广播通道。 前端每路最多保留一次正在进行的解码和一张最新待解码帧。

每张本地 JPEG 前增加 frameTiming 文本消息,包含同机后端发布时刻;JPEG 二进制头仍为 16 字节。前端在解码前后检查本机队列年龄,超过 100 ms 的待解码帧或解码结果直接释放,恢复后优先显示最新画面。后端不重放超过 100 ms 的缓存帧,本地视频 socket 写入超时后重连。旧版没有时间标记时仍支持 JPEG,但只能按 JS 收到时刻判断本地年龄。这些时间不能用于测量 ESP 到电脑的无线传输延迟。

已知限制与没做的

  • 同一队有两块装甲板在广播时,health(team) 是"最后写入者优先",四个格子可能混着两块 板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到; 日志里会警告一次,消歧办法是绑定具体的板子 id(health_of_board)。
  • 录制帧率是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与 DTO,所以至少能解释清楚。
  • csp 是一条收紧过的白名单,但它是"够用"而不是"最小":style-src 带 'unsafe-inline'(React 的内联 style 属性需要它),connect-src 放开了整个 ws://127.0.0.1:*(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端", 见"测试"一节最后一条。
  • 证书与公证没做,.dmg 只在本机可用。
  • 前端已有解码队列测试;布局、实际 WebView 解码和画面显示仍需浏览器预览及真机运行验证。

排查

需要测量相机到客户端的整条链路时,使用裁判配置启动校准模式:

.\RMIC-2026.exe --config .\referee.toml --latency-report .\latency-run.ndjson

报告路径必须是新文件。让相机完整拍到左侧四个彩色角块,避开右侧回显,固定相机和窗口位置。标记每 50 ms 更新;程序沿正常接收、解码和 VideoSurface 绘制路径记录每个标记的首次有效回显,拒绝曝光混合而无法可靠解码的帧。NDJSON 包含相邻 rAF 夹定的时间、解码完成到画布绘制时间和标记分析耗时,不保存图像或配置。面板扫描和像素响应未测量,精确物理光学延迟需用高速相机复核。正常启动不运行标记识别;校准模式不会额外订阅一份 UDP 视频。

pnpm test:latency 验证标记边界、旋转/镜像、混合曝光拒绝及屏幕原色泛白情形。

现象 原因
起不来,提示绑不上 UDP 5600 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 ~/.config/cam-desktop/startup-error.txt
窗口起来了但没画面 设置里看"摄像头"下拉:绑定的是哪一块、在不在线
状态栏一直"已连接,等待画面…" 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次)
队伍端下拉里只有一块板子 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选
右上角那条血显示"装甲板未接入" 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血"
改了队名,某一处还是旧名字 队名应统一来自快照 teamNames,血条使用 name。检查是否有组件自行拼接默认队名
输了/赢了之后画面中央一直有字 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板
被打中时画面不动,只有闪红 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 shake = true 读得进来但不生效
画面不动 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。RUST_LOG=debug 会打印完整通道地址,可以用 wscat 连上去看
回放失败 录制目录不在资源协议白名单里。用"用系统播放器打开"
无法开始录制 检查 ffmpeg 是否在 PATH 中,或在设置中指定路径。启动失败时界面会显示错误
两路画面与队伍对应不符 在设置中核对图传板槽位,必要时手动指定;装甲板队伍号由编译时的 ARMOR_TEAM 决定
macOS 上 WebKit 报 could not create directory .../WebKit/... 运行环境禁止写 ~/Library/WebKit(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响
libc 找不到 MAP_FAILED desktop/.cargo/config.toml 的 target 没改成宿主三元组