Skip to main content

cadmpeg_codec_nx/
container.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Parse the SPLMSSTR header and its `HEADER` and `FOOTER` directories.
3//!
4//! An NX part begins with the eight-byte `SPLMSSTR` signature. Container integers
5//! are little-endian. Directory entries name `/Root/...` paths and may carry an
6//! in-bounds file offset and size. [`crate::parasolid`] uses the canonical
7//! `/Root/UG_PART/UG_PART` span to bound its compressed-stream scan.
8
9use cadmpeg_ir::codec::{CodecError, ReadSeek};
10use cadmpeg_ir::cursor::bounded_len;
11use cadmpeg_ir::le::{u32_at as u32_le, u64_at as u64_le};
12
13/// The eight-byte signature used to identify an SPLMSSTR container.
14pub const MAGIC: &[u8; 8] = b"SPLMSSTR";
15
16/// A directory entry from the `HEADER` or `FOOTER` region.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct DirEntry {
19    /// The `/Root/...` path name.
20    pub name: String,
21    /// Which region the entry was read from.
22    pub region: Region,
23    /// An in-bounds byte offset and length, or `None` for non-file entries.
24    pub file_span: Option<(u64, u64)>,
25}
26
27/// Directory region containing an entry.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Region {
30    /// The `HEADER` region near the start of the file.
31    Header,
32    /// The `FOOTER` region near EOF.
33    Footer,
34}
35
36/// One 12-byte row in the canonical `UG_PART` segment index.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct SegmentIndexRow {
39    /// First little-endian row word.
40    pub type_code: u32,
41    /// Second little-endian row word.
42    pub subtype_code: u32,
43    /// Third little-endian row word.
44    pub value: u32,
45}
46
47/// Self-bounded segment index at the start of the canonical `UG_PART` payload.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct SegmentIndex<'a> {
50    /// Complete 12-byte rows before the declared table end.
51    pub rows: Vec<SegmentIndexRow>,
52    /// Zero to eleven trailing bytes after the last complete row.
53    pub padding: &'a [u8],
54    /// Declared payload-relative end of the index.
55    pub byte_len: usize,
56}
57
58/// One fixed-width member of the `RMFastLoad` object-id table.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct RmFastLoadObjectId {
61    /// Decoded little-endian object identifier.
62    pub value: u32,
63    /// Payload-relative offset of the four-byte table word.
64    pub offset: usize,
65    /// Exact serialized table word.
66    pub raw: [u8; 4],
67}
68
69/// Counted object-id table in `/Root/FastLoad/RMFastLoad`.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct RmFastLoadObjectIdTable {
72    /// Payload-relative offset of the `UGS::Solid::Topol` registry marker.
73    pub registry_offset: usize,
74    /// Payload-relative offset of the four-byte count word.
75    pub count_offset: usize,
76    /// Exact serialized little-endian count word.
77    pub raw_count: [u8; 4],
78    /// Ordered fixed-width object-id members.
79    pub object_ids: Vec<RmFastLoadObjectId>,
80}
81
82impl Region {
83    /// Return the directory-region label used in summaries.
84    pub fn label(self) -> &'static str {
85        match self {
86            Region::Header => "HEADER",
87            Region::Footer => "FOOTER",
88        }
89    }
90}
91
92impl Container {
93    /// Decode the self-bounded segment index in `/Root/UG_PART/UG_PART`.
94    pub fn segment_index(&self) -> Option<(&DirEntry, SegmentIndex<'_>)> {
95        let entry = self
96            .entries
97            .iter()
98            .find(|entry| entry.name == "/Root/UG_PART/UG_PART" && entry.file_span.is_some())?;
99        let (offset, size) = entry.file_span?;
100        let (offset, size) = (usize::try_from(offset).ok()?, usize::try_from(size).ok()?);
101        let payload = self.data.get(offset..offset.checked_add(size)?)?;
102        let row_one = payload.get(12..24)?;
103        let type_code = u32_le(row_one, 0)?;
104        let subtype_code = u32_le(row_one, 4)?;
105        let byte_len = usize::try_from(u32_le(row_one, 8)?).ok()?;
106        if type_code != 1 || subtype_code != 1 || !(24..=payload.len()).contains(&byte_len) {
107            return None;
108        }
109        let complete_len = byte_len / 12 * 12;
110        let rows = payload[..complete_len]
111            .chunks_exact(12)
112            .map(|row| SegmentIndexRow {
113                type_code: u32_le(row, 0).expect("complete segment-index row"),
114                subtype_code: u32_le(row, 4).expect("complete segment-index row"),
115                value: u32_le(row, 8).expect("complete segment-index row"),
116            })
117            .collect();
118        Some((
119            entry,
120            SegmentIndex {
121                rows,
122                padding: &payload[complete_len..byte_len],
123                byte_len,
124            },
125        ))
126    }
127
128    /// Locate independently size-framed NX object-model sections.
129    pub fn om_sections(&self) -> Vec<(&DirEntry, crate::om::Section<'_>)> {
130        let mut out = Vec::new();
131        for entry in &self.entries {
132            let Some((offset, size)) = entry.file_span else {
133                continue;
134            };
135            let (Ok(offset), Ok(size)) = (usize::try_from(offset), usize::try_from(size)) else {
136                continue;
137            };
138            let Some(payload) = self.data.get(offset..offset.saturating_add(size)) else {
139                continue;
140            };
141            out.extend(
142                crate::om::sections(payload)
143                    .into_iter()
144                    .map(|section| (entry, section)),
145            );
146        }
147        out
148    }
149
150    /// Locate indexed NX object-model sections in catalogued file entries.
151    pub fn indexed_om_sections(&self) -> Vec<(&DirEntry, crate::om::IndexedSection<'_>)> {
152        let mut out = Vec::new();
153        let mut seen = std::collections::BTreeSet::new();
154        for entry in &self.entries {
155            let Some((offset, size)) = entry.file_span else {
156                continue;
157            };
158            let (Ok(offset), Ok(size)) = (usize::try_from(offset), usize::try_from(size)) else {
159                continue;
160            };
161            let Some(payload) = self.data.get(offset..offset.saturating_add(size)) else {
162                continue;
163            };
164            for section in crate::om::indexed_sections(payload) {
165                if seen.insert((offset, section.object_id_table_offset)) {
166                    out.push((entry, section));
167                }
168            }
169        }
170        out
171    }
172
173    /// Extract child-part paths from catalogued external-reference payloads.
174    pub fn external_reference_paths(&self) -> Vec<String> {
175        self.external_reference_strings()
176            .into_iter()
177            .map(|(_, _, path)| path)
178            .collect()
179    }
180
181    /// Extract child-part strings with their owning entry and payload offset.
182    pub(crate) fn external_reference_strings(&self) -> Vec<(&DirEntry, usize, String)> {
183        self.entries
184            .iter()
185            .filter(|entry| entry.name.contains("ExternalReferences"))
186            .filter_map(|entry| entry.file_span.map(|span| (entry, span)))
187            .flat_map(|(entry, (offset, size))| {
188                let Ok(offset) = usize::try_from(offset) else {
189                    return Vec::new();
190                };
191                let Ok(size) = usize::try_from(size) else {
192                    return Vec::new();
193                };
194                self.data
195                    .get(offset..offset.saturating_add(size))
196                    .and_then(parse_extref_string_table)
197                    .map(|(_, strings)| {
198                        strings
199                            .into_iter()
200                            .map(|(relative, value)| (entry, relative, value))
201                            .collect()
202                    })
203                    .unwrap_or_default()
204            })
205            .collect()
206    }
207
208    /// Decode indexed EXTREFSTREAM record prefixes and sorted handle sets.
209    pub(crate) fn external_reference_records(&self) -> Vec<(&DirEntry, ExtrefRecord)> {
210        self.entries
211            .iter()
212            .filter(|entry| entry.name.contains("ExternalReferences"))
213            .filter_map(|entry| {
214                let (offset, size) = entry.file_span?;
215                let (offset, size) = (usize::try_from(offset).ok()?, usize::try_from(size).ok()?);
216                let payload = self.data.get(offset..offset.checked_add(size)?)?;
217                Some(
218                    parse_extref_records(payload)
219                        .into_iter()
220                        .map(move |record| (entry, record)),
221                )
222            })
223            .flatten()
224            .collect()
225    }
226
227    /// Retain every record boundary from each valid EXTREFSTREAM index.
228    pub(crate) fn external_reference_indexed_records(
229        &self,
230    ) -> Vec<(&DirEntry, ExtrefIndexedRecord)> {
231        self.entries
232            .iter()
233            .filter(|entry| entry.name.contains("ExternalReferences"))
234            .filter_map(|entry| {
235                let (offset, size) = entry.file_span?;
236                let (offset, size) = (usize::try_from(offset).ok()?, usize::try_from(size).ok()?);
237                let payload = self.data.get(offset..offset.checked_add(size)?)?;
238                Some(
239                    parse_extref_record_index(payload)?
240                        .into_iter()
241                        .map(move |record| (entry, record)),
242                )
243            })
244            .flatten()
245            .collect()
246    }
247
248    /// Decode the counted object-id table from `/Root/FastLoad/RMFastLoad`.
249    pub fn rmfastload_object_id_table(&self) -> Option<(&DirEntry, RmFastLoadObjectIdTable)> {
250        let entry = self
251            .entries
252            .iter()
253            .find(|entry| entry.name == "/Root/FastLoad/RMFastLoad")
254            .filter(|entry| entry.file_span.is_some())?;
255        let (offset, size) = entry.file_span?;
256        let (offset, size) = (usize::try_from(offset).ok()?, usize::try_from(size).ok()?);
257        let bytes = self.data.get(offset..offset.checked_add(size)?)?;
258        let registry_offset = find(bytes, b"UGS::Solid::Topol")?;
259        for count_offset in registry_offset..bytes.len().saturating_sub(4) {
260            let Some(count) = u32_le(bytes, count_offset).map(|value| value as usize) else {
261                continue;
262            };
263            if !(50..=70_000).contains(&count) {
264                continue;
265            }
266            let Some(raw_ids) = bytes.get(count_offset + 4..count_offset + 4 + count * 4) else {
267                continue;
268            };
269            let object_ids: Vec<_> = raw_ids
270                .chunks_exact(4)
271                .enumerate()
272                .map(|(ordinal, word)| {
273                    let raw = <[u8; 4]>::try_from(word)
274                        .expect("invariant: chunks_exact(4) yields four-byte slices");
275                    RmFastLoadObjectId {
276                        value: u32::from_le_bytes(raw),
277                        offset: count_offset + 4 + ordinal * 4,
278                        raw,
279                    }
280                })
281                .collect();
282            if object_ids
283                .iter()
284                .all(|object_id| (1..70_000).contains(&object_id.value))
285            {
286                let raw_count = bytes.get(count_offset..count_offset + 4)?.try_into().ok()?;
287                return Some((
288                    entry,
289                    RmFastLoadObjectIdTable {
290                        registry_offset,
291                        count_offset,
292                        raw_count,
293                        object_ids,
294                    },
295                ));
296            }
297        }
298        None
299    }
300}
301
302/// Decoded prefix of one indexed EXTREFSTREAM record.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub(crate) struct ExtrefRecord {
305    pub record_id: u32,
306    pub offset: usize,
307    pub declared_count: u16,
308    pub id_slots: [u32; 4],
309    pub handles: Vec<u32>,
310    pub closing_duplicate: bool,
311    pub prefix_byte_len: usize,
312    pub tail_byte_len: usize,
313}
314
315/// One externally bounded record from a validated EXTREFSTREAM index.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub(crate) struct ExtrefIndexedRecord {
318    pub record_id: u32,
319    pub offset: usize,
320    pub byte_len: usize,
321}
322
323fn find(bytes: &[u8], needle: &[u8]) -> Option<usize> {
324    bytes
325        .windows(needle.len())
326        .position(|window| window == needle)
327}
328
329pub(crate) fn parse_extref_string_table(payload: &[u8]) -> Option<(usize, Vec<(usize, String)>)> {
330    (0..payload.len().saturating_sub(4))
331        .rev()
332        .find_map(|marker| {
333            (payload[marker] == 1).then_some(())?;
334            let count = u32_le(payload, marker + 1)? as usize;
335            let mut pos = marker + 5;
336            // Each entry is a 2-byte length prefix plus at least one non-empty string byte.
337            let count = bounded_len(count as u64, 3, payload.len().saturating_sub(pos))?;
338            let mut out = Vec::with_capacity(count);
339            for _ in 0..count {
340                let raw_length = payload.get(pos..pos + 2)?;
341                let length = usize::from(u16::from_le_bytes([raw_length[0], raw_length[1]]));
342                let string_offset = pos + 2;
343                pos = string_offset.checked_add(length)?;
344                let raw = payload.get(string_offset..pos)?;
345                let value = std::str::from_utf8(raw).ok()?;
346                (!value.is_empty() && value.chars().all(|character| !character.is_control()))
347                    .then_some(())?;
348                out.push((string_offset, value.to_string()));
349            }
350            (pos == payload.len()).then_some((marker, out))
351        })
352}
353
354pub(crate) fn parse_extref_records(payload: &[u8]) -> Vec<ExtrefRecord> {
355    let Some(index) = parse_extref_record_index(payload) else {
356        return Vec::new();
357    };
358    let parse_record = |record_id, offset, end| -> Option<ExtrefRecord> {
359        let bytes = payload.get(offset..end)?;
360        (bytes.get(..4) == Some(&[1, 0, 0, 0]) && bytes.get(6) == Some(&1)).then_some(())?;
361        let declared_count = u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?);
362        let mut id_slots = [0; 4];
363        for (slot, value) in id_slots.iter_mut().enumerate() {
364            *value = u32::from_le_bytes(bytes.get(7 + slot * 4..11 + slot * 4)?.try_into().ok()?);
365        }
366        (bytes.get(23) == Some(&1)).then_some(())?;
367        let count = usize::from(*bytes.get(24)?);
368        (count >= 2).then_some(())?;
369        let handle_token_count = count - 1;
370        let prefix_byte_len = 26usize.checked_add(handle_token_count.checked_mul(5)?)?;
371        (prefix_byte_len <= bytes.len() && bytes.get(prefix_byte_len - 1) == Some(&(count as u8)))
372            .then_some(())?;
373        let mut handles = Vec::with_capacity(handle_token_count);
374        for handle_index in 0..handle_token_count {
375            let token = 25 + handle_index * 5;
376            (bytes.get(token) == Some(&0xe0)).then_some(())?;
377            handles.push(u32::from_be_bytes(
378                bytes.get(token + 1..token + 5)?.try_into().ok()?,
379            ));
380        }
381        let closing_duplicate = handle_token_count >= 2
382            && handles[handle_token_count - 1] == handles[handle_token_count - 2];
383        let unique_count = handle_token_count - usize::from(closing_duplicate);
384        handles[..unique_count]
385            .windows(2)
386            .all(|pair| pair[0] < pair[1])
387            .then_some(())?;
388        if closing_duplicate {
389            handles.pop();
390        }
391        Some(ExtrefRecord {
392            record_id,
393            offset,
394            declared_count,
395            id_slots,
396            handles,
397            closing_duplicate,
398            prefix_byte_len,
399            tail_byte_len: bytes.len() - prefix_byte_len,
400        })
401    };
402
403    index
404        .into_iter()
405        .filter_map(|record| {
406            let end = record.offset.checked_add(record.byte_len)?;
407            parse_record(record.record_id, record.offset, end)
408        })
409        .collect()
410}
411
412pub(crate) fn parse_extref_record_index(payload: &[u8]) -> Option<Vec<ExtrefIndexedRecord>> {
413    if !payload.starts_with(b"EXTREFSTREAM") || payload.get(24) != Some(&0) {
414        return None;
415    }
416    let (string_table, _) = parse_extref_string_table(payload)?;
417    let mut directory = Vec::new();
418    let mut record_ids = std::collections::BTreeSet::new();
419    let mut at = 25usize;
420    loop {
421        let record_id = u32_le(payload, at)?;
422        at += 4;
423        if record_id == 0 {
424            break;
425        }
426        record_ids.insert(record_id).then_some(())?;
427        let offset = u32_le(payload, at)?;
428        at += 4;
429        let offset = offset as usize;
430        if offset >= string_table {
431            return None;
432        }
433        directory.push((record_id, offset));
434    }
435    if directory.is_empty()
436        || !directory.windows(2).all(|pair| pair[0].1 < pair[1].1)
437        || at > directory[0].1
438    {
439        return None;
440    }
441    let mut records = Vec::with_capacity(directory.len());
442    for (index, (record_id, offset)) in directory.iter().copied().enumerate() {
443        let end = directory
444            .get(index + 1)
445            .map_or(string_table, |(_, offset)| *offset);
446        records.push(ExtrefIndexedRecord {
447            record_id,
448            offset,
449            byte_len: end.checked_sub(offset)?,
450        });
451    }
452    Some(records)
453}
454
455/// Decode the two exact empty indexed-record forms.
456pub(crate) fn parse_extref_empty_record(bytes: &[u8]) -> Option<bool> {
457    match bytes {
458        [1, 0, 0, 0, 0, 1] => Some(false),
459        [1, 0, 0, 0, 0, 1, 1] => Some(true),
460        _ => None,
461    }
462}
463
464/// Decode exact adjacent persistent-handle and tagged-reference pairs.
465pub(crate) fn parse_extref_reference_pairs(bytes: &[u8]) -> Vec<(usize, u32, u32)> {
466    let mut pairs = Vec::new();
467    let mut at = 0usize;
468    while at + 9 <= bytes.len() {
469        if bytes[at] == 0xe0 && bytes[at + 5] & 0xf0 == 0xc0 {
470            let handle = u32::from_be_bytes(
471                bytes[at + 1..at + 5]
472                    .try_into()
473                    .expect("four-byte persistent handle"),
474            );
475            let tagged_reference = u32::from_be_bytes(
476                bytes[at + 5..at + 9]
477                    .try_into()
478                    .expect("four-byte tagged reference"),
479            ) & 0x0fff_ffff;
480            pairs.push((at, handle, tagged_reference));
481            at += 9;
482        } else {
483            at += 1;
484        }
485    }
486    pairs
487}
488
489/// A parsed SPLMSSTR container and its directory entries.
490#[derive(Debug, Clone)]
491pub struct Container {
492    /// The whole file image.
493    pub data: Vec<u8>,
494    /// Version byte at file offset 8.
495    pub version: u8,
496    /// File-specific 24-bit little-endian value at offset 9.
497    pub file_tag: u32,
498    /// Offset of the `FOOTER` region.
499    pub footer_offset: u64,
500    /// Enumerated directory entries from both regions, in discovery order.
501    pub entries: Vec<DirEntry>,
502}
503
504/// Return whether `prefix` starts with [`MAGIC`].
505pub fn looks_like_nx(prefix: &[u8]) -> bool {
506    prefix.starts_with(MAGIC)
507}
508
509fn u24_le(d: &[u8], at: usize) -> u32 {
510    if at + 3 > d.len() {
511        return 0;
512    }
513    u32::from(d[at]) | (u32::from(d[at + 1]) << 8) | (u32::from(d[at + 2]) << 16)
514}
515
516fn u48_le(d: &[u8], at: usize) -> u64 {
517    let mut v = 0u64;
518    for i in 0..6 {
519        if at + i < d.len() {
520            v |= u64::from(d[at + i]) << (8 * i);
521        }
522    }
523    v
524}
525
526/// Read a complete SPLMSSTR file and parse its header and directories.
527pub fn scan(reader: &mut dyn ReadSeek) -> Result<Container, CodecError> {
528    reader
529        .seek(std::io::SeekFrom::Start(0))
530        .map_err(CodecError::Io)?;
531    let mut data = Vec::new();
532    reader.read_to_end(&mut data).map_err(CodecError::Io)?;
533    scan_bytes(data)
534}
535
536/// Parse an SPLMSSTR file image.
537pub fn scan_bytes(data: Vec<u8>) -> Result<Container, CodecError> {
538    if !data.starts_with(MAGIC) {
539        return Err(CodecError::WrongFormat(
540            "missing SPLMSSTR magic".to_string(),
541        ));
542    }
543    let version = data.get(8).copied().unwrap_or(0);
544    let file_tag = u24_le(&data, 9);
545    let footer_offset = u48_le(&data, 0x11);
546
547    let mut entries = Vec::new();
548    // The HEADER directory begins at +25 (`0x19`). Scan forward from there for
549    // entries until the first non-entry byte; the region is contiguous.
550    enumerate_region(&data, 0x19, Region::Header, &mut entries);
551    // The FOOTER region begins at the 48-bit offset with an ASCII `FOOTER` tag,
552    // then a `u32 LE` entry count; entries follow.
553    let fo = footer_offset as usize;
554    if fo + 10 <= data.len() && &data[fo..fo + 6] == b"FOOTER" {
555        enumerate_region(&data, fo + 10, Region::Footer, &mut entries);
556    }
557
558    Ok(Container {
559        data,
560        version,
561        file_tag,
562        footer_offset,
563        entries,
564    })
565}
566
567/// Walk a directory region starting at `from`, appending every entry whose
568/// `name_len:u32 LE` frames an in-bounds ASCII `/Root/...` path. Stops at the
569/// first position that does not frame such an entry (the region is contiguous).
570fn enumerate_region(data: &[u8], from: usize, region: Region, out: &mut Vec<DirEntry>) {
571    let mut o = from;
572    // The very first HEADER entry is the `/Root/` sentinel; a run of well-formed
573    // entries follows. Allow a bounded number of framing misses before giving up,
574    // because the 16-byte opaque payloads can contain bytes that briefly look like
575    // a length field.
576    let mut misses = 0usize;
577    while o + 4 <= data.len() && misses < 64 {
578        match try_entry(data, o, region) {
579            Some((entry, next)) => {
580                out.push(entry);
581                o = next;
582                misses = 0;
583            }
584            None => {
585                o += 1;
586                misses += 1;
587            }
588        }
589    }
590}
591
592/// Try to read one directory entry at `o`: `name_len:u32 LE`, then that many bytes
593/// of printable ASCII beginning `/Root`, then a 16-byte payload. Returns the entry
594/// and the offset just past its payload.
595fn try_entry(data: &[u8], o: usize, region: Region) -> Option<(DirEntry, usize)> {
596    let name_len = u32_le(data, o)? as usize;
597    if !(6..=128).contains(&name_len) {
598        return None;
599    }
600    let name_start = o + 4;
601    let name_end = name_start + name_len;
602    let raw = data.get(name_start..name_end)?;
603    if !raw.starts_with(b"/Root") || !raw.iter().all(|&b| (0x20..0x7f).contains(&b)) {
604        return None;
605    }
606    let name = String::from_utf8_lossy(raw).into_owned();
607    let payload = name_end;
608    // Interpret the 16-byte payload as a file span when it lands within the file.
609    let file_span = match (u64_le(data, payload), u64_le(data, payload + 8)) {
610        (Some(off), Some(size)) => {
611            let end = off.checked_add(size);
612            match end {
613                Some(e) if size > 0 && e <= data.len() as u64 && off >= 8 => Some((off, size)),
614                _ => None,
615            }
616        }
617        _ => None,
618    };
619    Some((
620        DirEntry {
621            name,
622            region,
623            file_span,
624        },
625        payload + 16,
626    ))
627}