Skip to main content

rcvbp/
lib.rs

1//! Reader and writer for Colorlight `.rcvbp` receiver-parameter files
2//! (`docs/rcvbp-format.md`). The record stream must tile the blob exactly.
3//!
4//! ```text
5//! file:   [32-byte header][zlib stream][u32 CRC trailer]
6//! header: 0x00 16 bytes  signature
7//!         0x10 u32       version (4)
8//!         0x14 u32       compressed size
9//!         0x18 u32       decompressed size
10//!         0x1c u32       reserved (0)
11//! record: [u16 size_le][u16 type][payload; size-4]   (size counts the header)
12//! ```
13
14pub mod image;
15pub mod record01;
16pub mod spec;
17
18pub use panelspec;
19pub use spec::ChipLookup;
20
21use anyhow::{bail, Context, Result};
22use panelspec::{ChipLibrary, PanelSpec};
23use serde::Serialize;
24use std::borrow::Cow;
25use std::io::{Read, Write};
26use std::path::Path;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Record {
30    /// Offset of the record within the decompressed blob.
31    pub offset: usize,
32    /// Record type: the two bytes stored after the size field.
33    pub rtype: [u8; 2],
34    pub payload: Vec<u8>,
35}
36
37impl Record {
38    /// A record not read from a file, ready to be written into one.
39    pub fn new(rtype: u16, payload: Vec<u8>) -> Self {
40        Self {
41            offset: 0,
42            rtype: rtype.to_be_bytes(),
43            payload,
44        }
45    }
46
47    pub fn type_u16(&self) -> u16 {
48        u16::from_be_bytes(self.rtype)
49    }
50
51    /// The id byte alone; the vendor parser ignores the marker byte before it.
52    #[must_use]
53    pub fn id(&self) -> u8 {
54        self.rtype[1]
55    }
56
57    /// True when the record carries no actual settings (empty table).
58    pub fn is_empty_table(&self) -> bool {
59        self.payload.iter().all(|&b| b == 0)
60    }
61
62    /// What the record holds, for listings; empty for a type not yet decoded.
63    #[must_use]
64    pub fn describe(&self) -> &'static str {
65        match (self.type_u16(), self.is_empty_table()) {
66            (_, true) => "(empty table)",
67            (0x0a01, _) => "main receiver parameters (geometry, scan, timing)",
68            (0x0a03, _) => "pixel/row mapping table",
69            (0x0a84, _) => "driver-chip register table",
70            (0x0a8a, _) => "secondary parameters",
71            (0x0aca, _) => "cabinet geometry",
72            (0x0a83 | 0x0a89, _) => "RGB coefficients",
73            _ => "",
74        }
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Rcvbp {
80    pub version: u32,
81    pub records: Vec<Record>,
82}
83
84impl Rcvbp {
85    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
86        let path = path.as_ref();
87        let d = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
88        Self::from_bytes(&d).with_context(|| format!("parse {}", path.display()))
89    }
90
91    pub fn from_bytes(d: &[u8]) -> Result<Self> {
92        if d.len() < 32 {
93            bail!("too short to be a .rcvbp");
94        }
95        let version = le_u32(d, 0x10)?;
96
97        // Signed files zlib-compress the record stream; legacy ones store it
98        // inline after the version field, with the CRC trailer inside it (slack).
99        let (blob, slack): (Cow<[u8]>, usize) = if d[0..4] == SIG_COMPRESSED {
100            let raw_len = le_u32(d, 0x18)? as usize;
101            let mut blob = Vec::with_capacity(raw_len.min(1 << 20));
102            flate2::read::ZlibDecoder::new(&d[0x20..])
103                .read_to_end(&mut blob)
104                .context("inflate rcvbp payload")?;
105            if blob.len() != raw_len {
106                bail!("inflated {} bytes but header says {raw_len}", blob.len());
107            }
108            (Cow::Owned(blob), 0)
109        } else {
110            (Cow::Borrowed(&d[0x14..]), 4)
111        };
112        let records = parse_records(&blob, slack)?;
113        Ok(Self { version, records })
114    }
115
116    pub fn find(&self, rtype: u16) -> Option<&Record> {
117        self.records.iter().find(|r| r.type_u16() == rtype)
118    }
119
120    /// Cabinet geometry from the 0x0aca record: (width, scan).
121    /// Panel height is not stored directly; it is scan * data groups.
122    pub fn geometry(&self) -> Option<(u16, u16)> {
123        let r = self.find(0x0aca)?;
124        if r.payload.len() < 4 {
125            return None;
126        }
127        Some((
128            u16::from_le_bytes([r.payload[0], r.payload[1]]),
129            u16::from_le_bytes([r.payload[2], r.payload[3]]),
130        ))
131    }
132
133    /// Width/scan from the main 0x0a01 parameter block: (width, scan).
134    pub fn main_geometry(&self) -> Option<(u8, u8)> {
135        let r = self.record_01()?;
136        if r.payload.len() < 2 {
137            return None;
138        }
139        Some((r.payload[0], r.payload[1]))
140    }
141
142    /// The first record with this id byte, whatever container marker it
143    /// carries (see [`Record::id`]).
144    #[must_use]
145    pub fn find_by_id(&self, id: u8) -> Option<&Record> {
146        self.records.iter().find(|r| r.id() == id)
147    }
148
149    /// The main parameter record.
150    #[must_use]
151    pub fn record_01(&self) -> Option<&Record> {
152        self.find_by_id(0x01)
153    }
154
155    /// Scan denominator (16, 32, 64) at record 0x01 +0x020. The byte at
156    /// +0x001 is stored module height, not scan.
157    pub fn scan(&self) -> Option<u8> {
158        self.record_01()?.payload.get(0x20).copied()
159    }
160
161    pub fn find_mut(&mut self, rtype: u16) -> Option<&mut Record> {
162        self.records.iter_mut().find(|r| r.type_u16() == rtype)
163    }
164
165    /// Replace a record's payload, or append the record if absent.
166    ///
167    /// New records are inserted before the trailing geometry record when there
168    /// is one, matching where vendor files place them.
169    pub fn upsert(&mut self, rtype: u16, payload: Vec<u8>) {
170        if let Some(r) = self.find_mut(rtype) {
171            r.payload = payload;
172            return;
173        }
174        let rec = Record::new(rtype, payload);
175        match self.records.iter().position(|r| r.type_u16() == 0x0aca) {
176            Some(i) => self.records.insert(i, rec),
177            None => self.records.push(rec),
178        }
179    }
180
181    pub fn remove(&mut self, rtype: u16) -> bool {
182        let before = self.records.len();
183        self.records.retain(|r| r.type_u16() != rtype);
184        self.records.len() != before
185    }
186
187    /// Serialise the records back into a record stream.
188    ///
189    /// # Errors
190    /// Fails if a record is too large for the 16-bit length field.
191    pub fn to_blob(&self) -> Result<Vec<u8>> {
192        let len = self.records.iter().map(|r| r.payload.len() + 4).sum();
193        let mut out = Vec::with_capacity(len);
194        for r in &self.records {
195            let size: u16 = r
196                .payload
197                .len()
198                .checked_add(4)
199                .and_then(|n| u16::try_from(n).ok())
200                .with_context(|| {
201                    format!(
202                        "record 0x{:04x} is too large to encode ({} bytes)",
203                        r.type_u16(),
204                        r.payload.len()
205                    )
206                })?;
207            out.extend_from_slice(&size.to_le_bytes());
208            out.extend_from_slice(&r.rtype);
209            out.extend_from_slice(&r.payload);
210        }
211        Ok(out)
212    }
213
214    /// Serialise to a complete `.rcvbp` file in the compressed variant.
215    ///
216    /// # Errors
217    /// Fails if a record cannot be encoded or compression fails.
218    pub fn to_file_bytes(&self) -> Result<Vec<u8>> {
219        let blob = self.to_blob()?;
220        let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
221        enc.write_all(&blob).context("compress record stream")?;
222        let compressed = enc.finish().context("finish compression")?;
223
224        let mut out = Vec::with_capacity(0x20 + compressed.len());
225        out.extend_from_slice(&SIGNATURE);
226        out.extend_from_slice(&self.version.to_le_bytes());
227        out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
228        out.extend_from_slice(&(blob.len() as u32).to_le_bytes());
229        out.extend_from_slice(&0u32.to_le_bytes());
230        out.extend_from_slice(&compressed);
231        let crc = trailer_crc(&out);
232        out.extend_from_slice(&crc.to_le_bytes());
233        Ok(out)
234    }
235
236    /// Write a `.rcvbp` file.
237    ///
238    /// # Errors
239    /// Fails if serialisation or the write fails.
240    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
241        let path = path.as_ref();
242        let bytes = self.to_file_bytes()?;
243        std::fs::write(path, &bytes).with_context(|| format!("write {}", path.display()))?;
244        Ok(())
245    }
246}
247
248/// A generated configuration file and where each of its bytes came from.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct Encoded {
251    /// The file bytes the card's tooling loads.
252    pub file: Vec<u8>,
253    /// One line per byte range placed, with its source.
254    pub sources: Vec<String>,
255}
256
257/// A registry entry: what a format is called and what its codec can do.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
259#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
260pub struct Format {
261    /// The name `--format` and the site use.
262    pub name: &'static str,
263    pub vendor: &'static str,
264    /// File extension without the dot.
265    pub extension: &'static str,
266    /// The codec writes a file from a spec.
267    pub generate: bool,
268    /// The codec reads a file back into a spec.
269    pub import: bool,
270}
271
272/// One vendor's configuration format: a panel spec in, the card's file out,
273/// and that file read back.
274///
275/// [`RcvbpCodec`] is the Colorlight implementation; a second vendor
276/// implements this in its own crate (docs/cards.md) and adds it to
277/// [`codecs`].
278pub trait Codec: Sync {
279    /// The registry entry.
280    fn format(&self) -> Format;
281    /// True when `file` starts the way this format's files start; what
282    /// [`detect`] reads.
283    fn matches(&self, file: &[u8]) -> bool;
284    /// The file for `spec` and its chip library.
285    ///
286    /// # Errors
287    /// Fails on a spec or library the format cannot hold.
288    fn generate(&self, spec: &PanelSpec, chip: &ChipLibrary) -> Result<Encoded>;
289    /// One line per record of `file`, as `rxp config info` lists them.
290    ///
291    /// # Errors
292    /// Fails when `file` is not in the format.
293    fn inspect(&self, file: &[u8]) -> Result<Vec<String>>;
294    /// The spec that regenerates `file`, with `chips` mapping a chip id to
295    /// a library, and the fields it could not recover by name. Implemented
296    /// when [`Format::import`] says so.
297    ///
298    /// # Errors
299    /// Fails when `file` is not in the format, or the codec cannot import.
300    fn import(&self, file: &[u8], chips: ChipLookup) -> Result<(PanelSpec, Vec<String>)> {
301        let _ = (file, chips);
302        bail!("format {}: import is not implemented", self.format().name)
303    }
304}
305
306/// The `.rcvbp` format behind [`Codec`].
307#[derive(Debug, Clone, Copy, Default)]
308pub struct RcvbpCodec;
309
310impl Codec for RcvbpCodec {
311    fn format(&self) -> Format {
312        Format {
313            name: "rcvbp",
314            vendor: "Colorlight",
315            extension: "rcvbp",
316            generate: true,
317            import: true,
318        }
319    }
320
321    fn matches(&self, file: &[u8]) -> bool {
322        file.starts_with(&SIG_COMPRESSED)
323    }
324
325    fn generate(&self, spec: &PanelSpec, chip: &ChipLibrary) -> Result<Encoded> {
326        let g = spec::generate(spec, chip)?;
327        Ok(Encoded {
328            file: g.rcvbp.to_file_bytes()?,
329            sources: g.sources,
330        })
331    }
332
333    fn inspect(&self, file: &[u8]) -> Result<Vec<String>> {
334        Ok(Rcvbp::from_bytes(file)?
335            .records
336            .iter()
337            .map(|r| format!("0x{:04x} {:5} bytes  {}", r.type_u16(), r.payload.len(), r.describe()))
338            .collect())
339    }
340
341    fn import(&self, file: &[u8], chips: ChipLookup) -> Result<(PanelSpec, Vec<String>)> {
342        spec::spec_from_rcvbp(file, chips)
343    }
344}
345
346/// The registered codecs, one per format; `rxp config formats` and the
347/// site's format list read this.
348#[must_use]
349pub fn codecs() -> &'static [&'static dyn Codec] {
350    &[&RcvbpCodec]
351}
352
353/// The registry entries, in registration order.
354pub fn formats() -> impl Iterator<Item = Format> {
355    codecs().iter().map(|c| c.format())
356}
357
358/// The codec registered under `name`.
359///
360/// # Errors
361/// Names the known formats when `name` is not one of them.
362pub fn codec(name: &str) -> Result<&'static dyn Codec> {
363    codecs()
364        .iter()
365        .copied()
366        .find(|c| c.format().name == name)
367        .with_context(|| {
368            let known: Vec<&str> = formats().map(|f| f.name).collect();
369            format!("format {name}: unknown; known formats: {}", known.join(", "))
370        })
371}
372
373/// The codec whose signature `file` starts with.
374///
375/// # Errors
376/// Names the known formats when none matches.
377pub fn detect(file: &[u8]) -> Result<&'static dyn Codec> {
378    codecs()
379        .iter()
380        .copied()
381        .find(|c| c.matches(file))
382        .with_context(|| {
383            let known: Vec<&str> = formats().map(|f| f.name).collect();
384            format!("format: not recognised from the file's first bytes; known formats: {}", known.join(", "))
385        })
386}
387
388/// CRC-32, reflected polynomial 0xEDB88320. The file trailer and the basic
389/// pack use it with different init/final xor.
390mod crc32 {
391    const TABLE: [u32; 256] = {
392        let mut t = [0u32; 256];
393        let mut i = 0;
394        while i < 256 {
395            let mut c = i as u32;
396            let mut k = 0;
397            while k < 8 {
398                c = if c & 1 == 1 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
399                k += 1;
400            }
401            t[i] = c;
402            i += 1;
403        }
404        t
405    };
406
407    pub fn update(mut crc: u32, data: &[u8]) -> u32 {
408        for &b in data {
409            crc = TABLE[((crc ^ u32::from(b)) & 0xff) as usize] ^ (crc >> 8);
410        }
411        crc
412    }
413}
414
415/// The trailer CRC: CRC-32 over the file up to the trailer, init 0, no final
416/// inversion (so it does not match a stock CRC-32). Pinned by `crc_tests`.
417#[must_use]
418pub fn trailer_crc(data: &[u8]) -> u32 {
419    crc32::update(0, data)
420}
421
422/// 16-byte signature of the compressed variant, copied from vendor files.
423const SIGNATURE: [u8; 16] = [
424    0x20, 0x20, 0x19, 0xbe, 0x74, 0x23, 0x43, 0x45, 0xb1, 0xc7, 0x93, 0x03, 0x9b, 0x83, 0xae, 0xab,
425];
426
427/// The first four signature bytes, enough to tell the variants apart.
428const SIG_COMPRESSED: [u8; 4] = [0x20, 0x20, 0x19, 0xbe];
429
430fn parse_records(blob: &[u8], slack: usize) -> Result<Vec<Record>> {
431    let mut records = Vec::new();
432    let mut off = 0usize;
433    while off + 4 + slack <= blob.len() {
434        let size = u16::from_le_bytes([blob[off], blob[off + 1]]) as usize;
435        if size < 4 {
436            bail!("record at 0x{off:05x} has bogus size {size}");
437        }
438        if off + size > blob.len() {
439            bail!(
440                "record at 0x{off:05x} size {size} overruns blob ({} bytes left)",
441                blob.len() - off
442            );
443        }
444        records.push(Record {
445            offset: off,
446            rtype: [blob[off + 2], blob[off + 3]],
447            payload: blob[off + 4..off + size].to_vec(),
448        });
449        off += size;
450    }
451    if blob.len() - off > slack {
452        bail!(
453            "records do not tile the blob: ended at 0x{off:05x} of 0x{:05x}",
454            blob.len()
455        );
456    }
457    Ok(records)
458}
459
460fn le_u32(d: &[u8], off: usize) -> Result<u32> {
461    let b: [u8; 4] = d
462        .get(off..off + 4)
463        .context("truncated rcvbp header")?
464        .try_into()?;
465    Ok(u32::from_le_bytes(b))
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    fn identities(records: &[Record]) -> Vec<(u16, &[u8])> {
473        records.iter().map(|r| (r.type_u16(), r.payload.as_slice())).collect()
474    }
475
476    fn sample() -> Rcvbp {
477        Rcvbp {
478            version: 4,
479            records: vec![
480                Record::new(0x0a01, vec![0x80, 0x20, 1, 0]),
481                Record::new(0x0a03, vec![7; 32]),
482                Record::new(0x0aca, vec![0x80, 0, 0x20, 0]),
483            ],
484        }
485    }
486
487    #[test]
488    fn the_codec_generates_and_reads_back_the_bench_spec() {
489        let spec = PanelSpec::parse(panelspec::embedded::PANELS[0].1).unwrap();
490        let chip = spec.chip_library(&|p| {
491            panelspec::embedded::chip(p).map(str::to_owned).ok_or_else(|| anyhow::anyhow!("{p}"))
492        }).unwrap();
493        let e = RcvbpCodec.generate(&spec, &chip).unwrap();
494        assert_eq!(e.file, spec::generate(&spec, &chip).unwrap().rcvbp.to_file_bytes().unwrap());
495        assert!(!e.sources.is_empty());
496        let lines = RcvbpCodec.inspect(&e.file).unwrap();
497        assert_eq!(lines.len(), 17);
498        assert_eq!(lines[0], "0x0a01   764 bytes  main receiver parameters (geometry, scan, timing)");
499        assert!(RcvbpCodec.inspect(&[0; 8]).is_err());
500    }
501
502    #[test]
503    fn the_registry_names_its_formats_and_refuses_others() {
504        let names: Vec<&str> = formats().map(|f| f.name).collect();
505        assert_eq!(names, ["rcvbp"]);
506        let f = codec("rcvbp").unwrap().format();
507        assert_eq!((f.vendor, f.extension, f.generate, f.import), ("Colorlight", "rcvbp", true, true));
508        let err = codec("novastar").err().map(|e| format!("{e:#}"));
509        assert_eq!(err.as_deref(), Some("format novastar: unknown; known formats: rcvbp"));
510    }
511
512    #[test]
513    fn the_signature_bytes_pick_the_codec() {
514        let file = sample().to_file_bytes().unwrap();
515        assert_eq!(detect(&file).unwrap().format().name, "rcvbp");
516        let err = detect(b"name = \"t\"\n").err().map(|e| format!("{e:#}"));
517        assert_eq!(
518            err.as_deref(),
519            Some("format: not recognised from the file's first bytes; known formats: rcvbp")
520        );
521    }
522
523    #[test]
524    fn records_round_trip_through_a_blob() {
525        let f = sample();
526        let blob = f.to_blob().unwrap();
527        let parsed = parse_records(&blob, 0).unwrap();
528        // `offset` differs between built and parsed records, so compare the rest.
529        assert_eq!(identities(&parsed), identities(&f.records));
530    }
531
532    #[test]
533    fn blob_tiles_exactly() {
534        let f = sample();
535        let blob = f.to_blob().unwrap();
536        let expected: usize = f.records.iter().map(|r| r.payload.len() + 4).sum();
537        assert_eq!(blob.len(), expected);
538    }
539
540    #[test]
541    fn upsert_replaces_existing_and_appends_new_before_geometry() {
542        let mut f = sample();
543        f.upsert(0x0a01, vec![1, 2, 3]);
544        assert_eq!(f.find(0x0a01).unwrap().payload, vec![1, 2, 3]);
545        assert_eq!(f.records.len(), 3);
546
547        f.upsert(0x0a84, vec![9; 8]);
548        assert_eq!(f.records.len(), 4);
549        assert_eq!(f.records.last().unwrap().type_u16(), 0x0aca);
550    }
551
552    #[test]
553    fn remove_reports_whether_it_removed_anything() {
554        let mut f = sample();
555        assert!(f.remove(0x0a03));
556        assert!(!f.remove(0x0a03));
557        assert!(f.find(0x0a03).is_none());
558    }
559
560    #[test]
561    fn a_written_file_parses_back_identically() {
562        let f = sample();
563        let bytes = f.to_file_bytes().unwrap();
564        assert_eq!(&bytes[..4], &SIG_COMPRESSED);
565
566        let dir = std::env::temp_dir().join("rcvbp-test");
567        std::fs::create_dir_all(&dir).unwrap();
568        let path = dir.join("round-trip.rcvbp");
569        std::fs::write(&path, &bytes).unwrap();
570
571        let back = Rcvbp::load(&path).unwrap();
572        assert_eq!(back.version, 4);
573        assert_eq!(identities(&back.records), identities(&f.records));
574        std::fs::remove_file(&path).ok();
575    }
576
577    #[test]
578    fn a_legacy_file_is_parsed_from_its_inline_record_stream() {
579        let f = sample();
580        let mut bytes = vec![0u8; 0x14];
581        bytes[0x10..0x14].copy_from_slice(&4u32.to_le_bytes());
582        bytes.extend_from_slice(&f.to_blob().unwrap());
583        bytes.extend_from_slice(&[0; 4]);
584        let back = Rcvbp::from_bytes(&bytes).unwrap();
585        assert_eq!(identities(&back.records), identities(&f.records));
586    }
587}
588
589#[cfg(test)]
590mod crc_tests {
591    use super::*;
592
593    #[test]
594    fn trailer_matches_the_reference_file() {
595        let path = concat!(
596            env!("CARGO_MANIFEST_DIR"),
597            "/../../third-party/configs/P2.5-32S-128X64-SM16269S-256X384I.rcvbp"
598        );
599        let d = std::fs::read(path).expect("reference config");
600        let expected = 0x128b_ebeeu32;
601        let (body, tail) = d.split_at(d.len() - 4);
602        assert_eq!(trailer_crc(body), expected);
603        assert_eq!(tail, &expected.to_le_bytes());
604    }
605
606    /// The reference loop the table replaced.
607    fn bit_serial_crc(data: &[u8]) -> u32 {
608        let mut crc: u32 = 0;
609        for &byte in data {
610            let mut c = (crc ^ u32::from(byte)) & 0xff;
611            for _ in 0..8 {
612                c = if c & 1 == 1 { (c >> 1) ^ 0xedb8_8320 } else { c >> 1 };
613            }
614            crc = (crc >> 8) ^ c;
615        }
616        crc
617    }
618
619    #[test]
620    fn the_table_matches_the_bit_serial_loop() {
621        let data: Vec<u8> = (0..4096u32).map(|i| (i * 7 + i / 3) as u8).collect();
622        assert_eq!(trailer_crc(&data), bit_serial_crc(&data));
623        assert_eq!(trailer_crc(&[]), 0);
624    }
625
626    #[test]
627    fn a_written_file_carries_a_valid_trailer() {
628        let f = Rcvbp {
629            version: 4,
630            records: vec![Record::new(0x0a01, vec![0x80, 0x20, 1, 0])],
631        };
632        let bytes = f.to_file_bytes().unwrap();
633        let (body, tail) = bytes.split_at(bytes.len() - 4);
634        assert_eq!(
635            trailer_crc(body).to_le_bytes(),
636            tail,
637            "written trailer must be the CRC of the body"
638        );
639    }
640}