From eafa1129596cbe3bfffcea76d05f11266a41e7cb Mon Sep 17 00:00:00 2001 From: Horldsence Date: Mon, 5 Oct 2026 21:45:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=86=99=E9=98=B2?= =?UTF-8?q?=E4=BD=9C=E5=BC=8A=E3=80=81=E7=BC=A9=E6=94=BE=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E3=80=81=E6=A7=BD=E4=BD=8D=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client/README.md 新增三节: 防作弊 说明为什么必须在**接收端**挡而不是界面里隐藏——Hello 广播已经带着 MAC 派生 id 和 IP,对手 tcpdump 一下就有,而数据本来也已收下解码。同时 明确写出这一版挡不住什么:对手知道 IP 后可以自己发 Subscribe,固件会向 任何订阅者推流。要真正封住得回固件加配对 token。 缩放 image-fit 必须跟着 zoom 在 contain/fill 之间切,否则放大不会改变画面; 缩回 1.0 要归位,否则下次放大从偏移开始像跳了一下。 槽位 记下"按 id 顺序对号入座"这个做法错在哪,以及为什么会出现画面左右互换。 根 README 排查表加两条:两格宽窄不一的检查点,以及放大后画面没变清晰的原因。 --- README.md | 2 + client/README.md | 161 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 295a53c..5e86387 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,8 @@ ge` 的构造必须留在事件循环。这条约束决定了整个客户端的 | `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 | | 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 | | WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 | +| 裁判端两块画面宽窄不一 | 检查那两格是否都写了相同的 `preferred-width`,且占位文字是绝对定位(`x`/`y`)而不是普通布局 | +| 放大后画面没变清晰 | `image-fit` 必须跟着 zoom 在 `contain`/`fill` 之间切。只用 `contain` 的话画面不会跟着放大 | 独立核对线路格式: diff --git a/client/README.md b/client/README.md index 2300e1d..595bbff 100644 --- a/client/README.md +++ b/client/README.md @@ -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 = 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 = 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`)。