Skip to main content

denise_fbdev/
info.rs

1//! Framebuffer geometry, read from sysfs.
2//!
3//! The classic way to ask fbdev about itself is `FBIOGET_VSCREENINFO` and
4//! `FBIOGET_FSCREENINFO`. Everything those return that matters here is also in
5//! `/sys/class/graphics/fbN/`, and reading files instead of issuing ioctls means
6//! no `libc` dependency, no `unsafe`, and — the part that actually pays — parsing
7//! that is testable on a machine with no framebuffer.
8//!
9//! What sysfs does not expose is the colour bitfield layout, so the byte order
10//! within a pixel is assumed rather than read. See [`PixelLayout`].
11
12use core::fmt;
13
14use denise::Size;
15
16/// How pixels are laid out in the mapped framebuffer.
17///
18/// sysfs reports the depth but not the channel order, so this is inferred. The
19/// assumptions hold for every mainline framebuffer driver and for DRM's fbdev
20/// emulation, which is what almost every modern `/dev/fb0` actually is; a device
21/// with an exotic byte order will render with its channels swapped rather than
22/// fail, and that is the trade a legacy fallback should make.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum PixelLayout {
26    /// 32 bits per pixel, `0xXXRRGGBB`. Matches Denise's own word layout, so
27    /// presenting is a copy.
28    Xrgb8888,
29    /// 16 bits per pixel, 5 red / 6 green / 5 blue. Common on SPI panels, and
30    /// needs a conversion on the way out.
31    Rgb565,
32}
33
34impl PixelLayout {
35    /// Bytes each pixel occupies.
36    #[inline]
37    pub const fn bytes_per_pixel(self) -> usize {
38        match self {
39            PixelLayout::Xrgb8888 => 4,
40            PixelLayout::Rgb565 => 2,
41        }
42    }
43
44    /// Infers the layout from a bit depth.
45    pub const fn from_bits_per_pixel(bpp: u32) -> Option<Self> {
46        match bpp {
47            32 => Some(PixelLayout::Xrgb8888),
48            16 => Some(PixelLayout::Rgb565),
49            _ => None,
50        }
51    }
52
53    /// Converts one `0xAARRGGBB` word to this layout's 16-bit encoding.
54    ///
55    /// Truncation, not dithering: a UI is flat colour and gradients are rare, so
56    /// the banding dithering would fix mostly is not there to fix.
57    #[inline]
58    pub const fn to_rgb565(word: u32) -> u16 {
59        let r = (word >> 19) & 0x1F;
60        let g = (word >> 10) & 0x3F;
61        let b = (word >> 3) & 0x1F;
62        ((r << 11) | (g << 5) | b) as u16
63    }
64}
65
66/// The geometry of a framebuffer.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct FbInfo {
69    /// Visible extent in pixels.
70    pub size: Size,
71    /// Distance between the starts of consecutive rows, in **bytes**.
72    ///
73    /// fbdev calls this `line_length`, and it is routinely wider than
74    /// `width * bytes_per_pixel`. Assuming otherwise is the classic fbdev bug: a
75    /// picture that shears diagonally on hardware and looks fine in a VM.
76    pub stride_bytes: u32,
77    /// Bit depth as reported.
78    pub bits_per_pixel: u32,
79    /// Inferred pixel layout.
80    pub layout: PixelLayout,
81}
82
83impl FbInfo {
84    /// Builds the geometry from raw sysfs attribute contents.
85    ///
86    /// `modes` is preferred for the visible extent because `virtual_size` includes
87    /// any area reserved for panning, which on a double-buffered framebuffer is
88    /// twice the height of what is actually on screen.
89    pub fn from_sysfs(
90        virtual_size: &str,
91        modes: &str,
92        stride: &str,
93        bits_per_pixel: &str,
94    ) -> Result<Self, FbInfoError> {
95        let virtual_size = parse_virtual_size(virtual_size)?;
96        let size = parse_modes(modes).unwrap_or(virtual_size);
97
98        // A mode wider than the allocation cannot be right; trust the allocation.
99        let size = Size::new(
100            size.width.min(virtual_size.width),
101            size.height.min(virtual_size.height),
102        );
103
104        let stride_bytes: u32 = stride.trim().parse().map_err(|_| FbInfoError::Unparsable {
105            attribute: "stride",
106        })?;
107
108        let bits_per_pixel: u32 =
109            bits_per_pixel
110                .trim()
111                .parse()
112                .map_err(|_| FbInfoError::Unparsable {
113                    attribute: "bits_per_pixel",
114                })?;
115
116        let layout = PixelLayout::from_bits_per_pixel(bits_per_pixel)
117            .ok_or(FbInfoError::UnsupportedDepth { bits_per_pixel })?;
118
119        if size.is_empty() {
120            return Err(FbInfoError::EmptyGeometry);
121        }
122
123        let minimum = size.width as usize * layout.bytes_per_pixel();
124        if (stride_bytes as usize) < minimum {
125            return Err(FbInfoError::StrideTooNarrow {
126                stride_bytes,
127                required: minimum as u32,
128            });
129        }
130
131        Ok(Self {
132            size,
133            stride_bytes,
134            bits_per_pixel,
135            layout,
136        })
137    }
138
139    /// Bytes the mapping must cover for this geometry.
140    pub fn required_bytes(&self) -> usize {
141        // The last row needs only its visible pixels, not the padding after them.
142        self.stride_bytes as usize * (self.size.height as usize - 1)
143            + self.size.width as usize * self.layout.bytes_per_pixel()
144    }
145
146    /// Row stride in pixels, when that is a whole number.
147    pub fn stride_pixels(&self) -> Option<u32> {
148        let bpp = self.layout.bytes_per_pixel() as u32;
149        self.stride_bytes
150            .is_multiple_of(bpp)
151            .then(|| self.stride_bytes / bpp)
152    }
153}
154
155impl fmt::Display for FbInfo {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(
158            f,
159            "{}x{} {}bpp {:?}, stride {} bytes",
160            self.size.width, self.size.height, self.bits_per_pixel, self.layout, self.stride_bytes
161        )
162    }
163}
164
165/// `virtual_size` is `"<width>,<height>"`.
166fn parse_virtual_size(text: &str) -> Result<Size, FbInfoError> {
167    let (w, h) = text.trim().split_once(',').ok_or(FbInfoError::Unparsable {
168        attribute: "virtual_size",
169    })?;
170    let width = w.trim().parse().map_err(|_| FbInfoError::Unparsable {
171        attribute: "virtual_size",
172    })?;
173    let height = h.trim().parse().map_err(|_| FbInfoError::Unparsable {
174        attribute: "virtual_size",
175    })?;
176    Ok(Size::new(width, height))
177}
178
179/// `modes` lists entries like `"U:1280x800p-60"`. Only the extent is wanted.
180///
181/// Returns `None` rather than failing: the attribute is empty on some drivers and
182/// `virtual_size` is a perfectly good fallback.
183fn parse_modes(text: &str) -> Option<Size> {
184    // "U:1280x800p-60" -> drop the "U:" tag, split on 'x', then take the leading
185    // digits of "800p-60" and ignore the timing suffix.
186    let line = text.lines().next()?.trim();
187    let geometry = line.rsplit_once(':').map_or(line, |(_, rest)| rest);
188    let (width, rest) = geometry.split_once('x')?;
189
190    let width: u32 = width.trim().parse().ok()?;
191    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
192    let height: u32 = digits.parse().ok()?;
193
194    (width > 0 && height > 0).then(|| Size::new(width, height))
195}
196
197/// Why a framebuffer's geometry could not be understood.
198#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
199#[non_exhaustive]
200pub enum FbInfoError {
201    /// A sysfs attribute did not hold what it should.
202    #[error("could not parse the {attribute} attribute")]
203    Unparsable {
204        /// Which attribute.
205        attribute: &'static str,
206    },
207
208    /// The depth is not one this backend can drive.
209    #[error("unsupported depth: {bits_per_pixel} bits per pixel")]
210    UnsupportedDepth {
211        /// The depth reported.
212        bits_per_pixel: u32,
213    },
214
215    /// The framebuffer reported a zero dimension.
216    #[error("the framebuffer has no visible area")]
217    EmptyGeometry,
218
219    /// The reported stride cannot hold one row.
220    #[error("stride of {stride_bytes} bytes cannot hold a row needing {required}")]
221    StrideTooNarrow {
222        /// The stride reported.
223        stride_bytes: u32,
224        /// What one row needs.
225        required: u32,
226    },
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    /// Exactly what the Alpine VM's virtio-gpu fbdev emulation reports.
234    #[test]
235    fn reads_a_real_devices_attributes() {
236        let info = FbInfo::from_sysfs("1280,800", "U:1280x800p-0", "5120", "32")
237            .expect("a real device should parse");
238        assert_eq!(info.size, Size::new(1280, 800));
239        assert_eq!(info.stride_bytes, 5120);
240        assert_eq!(info.layout, PixelLayout::Xrgb8888);
241        assert_eq!(info.stride_pixels(), Some(1280));
242        assert_eq!(info.required_bytes(), 1280 * 800 * 4);
243    }
244
245    #[test]
246    fn padded_stride_is_honoured() {
247        // The case that shears on hardware and looks perfect in a VM.
248        let info = FbInfo::from_sysfs("1366,768", "U:1366x768p-60", "5504", "32").expect("parses");
249        assert_eq!(info.stride_bytes, 5504);
250        assert_eq!(info.stride_pixels(), Some(1376));
251        assert_ne!(info.stride_pixels(), Some(info.size.width));
252    }
253
254    #[test]
255    fn visible_size_comes_from_modes_not_the_panning_allocation() {
256        // A framebuffer allocated at double height for panning is still 800 tall.
257        let info = FbInfo::from_sysfs("1280,1600", "U:1280x800p-60", "5120", "32").expect("parses");
258        assert_eq!(info.size, Size::new(1280, 800));
259    }
260
261    #[test]
262    fn an_empty_modes_attribute_falls_back_to_virtual_size() {
263        let info = FbInfo::from_sysfs("800,480", "", "3200", "32").expect("parses");
264        assert_eq!(info.size, Size::new(800, 480));
265    }
266
267    #[test]
268    fn a_mode_larger_than_the_allocation_is_clamped() {
269        let info = FbInfo::from_sysfs("800,480", "U:1920x1080p-60", "3200", "32").expect("parses");
270        assert_eq!(info.size, Size::new(800, 480));
271    }
272
273    #[test]
274    fn sixteen_bit_panels_are_supported() {
275        let info = FbInfo::from_sysfs("480,320", "U:480x320p-60", "960", "16").expect("parses");
276        assert_eq!(info.layout, PixelLayout::Rgb565);
277        assert_eq!(info.required_bytes(), 480 * 320 * 2);
278    }
279
280    #[test]
281    fn odd_depths_are_rejected_rather_than_guessed_at() {
282        assert_eq!(
283            FbInfo::from_sysfs("640,480", "", "1920", "24"),
284            Err(FbInfoError::UnsupportedDepth { bits_per_pixel: 24 })
285        );
286        assert!(matches!(
287            FbInfo::from_sysfs("640,480", "", "640", "8"),
288            Err(FbInfoError::UnsupportedDepth { .. })
289        ));
290    }
291
292    #[test]
293    fn a_stride_too_narrow_for_a_row_is_rejected() {
294        assert!(matches!(
295            FbInfo::from_sysfs("1280,800", "", "2560", "32"),
296            Err(FbInfoError::StrideTooNarrow { .. })
297        ));
298    }
299
300    #[test]
301    fn rubbish_attributes_are_reported_by_name() {
302        assert_eq!(
303            FbInfo::from_sysfs("nonsense", "", "5120", "32"),
304            Err(FbInfoError::Unparsable {
305                attribute: "virtual_size"
306            })
307        );
308        assert_eq!(
309            FbInfo::from_sysfs("1280,800", "", "wide", "32"),
310            Err(FbInfoError::Unparsable {
311                attribute: "stride"
312            })
313        );
314    }
315
316    #[test]
317    fn a_zero_dimension_is_rejected() {
318        assert_eq!(
319            FbInfo::from_sysfs("1280,0", "", "5120", "32"),
320            Err(FbInfoError::EmptyGeometry)
321        );
322    }
323
324    #[test]
325    fn rgb565_conversion_keeps_the_extremes_exact() {
326        assert_eq!(PixelLayout::to_rgb565(0xFF00_0000), 0x0000);
327        assert_eq!(PixelLayout::to_rgb565(0xFFFF_FFFF), 0xFFFF);
328        assert_eq!(PixelLayout::to_rgb565(0xFFFF_0000), 0xF800);
329        assert_eq!(PixelLayout::to_rgb565(0xFF00_FF00), 0x07E0);
330        assert_eq!(PixelLayout::to_rgb565(0xFF00_00FF), 0x001F);
331    }
332
333    #[test]
334    fn rgb565_conversion_is_monotonic() {
335        // A ramp must never go backwards, or gradients develop bands that move.
336        let mut previous = 0u16;
337        for level in 0..=255u32 {
338            let word = 0xFF00_0000 | (level << 8);
339            let green = PixelLayout::to_rgb565(word) & 0x07E0;
340            assert!(green >= previous, "green went backwards at {level}");
341            previous = green;
342        }
343    }
344}