Skip to main content

arcsec_core/index/
format.rs

1//! The on-disk blind index: `ARCSECIX` version 1.
2//!
3//! One file, memory-mapped, little-endian throughout, every section 8-byte aligned.
4//! Opening it checks the header (magic, version, byte-order marker, its own CRC) and
5//! that every section lies inside the file with the size its counts imply — a few
6//! hundred bytes of work, so a 1 GB index opens instantly. The section CRCs are
7//! checked only by [`BlindIndex::validate`] (`arcsec catalog verify`); lookups
8//! bounds-check every star reference they follow, so a corrupt body can make a solve
9//! fail but cannot make it read out of bounds.
10//!
11//! ```text
12//! header, 256 bytes
13//!   0  magic        [u8; 8]  "ARCSECIX"
14//!   8  version      u32      1
15//!  12  byte order   u32      0x0A0B0C0D as written (reads 0x0D0C0B0A if swapped)
16//!  16  header_len   u32      256
17//!  20  bins         u32      descriptor bins per dimension (128)
18//!  24  n_tiers      u32
19//!  28  star_bands   u32      declination bands in the star directory
20//!  32  n_stars      u64
21//!  40  n_patterns   u64
22//!  48  built_unix   i64
23//!  56  source       [u8; 16] database the stars came from, NUL-padded ("d80")
24//!  72  sections     5 × { offset u64, length u64, crc32 u32, reserved u32 }
25//!                   tiers, stars, star directory, keys, quads
26//! 192  reserved     zero
27//! 252  header crc32 over bytes 0..252
28//!
29//! tiers        n_tiers × 40 bytes, widest first:
30//!                radius f64 (rad), mag_cap f32, members u32,
31//!                first_pattern u64, n_patterns u64, n_anchors u64
32//! stars        n_stars × 12 bytes, sorted by (declination band, RA):
33//!                ra f32 (rad), dec f32 (rad), mag i16 (×100), tier u8, 0 u8
34//! star dir     (star_bands + 1) × u32: first star of each declination band
35//! keys         n_patterns × u64, sorted ascending within each tier
36//! quads        n_patterns × 4 × u32: star indices in canonical vertex order
37//! ```
38//!
39//! A tier's patterns are the contiguous range `first_pattern .. +n_patterns` of
40//! both `keys` and `quads`.
41
42use 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
51/// File magic.
52pub const MAGIC: &[u8; 8] = b"ARCSECIX";
53/// Format version this code reads and writes.
54pub const VERSION: u32 = 1;
55/// Conventional file name in the catalogue directory: `<db>.arcsecix`.
56pub 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/// One scale tier of the index.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct TierInfo {
70    /// Disc radius, radians: patterns are drawn from stars within this of an anchor,
71    /// so no pattern is wider than twice it.
72    pub radius: f64,
73    /// Faintest magnitude considered for this tier.
74    pub mag_cap: f32,
75    /// Stars per anchor group (anchor included).
76    pub members: u32,
77    /// First pattern of the tier in the key and quad arrays.
78    pub first_pattern: u64,
79    /// Patterns in the tier.
80    pub n_patterns: u64,
81    /// Anchors (groups) that produced patterns.
82    pub n_anchors: u64,
83}
84
85impl TierInfo {
86    /// The tier's pattern range.
87    #[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/// A star of the index.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct IndexStar {
96    /// Right ascension, radians.
97    pub ra: f32,
98    /// Declination, radians.
99    pub dec: f32,
100    /// Magnitude × 100.
101    pub mag: i16,
102    /// The widest tier any pattern using this star belongs to.
103    pub tier: u8,
104}
105
106/// An index assembled in memory, ready to write (the builder's output).
107#[derive(Debug, Default)]
108pub struct BuiltIndex {
109    /// Tiers, widest first.
110    pub tiers: Vec<TierInfo>,
111    /// Stars, sorted by declination band then RA (see [`star_band`]).
112    pub stars: Vec<IndexStar>,
113    /// First star of each declination band, `star_bands + 1` entries.
114    pub star_dir: Vec<u32>,
115    /// Pattern keys, sorted within each tier.
116    pub keys: Vec<u64>,
117    /// Pattern stars, parallel to `keys`.
118    pub quads: Vec<[u32; 4]>,
119    /// Source database name.
120    pub source: String,
121}
122
123/// Declination bands in the star directory: quarter-degree strips.
124pub const STAR_BANDS: u32 = 720;
125
126/// The star-directory band holding declination `dec` (radians).
127#[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
134// ── CRC-32 (IEEE 802.3, as zip and PNG use) ────────────────────────────────────
135
136const 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/// Incremental CRC-32.
159#[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
184// ── Writing ────────────────────────────────────────────────────────────────────
185
186/// A writer that tracks its offset and the CRC of the current section.
187struct 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    /// Start a section: pad to 8 bytes (padding is outside every section's CRC).
201    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    /// Total bytes the file will occupy (to within section padding).
212    #[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    /// Write the index to `path`, via a temporary file renamed into place, so a
224    /// reader never sees a half-written index.
225    ///
226    /// # Errors
227    ///
228    /// [`ArcsecError::CatalogIo`] if the file cannot be written.
229    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        // Placeholder header; rewritten at the end with the section table.
243        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// ── Reading ────────────────────────────────────────────────────────────────────
325
326#[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
350/// A memory-mapped blind index.
351pub 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/// Whether `path` starts with the index magic. Reads eight bytes.
374#[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    /// Map and check an index file. Cheap whatever the file's size: see the module
386    /// documentation for what is and is not checked here.
387    ///
388    /// # Errors
389    ///
390    /// [`ArcsecError::CatalogIo`] if the file cannot be opened, is not an index, is
391    /// another version or byte order, or its header is inconsistent.
392    pub fn open(path: &Path) -> Result<Self> {
393        let file = File::open(path).map_err(ArcsecError::CatalogIo)?;
394        // Safety: read-only mapping. As with the star databases, the file is not
395        // expected to change while a solve is reading it; the writer replaces it by
396        // rename, which leaves an existing mapping intact.
397        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    /// Recompute every section checksum: a full read of the file.
488    ///
489    /// # Errors
490    ///
491    /// [`ArcsecError::CatalogIo`] naming the first section whose checksum differs.
492    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    /// Tiers, widest first.
506    #[must_use]
507    pub fn tiers(&self) -> &[TierInfo] {
508        &self.tiers
509    }
510
511    /// Number of stars.
512    #[must_use]
513    pub fn n_stars(&self) -> usize {
514        self.n_stars
515    }
516
517    /// Number of patterns, all tiers.
518    #[must_use]
519    pub fn n_patterns(&self) -> usize {
520        self.n_patterns
521    }
522
523    /// Database the index was built from.
524    #[must_use]
525    pub fn source(&self) -> &str {
526        &self.source
527    }
528
529    /// Build time, seconds since the Unix epoch.
530    #[must_use]
531    pub fn built_unix(&self) -> i64 {
532        self.built_unix
533    }
534
535    /// File size in bytes.
536    #[must_use]
537    pub fn file_size(&self) -> usize {
538        self.map.len()
539    }
540
541    /// Bytes of the file belonging to one tier's patterns (keys and quads).
542    #[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    /// The patterns of `tier` whose key is exactly `key`.
553    #[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    /// The four stars of pattern `i` in canonical order; `None` if the pattern
579    /// index or any star index is out of range (a corrupt file).
580    #[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    /// Star `i`; `None` if out of range.
599    #[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    /// Call `f` for every star within `radius` (radians) of (`ra`, `dec`), and a few
615    /// just outside it: candidates come from whole directory bands and a RA window
616    /// widened for the band's declination, and the caller does the exact test.
617    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            // The band's worst-case cos(dec), for the RA half-width.
633            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                // Binary search for the first star with RA >= w0.
658                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; // inside the header: header CRC
753        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; // inside the quads: opens, fails validation
759        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}