1use crate::{Result, error::Error};
2
3#[derive(Debug, Clone, Copy)]
5pub struct PvoSectionHeader {
6 pub tag: [u8; 4],
7 pub data_length: u32,
8}
9
10#[derive(Debug, Clone)]
12pub struct PvoHeader {
13 pub depth: u32,
15 pub total_nodes: u32,
17 pub leaf_nodes: u32,
19 pub mlst_polygon_count: u32,
22 pub reserved_18: u32,
24 pub cell_size: u32,
26 pub center_x: i32,
28 pub center_y: i32,
30 pub center_z: i32,
32 pub reserved_2c: [u32; 4],
34}
35
36impl PvoHeader {
37 #[must_use]
39 pub const fn interior_nodes(&self) -> u32 {
40 self.total_nodes.saturating_sub(self.leaf_nodes)
41 }
42}
43
44pub const LEAF_REF_NONE: u32 = 0xFFFF_FFFF;
46
47pub const CHILD_REF_EMPTY: u32 = 0xFFFF_FFFE;
49
50#[derive(Debug, Clone)]
57pub struct OctrNode {
58 pub byte_offset: usize,
60 pub child_mask: u8,
62 pub leaf_ref: u32,
64 pub child_refs: Vec<(u8, u32)>,
67}
68
69impl OctrNode {
70 #[must_use]
72 pub const fn is_interior(&self) -> bool {
73 self.leaf_ref == LEAF_REF_NONE
74 }
75
76 #[must_use]
78 pub const fn is_leaf(&self) -> bool {
79 self.child_mask == 0
80 }
81
82 #[must_use]
84 pub const fn child_count(&self) -> u32 {
85 self.child_mask.count_ones()
86 }
87
88 #[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)]
96pub struct PlstEntry {
98 pub count: u16,
100 pub mlst_start: u32,
102}
103
104#[derive(Debug, Clone)]
105pub struct PlstLeaf {
107 pub byte_offset: usize,
109 pub entries: Vec<PlstEntry>,
110}
111
112#[derive(Debug, Clone)]
113pub 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 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 #[must_use]
157 pub fn count_interior_nodes(&self) -> usize {
158 self.octr_nodes.iter().filter(|n| n.is_interior()).count()
159 }
160
161 #[must_use]
163 pub fn count_leaf_nodes(&self) -> usize {
164 self.octr_nodes.iter().filter(|n| n.is_leaf()).count()
165 }
166
167 #[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(§ion_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 §ion_header.tag != b"OCTH" {
426 return Err(Error::Parse(format!(
427 "PVO expected OCTH section, got '{}'",
428 String::from_utf8_lossy(§ion_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#[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 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 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 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 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}