Skip to main content

rgpre/import/
pvo.rs

1use crate::{Result, error::Error};
2
3/// Section header shared by all PVO sections: 4-byte ASCII tag + 4-byte BE data length.
4#[derive(Debug, Clone, Copy)]
5pub struct PvoSectionHeader {
6    pub tag: [u8; 4],
7    pub data_length: u32,
8}
9
10/// OCTH section — file header (52 bytes payload).
11#[derive(Debug, Clone)]
12pub struct PvoHeader {
13    /// Maximum octree depth (always 10 in shipped files).
14    pub depth: u32,
15    /// Total octree node count. Equals `leaf_nodes + interior_nodes`.
16    pub total_nodes: u32,
17    /// Leaf node count.
18    pub leaf_nodes: u32,
19    /// Total entries in the MLST polygon index table.
20    /// Invariant: `mlst_polygon_count * 2 == mlst section data_length`.
21    pub mlst_polygon_count: u32,
22    /// Always 0 in shipped files.
23    pub reserved_18: u32,
24    /// Root cell half-extent (power of 2: 16384 or 8192).
25    pub cell_size: u32,
26    /// Octree root center X coordinate.
27    pub center_x: i32,
28    /// Octree root center Y coordinate.
29    pub center_y: i32,
30    /// Octree root center Z coordinate.
31    pub center_z: i32,
32    /// Always zero (4 × u32) in shipped files.
33    pub reserved_2c: [u32; 4],
34}
35
36impl PvoHeader {
37    /// Number of interior-only nodes (nodes with children but no leaf data).
38    #[must_use]
39    pub const fn interior_nodes(&self) -> u32 {
40        self.total_nodes.saturating_sub(self.leaf_nodes)
41    }
42}
43
44/// Special value for `OctrNode::leaf_ref` indicating no leaf data (interior-only node).
45pub const LEAF_REF_NONE: u32 = 0xFFFF_FFFF;
46
47/// Special value for child references indicating an empty-child sentinel.
48pub const CHILD_REF_EMPTY: u32 = 0xFFFF_FFFE;
49
50/// OCTR node record. Variable-length: `5 + popcount(child_mask) * 4` bytes.
51///
52/// Octant bit assignment:
53/// - bit `0` (`1`) = `z > center_z`
54/// - bit `1` (`2`) = `y > center_y`
55/// - bit `2` (`4`) = `x > center_x`
56#[derive(Debug, Clone)]
57pub struct OctrNode {
58    /// Position within the `OCTR` section data (used by `child_ref` lookups).
59    pub byte_offset: usize,
60    /// Bit *i* set = octant *i* child present.
61    pub child_mask: u8,
62    /// Byte offset into PLST section. `0xFFFFFFFF` = no leaf data.
63    pub leaf_ref: u32,
64    /// `(octant_index, byte_offset_into_OCTR)` per set bit, low bit first.
65    /// `0xFFFFFFFE` byte offset = empty-child sentinel.
66    pub child_refs: Vec<(u8, u32)>,
67}
68
69impl OctrNode {
70    /// Returns `true` when this node has no PLST leaf reference.
71    #[must_use]
72    pub const fn is_interior(&self) -> bool {
73        self.leaf_ref == LEAF_REF_NONE
74    }
75
76    /// Returns `true` when this node has no children.
77    #[must_use]
78    pub const fn is_leaf(&self) -> bool {
79        self.child_mask == 0
80    }
81
82    /// Returns the number of present children from `child_mask` bits.
83    #[must_use]
84    pub const fn child_count(&self) -> u32 {
85        self.child_mask.count_ones()
86    }
87
88    /// Returns the encoded byte size of this OCTR node record.
89    #[must_use]
90    pub fn record_size(&self) -> usize {
91        5 + usize::try_from(self.child_mask.count_ones()).unwrap_or(0) * 4
92    }
93}
94
95#[derive(Debug, Clone)]
96/// One PLST entry referencing a run of MLST polygon indices.
97pub struct PlstEntry {
98    /// Number of polygon indices in this sub-list.
99    pub count: u16,
100    /// Starting index into the MLST array.
101    pub mlst_start: u32,
102}
103
104#[derive(Debug, Clone)]
105/// One PLST leaf record containing one or more `PlstEntry` values.
106pub struct PlstLeaf {
107    /// Position within the `PLST` section data (used by `leaf_ref` lookups).
108    pub byte_offset: usize,
109    pub entries: Vec<PlstEntry>,
110}
111
112#[derive(Debug, Clone)]
113/// Parsed PVO file with octree nodes, leaf lists, and MLST indices.
114pub struct PvoFile {
115    pub header: PvoHeader,
116    pub octr_nodes: Vec<OctrNode>,
117    pub plst_leaves: Vec<PlstLeaf>,
118    pub mlst_indices: Vec<u16>,
119}
120
121impl PvoFile {
122    /// Resolves an OCTR leaf reference (PLST byte offset) to placed-object visibility IDs.
123    /// Preserves file order and duplicates. The no-leaf sentinel returns an empty list.
124    /// IDs below the world's MPSO count select that record; remaining IDs require the
125    /// runtime secondary object table. These IDs never identify individual model faces.
126    pub fn visibility_ids(&self, leaf_ref: u32) -> Result<Vec<u16>> {
127        if leaf_ref == LEAF_REF_NONE {
128            return Ok(Vec::new());
129        }
130        let leaf = self
131            .plst_leaves
132            .iter()
133            .find(|leaf| leaf.byte_offset == leaf_ref as usize)
134            .ok_or_else(|| {
135                Error::Parse(format!(
136                    "PVO leaf reference {leaf_ref} is not a PLST record"
137                ))
138            })?;
139        let mut ids = Vec::new();
140        for entry in &leaf.entries {
141            let start = entry.mlst_start as usize;
142            let end = start
143                .checked_add(usize::from(entry.count))
144                .ok_or_else(|| Error::Parse(format!("PVO leaf {leaf_ref} MLST range overflow")))?;
145            let slice = self.mlst_indices.get(start..end).ok_or_else(|| {
146                Error::Parse(format!(
147                    "PVO leaf {leaf_ref} MLST range {start}..{end} is out of bounds"
148                ))
149            })?;
150            ids.extend_from_slice(slice);
151        }
152        Ok(ids)
153    }
154
155    /// Counts OCTR nodes marked as interior-only nodes.
156    #[must_use]
157    pub fn count_interior_nodes(&self) -> usize {
158        self.octr_nodes.iter().filter(|n| n.is_interior()).count()
159    }
160
161    /// Counts OCTR nodes that have no child nodes.
162    #[must_use]
163    pub fn count_leaf_nodes(&self) -> usize {
164        self.octr_nodes.iter().filter(|n| n.is_leaf()).count()
165    }
166
167    /// Map an `MLST` index to an `MPSO` record index.
168    /// Indices `0..mpso_count-1` map directly to `MPSO` records.
169    /// Indices `>= mpso_count` belong to a secondary table and return `None`.
170    #[must_use]
171    pub const fn mlst_index_to_mpso(index: u16, mpso_count: u16) -> Option<u16> {
172        if index < mpso_count {
173            Some(index)
174        } else {
175            None
176        }
177    }
178}
179
180fn read_u32_le(data: &[u8], offset: usize) -> Result<u32> {
181    if offset + 4 > data.len() {
182        return Err(Error::Parse(format!(
183            "PVO read out of bounds at offset 0x{offset:X}"
184        )));
185    }
186    Ok(u32::from_le_bytes(
187        data[offset..offset + 4]
188            .try_into()
189            .map_err(|_| Error::Parse(format!("PVO failed to parse LE u32 at 0x{offset:X}")))?,
190    ))
191}
192
193fn read_u32_be(data: &[u8], offset: usize) -> Result<u32> {
194    if offset + 4 > data.len() {
195        return Err(Error::Parse(format!(
196            "PVO read out of bounds at offset 0x{offset:X}"
197        )));
198    }
199    Ok(u32::from_be_bytes(
200        data[offset..offset + 4]
201            .try_into()
202            .map_err(|_| Error::Parse(format!("PVO failed to parse BE u32 at 0x{offset:X}")))?,
203    ))
204}
205
206fn read_u16_le(data: &[u8], offset: usize) -> Result<u16> {
207    if offset + 2 > data.len() {
208        return Err(Error::Parse(format!(
209            "PVO read out of bounds at offset 0x{offset:X}"
210        )));
211    }
212    Ok(u16::from_le_bytes(
213        data[offset..offset + 2]
214            .try_into()
215            .map_err(|_| Error::Parse(format!("PVO failed to parse LE u16 at 0x{offset:X}")))?,
216    ))
217}
218
219fn read_i32_le(data: &[u8], offset: usize) -> Result<i32> {
220    let value = read_u32_le(data, offset)?;
221    Ok(i32::from_le_bytes(value.to_le_bytes()))
222}
223
224fn read_tag(data: &[u8], offset: usize) -> Result<[u8; 4]> {
225    if offset + 4 > data.len() {
226        return Err(Error::Parse(format!(
227            "PVO read out of bounds at offset 0x{offset:X} for tag"
228        )));
229    }
230    let mut tag = [0u8; 4];
231    tag.copy_from_slice(&data[offset..offset + 4]);
232    Ok(tag)
233}
234
235fn read_section_header(data: &[u8], offset: usize) -> Result<PvoSectionHeader> {
236    let tag = read_tag(data, offset)?;
237    let data_length = read_u32_be(data, offset + 4)?;
238    Ok(PvoSectionHeader { tag, data_length })
239}
240
241fn parse_header_payload(data: &[u8], offset: usize) -> Result<PvoHeader> {
242    if offset + 52 > data.len() {
243        return Err(Error::Parse(format!(
244            "PVO OCTH payload too short at 0x{offset:X}"
245        )));
246    }
247
248    Ok(PvoHeader {
249        depth: read_u32_le(data, offset)?,
250        total_nodes: read_u32_le(data, offset + 0x04)?,
251        leaf_nodes: read_u32_le(data, offset + 0x08)?,
252        mlst_polygon_count: read_u32_le(data, offset + 0x0C)?,
253        reserved_18: read_u32_le(data, offset + 0x10)?,
254        cell_size: read_u32_le(data, offset + 0x14)?,
255        center_x: read_i32_le(data, offset + 0x18)?,
256        center_y: read_i32_le(data, offset + 0x1C)?,
257        center_z: read_i32_le(data, offset + 0x20)?,
258        reserved_2c: [
259            read_u32_le(data, offset + 0x24)?,
260            read_u32_le(data, offset + 0x28)?,
261            read_u32_le(data, offset + 0x2C)?,
262            read_u32_le(data, offset + 0x30)?,
263        ],
264    })
265}
266
267fn parse_octr_nodes(payload: &[u8], expected_count: u32) -> Result<Vec<OctrNode>> {
268    let mut nodes = Vec::with_capacity(expected_count as usize);
269    let mut cursor = 0usize;
270
271    while cursor < payload.len() {
272        let node_offset = cursor;
273
274        if cursor + 5 > payload.len() {
275            return Err(Error::Parse(format!(
276                "PVO OCTR truncated node at offset 0x{cursor:X} \
277                 (need 5 bytes, have {})",
278                payload.len() - cursor
279            )));
280        }
281
282        let child_mask = payload[cursor];
283        cursor += 1;
284
285        let leaf_ref = u32::from_le_bytes(
286            payload[cursor..cursor + 4]
287                .try_into()
288                .map_err(|_| Error::Parse("PVO OCTR leaf_ref slice".to_string()))?,
289        );
290        cursor += 4;
291
292        let n_children = child_mask.count_ones() as usize;
293        let children_bytes = n_children * 4;
294        if cursor + children_bytes > payload.len() {
295            return Err(Error::Parse(format!(
296                "PVO OCTR truncated child_refs at offset 0x{cursor:X} \
297                 (need {children_bytes} bytes for {n_children} children, have {})",
298                payload.len() - cursor
299            )));
300        }
301
302        let mut child_refs = Vec::with_capacity(n_children);
303        for octant in 0u8..8 {
304            if child_mask & (1 << octant) != 0 {
305                let ref_offset = u32::from_le_bytes(
306                    payload[cursor..cursor + 4]
307                        .try_into()
308                        .map_err(|_| Error::Parse("PVO OCTR child_ref slice".to_string()))?,
309                );
310                child_refs.push((octant, ref_offset));
311                cursor += 4;
312            }
313        }
314
315        nodes.push(OctrNode {
316            byte_offset: node_offset,
317            child_mask,
318            leaf_ref,
319            child_refs,
320        });
321    }
322
323    if nodes.len() != expected_count as usize {
324        return Err(Error::Parse(format!(
325            "PVO OCTR node count mismatch: parsed {} nodes, header says {}",
326            nodes.len(),
327            expected_count
328        )));
329    }
330
331    Ok(nodes)
332}
333
334fn parse_plst_leaves(payload: &[u8]) -> Result<Vec<PlstLeaf>> {
335    let mut leaves = Vec::new();
336    let mut cursor = 0usize;
337
338    while cursor < payload.len() {
339        let leaf_offset = cursor;
340
341        if cursor + 1 > payload.len() {
342            return Err(Error::Parse(format!(
343                "PVO PLST truncated entry_count at offset 0x{cursor:X}"
344            )));
345        }
346        let entry_count = payload[cursor] as usize;
347        cursor += 1;
348
349        let entry_bytes = entry_count * 6;
350        if cursor + entry_bytes > payload.len() {
351            return Err(Error::Parse(format!(
352                "PVO PLST truncated entries at offset 0x{cursor:X} \
353                 (need {entry_bytes} bytes for {entry_count} entries, have {})",
354                payload.len() - cursor
355            )));
356        }
357
358        let mut entries = Vec::with_capacity(entry_count);
359        for _ in 0..entry_count {
360            let count = read_u16_le(payload, cursor)?;
361            let mlst_start = read_u32_le(payload, cursor + 2)?;
362            entries.push(PlstEntry { count, mlst_start });
363            cursor += 6;
364        }
365
366        leaves.push(PlstLeaf {
367            byte_offset: leaf_offset,
368            entries,
369        });
370    }
371
372    Ok(leaves)
373}
374
375fn parse_mlst_indices(payload: &[u8]) -> Result<Vec<u16>> {
376    if !payload.len().is_multiple_of(2) {
377        return Err(Error::Parse(format!(
378            "PVO MLST payload size {} is not a multiple of 2",
379            payload.len()
380        )));
381    }
382    let count = payload.len() / 2;
383    let mut indices = Vec::with_capacity(count);
384    for i in 0..count {
385        indices.push(read_u16_le(payload, i * 2)?);
386    }
387    Ok(indices)
388}
389
390fn read_section_payload<'a>(
391    data: &'a [u8],
392    cursor: &mut usize,
393    expected_tag: [u8; 4],
394) -> Result<&'a [u8]> {
395    let section_header = read_section_header(data, *cursor)?;
396    if section_header.tag != expected_tag {
397        return Err(Error::Parse(format!(
398            "PVO expected {} section, got '{}'",
399            String::from_utf8_lossy(&expected_tag),
400            String::from_utf8_lossy(&section_header.tag)
401        )));
402    }
403
404    *cursor = cursor.saturating_add(8);
405    let data_length = usize::try_from(section_header.data_length)
406        .map_err(|e| Error::Parse(format!("PVO section length does not fit usize: {e}")))?;
407    let payload_end = (*cursor)
408        .checked_add(data_length)
409        .ok_or_else(|| Error::Parse("PVO section payload end overflow".to_string()))?;
410    if payload_end > data.len() {
411        return Err(Error::Parse(format!(
412            "PVO {} section extends past end of file (section ends at 0x{payload_end:X}, file is 0x{:X} bytes)",
413            String::from_utf8_lossy(&expected_tag),
414            data.len()
415        )));
416    }
417
418    let payload = &data[*cursor..payload_end];
419    *cursor = payload_end;
420    Ok(payload)
421}
422
423fn parse_octh_section(data: &[u8], cursor: &mut usize) -> Result<PvoHeader> {
424    let section_header = read_section_header(data, *cursor)?;
425    if &section_header.tag != b"OCTH" {
426        return Err(Error::Parse(format!(
427            "PVO expected OCTH section, got '{}'",
428            String::from_utf8_lossy(&section_header.tag)
429        )));
430    }
431    if section_header.data_length != 52 {
432        return Err(Error::Parse(format!(
433            "PVO OCTH data_length expected 52 (0x34), got {} (0x{:X})",
434            section_header.data_length, section_header.data_length
435        )));
436    }
437
438    *cursor = cursor.saturating_add(8);
439    let header = parse_header_payload(data, *cursor)?;
440    *cursor = cursor.saturating_add(52);
441    Ok(header)
442}
443
444fn validate_end_section(data: &[u8], cursor: usize) -> Result<()> {
445    let end_section = read_section_header(data, cursor)?;
446    if &end_section.tag != b"END " {
447        return Err(Error::Parse(format!(
448            "PVO expected END section, got '{}'",
449            String::from_utf8_lossy(&end_section.tag)
450        )));
451    }
452    if end_section.data_length != 0 {
453        return Err(Error::Parse(format!(
454            "PVO END data_length expected 0, got {}",
455            end_section.data_length
456        )));
457    }
458    Ok(())
459}
460
461fn validate_leaf_count(header: &PvoHeader, octr_nodes: &[OctrNode]) -> Result<()> {
462    let actual_leaf_count = octr_nodes.iter().filter(|node| !node.is_interior()).count();
463    if actual_leaf_count != usize::try_from(header.leaf_nodes).unwrap_or(usize::MAX) {
464        return Err(Error::Parse(format!(
465            "PVO leaf_nodes mismatch: header says {}, OCTR has {} nodes with leaf data",
466            header.leaf_nodes, actual_leaf_count
467        )));
468    }
469    Ok(())
470}
471
472fn validate_node_refs(plst_size: usize, octr_nodes: &[OctrNode]) -> Result<()> {
473    let node_offsets: std::collections::HashSet<usize> =
474        octr_nodes.iter().map(|node| node.byte_offset).collect();
475
476    for node in octr_nodes {
477        for &(octant, ref_offset) in &node.child_refs {
478            if ref_offset == CHILD_REF_EMPTY {
479                continue;
480            }
481
482            let ref_offset_usize = usize::try_from(ref_offset)
483                .map_err(|e| Error::Parse(format!("PVO child_ref conversion failed: {e}")))?;
484            if !node_offsets.contains(&ref_offset_usize) {
485                return Err(Error::Parse(format!(
486                    "PVO OCTR node at 0x{:X} child octant {} references offset 0x{ref_offset:X} which is not a valid node byte_offset",
487                    node.byte_offset, octant
488                )));
489            }
490        }
491
492        if node.leaf_ref != LEAF_REF_NONE {
493            let leaf_ref_usize = usize::try_from(node.leaf_ref)
494                .map_err(|e| Error::Parse(format!("PVO leaf_ref conversion failed: {e}")))?;
495            if leaf_ref_usize >= plst_size {
496                return Err(Error::Parse(format!(
497                    "PVO OCTR node at 0x{:X} has leaf_ref 0x{:X} outside PLST range (0..0x{plst_size:X})",
498                    node.byte_offset, node.leaf_ref
499                )));
500            }
501        }
502    }
503
504    Ok(())
505}
506
507fn validate_plst_ranges(mlst_polygon_count: u32, plst_leaves: &[PlstLeaf]) -> Result<()> {
508    let mlst_count_u64 = u64::from(mlst_polygon_count);
509    for leaf in plst_leaves {
510        for entry in &leaf.entries {
511            let end = u64::from(entry.mlst_start) + u64::from(entry.count);
512            if end > mlst_count_u64 {
513                return Err(Error::Parse(format!(
514                    "PVO PLST leaf at 0x{:X} entry overflows MLST: mlst_start={} + count={} > mlst_polygon_count={}",
515                    leaf.byte_offset, entry.mlst_start, entry.count, mlst_polygon_count
516                )));
517            }
518        }
519    }
520    Ok(())
521}
522
523/// Section order: OCTH → OCTR → PLST → MLST → END.
524#[allow(clippy::missing_errors_doc)]
525pub fn parse_pvo_file(data: &[u8]) -> Result<PvoFile> {
526    if data.len() < 16 {
527        return Err(Error::Parse(format!(
528            "PVO file too small: {} bytes",
529            data.len()
530        )));
531    }
532
533    let mut cursor = 0usize;
534
535    let header = parse_octh_section(data, &mut cursor)?;
536    let octr_payload = read_section_payload(data, &mut cursor, *b"OCTR")?;
537    let octr_nodes = parse_octr_nodes(octr_payload, header.total_nodes)?;
538
539    let plst_payload = read_section_payload(data, &mut cursor, *b"PLST")?;
540    let plst_leaves = parse_plst_leaves(plst_payload)?;
541
542    let mlst_payload = read_section_payload(data, &mut cursor, *b"MLST")?;
543    let mlst_indices = parse_mlst_indices(mlst_payload)?;
544
545    if usize::try_from(header.mlst_polygon_count).unwrap_or(usize::MAX) != mlst_indices.len() {
546        return Err(Error::Parse(format!(
547            "PVO header mlst_polygon_count ({}) != MLST entry count ({})",
548            header.mlst_polygon_count,
549            mlst_indices.len()
550        )));
551    }
552
553    validate_end_section(data, cursor)?;
554    validate_leaf_count(&header, &octr_nodes)?;
555    validate_node_refs(plst_payload.len(), &octr_nodes)?;
556    validate_plst_ranges(header.mlst_polygon_count, &plst_leaves)?;
557
558    Ok(PvoFile {
559        header,
560        octr_nodes,
561        plst_leaves,
562        mlst_indices,
563    })
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn build_minimal_pvo() -> Vec<u8> {
571        let mut buf = Vec::new();
572
573        // OCTH: 2 nodes, 1 leaf, 3 MLST polygons, cell_size=16384, center=(100,-200,300)
574        buf.extend_from_slice(b"OCTH");
575        buf.extend_from_slice(&52u32.to_be_bytes());
576        buf.extend_from_slice(&10u32.to_le_bytes());
577        buf.extend_from_slice(&2u32.to_le_bytes());
578        buf.extend_from_slice(&1u32.to_le_bytes());
579        buf.extend_from_slice(&3u32.to_le_bytes());
580        buf.extend_from_slice(&0u32.to_le_bytes());
581        buf.extend_from_slice(&16384u32.to_le_bytes());
582        buf.extend_from_slice(&100i32.to_le_bytes());
583        buf.extend_from_slice(&(-200i32).to_le_bytes());
584        buf.extend_from_slice(&300i32.to_le_bytes());
585        for _ in 0..4 {
586            buf.extend_from_slice(&0u32.to_le_bytes());
587        }
588
589        // OCTR: node 0 (offset 0) = interior, 1 child in octant 0 pointing to node 1 (offset 9)
590        //        node 1 (offset 9) = leaf, leaf_ref=0 into PLST
591        let mut octr = Vec::new();
592        octr.push(0x01);
593        octr.extend_from_slice(&LEAF_REF_NONE.to_le_bytes());
594        octr.extend_from_slice(&9u32.to_le_bytes());
595        octr.push(0x00);
596        octr.extend_from_slice(&0u32.to_le_bytes());
597        buf.extend_from_slice(b"OCTR");
598        let octr_len = u32::try_from(octr.len()).expect("test OCTR length fits u32");
599        buf.extend_from_slice(&octr_len.to_be_bytes());
600        buf.extend_from_slice(&octr);
601
602        // PLST: 1 leaf with 1 entry: count=3, mlst_start=0
603        let mut plst = Vec::new();
604        plst.push(1u8);
605        plst.extend_from_slice(&3u16.to_le_bytes());
606        plst.extend_from_slice(&0u32.to_le_bytes());
607        buf.extend_from_slice(b"PLST");
608        let plst_len = u32::try_from(plst.len()).expect("test PLST length fits u32");
609        buf.extend_from_slice(&plst_len.to_be_bytes());
610        buf.extend_from_slice(&plst);
611
612        // MLST: 3 polygon indices
613        let mut mlst = Vec::new();
614        mlst.extend_from_slice(&10u16.to_le_bytes());
615        mlst.extend_from_slice(&20u16.to_le_bytes());
616        mlst.extend_from_slice(&30u16.to_le_bytes());
617        buf.extend_from_slice(b"MLST");
618        let mlst_len = u32::try_from(mlst.len()).expect("test MLST length fits u32");
619        buf.extend_from_slice(&mlst_len.to_be_bytes());
620        buf.extend_from_slice(&mlst);
621
622        buf.extend_from_slice(b"END ");
623        buf.extend_from_slice(&0u32.to_be_bytes());
624
625        buf
626    }
627
628    #[test]
629    fn parse_minimal_pvo() {
630        let data = build_minimal_pvo();
631        let file = parse_pvo_file(&data).expect("should parse minimal PVO");
632
633        assert_eq!(file.header.depth, 10);
634        assert_eq!(file.header.total_nodes, 2);
635        assert_eq!(file.header.leaf_nodes, 1);
636        assert_eq!(file.header.mlst_polygon_count, 3);
637        assert_eq!(file.header.reserved_18, 0);
638        assert_eq!(file.header.cell_size, 16384);
639        assert_eq!(file.header.center_x, 100);
640        assert_eq!(file.header.center_y, -200);
641        assert_eq!(file.header.center_z, 300);
642        assert_eq!(file.header.reserved_2c, [0; 4]);
643        assert_eq!(file.header.interior_nodes(), 1);
644
645        assert_eq!(file.octr_nodes.len(), 2);
646
647        let n0 = &file.octr_nodes[0];
648        assert_eq!(n0.byte_offset, 0);
649        assert_eq!(n0.child_mask, 0x01);
650        assert!(n0.is_interior());
651        assert!(!n0.is_leaf());
652        assert_eq!(n0.child_count(), 1);
653        assert_eq!(n0.child_refs, vec![(0, 9)]);
654        assert_eq!(n0.record_size(), 9);
655
656        let n1 = &file.octr_nodes[1];
657        assert_eq!(n1.byte_offset, 9);
658        assert_eq!(n1.child_mask, 0x00);
659        assert!(!n1.is_interior());
660        assert!(n1.is_leaf());
661        assert_eq!(n1.child_count(), 0);
662        assert!(n1.child_refs.is_empty());
663        assert_eq!(n1.leaf_ref, 0);
664        assert_eq!(n1.record_size(), 5);
665
666        assert_eq!(file.plst_leaves.len(), 1);
667        assert_eq!(file.plst_leaves[0].byte_offset, 0);
668        assert_eq!(file.plst_leaves[0].entries.len(), 1);
669        assert_eq!(file.plst_leaves[0].entries[0].count, 3);
670        assert_eq!(file.plst_leaves[0].entries[0].mlst_start, 0);
671
672        assert_eq!(file.mlst_indices, vec![10, 20, 30]);
673    }
674
675    #[test]
676    fn visibility_ids_resolve_records_and_reject_invalid_ranges() {
677        let mut file = parse_pvo_file(&build_minimal_pvo()).unwrap();
678        assert_eq!(file.visibility_ids(0).unwrap(), [10, 20, 30]);
679        assert!(file.visibility_ids(LEAF_REF_NONE).unwrap().is_empty());
680        assert!(file.visibility_ids(1).is_err());
681        file.plst_leaves[0].entries[0].mlst_start = 2;
682        assert!(file.visibility_ids(0).is_err());
683    }
684
685    #[test]
686    fn parse_rejects_bad_magic() {
687        let mut data = build_minimal_pvo();
688        data[0..4].copy_from_slice(b"NOPE");
689        let err = parse_pvo_file(&data).expect_err("expected parse error");
690        assert!(matches!(err, Error::Parse(_)));
691    }
692
693    #[test]
694    fn parse_rejects_too_small_file() {
695        let data = vec![0u8; 8];
696        let err = parse_pvo_file(&data).expect_err("expected parse error");
697        assert!(matches!(err, Error::Parse(_)));
698    }
699
700    #[test]
701    fn parse_rejects_mismatched_node_count() {
702        let mut data = build_minimal_pvo();
703        let total_nodes_offset = 8 + 4;
704        data[total_nodes_offset..total_nodes_offset + 4].copy_from_slice(&99u32.to_le_bytes());
705        let err = parse_pvo_file(&data).expect_err("expected node count mismatch");
706        assert!(matches!(err, Error::Parse(_)));
707    }
708
709    #[test]
710    fn parse_rejects_mismatched_mlst_count() {
711        let mut data = build_minimal_pvo();
712        let mlst_count_offset = 8 + 12;
713        data[mlst_count_offset..mlst_count_offset + 4].copy_from_slice(&999u32.to_le_bytes());
714        let err = parse_pvo_file(&data).expect_err("expected mlst count mismatch");
715        assert!(matches!(err, Error::Parse(_)));
716    }
717
718    #[test]
719    fn octr_node_all_children() {
720        let mut payload = Vec::new();
721        payload.push(0xFF);
722        payload.extend_from_slice(&42u32.to_le_bytes());
723        for i in 0u32..8 {
724            payload.extend_from_slice(&(i * 10).to_le_bytes());
725        }
726
727        let nodes = parse_octr_nodes(&payload, 1).expect("should parse");
728        assert_eq!(nodes.len(), 1);
729        assert_eq!(nodes[0].byte_offset, 0);
730        assert_eq!(nodes[0].child_mask, 0xFF);
731        assert_eq!(nodes[0].leaf_ref, 42);
732        assert_eq!(nodes[0].child_count(), 8);
733        assert_eq!(nodes[0].record_size(), 37);
734        assert_eq!(
735            nodes[0].child_refs,
736            vec![
737                (0, 0),
738                (1, 10),
739                (2, 20),
740                (3, 30),
741                (4, 40),
742                (5, 50),
743                (6, 60),
744                (7, 70)
745            ]
746        );
747    }
748}