1pub 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 pub offset: usize,
32 pub rtype: [u8; 2],
34 pub payload: Vec<u8>,
35}
36
37impl Record {
38 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 #[must_use]
53 pub fn id(&self) -> u8 {
54 self.rtype[1]
55 }
56
57 pub fn is_empty_table(&self) -> bool {
59 self.payload.iter().all(|&b| b == 0)
60 }
61
62 #[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 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 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 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 #[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 #[must_use]
151 pub fn record_01(&self) -> Option<&Record> {
152 self.find_by_id(0x01)
153 }
154
155 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct Encoded {
251 pub file: Vec<u8>,
253 pub sources: Vec<String>,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
259#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
260pub struct Format {
261 pub name: &'static str,
263 pub vendor: &'static str,
264 pub extension: &'static str,
266 pub generate: bool,
268 pub import: bool,
270}
271
272pub trait Codec: Sync {
279 fn format(&self) -> Format;
281 fn matches(&self, file: &[u8]) -> bool;
284 fn generate(&self, spec: &PanelSpec, chip: &ChipLibrary) -> Result<Encoded>;
289 fn inspect(&self, file: &[u8]) -> Result<Vec<String>>;
294 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#[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#[must_use]
349pub fn codecs() -> &'static [&'static dyn Codec] {
350 &[&RcvbpCodec]
351}
352
353pub fn formats() -> impl Iterator<Item = Format> {
355 codecs().iter().map(|c| c.format())
356}
357
358pub 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
373pub 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
388mod 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#[must_use]
418pub fn trailer_crc(data: &[u8]) -> u32 {
419 crc32::update(0, data)
420}
421
422const SIGNATURE: [u8; 16] = [
424 0x20, 0x20, 0x19, 0xbe, 0x74, 0x23, 0x43, 0x45, 0xb1, 0xc7, 0x93, 0x03, 0x9b, 0x83, 0xae, 0xab,
425];
426
427const 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 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 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}