文档:写防作弊、缩放布局、槽位语义
client/README.md 新增三节:
防作弊 说明为什么必须在**接收端**挡而不是界面里隐藏——Hello 广播已经带着
MAC 派生 id 和 IP,对手 tcpdump 一下就有,而数据本来也已收下解码。同时
明确写出这一版挡不住什么:对手知道 IP 后可以自己发 Subscribe,固件会向
任何订阅者推流。要真正封住得回固件加配对 token。
缩放 image-fit 必须跟着 zoom 在 contain/fill 之间切,否则放大不会改变画面;
缩回 1.0 要归位,否则下次放大从偏移开始像跳了一下。
槽位 记下"按 id 顺序对号入座"这个做法错在哪,以及为什么会出现画面左右互换。
根 README 排查表加两条:两格宽窄不一的检查点,以及放大后画面没变清晰的原因。
This commit is contained in:
+156
-5
@@ -125,13 +125,33 @@ std::rc::Rc::new(slint::VecModel::from(v)).into()
|
||||
|
||||
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
|
||||
|
||||
### 槽位不会左右跳动
|
||||
### 槽位一旦占定就不换人
|
||||
|
||||
裁判端按 sender_id 排序填两个槽位,**槽位保持稳定**——某块板子临时离线时不会重新
|
||||
洗牌。如果每次都重新排版,一块板子掉线会让剩下的画面从左边跳到右边,现场看非常像
|
||||
"两路串了"。
|
||||
裁判端有两个槽位,但**不要按 id 顺序重新对号入座**。
|
||||
|
||||
`view.sender_id != wanted` 才重建 `View`(清零帧率窗口),避免每 33ms 重置一次统计。
|
||||
原来那版是:
|
||||
|
||||
```rust
|
||||
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
|
||||
let wanted = ids.get(i).copied();
|
||||
```
|
||||
|
||||
看起来稳定,其实不是:低 id 的板子一离线,`ids` 就缩短成 `[B]`,B 落到**左**槽 ——
|
||||
画面左右互换。现场看非常像"两路串了",而实际上只是排序。
|
||||
|
||||
现在的规则:还**在线**的槽位保持不动;空槽或已掉线的槽位才从还没被占用的板子里按 id
|
||||
顺序取第一个。
|
||||
|
||||
```rust
|
||||
let still_here = view.sender_id.is_some_and(|id| cameras.iter().any(|c| c.id == id));
|
||||
if still_here { continue; }
|
||||
```
|
||||
|
||||
换人时优先挑一个和当前不同的板子,否则会出现"板子短暂掉线又回来,槽位原地打转",
|
||||
看起来像卡住。
|
||||
|
||||
`view.sender_id` 变了才重建 `View`(清零帧率窗口),否则每 33ms 重置一次统计,帧率
|
||||
永远算不出来。
|
||||
|
||||
## 帧率:按计数除以墙钟
|
||||
|
||||
@@ -160,6 +180,137 @@ let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
|
||||
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
|
||||
占位框消失,看起来像布局出错,而不是像"还没有数据"。
|
||||
|
||||
## 防作弊:release 队伍端只接收绑定的那一块
|
||||
|
||||
对抗赛里两队都用这套系统看自己小车的图传,所以要防止对手开第二个窗口连到另一队
|
||||
的板子。
|
||||
|
||||
**关键是这件事必须做在接收端,而不是界面里。**
|
||||
|
||||
一开始我按"release 下不显示 id 和 IP"理解,那样等于没做:
|
||||
|
||||
- 广播的 Hello 里就有 MAC 派生的 `sender_id` 和 IP。对手 `tcpdump` 一下全都知道,
|
||||
界面藏不藏毫无关系。
|
||||
- 数据本来已经收下并解码了。`.slint` 只控制显示,隐藏一行文字不阻止任何事。
|
||||
|
||||
所以是 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、只统计
|
||||
绑定的那一块。对手的板子即使把流推到了本机端口(IP 已泄露,`DEFAULT_PORT` 是公开
|
||||
常量),数据也不进入任何管线。
|
||||
|
||||
```rust
|
||||
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
|
||||
config.camera_id.filter(|_| args.camera_id.is_none())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
```
|
||||
|
||||
四种情况不限制,各有理由:
|
||||
|
||||
| 情况 | 为什么不限制 |
|
||||
|---|---|
|
||||
| 裁判端 | 中立第三方,本来就要同时看两块板子 |
|
||||
| debug 构建 | 开发时需要看到全场 |
|
||||
| 队伍端未绑定 | 选择界面需要列出候选,否则无从选起 |
|
||||
| `--camera-id` 覆盖了配置 | 显式指定了要看哪块,是有意的 |
|
||||
|
||||
### 遥测按编译模式门控
|
||||
|
||||
```rust
|
||||
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
|
||||
```
|
||||
|
||||
fps / 丢帧率 / 数据报数 / 帧间隔这些在队伍端 release 下**不产出**(空字符串,界面
|
||||
也不显示),stdout 的诊断日志同样门控。
|
||||
|
||||
由编译模式决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开一次
|
||||
就泄露了。
|
||||
|
||||
裁判端不受影响——双方遥测对判罚有用。
|
||||
|
||||
### `--list` 在 release 队伍端直接拒绝
|
||||
|
||||
它会打印场上所有板子的 id 和 IP,正是要防的那件事。与其列出来再叮嘱"别看",不如
|
||||
不给:
|
||||
|
||||
```
|
||||
Error: "--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。"
|
||||
```
|
||||
|
||||
### 选择界面保留
|
||||
|
||||
现场换机器时还要用它重新绑定。数据侧的闸门已经足够,界面列出候选不构成额外泄露
|
||||
(对手需要的是实时画面,不是 id 字符串)。
|
||||
|
||||
### 这挡不住什么
|
||||
|
||||
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后,可以自己发一个
|
||||
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。
|
||||
|
||||
真要挡住需要固件侧做配对(首次绑定发一个 token,固件只接受带 token 的订阅),
|
||||
代价是固件重启就失配。这次没做,因为需求定的是"避免一个队伍通过开其他窗口连接到
|
||||
其他队伍的图传",而这需要对手主动在自己机器上装这套客户端——纯 UI 或客户端侧的
|
||||
措施挡不住它。**这一条如果要真正封住,得回固件加 token。**
|
||||
|
||||
## 缩放与自适应布局
|
||||
|
||||
### 滚轮缩放 + 拖动平移
|
||||
|
||||
Slint 的 `Image` 没有缩放因子属性,所以实现是:把 `Image` 的 `width`/`height` 按
|
||||
`zoom` 放大后放进 `clip: true` 的容器,靠 `offset-x`/`offset-y` 平移。
|
||||
|
||||
- 滚轮改 `zoom`(每次 ×1.25,上限 8 倍)
|
||||
- 拖动改 `offset`(只在 `zoom > 1` 时生效——此时才有可移动的空间)
|
||||
- 双击复位
|
||||
- 缩回 1.0 时自动归位
|
||||
|
||||
最后一条容易漏:缩回 1.0 后若保留偏移,下一次放大会从偏移位置开始,看起来像
|
||||
"跳了一下"。
|
||||
|
||||
`image-fit` 必须跟着 `zoom` 切:
|
||||
|
||||
```
|
||||
image-fit: root.zoom <= 1.0 ? contain : fill;
|
||||
```
|
||||
|
||||
`contain` 在 `zoom = 1` 时保证完整可见且不变形,`fill` 才让画面跟着放大。**只用
|
||||
`contain` 的话放大不会有任何视觉变化**——这个 bug 很容易写出来,因为界面上确实
|
||||
变大了,只是画面没跟上。
|
||||
|
||||
裁判端两路的缩放**互相独立**:滚轮只作用于鼠标所在的那一路。
|
||||
|
||||
### 为什么两格必须等宽
|
||||
|
||||
裁判端曾经放大时左边占 55%、右边 45%;空的那一侧更极端时会被压成一条缝。
|
||||
|
||||
原因不是缺 `stretch`。`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按
|
||||
stretch 分。左格写"已连接,等待画面…"(8 字),右格"未收到画面"(5 字),左格就
|
||||
多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。
|
||||
|
||||
三处一起改:
|
||||
|
||||
1. **占位文字改成绝对定位**(给出 `x`/`y`),不参与父级尺寸计算。文字长短本不该
|
||||
决定画面大小。
|
||||
2. **两格统一 `preferred-width` + `horizontal-stretch: 1`**,需求变成相同,于是精确
|
||||
平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
|
||||
3. **缩放指示与复位按钮也绝对定位**。否则它们一出现就改变这一格的尺寸需求,把不
|
||||
等宽重新引进来——而且是在用户放大之后才出现,最难排查。
|
||||
|
||||
`preferred-width` 只接受长度或字面量 `100%`,不接受 `50%`。
|
||||
|
||||
### 启动即最大化
|
||||
|
||||
```rust
|
||||
ui.window().set_maximized(true);
|
||||
ui.show()?;
|
||||
```
|
||||
|
||||
**必须在 `show()` 之前**——之后调用会被后端当成"用户点了最大化按钮",而不是初始
|
||||
状态。
|
||||
|
||||
取 `set_maximized` 而不是 `set_fullscreen`:全屏会盖掉菜单栏和 Dock,切出去要按快
|
||||
捷键。裁判终端偶尔要切到别的窗口核对数据。
|
||||
|
||||
## 内存:解码只留最新一张
|
||||
|
||||
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
|
||||
|
||||
Reference in New Issue
Block a user