desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层: - desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按 发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。 - desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、 每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。 - protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来—— 装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选" 不需要改固件。 几个改对了的旧问题: - 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位 会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。 - 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出 几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。 - 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了" 会被读成"密码错了"。现在要求盐与摘要都在。 - 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。 现在先收尾上一段再开新的,并且重名往后排号。 - 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面** 不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。 测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用 ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
//! 推给界面的快照类型。
|
||||
//!
|
||||
//! # 为什么单独一个模块
|
||||
//!
|
||||
//! 这些结构体的字段名就是**界面看到的 JSON 字段名**,所以它们是接口的一部分,值得
|
||||
//! 集中放、集中改。放在这里而不是 Tauri 层,是因为其中几个的构造规则(比如"关掉
|
||||
//! 诊断时不留数字")需要单元测试。
|
||||
//!
|
||||
//! # 为什么 id 同时给数字和文本
|
||||
//!
|
||||
//! 界面显示、日志、配置文件里用的都是 `0x3D4D8A9A` 这种十六进制形式。让 Rust 侧拼好
|
||||
//! (`id_text`) 而不是让前端各处自己 `toString(16)`,可以避免"某处少补了前导零"这种
|
||||
//! 只能靠肉眼比对才发现的不一致。
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::armor::ArmorBoard;
|
||||
use crate::config::{Config, CrosshairConfig, GridConfig, HealthConfig, HitFeedbackConfig, Role};
|
||||
use crate::receiver::{CameraInfo, SourceStats};
|
||||
|
||||
/// 把 id 写成界面上、配置文件里、抓包时都一致的形式。
|
||||
pub fn id_text(id: u32) -> String {
|
||||
format!("0x{id:08X}")
|
||||
}
|
||||
|
||||
/// 一块图传板。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CameraDto {
|
||||
pub id: u32,
|
||||
pub id_text: String,
|
||||
pub addr: String,
|
||||
pub online: bool,
|
||||
pub silence_ms: u64,
|
||||
pub firmware: u16,
|
||||
}
|
||||
|
||||
impl From<&CameraInfo> for CameraDto {
|
||||
fn from(camera: &CameraInfo) -> Self {
|
||||
Self {
|
||||
id: camera.id,
|
||||
id_text: id_text(camera.id),
|
||||
addr: camera.addr.ip().to_string(),
|
||||
online: camera.is_online(),
|
||||
silence_ms: camera.silence().as_millis() as u64,
|
||||
firmware: camera.firmware,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一块装甲板主板。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArmorBoardDto {
|
||||
pub id: u32,
|
||||
pub id_text: String,
|
||||
pub addr: String,
|
||||
pub online: bool,
|
||||
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
|
||||
pub team: u8,
|
||||
/// 最后一次读数。整车一条血,所以这里一个数。
|
||||
pub hp: u16,
|
||||
pub plates_seen: u8,
|
||||
pub reports_all_plates: bool,
|
||||
pub silence_ms: u64,
|
||||
}
|
||||
|
||||
impl From<&ArmorBoard> for ArmorBoardDto {
|
||||
fn from(board: &ArmorBoard) -> Self {
|
||||
Self {
|
||||
id: board.id,
|
||||
id_text: id_text(board.id),
|
||||
addr: board.addr.ip().to_string(),
|
||||
online: board.is_online(),
|
||||
team: board.team,
|
||||
hp: board.hp,
|
||||
plates_seen: board.plates_seen.count_ones() as u8,
|
||||
reports_all_plates: board.reports_all_plates(),
|
||||
silence_ms: board.silence().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一路画面的读数。
|
||||
///
|
||||
/// # 关掉诊断时这些数字**不存在**,而不是被界面藏起来
|
||||
///
|
||||
/// 旧客户端在队伍端 release 下把这些字段填成空字符串。现在换成数字,规则不变:非诊断
|
||||
/// 模式下它们被置零/置空。由编译模式决定而不是命令行参数——命令行开关意味着二进制里
|
||||
/// 带着那套东西,忘开一次就泄露了。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CameraStatsDto {
|
||||
pub camera: u32,
|
||||
pub camera_text: String,
|
||||
pub online: bool,
|
||||
/// 界面直接显示这一行,所以"在线但还没画面"与"离线"的区别由这里给出,而不是让
|
||||
/// 前端从 online + hasFrames 猜。
|
||||
pub status: String,
|
||||
pub has_frames: bool,
|
||||
pub fps: f32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub frames_shown: u64,
|
||||
pub frames_dropped: u64,
|
||||
pub loss_percent: f32,
|
||||
pub datagrams: u64,
|
||||
pub last_frame_bytes: usize,
|
||||
pub gap_ms: Option<f32>,
|
||||
}
|
||||
|
||||
impl CameraStatsDto {
|
||||
/// `diagnostics` 为 false 时不产出技术数字(见类型文档)。
|
||||
pub fn build(
|
||||
diagnostics: bool,
|
||||
camera: u32,
|
||||
online: bool,
|
||||
stats: &SourceStats,
|
||||
fps: f32,
|
||||
) -> Self {
|
||||
let has_frames = stats.frames_shown > 0;
|
||||
|
||||
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
|
||||
// 比让人以为板子挂了诚实。
|
||||
//
|
||||
// 有画面时显示 id —— 这对调试有用。队伍端 release 下这一路只有自己绑定的板子,
|
||||
// 显示它不构成泄露(界面上根本没有别人的板子可选)。
|
||||
let status = if !has_frames {
|
||||
if online {
|
||||
"已连接,等待画面…".to_string()
|
||||
} else {
|
||||
"离线".to_string()
|
||||
}
|
||||
} else {
|
||||
id_text(camera)
|
||||
};
|
||||
|
||||
if !diagnostics {
|
||||
return Self {
|
||||
camera,
|
||||
camera_text: id_text(camera),
|
||||
online,
|
||||
status,
|
||||
has_frames,
|
||||
fps: 0.0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
frames_shown: 0,
|
||||
frames_dropped: 0,
|
||||
loss_percent: 0.0,
|
||||
datagrams: 0,
|
||||
last_frame_bytes: 0,
|
||||
gap_ms: None,
|
||||
};
|
||||
}
|
||||
|
||||
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是界面会显示成
|
||||
// "0x0"——一个看起来像分辨率的假分辨率。用 has_frames 把它挡掉。
|
||||
Self {
|
||||
camera,
|
||||
camera_text: id_text(camera),
|
||||
online,
|
||||
status,
|
||||
has_frames,
|
||||
fps: if has_frames { fps } else { 0.0 },
|
||||
width: if has_frames { stats.width } else { 0 },
|
||||
height: if has_frames { stats.height } else { 0 },
|
||||
frames_shown: stats.frames_shown,
|
||||
frames_dropped: stats.frames_dropped,
|
||||
loss_percent: stats.loss_percent(),
|
||||
datagrams: stats.datagrams,
|
||||
last_frame_bytes: stats.last_frame_len,
|
||||
gap_ms: stats.gap_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一边的血量。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TeamHealthDto {
|
||||
pub team: u8,
|
||||
pub name: String,
|
||||
/// 有没有可信的数据。false 时界面显示 `placeholder` 而不是 0——0 是一个看起来像
|
||||
/// 真值的假值,现场看到 0 会以为对方已经没血了。
|
||||
pub available: bool,
|
||||
pub placeholder: String,
|
||||
/// 整车血量(四条装甲板同值,所以取第一条)。
|
||||
pub hp: Option<u16>,
|
||||
/// 四条装甲板各自的读数;`available` 为 true 时有效。
|
||||
pub hps: Vec<u16>,
|
||||
/// 绑定的装甲板主板 id(用户端)/ 分配到的(裁判端)。
|
||||
pub board: Option<u32>,
|
||||
pub board_text: Option<String>,
|
||||
/// 血量偏低或已归零。
|
||||
pub critical: bool,
|
||||
}
|
||||
|
||||
impl TeamHealthDto {
|
||||
/// 还没有数据源。
|
||||
pub fn absent(team: u8, board: Option<u32>) -> Self {
|
||||
Self {
|
||||
team,
|
||||
name: team_name(team),
|
||||
available: false,
|
||||
placeholder: format!(
|
||||
"装甲板未接入:等主板在 UDP {} 上广播血量",
|
||||
protocol::ARMOR_PORT
|
||||
),
|
||||
hp: None,
|
||||
// 依然给出 4 个格子:界面用 `available` 决定颜色,空数组会让占位框整个消失,
|
||||
// 看起来像布局出错,而不是像"还没有数据"。
|
||||
hps: vec![0; crate::receiver::ARMOR_BOARDS],
|
||||
board,
|
||||
board_text: board.map(id_text),
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 有数据。
|
||||
pub fn present(team: u8, board: Option<u32>, hps: [u16; crate::receiver::ARMOR_BOARDS], low: u16) -> Self {
|
||||
let hp = hps.first().copied().unwrap_or(0);
|
||||
|
||||
Self {
|
||||
team,
|
||||
name: team_name(team),
|
||||
available: true,
|
||||
placeholder: String::new(),
|
||||
hp: Some(hp),
|
||||
hps: hps.to_vec(),
|
||||
board,
|
||||
board_text: board.map(id_text),
|
||||
critical: hp == 0 || hp <= low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 队伍名。
|
||||
pub fn team_name(team: u8) -> String {
|
||||
match team {
|
||||
0 => "队伍一".to_string(),
|
||||
_ => "队伍二".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次掉血(由 [`crate::armor::HealthTracker`] 派生)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HitDto {
|
||||
pub board: u32,
|
||||
pub team: u8,
|
||||
pub index: u8,
|
||||
pub from: u16,
|
||||
pub to: u16,
|
||||
pub damage: u16,
|
||||
/// Unix 毫秒。前端用它驱动闪红/抖动的动画,不需要自己计时。
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
/// 录制状态。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordingDto {
|
||||
pub camera: u32,
|
||||
pub camera_text: String,
|
||||
pub active: bool,
|
||||
pub path: Option<String>,
|
||||
/// 告诉 ffmpeg 的输入帧率。回放快放/慢放时,第一个要看的就是这个数。
|
||||
pub input_fps: f32,
|
||||
pub frames_written: u64,
|
||||
/// 因为写入端跟不上而丢掉的帧。丢帧不报出来的话,回放里"少了半秒"会看起来像
|
||||
/// 相机的问题。
|
||||
pub frames_dropped: u64,
|
||||
pub started_ms: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 裁判端两个槽位。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SlotsDto {
|
||||
/// 左槽位 → 摄像头 id。
|
||||
pub camera_a: Option<u32>,
|
||||
pub camera_b: Option<u32>,
|
||||
/// 左槽位 → 装甲板主板 id。
|
||||
pub armor_a: Option<u32>,
|
||||
pub armor_b: Option<u32>,
|
||||
pub manual: bool,
|
||||
}
|
||||
|
||||
/// 推给界面的完整状态。
|
||||
///
|
||||
/// 一次性给全,而不是让界面按需多次查询:5 Hz 下这条消息只有几百字节,而"界面各处
|
||||
/// 分别拉数据"会引入一类难查的问题——两个面板显示的是不同时刻的状态(比如绑定改了
|
||||
/// 一半)。前端因此只需要一个 reducer。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
pub role: Role,
|
||||
pub team: u8,
|
||||
pub mirrored: bool,
|
||||
/// 演示模式:画面与血量是本地合成的,不是场上的板子。界面必须把它显示出来,
|
||||
/// 否则截图与验收会被误读成真实链路。
|
||||
pub demo: bool,
|
||||
/// 是否产出技术读数(帧率/丢帧)。用户端 release 下为 false。
|
||||
pub diagnostics: bool,
|
||||
/// 密码闸门是否已经打开(会话内)。
|
||||
pub unlocked: bool,
|
||||
/// 发现列表是否放开(设置面板打开时)。开着才可能列出别人的板子。
|
||||
pub discovery_open: bool,
|
||||
pub uptime_ms: u64,
|
||||
/// 连着这个后端的界面数。
|
||||
pub clients: usize,
|
||||
pub config: ConfigView,
|
||||
pub cameras: Vec<CameraDto>,
|
||||
pub armor_boards: Vec<ArmorBoardDto>,
|
||||
/// 每块已知摄像头的读数。
|
||||
pub stats: Vec<CameraStatsDto>,
|
||||
/// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。
|
||||
pub slots: SlotsDto,
|
||||
/// 用户端一条(自己那边),裁判端两条。
|
||||
pub health: Vec<TeamHealthDto>,
|
||||
pub recordings: Vec<RecordingDto>,
|
||||
/// 启动时的说明(旧配置迁移、端口占用等)。
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 配置里可以给界面看的那部分。
|
||||
///
|
||||
/// **不含密码哈希**:界面只需要知道"设过没有",把 PHC 字符串送进 WebView 没有任何用处,
|
||||
/// 只是多一个能被日志、崩溃报告带走的东西。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigView {
|
||||
pub role: Role,
|
||||
/// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。
|
||||
pub role_chosen: bool,
|
||||
pub team: u8,
|
||||
pub camera_id: Option<u32>,
|
||||
pub camera_id_text: Option<String>,
|
||||
pub armor_id: Option<u32>,
|
||||
pub armor_id_text: Option<String>,
|
||||
pub mirror: bool,
|
||||
pub password_set: bool,
|
||||
pub hud: HudView,
|
||||
pub recording_dir: String,
|
||||
pub ffmpeg: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HudView {
|
||||
pub health: HealthConfig,
|
||||
pub crosshair: CrosshairConfig,
|
||||
pub grid: GridConfig,
|
||||
pub hit_feedback: HitFeedbackConfig,
|
||||
pub show_zoom: bool,
|
||||
pub show_stats: bool,
|
||||
}
|
||||
|
||||
impl From<&Config> for ConfigView {
|
||||
fn from(config: &Config) -> Self {
|
||||
Self {
|
||||
role: config.role,
|
||||
role_chosen: config.role_chosen,
|
||||
team: config.team,
|
||||
camera_id: config.camera_id,
|
||||
camera_id_text: config.camera_id.map(id_text),
|
||||
armor_id: config.armor_id,
|
||||
armor_id_text: config.armor_id.map(id_text),
|
||||
mirror: config.mirror,
|
||||
password_set: config.password.is_some(),
|
||||
hud: HudView {
|
||||
health: config.hud.health.clone(),
|
||||
crosshair: config.hud.crosshair.clone(),
|
||||
grid: config.hud.grid.clone(),
|
||||
hit_feedback: config.hud.hit_feedback.clone(),
|
||||
show_zoom: config.hud.show_zoom,
|
||||
show_stats: config.hud.show_stats,
|
||||
},
|
||||
recording_dir: config.recordings_dir().display().to_string(),
|
||||
ffmpeg: config.recording.ffmpeg.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::receiver::SourceStats;
|
||||
|
||||
fn stats() -> SourceStats {
|
||||
let mut stats = SourceStats::default();
|
||||
// 直接构造:SourceStats 的字段是 pub 的,只有 last_shown_at 是私有的。
|
||||
stats.frames_shown = 440;
|
||||
stats.frames_dropped = 10;
|
||||
stats.datagrams = 8000;
|
||||
stats.width = 640;
|
||||
stats.height = 480;
|
||||
stats.last_frame_len = 22_000;
|
||||
stats
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_off_leaves_no_numbers_behind() {
|
||||
// 不是"界面藏起来",是**不存在**。
|
||||
let dto = CameraStatsDto::build(false, 0x3D4D_8A9A, true, &stats(), 22.0);
|
||||
|
||||
assert_eq!(dto.frames_shown, 0);
|
||||
assert_eq!(dto.frames_dropped, 0);
|
||||
assert_eq!(dto.datagrams, 0);
|
||||
assert_eq!(dto.fps, 0.0);
|
||||
assert_eq!(dto.width, 0);
|
||||
assert_eq!(dto.gap_ms, None);
|
||||
assert_eq!(dto.last_frame_bytes, 0);
|
||||
// 但界面仍然需要知道画面来没来。
|
||||
assert!(dto.has_frames);
|
||||
assert!(dto.online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_on_reports_the_real_numbers() {
|
||||
let dto = CameraStatsDto::build(true, 0x3D4D_8A9A, true, &stats(), 22.0);
|
||||
|
||||
assert_eq!(dto.frames_shown, 440);
|
||||
assert_eq!(dto.frames_dropped, 10);
|
||||
assert!((dto.loss_percent - 2.2222).abs() < 0.01);
|
||||
assert_eq!((dto.width, dto.height), (640, 480));
|
||||
assert_eq!(dto.fps, 22.0);
|
||||
assert_eq!(dto.status, "0x3D4D8A9A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_seen_camera_does_not_report_a_fake_resolution() {
|
||||
let dto = CameraStatsDto::build(true, 1, true, &SourceStats::default(), 0.0);
|
||||
|
||||
assert_eq!(dto.status, "已连接,等待画面…");
|
||||
assert_eq!((dto.width, dto.height), (0, 0));
|
||||
assert!(!dto.has_frames, "0x0 would read like a real resolution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_camera_says_offline() {
|
||||
let dto = CameraStatsDto::build(true, 1, false, &SourceStats::default(), 0.0);
|
||||
assert_eq!(dto.status, "离线");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_health_is_a_placeholder_with_four_cells() {
|
||||
let dto = TeamHealthDto::absent(0, None);
|
||||
|
||||
assert!(!dto.available);
|
||||
assert_eq!(dto.hp, None);
|
||||
assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS);
|
||||
assert!(!dto.placeholder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn critical_health_is_flagged_at_zero_and_at_the_threshold() {
|
||||
let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30);
|
||||
assert!(low.critical, "at the threshold");
|
||||
|
||||
let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30);
|
||||
assert!(!ok.critical);
|
||||
|
||||
let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30);
|
||||
assert!(dead.critical);
|
||||
assert_eq!(dead.hp, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_reports_the_framerate_it_told_ffmpeg() {
|
||||
// 回放速度不对时,这是唯一能解释原因的数字。
|
||||
let dto = RecordingDto {
|
||||
camera: 1,
|
||||
camera_text: id_text(1),
|
||||
active: true,
|
||||
path: None,
|
||||
input_fps: 21.5,
|
||||
frames_written: 0,
|
||||
frames_dropped: 0,
|
||||
started_ms: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
assert!(json.contains(r#""inputFps":21.5"#), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_view_never_carries_the_password_hash() {
|
||||
let mut config = Config::default();
|
||||
config.role = Role::Team;
|
||||
config.password = Some("$argon2id$super$secret$hash".into());
|
||||
|
||||
let view = ConfigView::from(&config);
|
||||
assert!(view.password_set);
|
||||
|
||||
let json = serde_json::to_string(&view).unwrap();
|
||||
assert!(
|
||||
!json.contains("argon2id"),
|
||||
"the hash must not reach the webview: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_formatted_like_everywhere_else() {
|
||||
assert_eq!(id_text(0x3D4D_8A9A), "0x3D4D8A9A");
|
||||
assert_eq!(id_text(1), "0x00000001", "leading zeros matter for eyeballing");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user