1use core::ops::Range;
43use std::fs::File;
44use std::io::{self, BufWriter, Seek as _, Write};
45use std::path::Path;
46
47use memmap2::Mmap;
48
49use crate::error::{ArcsecError, Result};
50
51pub const MAGIC: &[u8; 8] = b"ARCSECIX";
53pub const VERSION: u32 = 1;
55pub const EXTENSION: &str = "arcsecix";
57
58const HEADER_LEN: usize = 256;
59const BYTE_ORDER: u32 = 0x0A0B_0C0D;
60const N_SECTIONS: usize = 5;
61const SECTIONS_AT: usize = 72;
62const HEADER_CRC_AT: usize = 252;
63const TIER_LEN: usize = 40;
64const STAR_LEN: usize = 12;
65const QUAD_LEN: usize = 16;
66
67#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct TierInfo {
70 pub radius: f64,
73 pub mag_cap: f32,
75 pub members: u32,
77 pub first_pattern: u64,
79 pub n_patterns: u64,
81 pub n_anchors: u64,
83}
84
85impl TierInfo {
86 #[must_use]
88 pub fn patterns(&self) -> Range<usize> {
89 self.first_pattern as usize..(self.first_pattern + self.n_patterns) as usize
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct IndexStar {
96 pub ra: f32,
98 pub dec: f32,
100 pub mag: i16,
102 pub tier: u8,
104}
105
106#[derive(Debug, Default)]
108pub struct BuiltIndex {
109 pub tiers: Vec<TierInfo>,
111 pub stars: Vec<IndexStar>,
113 pub star_dir: Vec<u32>,
115 pub keys: Vec<u64>,
117 pub quads: Vec<[u32; 4]>,
119 pub source: String,
121}
122
123pub const STAR_BANDS: u32 = 720;
125
126#[must_use]
128pub fn star_band(dec: f64) -> u32 {
129 let b = ((dec + core::f64::consts::FRAC_PI_2) / core::f64::consts::PI * f64::from(STAR_BANDS))
130 .floor();
131 (b.max(0.0) as u32).min(STAR_BANDS - 1)
132}
133
134const fn crc_table() -> [u32; 256] {
137 let mut t = [0u32; 256];
138 let mut i = 0;
139 while i < 256 {
140 let mut c = i as u32;
141 let mut k = 0;
142 while k < 8 {
143 c = if c & 1 != 0 {
144 0xEDB8_8320 ^ (c >> 1)
145 } else {
146 c >> 1
147 };
148 k += 1;
149 }
150 t[i] = c;
151 i += 1;
152 }
153 t
154}
155
156static CRC_TABLE: [u32; 256] = crc_table();
157
158#[derive(Clone, Copy)]
160struct Crc(u32);
161
162impl Crc {
163 fn new() -> Self {
164 Self(0xFFFF_FFFF)
165 }
166 fn update(&mut self, bytes: &[u8]) {
167 let mut c = self.0;
168 for &b in bytes {
169 c = CRC_TABLE[((c ^ u32::from(b)) & 0xFF) as usize] ^ (c >> 8);
170 }
171 self.0 = c;
172 }
173 fn finish(self) -> u32 {
174 !self.0
175 }
176}
177
178fn crc32(bytes: &[u8]) -> u32 {
179 let mut c = Crc::new();
180 c.update(bytes);
181 c.finish()
182}
183
184struct SectionWriter<W: Write> {
188 w: W,
189 pos: u64,
190 crc: Crc,
191}
192
193impl<W: Write> SectionWriter<W> {
194 fn put(&mut self, b: &[u8]) -> io::Result<()> {
195 self.w.write_all(b)?;
196 self.crc.update(b);
197 self.pos += b.len() as u64;
198 Ok(())
199 }
200 fn begin(&mut self) -> io::Result<u64> {
202 let pad = (8 - self.pos % 8) % 8;
203 self.w.write_all(&[0u8; 8][..pad as usize])?;
204 self.pos += pad;
205 self.crc = Crc::new();
206 Ok(self.pos)
207 }
208}
209
210impl BuiltIndex {
211 #[must_use]
213 pub fn file_size(&self) -> u64 {
214 (HEADER_LEN
215 + self.tiers.len() * TIER_LEN
216 + self.stars.len() * STAR_LEN
217 + self.star_dir.len() * 4
218 + self.keys.len() * 8
219 + self.quads.len() * QUAD_LEN
220 + 32) as u64
221 }
222
223 pub fn write(&self, path: &Path) -> Result<()> {
230 let tmp = path.with_extension(format!("{EXTENSION}.part"));
231 self.write_to(&tmp).map_err(ArcsecError::CatalogIo)?;
232 std::fs::rename(&tmp, path).map_err(ArcsecError::CatalogIo)
233 }
234
235 fn write_to(&self, path: &Path) -> io::Result<()> {
236 let f = File::create(path)?;
237 let mut sw = SectionWriter {
238 w: BufWriter::with_capacity(1 << 20, f),
239 pos: 0,
240 crc: Crc::new(),
241 };
242 sw.put(&[0u8; HEADER_LEN])?;
244 let mut sections = [(0u64, 0u64, 0u32); N_SECTIONS];
245
246 let off = sw.begin()?;
247 for t in &self.tiers {
248 sw.put(&t.radius.to_le_bytes())?;
249 sw.put(&t.mag_cap.to_le_bytes())?;
250 sw.put(&t.members.to_le_bytes())?;
251 sw.put(&t.first_pattern.to_le_bytes())?;
252 sw.put(&t.n_patterns.to_le_bytes())?;
253 sw.put(&t.n_anchors.to_le_bytes())?;
254 }
255 sections[0] = (off, sw.pos - off, sw.crc.finish());
256
257 let off = sw.begin()?;
258 for s in &self.stars {
259 let mut r = [0u8; STAR_LEN];
260 r[0..4].copy_from_slice(&s.ra.to_le_bytes());
261 r[4..8].copy_from_slice(&s.dec.to_le_bytes());
262 r[8..10].copy_from_slice(&s.mag.to_le_bytes());
263 r[10] = s.tier;
264 sw.put(&r)?;
265 }
266 sections[1] = (off, sw.pos - off, sw.crc.finish());
267
268 let off = sw.begin()?;
269 for &d in &self.star_dir {
270 sw.put(&d.to_le_bytes())?;
271 }
272 sections[2] = (off, sw.pos - off, sw.crc.finish());
273
274 let off = sw.begin()?;
275 for &k in &self.keys {
276 sw.put(&k.to_le_bytes())?;
277 }
278 sections[3] = (off, sw.pos - off, sw.crc.finish());
279
280 let off = sw.begin()?;
281 for q in &self.quads {
282 let mut r = [0u8; QUAD_LEN];
283 for (i, &s) in q.iter().enumerate() {
284 r[i * 4..i * 4 + 4].copy_from_slice(&s.to_le_bytes());
285 }
286 sw.put(&r)?;
287 }
288 sections[4] = (off, sw.pos - off, sw.crc.finish());
289
290 let mut h = [0u8; HEADER_LEN];
291 h[0..8].copy_from_slice(MAGIC);
292 h[8..12].copy_from_slice(&VERSION.to_le_bytes());
293 h[12..16].copy_from_slice(&BYTE_ORDER.to_le_bytes());
294 h[16..20].copy_from_slice(&(HEADER_LEN as u32).to_le_bytes());
295 h[20..24].copy_from_slice(&(super::pattern::BINS as u32).to_le_bytes());
296 h[24..28].copy_from_slice(&(self.tiers.len() as u32).to_le_bytes());
297 h[28..32].copy_from_slice(&STAR_BANDS.to_le_bytes());
298 h[32..40].copy_from_slice(&(self.stars.len() as u64).to_le_bytes());
299 h[40..48].copy_from_slice(&(self.keys.len() as u64).to_le_bytes());
300 let now = std::time::SystemTime::now()
301 .duration_since(std::time::UNIX_EPOCH)
302 .map_or(0, |d| d.as_secs() as i64);
303 h[48..56].copy_from_slice(&now.to_le_bytes());
304 let src = self.source.as_bytes();
305 let n = src.len().min(16);
306 h[56..56 + n].copy_from_slice(&src[..n]);
307 for (i, (o, l, c)) in sections.iter().enumerate() {
308 let at = SECTIONS_AT + i * 24;
309 h[at..at + 8].copy_from_slice(&o.to_le_bytes());
310 h[at + 8..at + 16].copy_from_slice(&l.to_le_bytes());
311 h[at + 16..at + 20].copy_from_slice(&c.to_le_bytes());
312 }
313 let crc = crc32(&h[..HEADER_CRC_AT]);
314 h[HEADER_CRC_AT..].copy_from_slice(&crc.to_le_bytes());
315
316 let mut f = sw.w.into_inner().map_err(io::IntoInnerError::into_error)?;
317
318 f.seek(io::SeekFrom::Start(0))?;
319 f.write_all(&h)?;
320 f.sync_all()
321 }
322}
323
324#[inline]
327fn u32_at(b: &[u8], at: usize) -> u32 {
328 u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
329}
330
331#[inline]
332fn u64_at(b: &[u8], at: usize) -> u64 {
333 let mut x = [0u8; 8];
334 x.copy_from_slice(&b[at..at + 8]);
335 u64::from_le_bytes(x)
336}
337
338#[inline]
339fn f32_at(b: &[u8], at: usize) -> f32 {
340 f32::from_bits(u32_at(b, at))
341}
342
343fn invalid(path: &Path, why: impl core::fmt::Display) -> ArcsecError {
344 ArcsecError::CatalogIo(io::Error::new(
345 io::ErrorKind::InvalidData,
346 format!("{}: not a usable arcsec blind index: {why}", path.display()),
347 ))
348}
349
350pub struct BlindIndex {
352 map: Mmap,
353 tiers: Vec<TierInfo>,
354 sections: [(usize, usize, u32); N_SECTIONS],
355 n_stars: usize,
356 n_patterns: usize,
357 star_bands: u32,
358 source: String,
359 built_unix: i64,
360}
361
362impl core::fmt::Debug for BlindIndex {
363 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
364 f.debug_struct("BlindIndex")
365 .field("source", &self.source)
366 .field("n_stars", &self.n_stars)
367 .field("n_patterns", &self.n_patterns)
368 .field("tiers", &self.tiers)
369 .finish_non_exhaustive()
370 }
371}
372
373#[must_use]
375pub fn is_blind_index(path: &Path) -> bool {
376 use std::io::Read as _;
377 let mut m = [0u8; 8];
378 File::open(path)
379 .and_then(|mut f| f.read_exact(&mut m))
380 .is_ok()
381 && &m == MAGIC
382}
383
384impl BlindIndex {
385 pub fn open(path: &Path) -> Result<Self> {
393 let file = File::open(path).map_err(ArcsecError::CatalogIo)?;
394 let map = unsafe { Mmap::map(&file) }.map_err(ArcsecError::CatalogIo)?;
398 if map.len() < HEADER_LEN || &map[..8] != MAGIC {
399 return Err(invalid(path, "bad magic"));
400 }
401 let version = u32_at(&map, 8);
402 if version != VERSION {
403 return Err(invalid(
404 path,
405 format!("format version {version}, this build reads {VERSION} - rebuild it"),
406 ));
407 }
408 if u32_at(&map, 12) != BYTE_ORDER {
409 return Err(invalid(path, "written with the other byte order"));
410 }
411 if crc32(&map[..HEADER_CRC_AT]) != u32_at(&map, HEADER_CRC_AT) {
412 return Err(invalid(path, "header checksum mismatch"));
413 }
414 if u32_at(&map, 16) as usize != HEADER_LEN
415 || f64::from(u32_at(&map, 20)) != super::pattern::BINS
416 {
417 return Err(invalid(
418 path,
419 "unsupported header length or descriptor bins",
420 ));
421 }
422 let n_tiers = u32_at(&map, 24) as usize;
423 let star_bands = u32_at(&map, 28);
424 let n_stars = usize::try_from(u64_at(&map, 32)).map_err(|_| invalid(path, "too large"))?;
425 let n_patterns =
426 usize::try_from(u64_at(&map, 40)).map_err(|_| invalid(path, "too large"))?;
427 let built_unix = u64_at(&map, 48) as i64;
428 let source = String::from_utf8_lossy(&map[56..72])
429 .trim_end_matches('\0')
430 .to_string();
431
432 let mut sections = [(0usize, 0usize, 0u32); N_SECTIONS];
433 let expected = [
434 n_tiers.checked_mul(TIER_LEN),
435 n_stars.checked_mul(STAR_LEN),
436 (star_bands as usize + 1).checked_mul(4),
437 n_patterns.checked_mul(8),
438 n_patterns.checked_mul(QUAD_LEN),
439 ];
440 for (i, s) in sections.iter_mut().enumerate() {
441 let at = SECTIONS_AT + i * 24;
442 let off = usize::try_from(u64_at(&map, at)).map_err(|_| invalid(path, "too large"))?;
443 let len =
444 usize::try_from(u64_at(&map, at + 8)).map_err(|_| invalid(path, "too large"))?;
445 if Some(len) != expected[i] || off.checked_add(len).is_none_or(|e| e > map.len()) {
446 return Err(invalid(
447 path,
448 format!("section {i} is truncated or mis-sized"),
449 ));
450 }
451 *s = (off, len, u32_at(&map, at + 16));
452 }
453
454 let tb = &map[sections[0].0..sections[0].0 + sections[0].1];
455 let mut tiers = Vec::with_capacity(n_tiers);
456 for t in 0..n_tiers {
457 let r = &tb[t * TIER_LEN..(t + 1) * TIER_LEN];
458 let tier = TierInfo {
459 radius: f64::from_bits(u64_at(r, 0)),
460 mag_cap: f32_at(r, 8),
461 members: u32_at(r, 12),
462 first_pattern: u64_at(r, 16),
463 n_patterns: u64_at(r, 24),
464 n_anchors: u64_at(r, 32),
465 };
466 let end = tier.first_pattern.checked_add(tier.n_patterns);
467 if !(tier.radius.is_finite() && tier.radius > 0.0)
468 || end.is_none_or(|e| e > n_patterns as u64)
469 {
470 return Err(invalid(path, format!("tier {t} is inconsistent")));
471 }
472 tiers.push(tier);
473 }
474
475 Ok(Self {
476 map,
477 tiers,
478 sections,
479 n_stars,
480 n_patterns,
481 star_bands,
482 source,
483 built_unix,
484 })
485 }
486
487 pub fn validate(&self) -> Result<()> {
493 const NAMES: [&str; N_SECTIONS] = ["tiers", "stars", "star directory", "keys", "quads"];
494 for (i, &(off, len, crc)) in self.sections.iter().enumerate() {
495 if crc32(&self.map[off..off + len]) != crc {
496 return Err(ArcsecError::CatalogIo(io::Error::new(
497 io::ErrorKind::InvalidData,
498 format!("blind index: {} section checksum mismatch", NAMES[i]),
499 )));
500 }
501 }
502 Ok(())
503 }
504
505 #[must_use]
507 pub fn tiers(&self) -> &[TierInfo] {
508 &self.tiers
509 }
510
511 #[must_use]
513 pub fn n_stars(&self) -> usize {
514 self.n_stars
515 }
516
517 #[must_use]
519 pub fn n_patterns(&self) -> usize {
520 self.n_patterns
521 }
522
523 #[must_use]
525 pub fn source(&self) -> &str {
526 &self.source
527 }
528
529 #[must_use]
531 pub fn built_unix(&self) -> i64 {
532 self.built_unix
533 }
534
535 #[must_use]
537 pub fn file_size(&self) -> usize {
538 self.map.len()
539 }
540
541 #[must_use]
543 pub fn tier_bytes(&self, t: &TierInfo) -> u64 {
544 t.n_patterns * (8 + QUAD_LEN as u64)
545 }
546
547 #[inline]
548 fn key(&self, i: usize) -> u64 {
549 u64_at(&self.map, self.sections[3].0 + i * 8)
550 }
551
552 #[must_use]
554 pub fn lookup(&self, tier: &TierInfo, key: u64) -> Range<usize> {
555 let r = tier.patterns();
556 let (mut lo, mut hi) = (r.start, r.end);
557 while lo < hi {
558 let mid = lo + (hi - lo) / 2;
559 if self.key(mid) < key {
560 lo = mid + 1;
561 } else {
562 hi = mid;
563 }
564 }
565 let start = lo;
566 let mut hi = r.end;
567 while lo < hi {
568 let mid = lo + (hi - lo) / 2;
569 if self.key(mid) <= key {
570 lo = mid + 1;
571 } else {
572 hi = mid;
573 }
574 }
575 start..lo
576 }
577
578 #[must_use]
581 pub fn quad(&self, i: usize) -> Option<[IndexStar; 4]> {
582 if i >= self.n_patterns {
583 return None;
584 }
585 let at = self.sections[4].0 + i * QUAD_LEN;
586 let mut out = [IndexStar {
587 ra: 0.0,
588 dec: 0.0,
589 mag: 0,
590 tier: 0,
591 }; 4];
592 for (k, s) in out.iter_mut().enumerate() {
593 *s = self.star(u32_at(&self.map, at + k * 4) as usize)?;
594 }
595 Some(out)
596 }
597
598 #[must_use]
600 pub fn star(&self, i: usize) -> Option<IndexStar> {
601 if i >= self.n_stars {
602 return None;
603 }
604 let at = self.sections[1].0 + i * STAR_LEN;
605 let b = &self.map[at..at + STAR_LEN];
606 Some(IndexStar {
607 ra: f32_at(b, 0),
608 dec: f32_at(b, 4),
609 mag: i16::from_le_bytes([b[8], b[9]]),
610 tier: b[10],
611 })
612 }
613
614 pub fn stars_near(&self, ra: f64, dec: f64, radius: f64, mut f: impl FnMut(&IndexStar)) {
618 use core::f64::consts::{FRAC_PI_2, PI};
619 if self.star_bands != STAR_BANDS {
620 return;
621 }
622 let lo_b = star_band((dec - radius).max(-FRAC_PI_2));
623 let hi_b = star_band((dec + radius).min(FRAC_PI_2));
624 let dir_at = self.sections[2].0;
625 for band in lo_b..=hi_b {
626 let s0 = u32_at(&self.map, dir_at + band as usize * 4) as usize;
627 let s1 =
628 (u32_at(&self.map, dir_at + (band as usize + 1) * 4) as usize).min(self.n_stars);
629 if s0 >= s1 {
630 continue;
631 }
632 let b_lo = f64::from(band) / f64::from(STAR_BANDS) * PI - FRAC_PI_2;
634 let b_hi = b_lo + PI / f64::from(STAR_BANDS);
635 let cos_min = b_lo.cos().min(b_hi.cos()).max(0.0);
636 let half = if cos_min * PI <= radius || dec.abs() + radius >= FRAC_PI_2 {
637 PI
638 } else {
639 (radius / cos_min).min(PI)
640 };
641 if half >= PI {
642 for i in s0..s1 {
643 if let Some(s) = self.star(i) {
644 f(&s);
645 }
646 }
647 continue;
648 }
649 let ra0 = (ra - half).rem_euclid(2.0 * PI);
650 let ra1 = (ra + half).rem_euclid(2.0 * PI);
651 let windows: &[(f64, f64)] = if ra0 <= ra1 {
652 &[(ra0, ra1)]
653 } else {
654 &[(ra0, 2.0 * PI), (0.0, ra1)]
655 };
656 for &(w0, w1) in windows {
657 let (mut lo, mut hi) = (s0, s1);
659 while lo < hi {
660 let mid = lo + (hi - lo) / 2;
661 let r = self.star(mid).map_or(f32::INFINITY, |s| s.ra);
662 if f64::from(r) < w0 {
663 lo = mid + 1;
664 } else {
665 hi = mid;
666 }
667 }
668 for i in lo..s1 {
669 let Some(s) = self.star(i) else { break };
670 if f64::from(s.ra) > w1 {
671 break;
672 }
673 f(&s);
674 }
675 }
676 }
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use crate::test_support::TempDir;
684
685 fn sample() -> BuiltIndex {
686 let stars: Vec<IndexStar> = (0..10)
687 .map(|i| IndexStar {
688 ra: 0.1 * i as f32,
689 dec: 0.2,
690 mag: 1000 + i,
691 tier: 0,
692 })
693 .collect();
694 let mut dir = vec![0u32; STAR_BANDS as usize + 1];
695 let b = star_band(0.2) as usize;
696 for (i, d) in dir.iter_mut().enumerate() {
697 *d = if i <= b { 0 } else { 10 };
698 }
699 BuiltIndex {
700 tiers: vec![TierInfo {
701 radius: 0.01,
702 mag_cap: 12.0,
703 members: 5,
704 first_pattern: 0,
705 n_patterns: 3,
706 n_anchors: 1,
707 }],
708 stars,
709 star_dir: dir,
710 keys: vec![5, 7, 7],
711 quads: vec![[0, 1, 2, 3], [1, 2, 3, 4], [5, 6, 7, 8]],
712 source: "d80".into(),
713 }
714 }
715
716 #[test]
717 fn round_trips_and_looks_up() {
718 let dir = TempDir::new("arcsecix_rt");
719 let p = dir.path().join("t.arcsecix");
720 sample().write(&p).unwrap();
721 assert!(is_blind_index(&p));
722 let ix = BlindIndex::open(&p).unwrap();
723 ix.validate().unwrap();
724 assert_eq!(ix.source(), "d80");
725 assert_eq!(ix.n_stars(), 10);
726 let t = ix.tiers()[0];
727 assert_eq!(ix.lookup(&t, 7), 1..3);
728 assert_eq!(ix.lookup(&t, 5), 0..1);
729 assert!(ix.lookup(&t, 6).is_empty());
730 assert_eq!(ix.quad(2).unwrap()[3].mag, 1008);
731 let mut n = 0;
732 ix.stars_near(0.3, 0.2, 0.11, |_| n += 1);
733 assert!((3..=5).contains(&n), "{n}");
734 }
735
736 #[test]
737 fn rejects_bad_magic_truncation_and_corruption() {
738 let dir = TempDir::new("arcsecix_bad");
739 let p = dir.path().join("t.arcsecix");
740 sample().write(&p).unwrap();
741 let good = std::fs::read(&p).unwrap();
742
743 let mut b = good.clone();
744 b[0] = b'X';
745 std::fs::write(&p, &b).unwrap();
746 assert!(BlindIndex::open(&p).is_err());
747
748 std::fs::write(&p, &good[..good.len() - 20]).unwrap();
749 assert!(BlindIndex::open(&p).is_err(), "truncated");
750
751 let mut b = good.clone();
752 b[30] ^= 1; std::fs::write(&p, &b).unwrap();
754 assert!(BlindIndex::open(&p).is_err());
755
756 let mut b = good.clone();
757 let n = b.len();
758 b[n - 3] ^= 0x40; std::fs::write(&p, &b).unwrap();
760 let ix = BlindIndex::open(&p).unwrap();
761 assert!(ix.validate().is_err());
762 }
763
764 #[test]
765 fn a_corrupt_star_reference_is_refused_not_followed() {
766 let dir = TempDir::new("arcsecix_ref");
767 let p = dir.path().join("t.arcsecix");
768 let mut s = sample();
769 s.quads[0] = [0, 1, 2, 999];
770 s.write(&p).unwrap();
771 let ix = BlindIndex::open(&p).unwrap();
772 assert!(ix.quad(0).is_none());
773 assert!(ix.quad(99).is_none());
774 }
775
776 #[test]
777 fn crc_matches_the_standard_check_value() {
778 assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
779 }
780}