Files
punktfunk/crates/punktfunk-core/src/config.rs
T
enricobuehler 4839c0e6f6
apple / swift (push) Successful in 1m16s
windows-host / package (push) Successful in 8m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
release / apple (push) Successful in 9m12s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m22s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m49s
apple / screenshots (push) Successful in 5m42s
android / android (push) Successful in 4m53s
arch / build-publish (push) Successful in 7m9s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m12s
ci / rust (push) Successful in 5m28s
deb / build-publish (push) Successful in 4m9s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 6m13s
flatpak / build-publish (push) Failing after 5m1s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m36s
fix(ci): windows clippy + rustfmt debt from the D3D11VA push
The windows workflow has been red since a69a83b5: clippy 1.96 rejects the two
field-reassign-with-default view-desc initializers in video_d3d11.rs (now struct
literals), and with clippy failing first, the rustfmt step never ran — cargo fmt
--all had genuine misses queued up in video_d3d11.rs / pf-presenter d3d11.rs +
vk.rs / core abr.rs + client.rs (plus this session's config.rs). Formatting only
beyond the two initializers; no behaviour change.

Verified: clippy -p pf-client-core --all-targets -D warnings clean on the RTX
Windows box, cargo fmt --all --check clean, core lib tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:47:06 +02:00

507 lines
21 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Session configuration and protocol/FEC parameters.
use crate::error::{PunktfunkError, Result};
use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES};
use zeroize::Zeroize;
/// Which side of the stream this session drives.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Host = 0,
Client = 1,
}
/// Negotiated protocol generation. P1 is GameStream-compatible (GF(2⁸)); P2 is the
/// `punktfunk/1` extension (GF(2¹⁶), multi-block framing, optional QUIC control).
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProtocolPhase {
P1GameStream = 1,
P2Punktfunk = 2,
}
/// Erasure-coding field. Mirrors the on-wire `fec_scheme` tag.
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FecScheme {
/// GF(2⁸) classic RS — Moonlight/GameStream compatible, ≤ 255 shards/block.
Gf8 = 0,
/// GF(2¹⁶) Leopard-RS — SIMD, O(n log n), up to 65535 shards/block.
Gf16 = 1,
}
impl FecScheme {
pub fn from_u8(v: u8) -> Option<FecScheme> {
match v {
0 => Some(FecScheme::Gf8),
1 => Some(FecScheme::Gf16),
_ => None,
}
}
/// Hard per-block total-shard ceiling for the field (data + recovery).
pub fn max_total_shards(self) -> usize {
match self {
FecScheme::Gf8 => 255,
FecScheme::Gf16 => u16::MAX as usize, // wire fields are u16
}
}
}
/// A client-sized display mode the host should produce on the virtual output.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Mode {
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
}
/// Which compositor backend a client would like the host to drive for its virtual output.
///
/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the
/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the
/// host decide (auto-detect from the running desktop). A concrete preference is honored only if
/// that backend is available on the host right now; otherwise the host falls back to auto-detect
/// and reports the real choice in `Welcome`. The wire form is a single byte (`0 = Auto`,
/// `1..=4` concrete), appended to `Hello`/`Welcome` — older peers simply omit/ignore it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum CompositorPref {
/// Let the host pick (auto-detect from the running desktop / its configured default).
#[default]
Auto,
/// KWin / KDE Plasma.
Kwin,
/// wlroots (Sway / Hyprland).
Wlroots,
/// Mutter / GNOME.
Mutter,
/// gamescope (spawned nested — available wherever the binary is installed).
Gamescope,
}
impl CompositorPref {
/// Wire byte. `0 = Auto`, `1 = Kwin`, `2 = Wlroots`, `3 = Mutter`, `4 = Gamescope`.
pub fn to_u8(self) -> u8 {
match self {
CompositorPref::Auto => 0,
CompositorPref::Kwin => 1,
CompositorPref::Wlroots => 2,
CompositorPref::Mutter => 3,
CompositorPref::Gamescope => 4,
}
}
/// Inverse of [`to_u8`](Self::to_u8). An unknown byte decodes to `Auto` — forward-compatible:
/// a future concrete value a peer doesn't recognize degrades to "let the host decide".
pub fn from_u8(v: u8) -> Self {
match v {
1 => CompositorPref::Kwin,
2 => CompositorPref::Wlroots,
3 => CompositorPref::Mutter,
4 => CompositorPref::Gamescope,
_ => CompositorPref::Auto,
}
}
/// Parse a CLI/config name (case-insensitive, with the usual desktop aliases). `None` for an
/// unrecognized name, so callers can error rather than silently defaulting to `Auto`.
pub fn from_name(s: &str) -> Option<Self> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"auto" | "detect" | "default" => CompositorPref::Auto,
"kwin" | "kde" | "plasma" => CompositorPref::Kwin,
"wlroots" | "sway" | "hyprland" | "wlr" => CompositorPref::Wlroots,
"mutter" | "gnome" => CompositorPref::Mutter,
"gamescope" => CompositorPref::Gamescope,
_ => return None,
})
}
/// Canonical lowercase identifier (`"auto"`, `"kwin"`, `"wlroots"`, `"mutter"`, `"gamescope"`).
pub fn as_str(self) -> &'static str {
match self {
CompositorPref::Auto => "auto",
CompositorPref::Kwin => "kwin",
CompositorPref::Wlroots => "wlroots",
CompositorPref::Mutter => "mutter",
CompositorPref::Gamescope => "gamescope",
}
}
}
/// Which virtual gamepad the host should create for a client's pads.
///
/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the
/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`), appended to `Hello`/`Welcome` — older peers simply
/// omit/ignore it (an unknown byte degrades to `Auto`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GamepadPref {
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
#[default]
Auto,
/// uinput X-Box 360 pad (the universal default — every game speaks XInput).
Xbox360,
/// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion.
DualSense,
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity
/// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger
/// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`).
XboxOne,
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
DualShock4,
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro,
/// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet
/// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)*
SteamController,
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
SteamDeck,
}
impl GamepadPref {
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`.
pub const fn to_u8(self) -> u8 {
match self {
GamepadPref::Auto => 0,
GamepadPref::Xbox360 => 1,
GamepadPref::DualSense => 2,
GamepadPref::XboxOne => 3,
GamepadPref::DualShock4 => 4,
GamepadPref::SteamController => 5,
GamepadPref::SteamDeck => 6,
}
}
/// Inverse of [`to_u8`](Self::to_u8). An unknown byte decodes to `Auto` — forward-compatible:
/// a future concrete value a peer doesn't recognize degrades to "let the host decide".
pub fn from_u8(v: u8) -> Self {
match v {
1 => GamepadPref::Xbox360,
2 => GamepadPref::DualSense,
3 => GamepadPref::XboxOne,
4 => GamepadPref::DualShock4,
5 => GamepadPref::SteamController,
6 => GamepadPref::SteamDeck,
_ => GamepadPref::Auto,
}
}
/// Parse a CLI/config name (case-insensitive, with the usual aliases). `None` for an
/// unrecognized name, so callers can error rather than silently defaulting to `Auto`.
pub fn from_name(s: &str) -> Option<Self> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"auto" | "default" => GamepadPref::Auto,
"xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360,
"dualsense" | "ds" | "ps5" => GamepadPref::DualSense,
"xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => {
GamepadPref::XboxOne
}
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
_ => return None,
})
}
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`).
pub fn as_str(self) -> &'static str {
match self {
GamepadPref::Auto => "auto",
GamepadPref::Xbox360 => "xbox360",
GamepadPref::DualSense => "dualsense",
GamepadPref::XboxOne => "xboxone",
GamepadPref::DualShock4 => "dualshock4",
GamepadPref::SteamController => "steamcontroller",
GamepadPref::SteamDeck => "steamdeck",
}
}
}
/// Per-block FEC parameters. Recovery count is derived from `fec_percent` exactly as
/// GameStream does: `m = ceil(k * fec_percent / 100)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FecConfig {
pub scheme: FecScheme,
/// Recovery overhead as a percentage of data shards (0 disables FEC).
pub fec_percent: u8,
/// Maximum data shards per FEC block; larger frames split into multiple blocks.
/// GF(2⁸) is bounded at 255 total shards, so keep this ≤ ~200 for `Gf8`.
pub max_data_per_block: u16,
}
impl FecConfig {
/// Recovery (parity) shard count for a block of `data_shards` shards.
pub fn recovery_for(&self, data_shards: usize) -> usize {
if self.fec_percent == 0 || data_shards == 0 {
return 0;
}
// ceil(k * pct / 100)
(data_shards * self.fec_percent as usize).div_ceil(100)
}
}
/// Largest shard payload that still fits a datagram once header + crypto overhead are
/// added. Bounds `shard_payload` so packets never exceed [`MAX_DATAGRAM_BYTES`].
pub const fn max_shard_payload() -> usize {
MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD
}
/// Largest **even** shard payload whose sealed wire datagram still fits an unfragmented IPv4/UDP
/// packet on a standard 1500-byte MTU: `1500 20 (IPv4) 8 (UDP) HEADER_LEN CRYPTO_OVERHEAD`
/// = 1408. Hosts should default `shard_payload` to this: one byte more and the kernel silently
/// splits EVERY video datagram into two IP fragments (a full frame plus a runt) — either fragment
/// lost = the datagram lost, roughly doubling per-datagram loss on Wi-Fi and eating straight into
/// FEC's recovery margin, plus per-pair kernel reassembly and runt airtime at line rate. (Exactly
/// what the previous hardcoded 1452 did: its MTU math forgot the punktfunk header + crypto ride
/// inside the UDP payload and counted the IP+UDP headers as 8 bytes instead of 28.)
pub const fn mtu1500_shard_payload() -> usize {
let p = 1500 - 20 - 8 - HEADER_LEN - CRYPTO_OVERHEAD;
p - p % 2 // FEC requires even shards
}
/// The IPv6 sibling of [`mtu1500_shard_payload`]: largest **even** shard payload whose sealed wire
/// datagram fits an unfragmented IPv6/UDP packet on a standard 1500-byte MTU:
/// `1500 40 (IPv6) 8 (UDP) HEADER_LEN CRYPTO_OVERHEAD` = 1388. The 20 extra header bytes
/// matter MORE here than on v4: IPv6 routers never fragment — an oversized datagram gets an ICMPv6
/// Packet-Too-Big at best and a silent blackhole at worst — so streaming the v4 size (1408) to a
/// v6 client wouldn't degrade the way v4 fragmentation did (the b5c30df saga), it would drop every
/// video datagram on any 1500-MTU hop.
pub const fn mtu1500_shard_payload_v6() -> usize {
let p = 1500 - 40 - 8 - HEADER_LEN - CRYPTO_OVERHEAD;
p - p % 2 // FEC requires even shards
}
/// The MTU-safe shard payload for a session streaming to `peer` (the QUIC remote — the data plane
/// dials the same address family): v6 sizing for a genuine IPv6 remote, v4 sizing otherwise —
/// including IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`, what a dual-stack `[::]` socket reports
/// for a v4 client), which ride IPv4 on the wire. Hosts pass this through
/// `Welcome::shard_payload`, so per-family sizing needs no wire change and old clients simply
/// follow the negotiated value.
pub fn mtu1500_shard_payload_for(peer: core::net::IpAddr) -> usize {
match peer {
core::net::IpAddr::V4(_) => mtu1500_shard_payload(),
core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => mtu1500_shard_payload(),
core::net::IpAddr::V6(_) => mtu1500_shard_payload_v6(),
}
}
/// Everything needed to construct a [`Session`](crate::session::Session).
///
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
/// on drop, so secrets neither leak into logs nor linger in freed memory.
#[derive(Clone)]
pub struct Config {
pub role: Role,
pub phase: ProtocolPhase,
pub fec: FecConfig,
/// Shard payload bytes per packet. Must be even and ≤ [`max_shard_payload`].
pub shard_payload: usize,
/// Largest encoded access unit the reassembler will accept (bounds memory against
/// hostile/corrupt headers; see [`Session`](crate::session::Session)).
pub max_frame_bytes: usize,
pub encrypt: bool,
/// AES-128 session key established during pairing. MUST be unique per session when
/// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]).
pub key: [u8; 16],
/// Per-session nonce salt, established alongside `key` during pairing. MUST be
/// unique per (key, session).
pub salt: [u8; 4],
/// Test hook: when non-zero, the loopback transport deterministically drops one of
/// every `loopback_drop_period` packets it sends. 0 = lossless.
pub loopback_drop_period: u32,
}
impl Drop for Config {
fn drop(&mut self) {
self.key.zeroize();
self.salt.zeroize();
}
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("role", &self.role)
.field("phase", &self.phase)
.field("fec", &self.fec)
.field("shard_payload", &self.shard_payload)
.field("max_frame_bytes", &self.max_frame_bytes)
.field("encrypt", &self.encrypt)
.field("key", &"<redacted>")
.field("salt", &"<redacted>")
.field("loopback_drop_period", &self.loopback_drop_period)
.finish()
}
}
impl Config {
/// Validate every invariant the hot path and the reassembler rely on. Rejecting here
/// is what keeps the receive-side parser's allocations bounded.
pub fn validate(&self) -> Result<()> {
if self.shard_payload == 0 || self.shard_payload % 2 != 0 {
return Err(PunktfunkError::InvalidArg(
"shard_payload must be even and > 0",
));
}
if self.shard_payload > max_shard_payload() {
return Err(PunktfunkError::InvalidArg(
"shard_payload too large to fit a datagram (header + crypto overhead)",
));
}
if self.fec.max_data_per_block == 0 {
return Err(PunktfunkError::InvalidArg("max_data_per_block must be > 0"));
}
// The per-block total (data + recovery) must fit both the field ceiling and the
// u16 wire fields.
let k = self.fec.max_data_per_block as usize;
let total = k + self.fec.recovery_for(k);
if total > self.fec.scheme.max_total_shards() {
return Err(PunktfunkError::InvalidArg(
"max_data_per_block + recovery exceeds the FEC scheme's shard ceiling",
));
}
if self.max_frame_bytes == 0 {
return Err(PunktfunkError::InvalidArg("max_frame_bytes must be > 0"));
}
// The frame must not need more FEC blocks than the u16 block-count field allows.
let total_data = self.max_frame_bytes.div_ceil(self.shard_payload).max(1);
let max_blocks = total_data.div_ceil(k).max(1);
if max_blocks > u16::MAX as usize {
return Err(PunktfunkError::InvalidArg(
"max_frame_bytes too large for this shard/block configuration (block count overflows u16)",
));
}
if self.encrypt && self.key == [0u8; 16] {
return Err(PunktfunkError::InvalidArg(
"encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)",
));
}
Ok(())
}
/// Sensible P1 defaults: GF(2⁸), 15% FEC, ~1 KiB shards, no encryption, 64 MiB frame
/// cap. When enabling encryption, replace `key`/`salt` with per-session values from
/// pairing — the all-zero defaults are rejected by [`validate`](Self::validate).
pub fn p1_defaults(role: Role) -> Self {
Config {
role,
phase: ProtocolPhase::P1GameStream,
fec: FecConfig {
scheme: FecScheme::Gf8,
fec_percent: 15,
max_data_per_block: 200,
},
shard_payload: 1024,
max_frame_bytes: 64 * 1024 * 1024,
encrypt: false,
key: [0u8; 16],
salt: [0u8; 4],
loopback_drop_period: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_encrypt_with_zero_key() {
let mut c = Config::p1_defaults(Role::Host);
c.encrypt = true; // key is still all-zero
assert!(c.validate().is_err());
c.key = [1u8; 16];
assert!(c.validate().is_ok());
}
#[test]
fn rejects_oversized_shard_payload() {
let mut c = Config::p1_defaults(Role::Host);
c.shard_payload = max_shard_payload() + 2; // still even, but won't fit a datagram
assert!(c.validate().is_err());
}
/// Pin the 1500-MTU wire math: the sealed datagram (header + shard + crypto) at the MTU-safe
/// shard payload must be ≤ 1472 (1500 IPv4 20 UDP 8), and one shard-step (+2) above must
/// not — the regression that shipped as 1452 and IP-fragmented every video datagram.
#[test]
fn mtu1500_shard_payload_never_fragments() {
let p = mtu1500_shard_payload();
assert_eq!(p % 2, 0, "FEC requires even shards");
assert!(p <= max_shard_payload());
let wire = HEADER_LEN + p + CRYPTO_OVERHEAD;
assert!(wire <= 1472, "sealed datagram {wire} B would IP-fragment");
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1472, "not maximal");
}
/// Pin the IPv6 wire math the same way: the sealed datagram must fit 1452 (1500 IPv6 40
/// UDP 8 — v6 routers don't fragment, so overshooting blackholes rather than degrades) and one
/// shard-step above must not.
#[test]
fn mtu1500_shard_payload_v6_never_blackholes() {
let p = mtu1500_shard_payload_v6();
assert_eq!(p % 2, 0, "FEC requires even shards");
assert!(p <= max_shard_payload());
let wire = HEADER_LEN + p + CRYPTO_OVERHEAD;
assert!(
wire <= 1452,
"sealed datagram {wire} B exceeds a 1500-MTU IPv6 hop"
);
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1452, "not maximal");
}
/// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6
/// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size.
#[test]
fn shard_payload_follows_peer_family() {
use core::net::IpAddr;
let v4: IpAddr = "192.168.1.50".parse().unwrap();
let v6: IpAddr = "fd00::50".parse().unwrap();
let mapped: IpAddr = "::ffff:192.168.1.50".parse().unwrap();
assert_eq!(mtu1500_shard_payload_for(v4), mtu1500_shard_payload());
assert_eq!(mtu1500_shard_payload_for(mapped), mtu1500_shard_payload());
assert_eq!(mtu1500_shard_payload_for(v6), mtu1500_shard_payload_v6());
}
#[test]
fn rejects_block_exceeding_scheme_ceiling() {
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
c.fec.max_data_per_block = 250;
c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255
assert!(c.validate().is_err());
}
#[test]
fn gamepad_pref_steam_roundtrip() {
use GamepadPref::*;
// Wire-byte round-trip for the Steam additions; an unknown byte still degrades to Auto.
for (p, b) in [(SteamController, 5u8), (SteamDeck, 6)] {
assert_eq!(p.to_u8(), b);
assert_eq!(GamepadPref::from_u8(b), p);
}
assert_eq!(GamepadPref::from_u8(99), Auto);
// Name parsing + canonical-name round-trip.
assert_eq!(GamepadPref::from_name("steamdeck"), Some(SteamDeck));
assert_eq!(GamepadPref::from_name("deck"), Some(SteamDeck));
assert_eq!(
GamepadPref::from_name("steamcontroller"),
Some(SteamController)
);
assert_eq!(SteamDeck.as_str(), "steamdeck");
assert_eq!(
GamepadPref::from_name(SteamController.as_str()),
Some(SteamController)
);
}
}