Skip to main content

plasma_prp/resource/
prp.rs

1//! .prp (Plasma Resource Page) binary format parser and writer
2//!
3//! Reads and writes the binary format used by Myst Online: Uru Live to store
4//! age data: geometry, textures, materials, scene nodes.
5
6use anyhow::{Context, Result, bail};
7use std::io::{Read, Write, Seek, SeekFrom, Cursor};
8use std::path::Path;
9
10// ============================================================================
11// Stream helpers — Plasma uses little-endian for all numeric types
12// ============================================================================
13
14pub trait PlasmaRead: Read {
15    fn read_u8(&mut self) -> Result<u8> {
16        let mut buf = [0u8; 1];
17        self.read_exact(&mut buf)?;
18        Ok(buf[0])
19    }
20
21    fn read_u16(&mut self) -> Result<u16> {
22        let mut buf = [0u8; 2];
23        self.read_exact(&mut buf)?;
24        Ok(u16::from_le_bytes(buf))
25    }
26
27    fn read_u32(&mut self) -> Result<u32> {
28        let mut buf = [0u8; 4];
29        self.read_exact(&mut buf)?;
30        Ok(u32::from_le_bytes(buf))
31    }
32
33    fn read_i16(&mut self) -> Result<i16> {
34        let mut buf = [0u8; 2];
35        self.read_exact(&mut buf)?;
36        Ok(i16::from_le_bytes(buf))
37    }
38
39    fn read_i32(&mut self) -> Result<i32> {
40        let mut buf = [0u8; 4];
41        self.read_exact(&mut buf)?;
42        Ok(i32::from_le_bytes(buf))
43    }
44
45    fn read_f32(&mut self) -> Result<f32> {
46        let mut buf = [0u8; 4];
47        self.read_exact(&mut buf)?;
48        Ok(f32::from_le_bytes(buf))
49    }
50
51    fn read_f64(&mut self) -> Result<f64> {
52        let mut buf = [0u8; 8];
53        self.read_exact(&mut buf)?;
54        Ok(f64::from_le_bytes(buf))
55    }
56
57    fn read_safe_string(&mut self) -> Result<String> {
58        let raw_len = self.read_u16()?;
59        let len = (raw_len & 0x0FFF) as usize;
60        if len == 0 {
61            return Ok(String::new());
62        }
63        let mut buf = vec![0u8; len];
64        self.read_exact(&mut buf)?;
65
66        // XOR-invert if high bit marker was set (0xF000 flag)
67        if raw_len & 0xF000 == 0xF000 {
68            for byte in &mut buf {
69                *byte = !*byte;
70            }
71        }
72
73        // Handle null terminator
74        if buf.last() == Some(&0) {
75            buf.pop();
76        }
77
78        Ok(String::from_utf8_lossy(&buf).into_owned())
79    }
80
81    fn read_matrix44(&mut self) -> Result<[f32; 16]> {
82        let mut m = [0f32; 16];
83        for val in &mut m {
84            *val = self.read_f32()?;
85        }
86        Ok(m)
87    }
88
89    fn skip(&mut self, n: usize) -> Result<()> {
90        let mut buf = vec![0u8; n];
91        self.read_exact(&mut buf)?;
92        Ok(())
93    }
94}
95
96impl<T: Read> PlasmaRead for T {}
97
98// ============================================================================
99// Stream write helpers — Plasma uses little-endian for all numeric types
100// ============================================================================
101
102pub trait PlasmaWrite: Write {
103    fn write_u8(&mut self, v: u8) -> Result<()> {
104        self.write_all(&[v])?;
105        Ok(())
106    }
107
108    fn write_u16(&mut self, v: u16) -> Result<()> {
109        self.write_all(&v.to_le_bytes())?;
110        Ok(())
111    }
112
113    fn write_u32(&mut self, v: u32) -> Result<()> {
114        self.write_all(&v.to_le_bytes())?;
115        Ok(())
116    }
117
118    fn write_i16(&mut self, v: i16) -> Result<()> {
119        self.write_all(&v.to_le_bytes())?;
120        Ok(())
121    }
122
123    fn write_i32(&mut self, v: i32) -> Result<()> {
124        self.write_all(&v.to_le_bytes())?;
125        Ok(())
126    }
127
128    fn write_f32(&mut self, v: f32) -> Result<()> {
129        self.write_all(&v.to_le_bytes())?;
130        Ok(())
131    }
132
133    fn write_f64(&mut self, v: f64) -> Result<()> {
134        self.write_all(&v.to_le_bytes())?;
135        Ok(())
136    }
137
138    fn write_safe_string(&mut self, s: &str) -> Result<()> {
139        let bytes = s.as_bytes();
140        let len = bytes.len();
141        let raw_len = (len as u16) | 0xF000; // set high nibble flag
142        self.write_all(&raw_len.to_le_bytes())?;
143
144        // Write XOR-inverted bytes (no null terminator — matches C++ WriteSafeString)
145        let inverted: Vec<u8> = bytes.iter().map(|b| !b).collect();
146        self.write_all(&inverted)?;
147        Ok(())
148    }
149
150    fn write_matrix44(&mut self, m: &[f32; 16]) -> Result<()> {
151        for val in m {
152            self.write_all(&val.to_le_bytes())?;
153        }
154        Ok(())
155    }
156}
157
158impl<T: Write> PlasmaWrite for T {}
159
160// ============================================================================
161// Page header
162// ============================================================================
163
164#[derive(Debug, Clone)]
165pub struct PageHeader {
166    pub version: u32,
167    pub sequence_number: u32,
168    pub flags: u16,
169    pub age_name: String,
170    pub page_name: String,
171    pub major_version: u16,
172    pub checksum: u32,
173    pub data_start: u32,
174    pub index_start: u32,
175    /// Class version table: (class_type, version) pairs.
176    /// Preserved for byte-identical round-tripping.
177    pub class_versions: Vec<(u16, u16)>,
178}
179
180impl PageHeader {
181    fn read(reader: &mut impl Read) -> Result<Self> {
182        let version = reader.read_u32()?;
183        if version != 6 {
184            bail!("Unsupported .prp version: {} (expected 6)", version);
185        }
186
187        let sequence_number = reader.read_u32()?;
188        let flags = reader.read_u16()?;
189        let age_name = reader.read_safe_string()?;
190        let page_name = reader.read_safe_string()?;
191        let major_version = reader.read_u16()?;
192        let checksum = reader.read_u32()?;
193        let data_start = reader.read_u32()?;
194        let index_start = reader.read_u32()?;
195
196        // Read class version table (only present in some versions)
197        let mut class_versions = Vec::new();
198        if data_start > 0 {
199            let num_class_versions = reader.read_u16()?;
200            for _ in 0..num_class_versions {
201                let class_type = reader.read_u16()?;
202                let version = reader.read_u16()?;
203                class_versions.push((class_type, version));
204            }
205        }
206
207        Ok(Self {
208            version,
209            sequence_number,
210            flags,
211            age_name,
212            page_name,
213            major_version,
214            checksum,
215            data_start,
216            index_start,
217            class_versions,
218        })
219    }
220
221    fn write(&self, writer: &mut impl Write) -> Result<()> {
222        writer.write_u32(self.version)?;
223        writer.write_u32(self.sequence_number)?;
224        writer.write_u16(self.flags)?;
225        writer.write_safe_string(&self.age_name)?;
226        writer.write_safe_string(&self.page_name)?;
227        writer.write_u16(self.major_version)?;
228        writer.write_u32(self.checksum)?;
229        writer.write_u32(self.data_start)?;
230        writer.write_u32(self.index_start)?;
231
232        // Class version table
233        if self.data_start > 0 {
234            writer.write_u16(self.class_versions.len() as u16)?;
235            for &(class_type, version) in &self.class_versions {
236                writer.write_u16(class_type)?;
237                writer.write_u16(version)?;
238            }
239        }
240
241        Ok(())
242    }
243}
244
245// ============================================================================
246// Object key (plUoid + file position)
247// ============================================================================
248
249#[derive(Debug, Clone)]
250pub struct ObjectKey {
251    pub class_type: u16,
252    pub object_name: String,
253    pub object_id: u32,
254    pub start_pos: u32,
255    pub data_len: u32,
256    pub location_sequence: u32,
257    pub location_flags: u16,
258    /// Packed load mask byte; 0xFF = always load.
259    /// C++ ref: plLoadMask (CoreLib/plLoadMask.h)
260    pub load_mask: u8,
261    /// Clone ID (0 if not cloned).
262    /// C++ ref: plUoid.h — fCloneID
263    pub clone_id: u16,
264    /// Clone player ID (0 if not cloned).
265    /// C++ ref: plUoid.h — fClonePlayerID
266    pub clone_player_id: u32,
267}
268
269impl ObjectKey {
270    /// Convert to a full Uoid for use as collision-free HashMap key.
271    pub fn to_uoid(&self) -> crate::core::uoid::Uoid {
272        crate::core::uoid::Uoid {
273            location: crate::core::location::Location {
274                sequence_number: self.location_sequence,
275                flags: self.location_flags,
276            },
277            class_type: self.class_type,
278            object_name: self.object_name.clone(),
279            object_id: self.object_id,
280            load_mask: crate::core::load_mask::LoadMask::from_byte(self.load_mask),
281            clone_id: self.clone_id,
282            clone_player_id: self.clone_player_id,
283        }
284    }
285
286    fn read(reader: &mut impl Read) -> Result<Self> {
287        let contents = reader.read_u8()?;
288
289        // plLocation
290        let location_sequence = reader.read_u32()?;
291        let location_flags = reader.read_u16()?;
292
293        // LoadMask (optional, 1 byte packed)
294        // C++ ref: plUoid.cpp:159 — kHasLoadMask = 0x2
295        let load_mask = if contents & 0x02 != 0 {
296            reader.read_u8()?
297        } else {
298            0xFF // SetAlways — C++ ref: plLoadMask.h
299        };
300
301        let class_type = reader.read_u16()?;
302        let object_id = reader.read_u32()?;
303        let object_name = reader.read_safe_string()?;
304
305        // Clone IDs (optional)
306        // C++ ref: plUoid.cpp:169-173 — kHasCloneIDs = 0x1
307        let (clone_id, clone_player_id) = if contents & 0x01 != 0 {
308            let clone_id = reader.read_u16()?;
309            let _reserved = reader.read_u16()?;
310            let clone_player_id = reader.read_u32()?;
311            (clone_id, clone_player_id)
312        } else {
313            (0, 0)
314        };
315
316        // File position
317        let start_pos = reader.read_u32()?;
318        let data_len = reader.read_u32()?;
319
320        Ok(Self {
321            class_type,
322            object_name,
323            object_id,
324            start_pos,
325            data_len,
326            location_sequence,
327            location_flags,
328            load_mask,
329            clone_id,
330            clone_player_id,
331        })
332    }
333
334    /// Write an ObjectKey in the PRP key index format.
335    pub fn write(&self, writer: &mut impl Write) -> Result<()> {
336        // Reconstruct contents byte
337        let has_load_mask = self.load_mask != 0xFF;
338        let has_clone = self.clone_id != 0 || self.clone_player_id != 0;
339        let mut contents: u8 = 0;
340        if has_clone { contents |= 0x01; }
341        if has_load_mask { contents |= 0x02; }
342        writer.write_u8(contents)?;
343
344        // plLocation
345        writer.write_u32(self.location_sequence)?;
346        writer.write_u16(self.location_flags)?;
347
348        // LoadMask
349        if has_load_mask {
350            writer.write_u8(self.load_mask)?;
351        }
352
353        writer.write_u16(self.class_type)?;
354        writer.write_u32(self.object_id)?;
355        writer.write_safe_string(&self.object_name)?;
356
357        // Clone IDs
358        if has_clone {
359            writer.write_u16(self.clone_id)?;
360            writer.write_u16(0u16)?; // reserved
361            writer.write_u32(self.clone_player_id)?;
362        }
363
364        // File position
365        writer.write_u32(self.start_pos)?;
366        writer.write_u32(self.data_len)?;
367
368        Ok(())
369    }
370}
371
372// ============================================================================
373// Plasma class types (from plFactory — only the ones we care about)
374// ============================================================================
375
376#[allow(dead_code)]
377pub mod class_types {
378    // Class indices from plCreatableIndex.h (0-based, excluding LIST_START)
379    pub const PL_SCENE_NODE: u16 = 0x0000;
380    pub const PL_SCENE_OBJECT: u16 = 0x0001;
381    pub const PL_MIPMAP: u16 = 0x0004;
382    pub const PL_CUBIC_ENVIRONMAP: u16 = 0x0005;
383    pub const PL_LAYER: u16 = 0x0006;
384    pub const HS_GMATERIAL: u16 = 0x0007;
385    pub const PL_DRAWABLE_SPANS: u16 = 0x004C;
386    pub const PL_CLUSTER_GROUP: u16 = 0x012B;
387    pub const PL_DYNAMIC_TEXT_MAP: u16 = 0x00AD;
388}
389
390// ============================================================================
391// Page reader — reads header, index, and provides object access
392// ============================================================================
393
394/// Key index group — preserves per-class metadata for round-tripping.
395#[derive(Debug, Clone)]
396pub struct KeyIndexGroup {
397    pub class_type: u16,
398    pub deprecated_flags: u8,
399}
400
401#[derive(Debug)]
402pub struct PrpPage {
403    pub header: PageHeader,
404    pub keys: Vec<ObjectKey>,
405    /// Per-class-type metadata from the key index, in file order.
406    /// Preserved for byte-identical round-tripping.
407    pub key_groups: Vec<KeyIndexGroup>,
408    data: Vec<u8>,
409}
410
411impl PrpPage {
412    fn parse_keys(cursor: &mut Cursor<&[u8]>, index_start: u64) -> Result<(Vec<ObjectKey>, Vec<KeyIndexGroup>)> {
413        cursor.seek(SeekFrom::Start(index_start))?;
414        let mut keys = Vec::new();
415        let mut key_groups = Vec::new();
416
417        let num_class_types = cursor.read_u32()?;
418        for _ in 0..num_class_types {
419            let class_type = cursor.read_u16()?;
420            let key_list_len = cursor.read_u32()?;
421            let pos_before = cursor.position();
422            let pos_end = pos_before + key_list_len as u64;
423
424            let deprecated_flags = cursor.read_u8()?;
425            let num_keys = cursor.read_u32()?;
426
427            key_groups.push(KeyIndexGroup { class_type, deprecated_flags });
428
429            for _ in 0..num_keys {
430                if cursor.position() >= pos_end { break; }
431                match ObjectKey::read(cursor) {
432                    Ok(key) => keys.push(key),
433                    Err(_) => break,
434                }
435            }
436            cursor.seek(SeekFrom::Start(pos_end))?;
437        }
438
439        Ok((keys, key_groups))
440    }
441
442    pub fn from_file(path: &Path) -> Result<Self> {
443        let data = std::fs::read(path)
444            .with_context(|| format!("Failed to read {:?}", path))?;
445        let mut cursor = Cursor::new(data.as_slice());
446
447        let header = PageHeader::read(&mut cursor)
448            .with_context(|| format!("Failed to parse header of {:?}", path))?;
449
450        let (keys, key_groups) = Self::parse_keys(&mut cursor, header.index_start as u64)?;
451
452        Ok(Self { header, keys, key_groups, data })
453    }
454
455    /// Parse a PRP page from raw bytes (for WASM HTTP loading).
456    pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
457        let mut cursor = Cursor::new(data.as_slice());
458        let header = PageHeader::read(&mut cursor)
459            .with_context(|| "Failed to parse PRP header from bytes")?;
460
461        let (keys, key_groups) = Self::parse_keys(&mut cursor, header.index_start as u64)?;
462
463        Ok(Self { header, keys, key_groups, data })
464    }
465
466    /// Get raw object data for a key
467    pub fn object_data(&self, key: &ObjectKey) -> Option<&[u8]> {
468        let start = key.start_pos as usize;
469        let end = start + key.data_len as usize;
470        if end <= self.data.len() {
471            Some(&self.data[start..end])
472        } else {
473            None
474        }
475    }
476
477    /// Get the full raw data blob (header + objects + index).
478    pub fn raw_data(&self) -> &[u8] {
479        &self.data
480    }
481
482    /// Get all keys of a specific class type
483    pub fn keys_of_type(&self, class_type: u16) -> Vec<&ObjectKey> {
484        self.keys.iter().filter(|k| k.class_type == class_type).collect()
485    }
486
487    /// Count objects by class type
488    pub fn count_by_type(&self, class_type: u16) -> usize {
489        self.keys.iter().filter(|k| k.class_type == class_type).count()
490    }
491
492    /// Serialize the PRP page back to bytes (byte-identical to original file).
493    ///
494    /// Reconstructs: header + raw object data section + key index.
495    pub fn to_bytes(&self) -> Result<Vec<u8>> {
496        let mut buf: Vec<u8> = Vec::with_capacity(self.data.len());
497
498        // 1. Write header
499        self.header.write(&mut buf)?;
500
501        // 2. Write data section (raw object bytes, preserved exactly)
502        let data_start = self.header.data_start as usize;
503        let index_start = self.header.index_start as usize;
504        if data_start <= self.data.len() && index_start <= self.data.len() {
505            // Pad if header is shorter than data_start
506            while buf.len() < data_start {
507                buf.push(0);
508            }
509            buf.extend_from_slice(&self.data[data_start..index_start]);
510        }
511
512        // 3. Write key index
513        self.write_key_index(&mut buf)?;
514
515        Ok(buf)
516    }
517
518    /// Write the key index section.
519    fn write_key_index(&self, writer: &mut impl Write) -> Result<()> {
520        // Group keys by class type, preserving the order from key_groups
521        let num_class_types = self.key_groups.len() as u32;
522        writer.write_u32(num_class_types)?;
523
524        for group in &self.key_groups {
525            let class_keys: Vec<&ObjectKey> = self.keys.iter()
526                .filter(|k| k.class_type == group.class_type)
527                .collect();
528
529            // Compute the byte length of the key list payload
530            // (deprecated_flags(1) + num_keys(4) + serialized keys)
531            let mut key_payload = Vec::new();
532            key_payload.write_u8(group.deprecated_flags)?;
533            key_payload.write_u32(class_keys.len() as u32)?;
534            for key in &class_keys {
535                key.write(&mut key_payload)?;
536            }
537
538            writer.write_u16(group.class_type)?;
539            writer.write_u32(key_payload.len() as u32)?;
540            writer.write_all(&key_payload)?;
541        }
542
543        Ok(())
544    }
545
546    /// Save the PRP page to a file (byte-identical round-trip).
547    pub fn save(&self, path: &Path) -> Result<()> {
548        let bytes = self.to_bytes()?;
549        std::fs::write(path, &bytes)
550            .with_context(|| format!("Failed to write {:?}", path))?;
551        Ok(())
552    }
553}
554
555// ============================================================================
556// Key reference reader (returns name if non-nil)
557// ============================================================================
558
559pub fn read_key_name(reader: &mut (impl Read + Seek)) -> Result<Option<String>> {
560    let non_nil = reader.read_u8()?;
561    if non_nil == 0 {
562        return Ok(None);
563    }
564    let contents = reader.read_u8()?;
565    reader.skip(6)?; // plLocation: sequence(4) + flags(2)
566    // C++ plUoid: kHasCloneIDs=0x01, kHasLoadMask=0x02
567    if contents & 0x02 != 0 {
568        reader.skip(1)?; // LoadMask (1 byte)
569    }
570    let _class_type = reader.read_u16()?;
571    let _object_id = reader.read_u32()?;
572    let name = reader.read_safe_string()?;
573    if contents & 0x01 != 0 {
574        reader.skip(8)?; // cloneID(2) + reserved(2) + clonePlayerID(4)
575    }
576    Ok(Some(name))
577}
578
579/// Public wrapper for skip_synched_object.
580pub fn skip_synched_object_pub(reader: &mut (impl Read + Seek)) -> Result<()> {
581    skip_synched_object(reader)
582}
583
584fn skip_synched_object(reader: &mut (impl Read + Seek)) -> Result<()> {
585    let synch_flags = reader.read_u32()?;
586    if synch_flags & 0x10 != 0 { // kExcludePersistentState
587        let n = reader.read_u16()?;
588        for _ in 0..n { reader.read_safe_string()?; }
589    }
590    if synch_flags & 0x20 != 0 { // kHasVolatileState
591        let n = reader.read_u16()?;
592        for _ in 0..n { reader.read_safe_string()?; }
593    }
594    Ok(())
595}
596
597// ============================================================================
598// hsGMaterial — parse to extract layer key names
599// ============================================================================
600
601/// Parse hsGMaterial to get the names of its layer key refs
602pub fn parse_material_layers(data: &[u8]) -> Result<Vec<String>> {
603    let mut cursor = Cursor::new(data);
604
605    // Creatable class index
606    let class_idx = cursor.read_i16()?;
607    if class_idx < 0 { bail!("Null creatable"); }
608
609    // hsKeyedObject::Read (self-key)
610    read_key_name(&mut cursor)?;
611
612    // plSynchedObject::Read
613    skip_synched_object(&mut cursor)?;
614
615    // hsGMaterial::Read
616    let _load_flags = cursor.read_u32()?;
617    let _comp_flags = cursor.read_u32()?;
618    let num_layers = cursor.read_u32()?;
619    let _num_piggybacks = cursor.read_u32()?;
620
621    let mut layer_names = Vec::new();
622    for _ in 0..num_layers {
623        if let Some(name) = read_key_name(&mut cursor)? {
624            layer_names.push(name);
625        }
626    }
627
628    Ok(layer_names)
629}
630
631// ============================================================================
632// plLayer — parse to extract texture key name
633// ============================================================================
634
635/// Parse plLayer to get the texture key name
636pub fn parse_layer_texture(data: &[u8]) -> Result<Option<String>> {
637    let mut cursor = Cursor::new(data);
638
639    // Creatable class index
640    let class_idx = cursor.read_i16()?;
641    if class_idx < 0 { bail!("Null creatable"); }
642
643    // plLayerInterface::Read
644    //   plSynchedObject::Read
645    //     hsKeyedObject::Read (self-key)
646    read_key_name(&mut cursor)?;
647    skip_synched_object(&mut cursor)?;
648    //   underLay key
649    read_key_name(&mut cursor)?;
650
651    // plLayer::Read
652    // hsGMatState: 5 uint32s
653    cursor.skip(20)?;
654    // Transform matrix (hsMatrix44)
655    let has_matrix = cursor.read_u8()?;
656    if has_matrix != 0 {
657        cursor.skip(64)?; // 16 floats
658    }
659    // PreshadeColor (4 floats)
660    cursor.skip(16)?;
661    // RuntimeColor (4 floats)
662    cursor.skip(16)?;
663    // AmbientColor (4 floats)
664    cursor.skip(16)?;
665    // SpecularColor (4 floats)
666    cursor.skip(16)?;
667    // UVWSrc (uint32)
668    cursor.skip(4)?;
669    // Opacity (float)
670    cursor.skip(4)?;
671    // LODBias (float)
672    cursor.skip(4)?;
673    // SpecularPower (float)
674    cursor.skip(4)?;
675
676    // Texture key — this is what we want!
677    let texture_name = read_key_name(&mut cursor)?;
678
679    Ok(texture_name)
680}
681
682/// Parsed layer state for compositing decisions.
683#[derive(Debug, Clone)]
684pub struct LayerState {
685    pub name: String,
686    pub texture_name: Option<String>,
687    pub blend_flags: u32,
688    pub shade_flags: u32,
689    pub misc_flags: u32,
690    pub z_flags: u32,
691    pub uv_channel: u8,
692    /// Full UVWSrc including mode bits (kUVWNormal=0x10000, kUVWPosition=0x20000, kUVWReflect=0x30000)
693    pub uvw_src_full: u32,
694    pub opacity: f32,
695    /// UV transform matrix from plLayer (hsMatrix44, row-major fMap[row][col]).
696    /// Applied as: uv_out = (transform * vec4(uv, 0, 1)).xy
697    /// Identity when None (no transform or identity flag set).
698    pub uv_transform: Option<[[f32; 4]; 4]>,
699    /// Material colors from plLayer::Read (plLayer.cpp:124-127)
700    /// Order: preshadeColor, runtimeColor, ambientColor, specularColor
701    pub preshade_color: [f32; 4],
702    pub runtime_color: [f32; 4],
703    pub ambient_color: [f32; 4],
704    pub specular_color: [f32; 4],
705}
706
707/// Parse a plLayer's blend/misc/z flags and UV channel (using correct key parsers).
708pub fn parse_layer_state(data: &[u8]) -> Result<LayerState> {
709    use crate::core::uoid::read_key_uoid;
710    use crate::core::synched_object::SynchedObjectData;
711
712    let mut cursor = Cursor::new(data);
713    let class_idx = cursor.read_i16()?;
714    if class_idx < 0 { bail!("Null creatable"); }
715
716    let self_key = read_key_uoid(&mut cursor)?;
717    let name = self_key.map(|u| u.object_name).unwrap_or_default();
718    let _synched = SynchedObjectData::read(&mut cursor)?;
719    let _underlay = read_key_uoid(&mut cursor)?;
720
721    // hsGMatState: 5 u32s
722    let blend_flags = cursor.read_u32()?;
723    let _clamp_flags = cursor.read_u32()?;
724    let shade_flags = cursor.read_u32()?;
725    let z_flags = cursor.read_u32()?;
726    let misc_flags = cursor.read_u32()?;
727
728    // UV Transform matrix (hsMatrix44: row-major fMap[row][col])
729    // C++ ref: hsMatrix44.cpp:870-882 — ReadBool + optionally 16 LE floats
730    let has_matrix = cursor.read_u8()?;
731    let uv_transform = if has_matrix != 0 {
732        let mut m = [[0.0f32; 4]; 4];
733        for row in &mut m {
734            for val in row.iter_mut() {
735                *val = cursor.read_f32()?;
736            }
737        }
738        Some(m)
739    } else {
740        None
741    };
742    // 4 colors: preshade, runtime, ambient, specular (hsColorRGBA = 4 floats each)
743    // plLayer.cpp:124-127
744    let mut preshade_color = [0.0f32; 4];
745    let mut runtime_color = [0.0f32; 4];
746    let mut ambient_color = [0.0f32; 4];
747    let mut specular_color = [0.0f32; 4];
748    for c in &mut preshade_color { *c = cursor.read_f32()?; }
749    for c in &mut runtime_color { *c = cursor.read_f32()?; }
750    for c in &mut ambient_color { *c = cursor.read_f32()?; }
751    for c in &mut specular_color { *c = cursor.read_f32()?; }
752    // UVWSrc, Opacity
753    let uvwsrc = cursor.read_u32()?;
754    let opacity = cursor.read_f32()?;
755    cursor.skip(8)?; // LODBias + SpecPower
756
757    // Texture key
758    let tex = read_key_uoid(&mut cursor)?;
759    let texture_name = tex.map(|u| u.object_name);
760
761    Ok(LayerState {
762        name,
763        texture_name,
764        blend_flags,
765        shade_flags,
766        misc_flags,
767        z_flags,
768        uv_channel: (uvwsrc & 0xFFFF) as u8,
769        uvw_src_full: uvwsrc,
770        opacity,
771        uv_transform,
772        preshade_color,
773        runtime_color,
774        ambient_color,
775        specular_color,
776    })
777}
778
779/// Parse hsGMaterial to get its compFlags.
780pub fn parse_material_comp_flags(data: &[u8]) -> Result<u32> {
781    use crate::core::uoid::read_key_uoid;
782    use crate::core::synched_object::SynchedObjectData;
783
784    let mut cursor = Cursor::new(data);
785    let class_idx = cursor.read_i16()?;
786    if class_idx < 0 { bail!("Null creatable"); }
787
788    let _self_key = read_key_uoid(&mut cursor)?;
789    let _synched = SynchedObjectData::read(&mut cursor)?;
790
791    let _load_flags = cursor.read_u32()?;
792    let comp_flags = cursor.read_u32()?;
793    Ok(comp_flags)
794}
795
796/// Parse a plLayer to get the UV channel index.
797/// Returns the UVWSrc channel (lower 16 bits).
798pub fn parse_layer_uv_channel(data: &[u8]) -> Result<u8> {
799    use crate::core::uoid::read_key_uoid;
800    use crate::core::synched_object::SynchedObjectData;
801
802    let mut cursor = Cursor::new(data);
803    let class_idx = cursor.read_i16()?;
804    if class_idx < 0 { bail!("Null creatable"); }
805
806    // plLayerInterface::Read
807    let _self_key = read_key_uoid(&mut cursor)?;
808    let _synched = SynchedObjectData::read(&mut cursor)?;
809    let _underlay = read_key_uoid(&mut cursor)?;
810
811    // plLayer::Read — state (5 u32) + matrix + 4 colors + uvwsrc
812    cursor.skip(20)?; // state
813    let has_matrix = cursor.read_u8()?;
814    if has_matrix != 0 { cursor.skip(64)?; }
815    cursor.skip(64)?; // 4 colors
816    let uvwsrc = cursor.read_u32()?;
817
818    Ok((uvwsrc & 0xFFFF) as u8)
819}
820
821/// UV scroll rate extracted from a plLayerAnimation's transform controller.
822/// Represents a constant-rate UV translation per second.
823#[derive(Debug, Clone, Copy)]
824pub struct UvScrollRate {
825    pub u: f32,
826    pub v: f32,
827}
828
829/// Key type constants matching Plasma's hsKeyFrame enum (hsKeys.h)
830#[allow(dead_code)]
831mod key_types {
832    pub const POINT3: u8 = 1;
833    pub const BEZ_POINT3: u8 = 2;
834    pub const SCALAR: u8 = 3;
835    pub const BEZ_SCALAR: u8 = 4;
836    pub const SCALE: u8 = 5;
837    pub const BEZ_SCALE: u8 = 6;
838    pub const QUAT: u8 = 7;
839    pub const COMPRESSED_QUAT32: u8 = 8;
840    pub const COMPRESSED_QUAT64: u8 = 9;
841    pub const MAX_KEY: u8 = 10;
842    pub const MATRIX33: u8 = 11;
843    pub const MATRIX44: u8 = 12;
844}
845
846/// Byte size of a single key for a given key type (frame u16 + value data).
847fn key_size(key_type: u8) -> Option<usize> {
848    match key_type {
849        key_types::POINT3 => Some(2 + 12),          // frame + 3 floats
850        key_types::BEZ_POINT3 => Some(2 + 36),      // frame + 3 tangent pairs + 3 floats
851        key_types::SCALAR => Some(2 + 4),            // frame + 1 float
852        key_types::BEZ_SCALAR => Some(2 + 12),       // frame + inTan + outTan + value
853        key_types::SCALE => Some(2 + 28),            // frame + hsScaleValue (3 floats + quat)
854        key_types::BEZ_SCALE => Some(2 + 52),        // frame + 2 tangents + hsScaleValue
855        key_types::QUAT => Some(2 + 16),             // frame + 4 floats
856        key_types::COMPRESSED_QUAT32 => Some(2 + 4), // frame + u32
857        key_types::COMPRESSED_QUAT64 => Some(2 + 8), // frame + 2 u32
858        key_types::MATRIX33 => Some(2 + 36),          // frame + 9 floats
859        key_types::MATRIX44 => Some(2 + 64),           // frame + 16 floats
860        _ => None,
861    }
862}
863
864/// Controller class IDs as they appear in compiled PRP files.
865/// NOTE: These differ from the open-source plCreatableIndex.h because the game
866/// data was compiled with the original CyanWorlds class numbering, not the
867/// reorganized open-source enum.
868/// Verified empirically from Cleft_District_Cleft.prp binary analysis.
869const LEAF_CONTROLLER: u16 = 0x0230;    // plLeafController
870const COMPOUND_CONTROLLER: u16 = 0x0231; // plCompoundController
871
872/// Skip a plCreatable reference (class_idx + data).
873/// Returns Ok(true) if a creatable was read, Ok(false) if null (0x8000).
874fn skip_creatable(cursor: &mut Cursor<&[u8]>) -> Result<bool> {
875    let class_idx = cursor.read_u16()?;
876    if class_idx == 0x8000 { return Ok(false); } // null creatable
877
878    match class_idx {
879        LEAF_CONTROLLER => {
880            skip_leaf_controller(cursor)?;
881        }
882        COMPOUND_CONTROLLER => {
883            // 3 sub-controllers (X, Y, Z) — used for plCompoundController,
884            // plCompoundRotController, plCompoundPosController, plTMController
885            for _ in 0..3 {
886                skip_creatable(cursor)?;
887            }
888        }
889        _ => {
890            // Unknown controller type — can't determine size to skip.
891            // Return error so callers can handle gracefully.
892            bail!("Unknown controller class 0x{:04X}", class_idx);
893        }
894    }
895    Ok(true)
896}
897
898/// Skip a plLeafController's data (type + numKeys + key data).
899fn skip_leaf_controller(cursor: &mut Cursor<&[u8]>) -> Result<()> {
900    let key_type = cursor.read_u8()?;
901    let num_keys = cursor.read_u32()?;
902    if let Some(ks) = key_size(key_type) {
903        cursor.skip(ks * num_keys as usize)?;
904    } else if key_type == key_types::MAX_KEY {
905        // k3dsMaxKeyFrame — complex, skip by reading hsAffineParts per key
906        // frame(2) + T(12) + Q(16) + U(16) + K(12) + f(4) = 62 bytes per key
907        cursor.skip(62 * num_keys as usize)?;
908    } else {
909        bail!("Unknown key type {} with {} keys", key_type, num_keys);
910    }
911    Ok(())
912}
913
914/// Read a plLeafController's matrix44 keyframes and extract UV scroll rate.
915/// Returns Some(UvScrollRate) if the controller has ≥2 matrix44 keys that
916/// represent a linear UV translation.
917fn read_transform_leaf_controller(cursor: &mut Cursor<&[u8]>) -> Result<Option<UvScrollRate>> {
918    let key_type = cursor.read_u8()?;
919    let num_keys = cursor.read_u32()?;
920
921    if key_type != key_types::MATRIX44 || num_keys < 2 {
922        // Not a matrix44 controller or too few keys — skip
923        if let Some(ks) = key_size(key_type) {
924            cursor.skip(ks * num_keys as usize)?;
925        } else if key_type == key_types::MAX_KEY {
926            cursor.skip(62 * num_keys as usize)?;
927        } else {
928            bail!("Unknown key type {} with {} keys", key_type, num_keys);
929        }
930        return Ok(None);
931    }
932
933    // Read first and last matrix44 keyframes to compute scroll rate.
934    // Format per key: [u16 frame] [16 × f32 matrix] = 66 bytes
935    let frame0 = cursor.read_u16()?;
936    let mat0 = cursor.read_matrix44()?;
937
938    // Skip to last key
939    if num_keys > 2 {
940        let skip_bytes = 66 * (num_keys as usize - 2);
941        let pos = cursor.position() as usize;
942        let data_len = cursor.get_ref().len();
943        if pos + skip_bytes + 66 > data_len {
944            // Not enough data for all keys — object may be truncated
945            return Ok(None);
946        }
947        cursor.skip(skip_bytes)?;
948    }
949
950    let frame_last = cursor.read_u16()?;
951    let mat_last = cursor.read_matrix44()?;
952
953    let dt_frames = frame_last as f32 - frame0 as f32;
954    if dt_frames <= 0.0 { return Ok(None); }
955
956    // Plasma animation runs at 30 fps (MAX_FRAMES_PER_SEC)
957    let dt_secs = dt_frames / 30.0;
958
959    // UV translation is in the matrix translation component.
960    // hsMatrix44 row-major: translation at m[3] (U offset) and m[7] (V offset).
961    let du = mat_last[3] - mat0[3];
962    let dv = mat_last[7] - mat0[7];
963
964    let rate_u = du / dt_secs;
965    let rate_v = dv / dt_secs;
966
967    // Filter out nonsensical values (non-translation matrices, or no scroll)
968    if (rate_u.abs() < 1e-6 && rate_v.abs() < 1e-6) || rate_u.abs() > 100.0 || rate_v.abs() > 100.0 {
969        return Ok(None);
970    }
971
972    Ok(Some(UvScrollRate {
973        u: rate_u,
974        v: rate_v,
975    }))
976}
977
978/// Parse a plLayerAnimation to extract the UV scroll rate from its
979/// transform controller (the 6th controller in the read order).
980///
981/// C++ ref: plLayerAnimation.cpp:79-88 — reads 6 controllers:
982///   fPreshadeColorCtl, fRuntimeColorCtl, fAmbientColorCtl,
983///   fSpecularColorCtl, fOpacityCtl, fTransformCtl
984/// Parsed plLayerAnimation runtime data.
985/// Contains animated property controllers for opacity, color, transform.
986#[derive(Debug, Clone)]
987pub struct LayerAnimData {
988    pub self_key: Option<crate::core::uoid::Uoid>,
989    pub underlay_key: Option<crate::core::uoid::Uoid>,
990    /// Opacity keyframes: (time_secs, value_0_to_100) pairs.
991    pub opacity_keys: Vec<(f32, f32)>,
992    /// Whether this has an opacity controller at all.
993    pub has_opacity: bool,
994    /// Preshade color keyframes: (time_secs, [r, g, b]).
995    /// C++ ref: plController.cpp:221-228 — color uses Point3 keys, maps r=X g=Y b=Z.
996    pub preshade_color_keys: Vec<(f32, [f32; 3])>,
997    /// Runtime color keyframes: (time_secs, [r, g, b]).
998    pub runtime_color_keys: Vec<(f32, [f32; 3])>,
999    /// Ambient color keyframes: (time_secs, [r, g, b]).
1000    pub ambient_color_keys: Vec<(f32, [f32; 3])>,
1001    /// Specular color keyframes: (time_secs, [r, g, b]).
1002    pub specular_color_keys: Vec<(f32, [f32; 3])>,
1003    /// Total animation length in seconds (max of all controller lengths).
1004    /// C++ ref: plLayerAnimationBase::IMakeUniformLength (plLayerAnimation.cpp:276-293)
1005    pub length: f32,
1006    /// SDL variable name (only for plLayerSDLAnimation, 0x00F0).
1007    /// When set, animation time = sdl_value * length.
1008    pub sdl_var_name: Option<String>,
1009}
1010
1011/// Read color keyframes (Point3 keys) from a leaf controller.
1012/// C++ ref: plController.cpp:221-228 — color uses Point3 keys, maps r=X g=Y b=Z.
1013fn read_color_keys(cursor: &mut Cursor<&[u8]>) -> Result<Vec<(f32, [f32; 3])>> {
1014    let mut keys = Vec::new();
1015    let class_id = cursor.read_u16()?;
1016    if class_id == 0x8000 { return Ok(keys); }
1017
1018    if class_id == LEAF_CONTROLLER {
1019        let key_type = cursor.read_u8()?;
1020        let num_keys = cursor.read_u32()?;
1021        if key_type == key_types::POINT3 {
1022            for _ in 0..num_keys {
1023                let frame = cursor.read_u16()? as f32 / 30.0;
1024                let x = cursor.read_f32()?;
1025                let y = cursor.read_f32()?;
1026                let z = cursor.read_f32()?;
1027                keys.push((frame, [x, y, z]));
1028            }
1029        } else if key_type == key_types::BEZ_POINT3 {
1030            for _ in 0..num_keys {
1031                let frame = cursor.read_u16()? as f32 / 30.0;
1032                cursor.skip(24)?; // inTan(3f) + outTan(3f) = 24 bytes
1033                let x = cursor.read_f32()?;
1034                let y = cursor.read_f32()?;
1035                let z = cursor.read_f32()?;
1036                keys.push((frame, [x, y, z]));
1037            }
1038        } else {
1039            if let Some(ks) = key_size(key_type) {
1040                cursor.skip(ks * num_keys as usize)?;
1041            }
1042        }
1043    } else if class_id == COMPOUND_CONTROLLER {
1044        for _ in 0..3 { skip_creatable(cursor)?; }
1045    }
1046    Ok(keys)
1047}
1048
1049/// Read scalar (opacity) keyframes from a leaf controller.
1050fn read_opacity_keys(cursor: &mut Cursor<&[u8]>) -> Result<(bool, Vec<(f32, f32)>)> {
1051    let mut keys = Vec::new();
1052    let class_id = cursor.read_u16()?;
1053    if class_id == 0x8000 { return Ok((false, keys)); }
1054
1055    if class_id == LEAF_CONTROLLER {
1056        let key_type = cursor.read_u8()?;
1057        let num_keys = cursor.read_u32()?;
1058        if key_type == key_types::SCALAR {
1059            for _ in 0..num_keys {
1060                let frame = cursor.read_u16()? as f32 / 30.0;
1061                let value = cursor.read_f32()?;
1062                keys.push((frame, value));
1063            }
1064        } else if key_type == key_types::BEZ_SCALAR {
1065            for _ in 0..num_keys {
1066                let frame = cursor.read_u16()? as f32 / 30.0;
1067                let _in_tan = cursor.read_f32()?;
1068                let _out_tan = cursor.read_f32()?;
1069                let value = cursor.read_f32()?;
1070                keys.push((frame, value));
1071            }
1072        } else {
1073            if let Some(ks) = key_size(key_type) {
1074                cursor.skip(ks * num_keys as usize)?;
1075            }
1076        }
1077    } else if class_id == COMPOUND_CONTROLLER {
1078        for _ in 0..3 { skip_creatable(cursor)?; }
1079    }
1080    Ok((true, keys))
1081}
1082
1083/// Parse a plLayerAnimation to extract all controller keyframes.
1084/// C++ ref: plLayerAnimationBase::Read (plLayerAnimation.cpp:79-120)
1085pub fn parse_layer_animation_full(data: &[u8]) -> Result<LayerAnimData> {
1086    use crate::core::uoid::read_key_uoid;
1087    use crate::core::synched_object::SynchedObjectData;
1088
1089    let mut cursor = Cursor::new(data);
1090    let class_idx = cursor.read_i16()?;
1091    if class_idx < 0 { bail!("Null creatable"); }
1092
1093    let self_key = read_key_uoid(&mut cursor)?;
1094    let _synched = SynchedObjectData::read(&mut cursor)?;
1095    let underlay_key = read_key_uoid(&mut cursor)?;
1096
1097    // Read 4 color controllers: preshade, runtime, ambient, specular
1098    let preshade_color_keys = read_color_keys(&mut cursor)?;
1099    let runtime_color_keys = read_color_keys(&mut cursor)?;
1100    let ambient_color_keys = read_color_keys(&mut cursor)?;
1101    let specular_color_keys = read_color_keys(&mut cursor)?;
1102
1103    // Read 5th controller: opacity
1104    let (has_opacity, opacity_keys) = read_opacity_keys(&mut cursor)?;
1105
1106    // Skip 6th controller: transform
1107    skip_creatable(&mut cursor)?;
1108
1109    // Compute uniform length
1110    let mut length: f32 = 0.0;
1111    if let Some(last) = preshade_color_keys.last() { length = length.max(last.0); }
1112    if let Some(last) = runtime_color_keys.last() { length = length.max(last.0); }
1113    if let Some(last) = ambient_color_keys.last() { length = length.max(last.0); }
1114    if let Some(last) = specular_color_keys.last() { length = length.max(last.0); }
1115    if let Some(last) = opacity_keys.last() { length = length.max(last.0); }
1116
1117    Ok(LayerAnimData {
1118        self_key,
1119        underlay_key,
1120        opacity_keys,
1121        has_opacity,
1122        preshade_color_keys,
1123        runtime_color_keys,
1124        ambient_color_keys,
1125        specular_color_keys,
1126        length,
1127        sdl_var_name: None,
1128    })
1129}
1130
1131pub fn parse_layer_animation_scroll(data: &[u8]) -> Result<Option<UvScrollRate>> {
1132    use crate::core::uoid::read_key_uoid;
1133    use crate::core::synched_object::SynchedObjectData;
1134
1135    let mut cursor = Cursor::new(data);
1136    let class_idx = cursor.read_i16()?;
1137    if class_idx < 0 { bail!("Null creatable"); }
1138
1139    // plLayerInterface::Read: self-key + synched + underlay key
1140    let _self_key = read_key_uoid(&mut cursor)?;
1141    let _synched = SynchedObjectData::read(&mut cursor)?;
1142    let _underlay = read_key_uoid(&mut cursor)?;
1143
1144    // Skip first 5 controllers (preshade, runtime, ambient, specular, opacity)
1145    for _ in 0..5 {
1146        skip_creatable(&mut cursor)?;
1147    }
1148
1149    // Read 6th controller: transform
1150    let xform_class = cursor.read_u16()?;
1151    if xform_class == 0x8000 { return Ok(None); } // no transform controller
1152
1153    match xform_class {
1154        LEAF_CONTROLLER => {
1155            read_transform_leaf_controller(&mut cursor)
1156        }
1157        COMPOUND_CONTROLLER => {
1158            // CompoundController has 3 sub-controllers (X, Y, Z).
1159            // UV scrolling in a compound controller is unusual — skip.
1160            Ok(None)
1161        }
1162        _ => {
1163            // Unknown controller type
1164            Ok(None)
1165        }
1166    }
1167}
1168
1169/// Parse a plLayerSDLAnimation (0x00F0) — same base + SDL var name.
1170/// C++ ref: plLayerSDLAnimation::Read (plLayerAnimation.cpp:725-730)
1171pub fn parse_layer_sdl_animation_full(data: &[u8]) -> Result<LayerAnimData> {
1172    let mut la = parse_layer_animation_full(data)?;
1173    use crate::core::uoid::read_key_uoid;
1174    use crate::core::synched_object::SynchedObjectData;
1175
1176    let mut cursor = Cursor::new(data);
1177    let _class_idx = cursor.read_i16()?;
1178    let _self_key = read_key_uoid(&mut cursor)?;
1179    let _synched = SynchedObjectData::read(&mut cursor)?;
1180    let _underlay = read_key_uoid(&mut cursor)?;
1181    for _ in 0..6 { skip_creatable(&mut cursor)?; }
1182    match cursor.read_safe_string() {
1183        Ok(name) if !name.is_empty() => { la.sdl_var_name = Some(name); }
1184        _ => {}
1185    }
1186    Ok(la)
1187}
1188
1189/// Parse a plLayerAnimation (or plLayerSDLAnimation, plLayerLinkAnimation) to
1190/// extract the underlay key name. The underlay is the plLayer beneath this
1191/// animation that holds the actual texture reference.
1192///
1193/// Format: [i16 classIdx] [plLayerInterface::Read: self_key + synched + underlay_key]
1194///         [6 controller creatables] [plAnimTimeConvert...]
1195///
1196/// We only need the underlay key name.
1197pub fn parse_layer_animation_underlay(data: &[u8]) -> Result<Option<crate::core::uoid::Uoid>> {
1198    use crate::core::uoid::read_key_uoid;
1199    use crate::core::synched_object::SynchedObjectData;
1200
1201    let mut cursor = Cursor::new(data);
1202    let class_idx = cursor.read_i16()?;
1203    if class_idx < 0 { bail!("Null creatable"); }
1204
1205    // plLayerInterface::Read: self-key + synched + underlay key
1206    // Use the correct (fixed) parsers from core::uoid
1207    let _self_key = read_key_uoid(&mut cursor)?;
1208    let _synched = SynchedObjectData::read(&mut cursor)?;
1209    let underlay = read_key_uoid(&mut cursor)?;
1210    Ok(underlay)
1211}
1212
1213#[cfg(test)]
1214mod scroll_tests {
1215    use super::*;
1216    use std::path::Path;
1217
1218    #[test]
1219    fn test_parse_cleft_layer_animation_scroll() {
1220        let path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
1221        if !path.exists() {
1222            eprintln!("Skipping test: {:?} not found", path);
1223            return;
1224        }
1225        let page = PrpPage::from_file(path).unwrap();
1226        let mut parsed = 0;
1227        let mut ok = 0;
1228        let mut errs = 0;
1229        let mut with_scroll = 0;
1230
1231        for key in page.keys_of_type(0x0043) { // plLayerAnimation
1232            if let Some(data) = page.object_data(key) {
1233                parsed += 1;
1234                match parse_layer_animation_scroll(data) {
1235                    Ok(Some(scroll)) => {
1236                        with_scroll += 1;
1237                        ok += 1;
1238                        eprintln!("  scroll '{}': u={:.4} v={:.4}", key.object_name, scroll.u, scroll.v);
1239                    }
1240                    Ok(None) => { ok += 1; }
1241                    Err(e) => {
1242                        errs += 1;
1243                        if errs <= 3 {
1244                            let hex: Vec<String> = data.iter().take(100).map(|b| format!("{:02x}", b)).collect();
1245                            eprintln!("  ERR '{}': {} (data: {}...)", key.object_name, e, hex.join(" "));
1246                        }
1247                    }
1248                }
1249            }
1250        }
1251        eprintln!("plLayerAnimation: {} total, {} ok, {} with scroll, {} errors",
1252            parsed, ok, with_scroll, errs);
1253
1254        // Also test Teledahn which has water
1255        let path2 = Path::new("../../Plasma/staging/client/dat/Teledahn_District_Teledahn.prp");
1256        if !path2.exists() {
1257            eprintln!("Skipping Teledahn test");
1258            return;
1259        }
1260        let page2 = PrpPage::from_file(path2).unwrap();
1261        let mut t_parsed = 0;
1262        let mut t_ok = 0;
1263        let mut t_scroll = 0;
1264        let mut t_err = 0;
1265        for key in page2.keys_of_type(0x0043) {
1266            if let Some(data) = page2.object_data(key) {
1267                t_parsed += 1;
1268                match parse_layer_animation_scroll(data) {
1269                    Ok(Some(scroll)) => {
1270                        t_scroll += 1;
1271                        t_ok += 1;
1272                        eprintln!("  Teledahn scroll '{}': u={:.4} v={:.4}", key.object_name, scroll.u, scroll.v);
1273                    }
1274                    Ok(None) => { t_ok += 1; }
1275                    Err(_) => { t_err += 1; }
1276                }
1277            }
1278        }
1279        eprintln!("Teledahn plLayerAnimation: {} total, {} ok, {} with scroll, {} errors",
1280            t_parsed, t_ok, t_scroll, t_err);
1281    }
1282
1283    #[test]
1284    fn test_parse_layer_animation_color_keys() {
1285        let path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
1286        if !path.exists() { eprintln!("Skipping: {:?} not found", path); return; }
1287        let page = PrpPage::from_file(path).unwrap();
1288        let mut parsed = 0;
1289        let mut with_color = 0;
1290        let mut errors = 0;
1291        for key in page.keys_of_type(0x0043) {
1292            if let Some(data) = page.object_data(key) {
1293                parsed += 1;
1294                match parse_layer_animation_full(data) {
1295                    Ok(la) => {
1296                        let c = la.preshade_color_keys.len() + la.runtime_color_keys.len()
1297                            + la.ambient_color_keys.len() + la.specular_color_keys.len();
1298                        if c > 0 { with_color += 1; }
1299                    }
1300                    Err(_) => { errors += 1; }
1301                }
1302            }
1303        }
1304        eprintln!("Color test: {} total, {} with color, {} errors", parsed, with_color, errors);
1305        assert!(errors <= 2);
1306    }
1307
1308    #[test]
1309    fn test_parse_layer_sdl_animation() {
1310        let path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
1311        if !path.exists() { eprintln!("Skipping: {:?} not found", path); return; }
1312        let page = PrpPage::from_file(path).unwrap();
1313        let mut parsed = 0;
1314        let mut errors = 0;
1315        for key in page.keys_of_type(0x00F0) {
1316            if let Some(data) = page.object_data(key) {
1317                parsed += 1;
1318                match parse_layer_sdl_animation_full(data) {
1319                    Ok(la) => {
1320                        eprintln!("  SDL '{}': var={:?} len={:.2}s", key.object_name, la.sdl_var_name, la.length);
1321                    }
1322                    Err(_) => { errors += 1; }
1323                }
1324            }
1325        }
1326        eprintln!("SDL anim: {} total, {} errors", parsed, errors);
1327        if parsed > 0 { assert_eq!(errors, 0); }
1328    }
1329}
1330
1331// ============================================================================
1332// plMipmap / plBitmap data extraction
1333// Translated from plMipmap::Read() in plMipmap.cpp, plBitmap::Read() in plBitmap.cpp
1334// ============================================================================
1335
1336/// plBitmap compression types.
1337/// Translated from plBitmap enum in plBitmap.h
1338pub mod bitmap_compression {
1339    pub const UNCOMPRESSED: u8 = 0x0;
1340    pub const DIRECTX_COMPRESSION: u8 = 0x1;
1341    pub const JPEG_COMPRESSION: u8 = 0x2;
1342    pub const PNG_COMPRESSION: u8 = 0x3;
1343}
1344
1345/// plBitmap::DirectXInfo::CompressionType.
1346/// Translated from plBitmap::DirectXInfo enum in plBitmap.h
1347pub mod dxt_type {
1348    pub const ERROR: u8 = 0x0;
1349    pub const DXT1: u8 = 0x1;
1350    // DXT2 = 0x2, DXT3 = 0x3, DXT4 = 0x4 — unused in Plasma
1351    pub const DXT5: u8 = 0x5;
1352}
1353
1354/// plBitmap::UncompressedInfo::Type.
1355/// Translated from plBitmap::UncompressedInfo enum in plBitmap.h
1356pub mod uncompressed_type {
1357    pub const RGB8888: u8 = 0x00;
1358    pub const RGB4444: u8 = 0x01;
1359    pub const RGB1555: u8 = 0x02;
1360    pub const INTEN8: u8 = 0x03;
1361    pub const A_INTEN88: u8 = 0x04;
1362}
1363
1364#[derive(Debug, Clone)]
1365pub struct MipmapData {
1366    pub name: String,
1367    pub width: u32,
1368    pub height: u32,
1369    /// plBitmap::fCompressionType — 0=uncompressed, 1=DirectX, 2=JPEG, 3=PNG
1370    pub compression: u8,
1371    /// For DirectX: DXT type (1=DXT1, 5=DXT5). For uncompressed: pixel type.
1372    pub pixel_format: u8,
1373    /// plBitmap::DirectXInfo::fBlockSize — 8 for DXT1, 16 for DXT5
1374    pub block_size: u8,
1375    /// plBitmap::fPixelSize — bits per pixel (8, 16, 32)
1376    pub pixel_size: u8,
1377    pub num_levels: u8,
1378    /// Raw pixel data for all mip levels, contiguous.
1379    /// Level 0 at offset 0, level 1 at offset level_sizes[0], etc.
1380    pub pixel_data: Vec<u8>,
1381    /// Size of each mip level in bytes.
1382    /// Translated from plMipmap::IBuildLevelSizes() in plMipmap.cpp
1383    pub level_sizes: Vec<u32>,
1384    /// plMipmap::fRowBytes — bytes per row at level 0
1385    pub row_bytes: u32,
1386}
1387
1388impl MipmapData {
1389    /// Software-decode DXT1/DXT5 compressed texture to RGBA8 bytes.
1390    /// Returns None for uncompressed or unsupported formats.
1391    pub fn decode_to_rgba(&self) -> Option<Vec<u8>> {
1392        if self.compression != 1 { // Not DirectX compressed
1393            // Uncompressed 32-bit: Plasma stores as BGRA (hsColor32 { b, g, r, a }),
1394            // swizzle to RGBA for wgpu Rgba8Unorm format
1395            if self.pixel_size == 32 && !self.pixel_data.is_empty() {
1396                let pixel_count = (self.width * self.height) as usize;
1397                let expected = pixel_count * 4;
1398                if self.pixel_data.len() < expected { return None; }
1399                let mut rgba = vec![0u8; expected];
1400                for i in 0..pixel_count {
1401                    let s = i * 4;
1402                    rgba[s]     = self.pixel_data[s + 2]; // R ← byte 2
1403                    rgba[s + 1] = self.pixel_data[s + 1]; // G ← byte 1
1404                    rgba[s + 2] = self.pixel_data[s];     // B ← byte 0
1405                    rgba[s + 3] = self.pixel_data[s + 3]; // A ← byte 3
1406                }
1407                return Some(rgba);
1408            }
1409            return None;
1410        }
1411        let w = self.width as usize;
1412        let h = self.height as usize;
1413        if w == 0 || h == 0 { return None; }
1414        let blocks_wide = (w + 3) / 4;
1415        let blocks_high = (h + 3) / 4;
1416        let is_dxt5 = self.pixel_format == 5;
1417        let block_size: usize = if is_dxt5 { 16 } else { 8 };
1418        let needed = blocks_wide * blocks_high * block_size;
1419        if self.pixel_data.len() < needed { return None; }
1420
1421        let mut rgba = vec![0u8; w * h * 4];
1422        let expand5 = |v: u8| -> u8 { (v << 3) | (v >> 2) };
1423        let expand6 = |v: u8| -> u8 { (v << 2) | (v >> 4) };
1424
1425        for by in 0..blocks_high {
1426            for bx in 0..blocks_wide {
1427                let block_off = (by * blocks_wide + bx) * block_size;
1428                let color_off = if is_dxt5 { block_off + 8 } else { block_off };
1429                let cb = &self.pixel_data[color_off..color_off + 8];
1430                let c0 = u16::from_le_bytes([cb[0], cb[1]]);
1431                let c1 = u16::from_le_bytes([cb[2], cb[3]]);
1432                let (r0, g0, b0) = ((c0 >> 11) as u8 & 0x1F, (c0 >> 5) as u8 & 0x3F, c0 as u8 & 0x1F);
1433                let (r1, g1, b1) = ((c1 >> 11) as u8 & 0x1F, (c1 >> 5) as u8 & 0x3F, c1 as u8 & 0x1F);
1434
1435                let colors: [[u8; 4]; 4] = if c0 > c1 {
1436                    let a = [expand5(r0), expand6(g0), expand5(b0), 255];
1437                    let b = [expand5(r1), expand6(g1), expand5(b1), 255];
1438                    let c2 = [((2*a[0] as u16+b[0] as u16)/3) as u8, ((2*a[1] as u16+b[1] as u16)/3) as u8, ((2*a[2] as u16+b[2] as u16)/3) as u8, 255];
1439                    let c3 = [((a[0] as u16+2*b[0] as u16)/3) as u8, ((a[1] as u16+2*b[1] as u16)/3) as u8, ((a[2] as u16+2*b[2] as u16)/3) as u8, 255];
1440                    [a, b, c2, c3]
1441                } else {
1442                    let a = [expand5(r0), expand6(g0), expand5(b0), 255];
1443                    let b = [expand5(r1), expand6(g1), expand5(b1), 255];
1444                    let c2 = [((a[0] as u16+b[0] as u16)/2) as u8, ((a[1] as u16+b[1] as u16)/2) as u8, ((a[2] as u16+b[2] as u16)/2) as u8, 255];
1445                    [a, b, c2, [0, 0, 0, 0]]
1446                };
1447                let indices = u32::from_le_bytes([cb[4], cb[5], cb[6], cb[7]]);
1448
1449                let alpha_block: [u8; 16] = if is_dxt5 {
1450                    let ab = &self.pixel_data[block_off..block_off + 8];
1451                    let (a0, a1) = (ab[0], ab[1]);
1452                    let mut alphas = [0u8; 8];
1453                    alphas[0] = a0; alphas[1] = a1;
1454                    if a0 > a1 {
1455                        for i in 1..7u16 { alphas[(i+1) as usize] = (((7-i)*a0 as u16 + i*a1 as u16)/7) as u8; }
1456                    } else {
1457                        for i in 1..5u16 { alphas[(i+1) as usize] = (((5-i)*a0 as u16 + i*a1 as u16)/5) as u8; }
1458                        alphas[6] = 0; alphas[7] = 255;
1459                    }
1460                    let idx_bits = (ab[2] as u64)|((ab[3] as u64)<<8)|((ab[4] as u64)<<16)|((ab[5] as u64)<<24)|((ab[6] as u64)<<32)|((ab[7] as u64)<<40);
1461                    let mut result = [255u8; 16];
1462                    for i in 0..16 { result[i] = alphas[((idx_bits >> (i as u64 * 3)) & 7) as usize]; }
1463                    result
1464                } else { [255; 16] };
1465
1466                for py in 0..4 {
1467                    for px in 0..4 {
1468                        let (x, y) = (bx * 4 + px, by * 4 + py);
1469                        if x >= w || y >= h { continue; }
1470                        let pi = py * 4 + px;
1471                        let ci = ((indices >> (pi * 2)) & 3) as usize;
1472                        let out = y * w * 4 + x * 4;
1473                        rgba[out] = colors[ci][0]; rgba[out+1] = colors[ci][1];
1474                        rgba[out+2] = colors[ci][2]; rgba[out+3] = alpha_block[pi];
1475                    }
1476                }
1477            }
1478        }
1479        Some(rgba)
1480    }
1481
1482    /// Parse a plMipmap from raw object data.
1483    /// Translated from plMipmap::Read() in plMipmap.cpp
1484    /// Inheritance: plMipmap : plBitmap : hsKeyedObject (NO plSynchedObject!)
1485    pub fn from_object_data(data: &[u8], name: &str) -> Result<Self> {
1486        let mut cursor = Cursor::new(data);
1487
1488        // Creatable class index (hsKeyedObject prologue)
1489        let class_idx = cursor.read_i16()?;
1490        if class_idx < 0 {
1491            bail!("Null creatable");
1492        }
1493
1494        // hsKeyedObject::Read — key reference
1495        read_key_name(&mut cursor)?;
1496
1497        // === plBitmap::Read(stream) ===
1498        // Translated from plBitmap::Read() in plBitmap.cpp
1499        let version = cursor.read_u8()?;  // sBitmapVersion, must be 2
1500        if version != 2 {
1501            bail!("Unsupported bitmap version: {} (expected 2)", version);
1502        }
1503        let pixel_size = cursor.read_u8()?;    // fPixelSize (bits per pixel)
1504        let _space = cursor.read_u8()?;         // fSpace
1505        let _flags = cursor.read_u16()?;        // fFlags
1506        let compression_type = cursor.read_u8()?; // fCompressionType
1507
1508        // Compression-specific info — union in C++
1509        // Translated from plBitmap::Read() branch on fCompressionType
1510        let (pixel_format, block_size) = match compression_type {
1511            bitmap_compression::UNCOMPRESSED |
1512            bitmap_compression::JPEG_COMPRESSION |
1513            bitmap_compression::PNG_COMPRESSION => {
1514                // fUncompressedInfo.fType (1 byte)
1515                let fmt = cursor.read_u8()?;
1516                (fmt, 0u8)
1517            }
1518            bitmap_compression::DIRECTX_COMPRESSION => {
1519                // fDirectXInfo: fBlockSize (1 byte) + fCompressionType (1 byte)
1520                let bs = cursor.read_u8()?;
1521                let ct = cursor.read_u8()?;
1522                (ct, bs)
1523            }
1524            _ => {
1525                bail!("Unknown compression type: {}", compression_type);
1526            }
1527        };
1528
1529        // Modification timestamps
1530        let _low_modified_time = cursor.read_u32()?;
1531        let _high_modified_time = cursor.read_u32()?;
1532
1533        // === plMipmap::Read(stream) ===
1534        // Translated from plMipmap::Read() in plMipmap.cpp
1535        let width = cursor.read_u32()?;
1536        let height = cursor.read_u32()?;
1537        let row_bytes = cursor.read_u32()?;
1538        let total_size = cursor.read_u32()?;
1539        let num_levels = cursor.read_u8()?;
1540
1541        if width == 0 || height == 0 {
1542            return Ok(Self {
1543                name: name.to_string(), width, height,
1544                compression: compression_type, pixel_format, block_size,
1545                pixel_size, num_levels, pixel_data: Vec::new(),
1546                level_sizes: Vec::new(), row_bytes,
1547            });
1548        }
1549
1550        // IBuildLevelSizes() — compute per-level sizes
1551        // Translated from plMipmap::IBuildLevelSizes() in plMipmap.cpp
1552        let level_sizes = build_level_sizes(
1553            width, height, row_bytes, num_levels,
1554            compression_type, block_size,
1555        );
1556
1557        // Read pixel data based on compression type
1558        // Translated from plMipmap::Read() dispatch in plMipmap.cpp
1559        let mut pixel_data = Vec::new();
1560        if total_size > 0 {
1561            let remaining = data.len().saturating_sub(cursor.position() as usize);
1562            let read_size = (total_size as usize).min(remaining);
1563
1564            match compression_type {
1565                bitmap_compression::DIRECTX_COMPRESSION => {
1566                    // DirectX: raw read of fTotalSize bytes
1567                    // Translated from plMipmap::Read() — stream->Read(fTotalSize, fImage)
1568                    pixel_data = vec![0u8; read_size];
1569                    cursor.read_exact(&mut pixel_data)?;
1570                }
1571                bitmap_compression::UNCOMPRESSED => {
1572                    // IReadRawImage — reads level-by-level with endian swap
1573                    // Translated from plMipmap::IReadRawImage() in plMipmap.cpp
1574                    // On LE systems (x86/ARM), the u32/u16 reads are no-ops.
1575                    // We read raw bytes since our target is always LE.
1576                    pixel_data = vec![0u8; read_size];
1577                    cursor.read_exact(&mut pixel_data)?;
1578                }
1579                bitmap_compression::JPEG_COMPRESSION => {
1580                    // IReadJPEGImage — JPEG decompression not yet implemented.
1581                    // Read raw bytes for now; GPU upload will skip these.
1582                    pixel_data = vec![0u8; read_size];
1583                    cursor.read_exact(&mut pixel_data)?;
1584                    log::debug!("JPEG texture '{}' ({}x{}) — decompression not implemented",
1585                        name, width, height);
1586                }
1587                bitmap_compression::PNG_COMPRESSION => {
1588                    // IReadPNGImage — PNG decompression not yet implemented.
1589                    pixel_data = vec![0u8; read_size];
1590                    cursor.read_exact(&mut pixel_data)?;
1591                    log::debug!("PNG texture '{}' ({}x{}) — decompression not implemented",
1592                        name, width, height);
1593                }
1594                _ => {
1595                    bail!("Unknown compression type: {}", compression_type);
1596                }
1597            }
1598        }
1599
1600        Ok(Self {
1601            name: name.to_string(),
1602            width,
1603            height,
1604            compression: compression_type,
1605            pixel_format,
1606            block_size,
1607            pixel_size,
1608            num_levels,
1609            pixel_data,
1610            level_sizes,
1611            row_bytes,
1612        })
1613    }
1614
1615    /// Parse a plCubicEnvironmap and extract the first face as a regular mipmap.
1616    /// C++ ref: plCubicEnvironmap::Read (plCubicEnvironmap.cpp:99-110)
1617    /// Format: hsKeyedObject + plBitmap (outer) + 6 × plMipmap (face)
1618    /// Each face's plMipmap::Read includes its own plBitmap::Read + mipmap data.
1619    /// Read all 6 faces from a plCubicEnvironmap.
1620    /// C++ ref: plCubicEnvironmap.cpp:99-110 — reads 6 plMipmap faces in order:
1621    /// kLeftFace=0, kRightFace=1, kFrontFace=2, kBackFace=3, kTopFace=4, kBottomFace=5
1622    pub fn from_cubic_envmap_data(data: &[u8], name: &str) -> Result<Vec<Self>> {
1623        let mut cursor = Cursor::new(data);
1624
1625        // hsKeyedObject prologue
1626        let class_idx = cursor.read_i16()?;
1627        if class_idx < 0 { bail!("Null creatable"); }
1628        read_key_name(&mut cursor)?;
1629
1630        // Outer plBitmap::Read — skip (face bitmaps have their own headers)
1631        Self::skip_bitmap_header(&mut cursor)?;
1632
1633        // Read all 6 faces
1634        let mut faces = Vec::with_capacity(6);
1635        for i in 0..6 {
1636            let face_name = format!("{}_face{}", name, i);
1637            let face = Self::read_mipmap_from_cursor(&mut cursor, &face_name)?;
1638            faces.push(face);
1639        }
1640        Ok(faces)
1641    }
1642
1643    /// Skip a plBitmap header in the stream.
1644    fn skip_bitmap_header(cursor: &mut Cursor<&[u8]>) -> Result<()> {
1645        let _version = cursor.read_u8()?;
1646        let _pixel_size = cursor.read_u8()?;
1647        let _space = cursor.read_u8()?;
1648        let _flags = cursor.read_u16()?;
1649        let compression_type = cursor.read_u8()?;
1650        match compression_type {
1651            bitmap_compression::UNCOMPRESSED |
1652            bitmap_compression::JPEG_COMPRESSION |
1653            bitmap_compression::PNG_COMPRESSION => { cursor.skip(1)?; }
1654            bitmap_compression::DIRECTX_COMPRESSION => { cursor.skip(2)?; }
1655            _ => { bail!("Unknown compression type: {}", compression_type); }
1656        }
1657        cursor.skip(8)?; // timestamps
1658        Ok(())
1659    }
1660
1661    /// Read a plMipmap (plBitmap header + mipmap data) from the current cursor position.
1662    fn read_mipmap_from_cursor(cursor: &mut Cursor<&[u8]>, name: &str) -> Result<Self> {
1663        // plBitmap::Read
1664        let version = cursor.read_u8()?;
1665        if version != 2 { bail!("Unsupported bitmap version: {}", version); }
1666        let pixel_size = cursor.read_u8()?;
1667        let _space = cursor.read_u8()?;
1668        let _flags = cursor.read_u16()?;
1669        let compression_type = cursor.read_u8()?;
1670        let (pixel_format, block_size) = match compression_type {
1671            bitmap_compression::UNCOMPRESSED |
1672            bitmap_compression::JPEG_COMPRESSION |
1673            bitmap_compression::PNG_COMPRESSION => { (cursor.read_u8()?, 0u8) }
1674            bitmap_compression::DIRECTX_COMPRESSION => {
1675                let bs = cursor.read_u8()?;
1676                let ct = cursor.read_u8()?;
1677                (ct, bs)
1678            }
1679            _ => { bail!("Unknown compression type: {}", compression_type); }
1680        };
1681        cursor.skip(8)?; // timestamps
1682
1683        // plMipmap data
1684        let width = cursor.read_u32()?;
1685        let height = cursor.read_u32()?;
1686        let row_bytes = cursor.read_u32()?;
1687        let total_size = cursor.read_u32()?;
1688        let num_levels = cursor.read_u8()?;
1689
1690        if width == 0 || height == 0 || total_size == 0 {
1691            return Ok(Self {
1692                name: name.to_string(), width, height,
1693                compression: compression_type, pixel_format, block_size,
1694                pixel_size, num_levels, pixel_data: Vec::new(),
1695                level_sizes: Vec::new(), row_bytes,
1696            });
1697        }
1698
1699        let level_sizes = build_level_sizes(width, height, row_bytes, num_levels, compression_type, block_size);
1700        let remaining = cursor.get_ref().len().saturating_sub(cursor.position() as usize);
1701        let read_size = (total_size as usize).min(remaining);
1702        let mut pixel_data = vec![0u8; read_size];
1703        cursor.read_exact(&mut pixel_data)?;
1704
1705        Ok(Self {
1706            name: name.to_string(), width, height,
1707            compression: compression_type, pixel_format, block_size,
1708            pixel_size, num_levels, pixel_data, level_sizes, row_bytes,
1709        })
1710    }
1711}
1712
1713/// Compute per-level byte sizes for all mip levels.
1714/// Translated from plMipmap::IBuildLevelSizes() in plMipmap.cpp
1715fn build_level_sizes(
1716    width: u32, height: u32, row_bytes: u32, num_levels: u8,
1717    compression_type: u8, block_size: u8,
1718) -> Vec<u32> {
1719    let mut sizes = Vec::with_capacity(num_levels as usize);
1720    let mut w = width;
1721    let mut h = height;
1722    let mut rb = row_bytes;
1723
1724    for _ in 0..num_levels {
1725        let level_size = if compression_type == bitmap_compression::DIRECTX_COMPRESSION {
1726            // Translated from plMipmap::IBuildLevelSizes() DXT path
1727            if (w | h) & 0x03 != 0 {
1728                // Sub-4x4 block: use height × rowBytes
1729                h * rb
1730            } else {
1731                // Full blocks: (height × width × blockSize) >> 4
1732                (h * w * block_size as u32) >> 4
1733            }
1734        } else {
1735            // Uncompressed / JPEG / PNG: height × rowBytes
1736            h * rb
1737        };
1738
1739        sizes.push(level_size);
1740
1741        // Scale down for next level
1742        // Translated from plMipmap::IBuildLevelSizes() mip reduction
1743        if w > 1 { w >>= 1; rb >>= 1; }
1744        if h > 1 { h >>= 1; }
1745    }
1746
1747    sizes
1748}
1749
1750// ============================================================================
1751// plPythonFileMod — parse script filename, receivers, and parameters
1752// ============================================================================
1753
1754/// Parsed parameter from a plPythonFileMod.
1755/// C++ ref: plPythonParameter.h:59-475
1756#[derive(Debug, Clone)]
1757pub enum PythonParamValue {
1758    Int(i32),
1759    Float(f32),
1760    Bool(bool),
1761    String(String),
1762    Key(Option<crate::core::uoid::Uoid>),
1763    None,
1764}
1765
1766/// A single script parameter (id + typed value).
1767#[derive(Debug, Clone)]
1768pub struct PythonParam {
1769    pub id: i32,
1770    pub value_type: i32,
1771    pub value: PythonParamValue,
1772}
1773
1774/// Parsed plPythonFileMod data from a PRP object.
1775#[derive(Debug, Clone)]
1776pub struct PythonFileModData {
1777    /// Script filename (e.g. "Cleft.py")
1778    pub script_file: String,
1779    /// Receiver keys — scene objects that notify this script
1780    pub receivers: Vec<crate::core::uoid::Uoid>,
1781    /// Script parameters set in 3ds Max
1782    pub parameters: Vec<PythonParam>,
1783    /// The Uoid of the plPythonFileMod object itself (for receiver matching)
1784    pub self_key: Option<crate::core::uoid::Uoid>,
1785}
1786
1787/// Parse a plPythonFileMod from raw PRP object data.
1788///
1789/// Serialization chain:
1790///   class_idx(i16) → hsKeyedObject(self-key) → plSynchedObject → plMultiModifier(hsBitVector) →
1791///   SafeString(filename) → u32(receiver count) → keys → u32(param count) → params
1792///
1793/// C++ ref: plPythonFileMod.cpp:1686-1704, plPythonParameter.h:362-421
1794pub fn parse_python_file_mod(data: &[u8]) -> Result<PythonFileModData> {
1795    use crate::core::uoid::read_key_uoid;
1796
1797    let mut cursor = Cursor::new(data);
1798
1799    // Creatable class index (i16)
1800    let _class_idx = cursor.read_i16()?;
1801
1802    // hsKeyedObject::Read — self-key
1803    let self_key = read_key_uoid(&mut cursor)?;
1804
1805    // plSynchedObject::Read
1806    skip_synched_object(&mut cursor)?;
1807
1808    // plMultiModifier::Read — hsBitVector
1809    // C++ ref: hsBitVector.cpp:90-101 — u32 count, then count × u32 words
1810    let num_bit_vectors = cursor.read_u32()?;
1811    for _ in 0..num_bit_vectors {
1812        let _word = cursor.read_u32()?;
1813    }
1814
1815    // plPythonFileMod data
1816    let script_file = cursor.read_safe_string()?;
1817
1818    // Receiver keys
1819    let num_receivers = cursor.read_u32()?;
1820    let mut receivers = Vec::with_capacity(num_receivers as usize);
1821    for _ in 0..num_receivers {
1822        if let Some(uoid) = read_key_uoid(&mut cursor)? {
1823            receivers.push(uoid);
1824        }
1825    }
1826
1827    // Parameters
1828    let num_params = cursor.read_u32()?;
1829    let mut parameters = Vec::with_capacity(num_params as usize);
1830    for _ in 0..num_params {
1831        let id = cursor.read_i32()?;
1832        let value_type = cursor.read_i32()?;
1833
1834        // C++ plPythonParameter::valueType enum starts at kInt=1
1835        let value = match value_type {
1836            1 => { // kInt
1837                let v = cursor.read_i32()?;
1838                PythonParamValue::Int(v)
1839            }
1840            2 => { // kFloat
1841                let v = cursor.read_f32()?;
1842                PythonParamValue::Float(v)
1843            }
1844            3 => { // kbool — C++ ReadBOOL() reads 4 bytes (hsStream.h:90)
1845                let v = cursor.read_u32()?;
1846                PythonParamValue::Bool(v != 0)
1847            }
1848            4 | 13 => { // kString, kAnimationName
1849                let count = cursor.read_u32()? as usize;
1850                if count > 0 {
1851                    let mut buf = vec![0u8; count - 1];
1852                    cursor.read_exact(&mut buf)?;
1853                    let _null = cursor.read_u8()?; // consume null terminator
1854                    PythonParamValue::String(String::from_utf8_lossy(&buf).into_owned())
1855                } else {
1856                    PythonParamValue::String(String::new())
1857                }
1858            }
1859            5..=12 | 14..=23 => { // All key types
1860                let uoid = read_key_uoid(&mut cursor)?;
1861                PythonParamValue::Key(uoid)
1862            }
1863            24 => { // kNone
1864                PythonParamValue::None
1865            }
1866            _ => {
1867                log::warn!("Unknown plPythonParameter type {}", value_type);
1868                PythonParamValue::None
1869            }
1870        };
1871
1872        parameters.push(PythonParam { id, value_type, value });
1873    }
1874
1875    Ok(PythonFileModData {
1876        script_file,
1877        receivers,
1878        parameters,
1879        self_key,
1880    })
1881}
1882
1883// ============================================================================
1884// plInterfaceInfoModifier — identifies clickable objects
1885// ============================================================================
1886
1887/// Parsed plInterfaceInfoModifier: marks an object as clickable and lists its logic modifiers.
1888/// C++ ref: plInterfaceInfoModifier.cpp:60-67
1889#[derive(Debug, Clone)]
1890pub struct InterfaceInfoModData {
1891    pub self_key: Option<crate::core::uoid::Uoid>,
1892    /// Keys to plLogicModifier instances on this clickable object.
1893    pub logic_keys: Vec<crate::core::uoid::Uoid>,
1894}
1895
1896/// Parse plInterfaceInfoModifier from PRP object data.
1897/// Format: plSingleModifier::Read (self-key + synched + hsBitVector) → u32 key_count → keys
1898pub fn parse_interface_info_modifier(data: &[u8]) -> Result<InterfaceInfoModData> {
1899    use crate::core::uoid::read_key_uoid;
1900    let mut cursor = Cursor::new(data);
1901
1902    let _class_idx = cursor.read_i16()?;
1903    let self_key = read_key_uoid(&mut cursor)?;
1904    skip_synched_object(&mut cursor)?;
1905    // hsBitVector fFlags
1906    let n_words = cursor.read_u32()?;
1907    for _ in 0..n_words { let _ = cursor.read_u32()?; }
1908
1909    let key_count = cursor.read_u32()?;
1910    let mut logic_keys = Vec::with_capacity(key_count as usize);
1911    for _ in 0..key_count {
1912        if let Some(uoid) = read_key_uoid(&mut cursor)? {
1913            logic_keys.push(uoid);
1914        }
1915    }
1916
1917    Ok(InterfaceInfoModData { self_key, logic_keys })
1918}
1919
1920// ============================================================================
1921// plOneShotMod — one-shot avatar animation modifier
1922// ============================================================================
1923
1924/// Parsed plOneShotMod: holds animation name and seek parameters for one-shot behaviors.
1925/// C++ ref: plOneShotMod.h:54-82, plOneShotMod.cpp:154-165
1926#[derive(Debug, Clone)]
1927pub struct OneShotModData {
1928    pub self_key: Option<crate::core::uoid::Uoid>,
1929    /// The name of the animation to play (e.g., "ClickTurnLeft").
1930    pub anim_name: String,
1931    /// How long to seek to the one-shot position (seconds).
1932    pub seek_duration: f32,
1933    /// Whether the user can control animation progress.
1934    pub drivable: bool,
1935    /// Whether the user can reverse the animation.
1936    pub reversable: bool,
1937    /// Use smart seek to walk to the seek point.
1938    pub smart_seek: bool,
1939    /// Skip seeking entirely (teleport to position).
1940    pub no_seek: bool,
1941}
1942
1943/// Parse plOneShotMod from PRP object data.
1944/// Format: plMultiModifier::Read (self-key + synched + hsBitVector) → SafeString animName
1945///         → f32 seekDuration → bool×4 (drivable, reversable, smartSeek, noSeek)
1946/// C++ ref: plOneShotMod.cpp:154-165
1947pub fn parse_one_shot_mod(data: &[u8]) -> Result<OneShotModData> {
1948    use crate::core::uoid::read_key_uoid;
1949    let mut cursor = Cursor::new(data);
1950
1951    // Creatable class index (i16)
1952    let _class_idx = cursor.read_i16()?;
1953
1954    // hsKeyedObject::Read — self-key
1955    let self_key = read_key_uoid(&mut cursor)?;
1956
1957    // plSynchedObject::Read
1958    skip_synched_object(&mut cursor)?;
1959
1960    // plMultiModifier::Read — hsBitVector fFlags
1961    let num_bit_vectors = cursor.read_u32()?;
1962    for _ in 0..num_bit_vectors {
1963        let _word = cursor.read_u32()?;
1964    }
1965
1966    // plOneShotMod data
1967    let anim_name = cursor.read_safe_string()?;
1968    let seek_duration = cursor.read_f32()?;
1969    let drivable = cursor.read_u8()? != 0;     // ReadBool = 1 byte
1970    let reversable = cursor.read_u8()? != 0;
1971    let smart_seek = cursor.read_u8()? != 0;
1972    let no_seek = cursor.read_u8()? != 0;
1973
1974    Ok(OneShotModData {
1975        self_key,
1976        anim_name,
1977        seek_duration,
1978        drivable,
1979        reversable,
1980        smart_seek,
1981        no_seek,
1982    })
1983}
1984
1985// ============================================================================
1986// plLogicModifier — evaluates conditions and sends plNotifyMsg
1987// ============================================================================
1988
1989/// Parsed plLogicModifier — enough data for click dispatch.
1990/// C++ ref: plLogicModifier.cpp:259-271, plLogicModBase.cpp:280-297
1991#[derive(Debug, Clone)]
1992pub struct LogicModData {
1993    pub self_key: Option<crate::core::uoid::Uoid>,
1994    /// Notify receiver keys (from the embedded plNotifyMsg).
1995    pub notify_receivers: Vec<crate::core::uoid::Uoid>,
1996    /// fID from the embedded plNotifyMsg — passed as `id` to script OnNotify.
1997    /// C++ ref: plNotifyMsg.cpp:812 — fID read as i32 after fType and fState.
1998    pub notify_id: i32,
1999    /// Cursor type from fMyCursor. C++ ref: plCursorChangeMsg cursor types.
2000    pub cursor_type: i32,
2001    /// Condition keys from plLogicModifier::fConditionList.
2002    /// C++ ref: plLogicModifier.cpp:Read — ReadKeyNotifyMe for each condition.
2003    pub condition_keys: Vec<crate::core::uoid::Uoid>,
2004    /// plLogicModBase flags (hsBitVector).
2005    /// C++ ref: plLogicModBase.h — kOneShot=0x4, kMultiTrigger=0x8, etc.
2006    pub flags: u32,
2007    /// Whether this logic modifier is disabled.
2008    /// C++ ref: plLogicModBase::fDisabled
2009    pub disabled: bool,
2010}
2011
2012/// Parse plLogicModifier from PRP object data.
2013///
2014/// We parse just enough to extract notify receivers and cursor type,
2015/// skipping the full command list and condition list deserialization.
2016/// Format: plSingleModifier + plLogicModBase(commands + notify + flags + disabled)
2017///         + u32 condition_count + condition_keys + i32 cursor_type
2018pub fn parse_logic_modifier(data: &[u8]) -> Result<LogicModData> {
2019    use crate::core::uoid::read_key_uoid;
2020    let mut cursor = Cursor::new(data);
2021
2022    let _class_idx = cursor.read_i16()?;
2023    let self_key = read_key_uoid(&mut cursor)?;
2024    skip_synched_object(&mut cursor)?;
2025    // hsBitVector fFlags (plSingleModifier)
2026    let n_words = cursor.read_u32()?;
2027    for _ in 0..n_words { let _ = cursor.read_u32()?; }
2028
2029    // plLogicModBase::Read
2030    // u32 command_count, then skip creatables (complex — we skip by scanning)
2031    let cmd_count = cursor.read_u32()?;
2032    for _ in 0..cmd_count {
2033        // Each command is a ReadCreatable: i16 class_idx, then object data.
2034        // We can't easily skip these without knowing each type's size,
2035        // so for now only support cmd_count == 0 (common for click activators).
2036        if cmd_count > 0 {
2037            // Can't skip commands — return partial data
2038            return Ok(LogicModData {
2039                self_key,
2040                notify_receivers: Vec::new(),
2041                notify_id: 0,
2042                cursor_type: 0,
2043                condition_keys: Vec::new(),
2044                flags: 0,
2045                disabled: false,
2046            });
2047        }
2048    }
2049
2050    // fNotify: ReadCreatable → plNotifyMsg
2051    // Read class index for the plNotifyMsg creatable
2052    let notify_class = cursor.read_i16()?;
2053    let mut notify_receivers = Vec::new();
2054    let mut notify_id = 0i32;
2055    if notify_class >= 0 {
2056        // plNotifyMsg inherits plMessage which has sender + receivers
2057        // plMessage::IMsgRead: sender_key, then u32 num_receivers, then receiver keys,
2058        //   then f64 timestamp, then BCastFlags (u32)
2059        let _sender = read_key_uoid(&mut cursor)?;
2060        let num_receivers = cursor.read_u32()?;
2061        for _ in 0..num_receivers {
2062            if let Some(uoid) = read_key_uoid(&mut cursor)? {
2063                notify_receivers.push(uoid);
2064            }
2065        }
2066        let _timestamp = cursor.read_f32()?; let _ = cursor.read_f32()?; // f64 as 2×f32
2067        let _bcast_flags = cursor.read_u32()?;
2068
2069        // plNotifyMsg own data: fType(i32), fState(f32), fID(i32)
2070        // C++ ref: plNotifyMsg.cpp:809-815
2071        let _notify_type = cursor.read_i32()?;
2072        let _notify_state = cursor.read_f32()?;
2073        notify_id = cursor.read_i32()?;
2074
2075        // event_count(u32) + events — skip
2076        let num_events = cursor.read_u32()?;
2077        for _ in 0..num_events {
2078            let event_type = cursor.read_i32()?;
2079            match event_type {
2080                1 => cursor.skip(1 + 1)?,     // kCollision: 2 bools
2081                7 => cursor.skip(1 + 1)?,     // kActivate: 2 bools
2082                8 => cursor.skip(4)?,         // kCallback: i32
2083                9 => cursor.skip(4)?,         // kResponderState: i32
2084                _ => {} // other types may have keys — stop parsing
2085            }
2086        }
2087    }
2088
2089    // hsBitVector fFlags (plLogicModBase)
2090    let n_words2 = cursor.read_u32()?;
2091    let flags = if n_words2 > 0 { cursor.read_u32()? } else { 0 };
2092    for _ in 1..n_words2 { let _ = cursor.read_u32()?; }
2093
2094    // bool fDisabled
2095    let disabled = cursor.read_u8()? != 0;
2096
2097    // plLogicModifier own data:
2098    // u32 condition_count, condition keys (ReadKeyNotifyMe = regular key read)
2099    let cond_count = cursor.read_u32()?;
2100    let mut condition_keys = Vec::with_capacity(cond_count as usize);
2101    for _ in 0..cond_count {
2102        if let Some(key) = read_key_uoid(&mut cursor)? {
2103            condition_keys.push(key);
2104        }
2105    }
2106
2107    // i32 fMyCursor
2108    let cursor_type = cursor.read_i32()?;
2109
2110    Ok(LogicModData {
2111        self_key,
2112        notify_receivers,
2113        notify_id,
2114        cursor_type,
2115        condition_keys,
2116        flags,
2117        disabled,
2118    })
2119}
2120
2121// ============================================================================
2122// plResponderModifier — state machine that executes command sequences
2123// ============================================================================
2124
2125/// A one-shot callback entry from plOneShotCallbacks.
2126/// C++ ref: plOneShotCallbacks.h:65-73
2127#[derive(Debug, Clone)]
2128pub struct OneShotCallback {
2129    /// Animation marker name (e.g., "Touch" — resolved to time at runtime).
2130    pub marker: String,
2131    /// Key of the object to notify when this marker is reached.
2132    pub receiver: Option<crate::core::uoid::Uoid>,
2133    /// User data passed back with the callback.
2134    pub user: i16,
2135}
2136
2137/// A parsed command from a responder state.
2138#[derive(Debug, Clone)]
2139pub struct ResponderCmd {
2140    /// Class index of the command message (e.g., 0x0206 = plAnimCmdMsg).
2141    pub class_id: u16,
2142    /// Callback wait dependency (-1 = none).
2143    pub wait_on: i8,
2144    /// For plAnimCmdMsg: animation name.
2145    pub anim_name: Option<String>,
2146    /// For plAnimCmdMsg: command flags (hsBitVector word 0).
2147    pub anim_flags: u32,
2148    /// For plNotifyMsg: receiver keys.
2149    pub notify_receivers: Vec<crate::core::uoid::Uoid>,
2150    /// Receiver keys from the base plMessage (target objects for the command).
2151    pub msg_receivers: Vec<crate::core::uoid::Uoid>,
2152    /// For plSoundMsg: sound index.
2153    pub sound_index: i32,
2154    /// For plSoundMsg: command flags (hsBitVector word 0).
2155    pub sound_flags: u32,
2156    /// For plOneShotMsg: animation callbacks (marker → receiver).
2157    /// C++ ref: plOneShotCallbacks.h — sent when animation reaches named markers.
2158    pub oneshot_callbacks: Vec<OneShotCallback>,
2159    /// For plTimerCallbackMsg: timer ID (callback wait ID).
2160    /// C++ ref: plTimerCallbackMsg.h — fID returned when timer fires.
2161    pub timer_id: i32,
2162    /// For plTimerCallbackMsg: delay in seconds.
2163    /// C++ ref: plTimerCallbackMsg.h — fTime passed to plgTimerCallbackMgr::NewTimer.
2164    pub timer_delay: f32,
2165    /// For plEnableMsg: enable/disable command flags.
2166    /// C++ ref: plEnableMsg.h — hsBitVector fCmd (bit 0 = disable, bit 1 = enable).
2167    pub enable_cmd: u32,
2168}
2169
2170/// A parsed responder state.
2171#[derive(Debug, Clone)]
2172pub struct ResponderState {
2173    pub num_callbacks: u8,
2174    pub switch_to_state: u8,
2175    pub commands: Vec<ResponderCmd>,
2176}
2177
2178/// Parsed plResponderModifier.
2179/// C++ ref: plResponderModifier.cpp:627-665
2180#[derive(Debug, Clone)]
2181pub struct ResponderModData {
2182    pub self_key: Option<crate::core::uoid::Uoid>,
2183    pub states: Vec<ResponderState>,
2184    pub cur_state: u8,
2185    pub enabled: bool,
2186    pub flags: u8,
2187    /// Runtime: current command index during execution (-1 = not running).
2188    /// C++ ref: plResponderModifier::fCurCommand
2189    pub cur_command: i32,
2190    /// Runtime: bitfield of completed callback events.
2191    /// C++ ref: plResponderModifier::fCompletedEvents
2192    pub completed_events: u64,
2193    /// Runtime: the key that triggered this responder (for NotifyMsg receiver substitution).
2194    /// C++ ref: plResponderModifier::fTriggerer — set from msg->GetSender() in Trigger()
2195    pub triggerer: Option<crate::core::uoid::Uoid>,
2196}
2197
2198/// Read plMessage::IMsgRead base fields, returning (sender, receivers).
2199/// C++ ref: plMessage.cpp:109-121
2200fn read_msg_base(cursor: &mut Cursor<&[u8]>) -> Result<(Option<crate::core::uoid::Uoid>, Vec<crate::core::uoid::Uoid>)> {
2201    use crate::core::uoid::read_key_uoid;
2202    let sender = read_key_uoid(cursor)?;
2203    let num_receivers = cursor.read_u32()?;
2204    let mut receivers = Vec::with_capacity(num_receivers as usize);
2205    for _ in 0..num_receivers {
2206        if let Some(u) = read_key_uoid(cursor)? {
2207            receivers.push(u);
2208        }
2209    }
2210    // f64 timestamp + u32 BCastFlags
2211    cursor.skip(12)?;
2212    Ok((sender, receivers))
2213}
2214
2215/// Read plMessageWithCallbacks (extends plMessage with callback list).
2216/// Returns (sender, receivers, num_callbacks_skipped).
2217/// C++ ref: plMessageWithCallbacks.cpp:66-79
2218fn read_msg_with_callbacks(cursor: &mut Cursor<&[u8]>) -> Result<(Option<crate::core::uoid::Uoid>, Vec<crate::core::uoid::Uoid>)> {
2219    let (sender, receivers) = read_msg_base(cursor)?;
2220    let num_callbacks = cursor.read_u32()?;
2221    // Each callback is a ReadCreatable (plEventCallbackMsg)
2222    for _ in 0..num_callbacks {
2223        let cb_class = cursor.read_i16()?;
2224        if cb_class >= 0 {
2225            // plEventCallbackMsg: IMsgRead + f32 + 4×i16
2226            let _ = read_msg_base(cursor)?;
2227            cursor.skip(12)?; // f32 eventTime + i16×4 (event, index, repeats, user)
2228        }
2229    }
2230    Ok((sender, receivers))
2231}
2232
2233/// Parse a single responder command (creatable message).
2234/// Returns None if the command type is unknown and can't be skipped.
2235fn parse_responder_cmd(cursor: &mut Cursor<&[u8]>) -> Result<Option<ResponderCmd>> {
2236    let class_id = cursor.read_u16()?;
2237
2238    let mut cmd = ResponderCmd {
2239        class_id,
2240        wait_on: -1,
2241        anim_name: None,
2242        anim_flags: 0,
2243        notify_receivers: Vec::new(),
2244        msg_receivers: Vec::new(),
2245        sound_index: -1,
2246        sound_flags: 0,
2247        oneshot_callbacks: Vec::new(),
2248        timer_id: -1,
2249        timer_delay: 0.0,
2250        enable_cmd: 0,
2251    };
2252
2253    match class_id {
2254        0x0206 => { // plAnimCmdMsg
2255            let (_, receivers) = read_msg_with_callbacks(cursor)?;
2256            cmd.msg_receivers = receivers;
2257            // hsBitVector fCmd
2258            let n = cursor.read_u32()?;
2259            cmd.anim_flags = if n > 0 { cursor.read_u32()? } else { 0 };
2260            for _ in 1..n { let _ = cursor.read_u32()?; }
2261            // f32 × 7: begin, end, loopEnd, loopBegin, speed, speedChangeRate, time
2262            cursor.skip(28)?;
2263            // SafeString animName, loopName
2264            cmd.anim_name = Some(cursor.read_safe_string()?);
2265            let _loop_name = cursor.read_safe_string()?;
2266        }
2267        0x025A => { // plSoundMsg
2268            let (_, receivers) = read_msg_with_callbacks(cursor)?;
2269            cmd.msg_receivers = receivers;
2270            // hsBitVector fCmd
2271            let n = cursor.read_u32()?;
2272            cmd.sound_flags = if n > 0 { cursor.read_u32()? } else { 0 };
2273            for _ in 1..n { let _ = cursor.read_u32()?; }
2274            // f64 begin, f64 end, bool loop, bool playing, f32 speed, f64 time
2275            cursor.skip(8 + 8 + 1 + 1 + 4 + 8)?;
2276            // i32 index, i32 repeats, u32 nameStr, f32 volume, u8 fadeType
2277            cmd.sound_index = cursor.read_i32()?;
2278            cursor.skip(4 + 4 + 4 + 1)?;
2279        }
2280        0x02ED => { // plNotifyMsg
2281            let (_, receivers) = read_msg_base(cursor)?;
2282            cmd.msg_receivers = receivers.clone();
2283            cmd.notify_receivers = receivers;
2284            // fType(i32), fState(f32), fID(i32)
2285            cursor.skip(12)?;
2286            // u32 numEvents, then events
2287            let num_events = cursor.read_u32()?;
2288            for _ in 0..num_events {
2289                let event_type = cursor.read_i32()?;
2290                // Skip event-specific data based on type
2291                match event_type {
2292                    1 => cursor.skip(1 + 1)?, // kCollision: 2 bools (keys read as nil)
2293                    2 => cursor.skip(1 + 12)?, // kPicked: bool + hitPoint(3×f32) (keys read as nil)
2294                    3 => cursor.skip(4 + 1)?, // kControlKey: i32 + bool
2295                    7 => cursor.skip(1 + 1)?, // kActivate: 2 bools
2296                    8 => cursor.skip(4)?,     // kCallback: i32
2297                    9 => cursor.skip(4)?,     // kResponderState: i32
2298                    _ => {
2299                        // Complex event types with keys — bail
2300                        return Ok(Some(cmd));
2301                    }
2302                }
2303            }
2304        }
2305        0x0254 => { // plEnableMsg
2306            let (_, receivers) = read_msg_base(cursor)?;
2307            cmd.msg_receivers = receivers;
2308            // hsBitVector fCmd (bit 0 = disable, bit 1 = enable)
2309            let n1 = cursor.read_u32()?;
2310            cmd.enable_cmd = if n1 > 0 { cursor.read_u32()? } else { 0 };
2311            for _ in 1..n1 { let _ = cursor.read_u32()?; }
2312            // hsBitVector fTypes
2313            let n2 = cursor.read_u32()?;
2314            for _ in 0..n2 { let _ = cursor.read_u32()?; }
2315        }
2316        0x024F => { // plTimerCallbackMsg
2317            let (_, receivers) = read_msg_base(cursor)?;
2318            cmd.msg_receivers = receivers;
2319            cmd.timer_id = cursor.read_i32()?;
2320            cmd.timer_delay = cursor.read_f32()?;
2321        }
2322        0x020A => { // plCameraMsg
2323            let (_, receivers) = read_msg_base(cursor)?;
2324            cmd.msg_receivers = receivers;
2325            // hsBitVector fCmd
2326            let n = cursor.read_u32()?;
2327            for _ in 0..n { let _ = cursor.read_u32()?; }
2328            // f64 transTime, bool activated
2329            cursor.skip(8 + 1)?;
2330            // Key newCam, Key triggerer
2331            let _ = crate::core::uoid::read_key_uoid(cursor)?;
2332            let _ = crate::core::uoid::read_key_uoid(cursor)?;
2333            // plCameraConfig: 8×f32 + 3×f32(offset) + bool
2334            cursor.skip(44 + 1)?;
2335        }
2336        0x0302 => { // plResponderEnableMsg
2337            let (_, receivers) = read_msg_base(cursor)?;
2338            cmd.msg_receivers = receivers;
2339            cursor.skip(1)?; // bool fEnable
2340        }
2341        0x0306 => { // plResponderMsg
2342            let (_, receivers) = read_msg_base(cursor)?;
2343            cmd.msg_receivers = receivers;
2344            // No additional fields serialized
2345        }
2346        0x0332 => { // plAGCmdMsg
2347            let (_, receivers) = read_msg_base(cursor)?;
2348            cmd.msg_receivers = receivers;
2349            // hsBitVector fCmd
2350            let n = cursor.read_u32()?;
2351            for _ in 0..n { let _ = cursor.read_u32()?; }
2352            // f32×4: blend, blendRate, amp, ampRate
2353            cursor.skip(16)?;
2354            // SafeString animName
2355            cmd.anim_name = Some(cursor.read_safe_string()?);
2356        }
2357        0x0335 => { // plExcludeRegionMsg
2358            let (_, receivers) = read_msg_base(cursor)?;
2359            cmd.msg_receivers = receivers;
2360            // u8 fCmd, u32 fSynchFlags
2361            cursor.skip(1 + 4)?;
2362        }
2363        0x0307 => { // plOneShotMsg (extends plResponderMsg)
2364            let (_, receivers) = read_msg_base(cursor)?;
2365            cmd.msg_receivers = receivers;
2366            // plOneShotCallbacks::Read — u32 count, then (SafeString + plKey + i16) triples
2367            // C++ ref: plOneShotCallbacks.cpp:72-84
2368            let n = cursor.read_u32()?;
2369            for _ in 0..n {
2370                let marker = cursor.read_safe_string()?;
2371                let receiver = crate::core::uoid::read_key_uoid(cursor)?;
2372                let user = cursor.read_i16()?;
2373                cmd.oneshot_callbacks.push(OneShotCallback { marker, receiver, user });
2374            }
2375        }
2376        0x03BF => { // plSubWorldMsg
2377            let (_, receivers) = read_msg_base(cursor)?;
2378            cmd.msg_receivers = receivers;
2379            let _ = crate::core::uoid::read_key_uoid(cursor)?; // fWorldKey
2380        }
2381        0x0453 => { // plSimSuppressMsg
2382            let (_, receivers) = read_msg_base(cursor)?;
2383            cmd.msg_receivers = receivers;
2384            cursor.skip(1)?; // bool fSuppress
2385        }
2386        0x0393 => { // plArmatureEffectStateMsg
2387            // C++ ref: plArmatureEffectMsg.cpp — IMsgRead + u8 surface + bool addSurface
2388            let (_, receivers) = read_msg_base(cursor)?;
2389            cmd.msg_receivers = receivers;
2390            cursor.skip(1 + 1)?; // u8 fSurface + bool fAddSurface
2391        }
2392        _ => {
2393            // Unknown command type — can't skip, return what we have
2394            log::debug!("Unknown responder command class 0x{:04X}", class_id);
2395            return Ok(None);
2396        }
2397    }
2398
2399    Ok(Some(cmd))
2400}
2401
2402/// Parse plResponderModifier from PRP object data.
2403/// C++ ref: plResponderModifier.cpp:627-665
2404pub fn parse_responder_modifier(data: &[u8]) -> Result<ResponderModData> {
2405    use crate::core::uoid::read_key_uoid;
2406    let mut cursor = Cursor::new(data);
2407
2408    // plSingleModifier::Read
2409    let _class_idx = cursor.read_i16()?;
2410    let self_key = read_key_uoid(&mut cursor)?;
2411    skip_synched_object(&mut cursor)?;
2412    let n_flags = cursor.read_u32()?;
2413    for _ in 0..n_flags { let _ = cursor.read_u32()?; }
2414
2415    let num_states = cursor.read_u8()?;
2416    let mut states = Vec::with_capacity(num_states as usize);
2417    let mut parse_ok = true;
2418
2419    for _si in 0..num_states {
2420        if !parse_ok { break; }
2421
2422        let num_callbacks = cursor.read_u8()?;
2423        let switch_to_state = cursor.read_u8()?;
2424        let num_cmds = cursor.read_u8()?;
2425
2426        let mut commands = Vec::with_capacity(num_cmds as usize);
2427        for _ in 0..num_cmds {
2428            if !parse_ok { break; }
2429            match parse_responder_cmd(&mut cursor) {
2430                Ok(Some(mut cmd)) => {
2431                    let wait_on = cursor.read_u8()? as i8;
2432                    cmd.wait_on = wait_on;
2433                    commands.push(cmd);
2434                }
2435                Ok(None) => {
2436                    // Unknown command — can't continue parsing
2437                    parse_ok = false;
2438                    break;
2439                }
2440                Err(e) => {
2441                    log::debug!("Responder cmd parse error: {}", e);
2442                    parse_ok = false;
2443                    break;
2444                }
2445            }
2446        }
2447
2448        if parse_ok {
2449            // Read wait-to-cmd map
2450            let map_size = cursor.read_u8()?;
2451            for _ in 0..map_size {
2452                let _wait = cursor.read_u8()?;
2453                let _cmd = cursor.read_u8()?;
2454            }
2455        }
2456
2457        states.push(ResponderState {
2458            num_callbacks,
2459            switch_to_state,
2460            commands,
2461        });
2462    }
2463
2464    // Read footer fields only if all states parsed successfully
2465    let (cur_state, enabled, flags) = if parse_ok {
2466        let cs = cursor.read_u8()?;
2467        let en = cursor.read_u8()? != 0; // ReadBool = 1 byte (hsStream.h:91)
2468        let fl = cursor.read_u8()?;
2469        (cs, en, fl)
2470    } else {
2471        (0, true, 0x01) // defaults: state 0, enabled, kDetectTrigger
2472    };
2473
2474    Ok(ResponderModData {
2475        self_key,
2476        states,
2477        cur_state,
2478        enabled,
2479        flags,
2480        cur_command: -1,
2481        completed_events: 0,
2482        triggerer: None,
2483    })
2484}
2485
2486// ============================================================================
2487// plObjectInVolumeDetector — trigger volume (class 0x007B)
2488// C++ ref: plCollisionDetector.cpp:215-219, plDetectorModifier.cpp:50-60
2489// ============================================================================
2490
2491/// Parsed plObjectInVolumeDetector data.
2492#[derive(Debug, Clone)]
2493pub struct VolumeDetectorData {
2494    pub name: String,
2495    /// Collision type flags (kTypeEnter=0x01, kTypeExit=0x02, etc.)
2496    pub collision_type: u8,
2497    /// Keys of modifiers to notify (plLogicModifier, plResponderModifier, etc.)
2498    pub receivers: Vec<crate::core::uoid::Uoid>,
2499    /// Optional proxy key — alternative hitee for activation messages.
2500    pub proxy_key: Option<crate::core::uoid::Uoid>,
2501}
2502
2503/// Parse plObjectInVolumeDetector from PRP object data.
2504///
2505/// Inheritance: plObjectInVolumeDetector → plCollisionDetector → plDetectorModifier
2506///              → plSingleModifier → plModifier → plSynchedObject → hsKeyedObject
2507///
2508/// C++ ref: plCollisionDetector.cpp:215-219, plDetectorModifier.cpp:50-60
2509pub fn parse_volume_detector(data: &[u8]) -> Result<VolumeDetectorData> {
2510    use crate::core::uoid::read_key_uoid;
2511    let mut cursor = Cursor::new(data);
2512
2513    // Creatable class index
2514    let _class_idx = cursor.read_i16()?;
2515
2516    // hsKeyedObject::Read — self-key
2517    let self_key = read_key_uoid(&mut cursor)?;
2518    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
2519
2520    // plSynchedObject::Read
2521    skip_synched_object(&mut cursor)?;
2522
2523    // plSingleModifier::Read — hsBitVector fFlags
2524    let num_bit_vectors = cursor.read_u32()?;
2525    for _ in 0..num_bit_vectors {
2526        let _word = cursor.read_u32()?;
2527    }
2528
2529    // plDetectorModifier::Read
2530    // C++ ref: plDetectorModifier.cpp:50-60
2531    let receiver_count = cursor.read_u32()?;
2532    let mut receivers = Vec::with_capacity(receiver_count as usize);
2533    for _ in 0..receiver_count {
2534        if let Some(uoid) = read_key_uoid(&mut cursor)? {
2535            receivers.push(uoid);
2536        }
2537    }
2538    // Remote modifier key
2539    let _remote_mod = read_key_uoid(&mut cursor)?;
2540    // Proxy key
2541    let proxy_key = read_key_uoid(&mut cursor)?;
2542
2543    // plCollisionDetector::Read — u8 fType
2544    let collision_type = cursor.read_u8()?;
2545
2546    Ok(VolumeDetectorData {
2547        name,
2548        collision_type,
2549        receivers,
2550        proxy_key,
2551    })
2552}
2553
2554// ============================================================================
2555// plPostEffectMod — GUI camera for dialog screen projection (class 0x007A)
2556// C++ ref: plPostEffectMod.cpp:281-301
2557// ============================================================================
2558
2559/// Read hsMatrix44 from stream: 1-byte bool + optionally 16 × f32.
2560/// C++ ref: hsMatrix44::Read (hsMatrix44.cpp) — ReadBool then 16 floats if not identity.
2561fn read_hs_matrix44(reader: &mut (impl std::io::Read + Seek)) -> Result<[f32; 16]> {
2562    let has_data = reader.read_u8()? != 0;
2563    if has_data {
2564        let mut m = [0f32; 16];
2565        for val in &mut m {
2566            *val = reader.read_f32()?;
2567        }
2568        Ok(m)
2569    } else {
2570        // Identity matrix
2571        Ok([
2572            1.0, 0.0, 0.0, 0.0,
2573            0.0, 1.0, 0.0, 0.0,
2574            0.0, 0.0, 1.0, 0.0,
2575            0.0, 0.0, 0.0, 1.0,
2576        ])
2577    }
2578}
2579
2580/// Parsed plPostEffectMod data — contains the camera transform for GUI projection.
2581#[derive(Debug, Clone)]
2582pub struct PostEffectModData {
2583    pub name: String,
2584    pub hither: f32,
2585    pub yon: f32,
2586    pub fov_x: f32,
2587    pub fov_y: f32,
2588    /// World-to-camera matrix (hsMatrix44, row-major 4x4).
2589    pub w2c: [f32; 16],
2590    /// Camera-to-world matrix (hsMatrix44, row-major 4x4).
2591    pub c2w: [f32; 16],
2592}
2593
2594/// Parse plPostEffectMod from PRP object data.
2595/// C++ ref: plPostEffectMod.cpp:281-301
2596/// Inheritance: plPostEffectMod → plSingleModifier → plModifier → plSynchedObject
2597pub fn parse_post_effect_mod(data: &[u8]) -> Result<PostEffectModData> {
2598    use crate::core::uoid::read_key_uoid;
2599    let mut cursor = Cursor::new(data);
2600
2601    let _class_idx = cursor.read_i16()?;
2602    let self_key = read_key_uoid(&mut cursor)?;
2603    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
2604    skip_synched_object(&mut cursor)?;
2605
2606    // plSingleModifier: hsBitVector flags
2607    let num_bv = cursor.read_u32()?;
2608    for _ in 0..num_bv { let _w = cursor.read_u32()?; }
2609
2610    // plPostEffectMod: hsBitVector state
2611    let num_sv = cursor.read_u32()?;
2612    let mut state_bits = 0u32;
2613    for i in 0..num_sv {
2614        let w = cursor.read_u32()?;
2615        if i == 0 { state_bits = w; }
2616    }
2617    log::debug!("  PostEffectMod flags_bv={} state_bv={} state_bits=0x{:X} pos={}",
2618        num_bv, num_sv, state_bits, cursor.position());
2619
2620    // Camera params
2621    let hither = cursor.read_f32()?;
2622    let yon = cursor.read_f32()?;
2623    let fov_x = cursor.read_f32()?;
2624    let fov_y = cursor.read_f32()?;
2625    log::debug!("  hither={} yon={} fov_x={} fov_y={}", hither, yon, fov_x, fov_y);
2626
2627    // Node key
2628    let _node_key = read_key_uoid(&mut cursor)?;
2629
2630    // W2C and C2W matrices (hsMatrix44::Read = 1-byte bool + optionally 16 × f32)
2631    let w2c = read_hs_matrix44(&mut cursor)?;
2632    let c2w = read_hs_matrix44(&mut cursor)?;
2633
2634    log::debug!("  W2C row0=({:.3},{:.3},{:.3},{:.3})", w2c[0], w2c[1], w2c[2], w2c[3]);
2635    log::debug!("  W2C row1=({:.3},{:.3},{:.3},{:.3})", w2c[4], w2c[5], w2c[6], w2c[7]);
2636    log::debug!("  W2C row2=({:.3},{:.3},{:.3},{:.3})", w2c[8], w2c[9], w2c[10], w2c[11]);
2637    log::debug!("  W2C row3=({:.3},{:.3},{:.3},{:.3})", w2c[12], w2c[13], w2c[14], w2c[15]);
2638
2639    Ok(PostEffectModData {
2640        name, hither, yon, fov_x, fov_y, w2c, c2w,
2641    })
2642}
2643
2644// ============================================================================
2645// pfGUI controls — button, textbox, editbox, checkbox
2646// C++ ref: pfGameGUIMgr/pfGUIControlMod.cpp, pfGUIButtonMod.cpp, etc.
2647// ============================================================================
2648
2649/// Parsed GUI control data.
2650#[derive(Debug, Clone)]
2651pub struct GuiControlData {
2652    pub name: String,
2653    pub control_type: String,
2654    pub tag_id: u32,
2655    pub visible: bool,
2656    pub text: Option<String>,
2657}
2658
2659/// Skip pfGUICtrlProcWriteableObject inline.
2660/// C++ ref: pfGUIControlHandlers.cpp:70-100 — u32 type, then type-specific IRead.
2661fn skip_gui_proc(reader: &mut (impl std::io::Read + Seek)) -> Result<()> {
2662    let proc_type = reader.read_u32()?; // u32 LE type
2663    match proc_type {
2664        3 => {} // kNull — return nullptr, no more data
2665        0 => {  // kConsoleCmd — u32 strlen + string bytes
2666            let len = reader.read_u32()? as usize;
2667            if len > 0 { reader.skip(len)?; }
2668        }
2669        1 => {} // kPythonScript — no extra data
2670        2 => {} // kCloseDlg — no extra data
2671        _ => {} // unknown — hope for the best
2672    }
2673    Ok(())
2674}
2675
2676/// Parse pfGUIControlMod base class data from stream.
2677/// Returns (tag_id, visible). Leaves cursor after base class data.
2678/// C++ ref: pfGUIControlMod.cpp:780-823
2679fn parse_gui_control_base(cursor: &mut Cursor<&[u8]>) -> Result<(u32, bool)> {
2680    use crate::core::uoid::read_key_uoid;
2681
2682    // plSingleModifier: hsBitVector flags
2683    let num_bv = cursor.read_u32()?;
2684    let mut flags = 0u32;
2685    for i in 0..num_bv {
2686        let w = cursor.read_u32()?;
2687        if i == 0 { flags = w; }
2688    }
2689
2690    // pfGUIControlMod fields
2691    let tag_id = cursor.read_u32()?;
2692    let visible = cursor.read_u8()? != 0;
2693
2694    // pfGUICtrlProcWriteableObject handler
2695    skip_gui_proc(cursor)?;
2696
2697    // Dynamic text map (optional)
2698    let has_dyn_text = cursor.read_u8()? != 0;
2699    if has_dyn_text {
2700        let _layer_key = read_key_uoid(cursor)?;
2701        let _dyn_text_key = read_key_uoid(cursor)?;
2702    }
2703
2704    // Color scheme (optional)
2705    // C++ ref: pfGUIColorScheme::Read — 4 colors + bool + string + 2 bytes
2706    let has_color = cursor.read_u8()? != 0;
2707    if has_color {
2708        cursor.skip(4 * 4 * 4)?; // 4 × hsColorRGBA (16 floats)
2709        cursor.read_u32()?;      // fTransparent (ReadBOOL = 4 bytes)
2710        cursor.read_safe_string()?; // fFontFace
2711        cursor.read_u8()?;       // fFontSize
2712        cursor.read_u8()?;       // fFontFlags
2713    }
2714
2715    // Sound indices
2716    let sound_count = cursor.read_u8()? as usize;
2717    cursor.skip(sound_count * 4)?; // u32 indices
2718
2719    // Proxy key (if kHasProxy = bit 21 in flags)
2720    if flags & (1 << 21) != 0 {
2721        let _proxy_key = read_key_uoid(cursor)?;
2722    }
2723
2724    // Skin key
2725    let _skin_key = read_key_uoid(cursor)?;
2726
2727    Ok((tag_id, visible))
2728}
2729
2730/// Parse a pfGUIButtonMod from PRP data — extracts name and tag ID.
2731/// Resilient: returns partial data on parse failure.
2732/// C++ ref: pfGUIButtonMod.cpp:199-219
2733pub fn parse_gui_button(data: &[u8]) -> Result<GuiControlData> {
2734    use crate::core::uoid::read_key_uoid;
2735    let mut cursor = Cursor::new(data);
2736
2737    let _class_idx = cursor.read_i16()?;
2738    let self_key = read_key_uoid(&mut cursor)?;
2739    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
2740    skip_synched_object(&mut cursor)?;
2741
2742    // Try to parse control base; return partial data if it fails
2743    match parse_gui_control_base(&mut cursor) {
2744        Ok((tag_id, visible)) => Ok(GuiControlData {
2745            name,
2746            control_type: "Button".to_string(),
2747            tag_id,
2748            visible,
2749            text: None,
2750        }),
2751        Err(_) => Ok(GuiControlData {
2752            name,
2753            control_type: "Button".to_string(),
2754            tag_id: 0,
2755            visible: true,
2756            text: None,
2757        }),
2758    }
2759}
2760
2761/// Parse a pfGUITextBoxMod from PRP data — extracts name, tag ID, and text.
2762/// Resilient: returns partial data on parse failure.
2763/// C++ ref: pfGUITextBoxMod.cpp:141-162
2764pub fn parse_gui_textbox(data: &[u8]) -> Result<GuiControlData> {
2765    use crate::core::uoid::read_key_uoid;
2766    let mut cursor = Cursor::new(data);
2767
2768    let _class_idx = cursor.read_i16()?;
2769    let self_key = read_key_uoid(&mut cursor)?;
2770    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
2771    skip_synched_object(&mut cursor)?;
2772
2773    let (tag_id, visible) = match parse_gui_control_base(&mut cursor) {
2774        Ok(v) => v,
2775        Err(_) => return Ok(GuiControlData {
2776            name, control_type: "TextBox".to_string(), tag_id: 0, visible: true, text: None,
2777        }),
2778    };
2779
2780    // pfGUITextBoxMod-specific: text content
2781    let text = if let Ok(text_len) = cursor.read_u32() {
2782        let text_len = text_len as usize;
2783        if text_len > 0 && text_len < 65536 {
2784            let mut text_bytes = vec![0u8; text_len];
2785            if cursor.read_exact(&mut text_bytes).is_ok() {
2786                let s = String::from_utf8_lossy(&text_bytes).trim_end_matches('\0').to_string();
2787                if s.is_empty() { None } else { Some(s) }
2788            } else { None }
2789        } else { None }
2790    } else { None };
2791
2792    Ok(GuiControlData {
2793        name,
2794        control_type: "TextBox".to_string(),
2795        tag_id,
2796        visible,
2797        text,
2798    })
2799}
2800
2801// ============================================================================
2802// plParticleSystem — particle effect (class 0x0008)
2803// C++ ref: plParticleSystem.cpp:608-648
2804// ============================================================================
2805
2806/// Minimal parsed particle system data.
2807#[derive(Debug, Clone)]
2808pub struct ParticleSystemData {
2809    pub name: String,
2810    /// Material key name (texture for particles).
2811    pub material_name: Option<String>,
2812    /// Texture atlas dimensions.
2813    pub x_tiles: u32,
2814    pub y_tiles: u32,
2815    /// Maximum particle count.
2816    pub max_particles: u32,
2817    /// Acceleration vector (gravity/wind).
2818    pub accel: [f32; 3],
2819    /// Pre-simulation time (seconds). First frame runs sim for this duration.
2820    pub pre_sim: f32,
2821    /// Drag factor. Per-frame: vel *= max(0, 1 + drag * dt). Negative = decelerate.
2822    pub drag: f32,
2823    /// Wind multiplier for environmental wind effects.
2824    pub wind_mult: f32,
2825    /// Emitter source positions (from plSimpleParticleGenerator).
2826    pub emitter_sources: Vec<[f32; 3]>,
2827    /// Particle size (width, height) from generator.
2828    pub particle_size: [f32; 2],
2829    /// Number of emitters parsed.
2830    pub num_emitters: u32,
2831    /// Generator particle lifetime range (min, max) in seconds.
2832    pub particle_life_range: [f32; 2],
2833    /// Particles per second emission rate.
2834    pub particles_per_second: f32,
2835    /// Velocity range (min, max) for spawned particles.
2836    pub velocity_range: [f32; 2],
2837    /// Cone angle range (radians) for spawn direction jitter.
2838    pub angle_range: f32,
2839    /// Scale range (min, max) for particle size variation.
2840    pub scale_range: [f32; 2],
2841    /// Emitter miscFlags — orientation mode + normal mode.
2842    /// C++ ref: plParticleEmitter.h:106-129
2843    ///   kNormalViewFacing=0x100, kOrientationUp=0x10000000,
2844    ///   kOrientationVelocityBased=0x20000000, kOrientationVelocityStretch=0x40000000
2845    pub emitter_misc_flags: u32,
2846    /// Emitter color (RGBA). Applied to all particles from this emitter.
2847    pub emitter_color: [f32; 4],
2848}
2849
2850/// Skip a Creatable controller inline (plLeafController or nil).
2851/// C++ ref: plResManager::ReadCreatable → u16 classIdx, then object.Read()
2852/// plLeafController::Read: u8 type + u32 numKeys + keyframe data
2853fn skip_creatable_controller(reader: &mut (impl std::io::Read + Seek)) -> Result<()> {
2854    let class_idx = reader.read_u16()?;
2855    if class_idx & 0x8000 != 0 {
2856        return Ok(()); // nil
2857    }
2858
2859    // plLeafController (0x01A9) or plCompoundController (0x01AA)
2860    if class_idx == 0x01A9 {
2861        // plLeafController::Read: u8 type + u32 numKeys + numKeys × key_size
2862        let key_type = reader.read_u8()?;
2863        let num_keys = reader.read_u32()?;
2864        let key_size: usize = match key_type {
2865            0 => 0,  // kUnknownKeyFrame
2866            1 => 14, // kPoint3KeyFrame: u16 + 3×f32
2867            2 => 38, // kBezPoint3KeyFrame: u16 + 9×f32
2868            3 => 6,  // kScalarKeyFrame: u16 + f32
2869            4 => 14, // kBezScalarKeyFrame: u16 + 3×f32
2870            5 => 30, // kScaleKeyFrame: u16 + 7×f32 (quat+point3)
2871            6 => 54, // kBezScaleKeyFrame: u16 + 13×f32
2872            7 => 18, // kQuatKeyFrame: u16 + 4×f32
2873            8 => 6,  // kCompressedQuatKeyFrame32: u16 + u32
2874            9 => 10, // kCompressedQuatKeyFrame64: u16 + u64
2875            10 => 42, // k3dsMaxKeyFrame
2876            11 => 38, // kMatrix33KeyFrame: u16 + 9×f32
2877            12 => 66, // kMatrix44KeyFrame: u16 + 16×f32
2878            _ => bail!("Unknown keyframe type {}", key_type),
2879        };
2880        reader.seek(SeekFrom::Current((num_keys as usize * key_size) as i64))?;
2881    } else if class_idx == 0x01AA {
2882        // plCompoundController: 3 sub-controllers (X, Y, Z)
2883        for _ in 0..3 {
2884            skip_creatable_controller(reader)?;
2885        }
2886    } else {
2887        bail!("Unsupported controller class 0x{:04X}", class_idx);
2888    }
2889    Ok(())
2890}
2891
2892/// Try to parse plParticleSystem from PRP object data.
2893/// Only extracts material key and basic params — skips controllers.
2894///
2895/// C++ ref: plParticleSystem.cpp:608-648
2896/// Inheritance: plParticleSystem → plModifier → plSynchedObject → hsKeyedObject
2897pub fn parse_particle_system(data: &[u8]) -> Result<ParticleSystemData> {
2898    use crate::core::uoid::read_key_uoid;
2899    let mut cursor = Cursor::new(data);
2900
2901    // Creatable class index
2902    let _class_idx = cursor.read_i16()?;
2903
2904    // hsKeyedObject::Read — self-key
2905    let self_key = read_key_uoid(&mut cursor)?;
2906    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
2907
2908    // plSynchedObject::Read
2909    skip_synched_object(&mut cursor)?;
2910
2911    // plModifier has no Read/Write — goes straight to plParticleSystem data.
2912    // (plSingleModifier adds hsBitVector, but plModifier does not)
2913
2914    // Material key (first field in plParticleSystem::Read)
2915    let mat_key = read_key_uoid(&mut cursor)?;
2916    let material_name = mat_key.map(|k| k.object_name);
2917
2918    // 5 Controllers as Creatables — each is u16 classIdx, then object data.
2919    // 0x8000 = nil. For non-nil: skip plLeafController data.
2920    // C++ ref: plResManager::ReadCreatable → u16 classIdx, then object.Read()
2921    for _i in 0..5 {
2922        skip_creatable_controller(&mut cursor)?;
2923    }
2924
2925    // All controllers nil — read the params
2926    let x_tiles = cursor.read_u32()?;
2927    let y_tiles = cursor.read_u32()?;
2928    let max_particles = cursor.read_u32()?;
2929    let _max_emitters = cursor.read_u32()?;
2930    let pre_sim = cursor.read_f32()?;
2931    let accel_x = cursor.read_f32()?;
2932    let accel_y = cursor.read_f32()?;
2933    let accel_z = cursor.read_f32()?;
2934    let drag = cursor.read_f32()?;
2935    let wind_mult = cursor.read_f32()?;
2936
2937    // Read emitters: u32 numValidEmitters, then each as ReadCreatable
2938    let num_emitters = cursor.read_u32()?;
2939    let mut emitter_sources = Vec::new();
2940    let mut particle_size = [1.0_f32, 1.0];
2941    let mut particle_life_range = [1.0_f32, 1.0];
2942    let mut particles_per_second = 0.0_f32;
2943    let mut velocity_range = [0.0_f32, 0.0];
2944    let mut angle_range = 0.0_f32;
2945    let mut scale_range = [1.0_f32, 1.0];
2946    let mut emitter_misc_flags = 0u32;
2947    let mut emitter_color = [1.0_f32, 1.0, 1.0, 1.0];
2948
2949    for _ei in 0..num_emitters {
2950        // ReadCreatable: u16 classIdx
2951        let emitter_class = cursor.read_u16()?;
2952        if emitter_class & 0x8000 != 0 { continue; }
2953
2954        // plParticleEmitter::Read — first reads a generator via ReadCreatable
2955        let gen_class = cursor.read_u16()?;
2956        if gen_class & 0x8000 == 0 {
2957            log::debug!("  Emitter generator class: 0x{:04X}", gen_class);
2958            if gen_class == 0x02D8 {
2959                // plSimpleParticleGenerator::Read (0x02D8)
2960                let _gen_life = cursor.read_f32()?;
2961                let part_life_min = cursor.read_f32()?;
2962                let part_life_max = cursor.read_f32()?;
2963                particle_life_range = [part_life_min, part_life_max];
2964                let pps = cursor.read_f32()?;
2965                particles_per_second = pps;
2966                let num_sources = cursor.read_u32()?;
2967                for _ in 0..num_sources {
2968                    let px = cursor.read_f32()?;
2969                    let py = cursor.read_f32()?;
2970                    let pz = cursor.read_f32()?;
2971                    emitter_sources.push([px, py, pz]);
2972                    let _pitch = cursor.read_f32()?;
2973                    let _yaw = cursor.read_f32()?;
2974                }
2975                let ang = cursor.read_f32()?;
2976                angle_range = ang;
2977                let vel_min = cursor.read_f32()?;
2978                let vel_max = cursor.read_f32()?;
2979                velocity_range = [vel_min, vel_max];
2980                let x_size = cursor.read_f32()?;
2981                let y_size = cursor.read_f32()?;
2982                particle_size = [x_size, y_size];
2983                let sc_min = cursor.read_f32()?;
2984                let sc_max = cursor.read_f32()?;
2985                scale_range = [sc_min, sc_max];
2986                cursor.skip(4 * 2)?; // massRange, radsPerSec
2987            } else if gen_class == 0x0336 {
2988                // plOneTimeParticleGenerator: positions + directions
2989                let count = cursor.read_u32()?;
2990                let x_size = cursor.read_f32()?;
2991                let y_size = cursor.read_f32()?;
2992                particle_size = [x_size, y_size];
2993                let sc_min = cursor.read_f32()?;
2994                let sc_max = cursor.read_f32()?;
2995                scale_range = [sc_min, sc_max];
2996                cursor.skip(4)?; // radsPerSecRange
2997                for _ in 0..count {
2998                    let px = cursor.read_f32()?;
2999                    let py = cursor.read_f32()?;
3000                    let pz = cursor.read_f32()?;
3001                    emitter_sources.push([px, py, pz]);
3002                }
3003                cursor.skip((count as usize) * 12)?; // skip direction vectors
3004            } else {
3005                // Unknown generator type — can't skip reliably
3006                break;
3007            }
3008        }
3009        // Rest of plParticleEmitter::Read: u32 spanIndex + u32 maxParticles + u32 miscFlags + hsColorRGBA
3010        // C++ ref: plParticleEmitter.h:106-129 — miscFlags contains orientation + normal modes
3011        let _span_idx = cursor.read_u32()?;
3012        let _max_parts = cursor.read_u32()?;
3013        let misc_flags = cursor.read_u32()?;
3014        emitter_misc_flags = misc_flags;
3015        let cr = cursor.read_f32()?;
3016        let cg = cursor.read_f32()?;
3017        let cb = cursor.read_f32()?;
3018        let ca = cursor.read_f32()?;
3019        emitter_color = [cr, cg, cb, ca];
3020    }
3021
3022    Ok(ParticleSystemData {
3023        name,
3024        material_name,
3025        x_tiles, y_tiles,
3026        max_particles,
3027        accel: [accel_x, accel_y, accel_z],
3028        pre_sim,
3029        drag,
3030        wind_mult,
3031        emitter_sources,
3032        particle_size,
3033        num_emitters,
3034        particle_life_range,
3035        particles_per_second,
3036        velocity_range,
3037        angle_range,
3038        scale_range,
3039        emitter_misc_flags,
3040        emitter_color,
3041    })
3042}
3043
3044// ============================================================================
3045// plWaveSet7 — water surface (class 0x00FB)
3046// C++ ref: plWaveSet7.cpp:332-380, plFixedWaterState7.cpp:113-160
3047// ============================================================================
3048
3049/// Parsed water surface data.
3050/// C++ ref: plFixedWaterState7 (plFixedWaterState7.h:52-129)
3051#[derive(Debug, Clone)]
3052pub struct WaveSetData {
3053    pub name: String,
3054    pub water_height: f32,
3055    pub water_tint: [f32; 4],  // RGBA
3056    pub opacity: f32,
3057    pub max_length: f32,
3058    /// GeoState: wave geometry parameters (plFixedWaterState7::GeoState)
3059    pub geo_max_len: f32,
3060    pub geo_min_len: f32,
3061    pub geo_amp_over_len: f32,
3062    pub geo_chop: f32,
3063    pub geo_angle_dev: f32,
3064    /// Wind direction (normalized 2D, stored as 3 floats in PRP)
3065    pub wind_dir: [f32; 3],
3066}
3067
3068/// Parse plWaveSet7 from PRP object data.
3069///
3070/// C++ ref: plWaveSet7.cpp:332-380
3071/// Inheritance: plWaveSet7 → plMultiModifier → plModifier → plSynchedObject
3072pub fn parse_wave_set(data: &[u8]) -> Result<WaveSetData> {
3073    use crate::core::uoid::read_key_uoid;
3074    let mut cursor = Cursor::new(data);
3075
3076    let _class_idx = cursor.read_i16()?;
3077    let self_key = read_key_uoid(&mut cursor)?;
3078    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
3079
3080    // plSynchedObject::Read
3081    skip_synched_object(&mut cursor)?;
3082
3083    // plMultiModifier::Read — hsBitVector fFlags
3084    let num_bv = cursor.read_u32()?;
3085    let mut flags = 0u32;
3086    for i in 0..num_bv {
3087        let w = cursor.read_u32()?;
3088        if i == 0 { flags = w; }
3089    }
3090
3091    // plWaveSet7::Read — fMaxLen
3092    let max_length = cursor.read_f32()?;
3093
3094    // plFixedWaterState7::Read — 60 float values total
3095    // C++ ref: plFixedWaterState7.cpp:113-160, plFixedWaterState7.h:52-129
3096    //
3097    // GeoState: 5 floats (maxLen, minLen, ampOverLen, chop, angleDev)
3098    let geo_max_len = cursor.read_f32()?;
3099    let geo_min_len = cursor.read_f32()?;
3100    let geo_amp_over_len = cursor.read_f32()?;
3101    let geo_chop = cursor.read_f32()?;
3102    let geo_angle_dev = cursor.read_f32()?;
3103
3104    // TexState: 5 floats (maxLen, minLen, ampOverLen, chop, angleDev)
3105    cursor.skip(5 * 4)?;
3106
3107    // RippleScale: 1 float
3108    cursor.skip(4)?;
3109
3110    // WindDir: 3 floats (hsVector3)
3111    let wind_x = cursor.read_f32()?;
3112    let wind_y = cursor.read_f32()?;
3113    let wind_z = cursor.read_f32()?;
3114
3115    // SpecVec: 3 floats (hsVector3)
3116    cursor.skip(3 * 4)?;
3117
3118    // WaterHeight: 1 float
3119    let water_height = cursor.read_f32()?;
3120
3121    // WaterOffset: 3 floats (hsVector3, NOT 1 float!)
3122    cursor.skip(3 * 4)?;
3123
3124    // MaxAtten: 3 floats (hsVector3)
3125    cursor.skip(3 * 4)?;
3126
3127    // MinAtten: 3 floats (hsVector3)
3128    cursor.skip(3 * 4)?;
3129
3130    // DepthFalloff: 3 floats (hsVector3)
3131    cursor.skip(3 * 4)?;
3132
3133    // Wispiness: 1 float
3134    cursor.skip(4)?;
3135
3136    // ShoreTint: 4 floats (hsColorRGBA)
3137    cursor.skip(4 * 4)?;
3138
3139    // MaxColor: 4 floats (hsColorRGBA)
3140    cursor.skip(4 * 4)?;
3141
3142    // MinColor: 4 floats (hsColorRGBA)
3143    cursor.skip(4 * 4)?;
3144
3145    // EdgeOpac: 1 float
3146    let opacity = cursor.read_f32()?;
3147
3148    // EdgeRadius: 1 float
3149    cursor.skip(4)?;
3150
3151    // Period, FingerLength: 2 floats
3152    cursor.skip(2 * 4)?;
3153
3154    // WaterTint: 4 floats (hsColorRGBA)
3155    let r = cursor.read_f32()?;
3156    let g = cursor.read_f32()?;
3157    let b = cursor.read_f32()?;
3158    let a = cursor.read_f32()?;
3159
3160    Ok(WaveSetData {
3161        name,
3162        water_height,
3163        water_tint: [r, g, b, a],
3164        opacity,
3165        max_length,
3166        geo_max_len,
3167        geo_min_len,
3168        geo_amp_over_len,
3169        geo_chop,
3170        geo_angle_dev,
3171        wind_dir: [wind_x, wind_y, wind_z],
3172    })
3173}
3174
3175// ============================================================================
3176// plWin32Sound — sound object (classes 0x0096, 0x0084, 0x00FD)
3177// C++ ref: plSound.cpp:1223-1273, plWin32Sound.cpp:456-460
3178// ============================================================================
3179
3180/// Parsed sound data — minimal fields for logging.
3181#[derive(Debug, Clone)]
3182pub struct SoundData {
3183    pub name: String,
3184    pub volume: f32,
3185    pub looping: bool,
3186    pub is_3d: bool,
3187    pub auto_start: bool,
3188    pub sound_type: u8, // 0=SFX, 1=Ambience, 2=Music, 3=GUI, 4=NPCVoices
3189    /// Name of the sound buffer (WAV/OGG file reference).
3190    pub buffer_name: Option<String>,
3191    /// World position for 3D sounds (from scene object transform).
3192    pub position: [f32; 3],
3193    /// Min falloff distance (full volume within this range).
3194    pub min_falloff: f32,
3195    /// Max falloff distance (silent beyond this range).
3196    pub max_falloff: f32,
3197    /// Soft volume region key name (for volume-based attenuation).
3198    /// C++ ref: plSound::fSoftRegion → plSoftVolume, read at plSound.cpp:1263
3199    pub soft_region: Option<String>,
3200}
3201
3202/// Parse plWin32StaticSound or plWin32StreamingSound from PRP object data.
3203///
3204/// C++ ref: plSound.cpp:1223-1273 (IRead), plWin32Sound.cpp:456-460
3205/// Inheritance: plWin32StaticSound → plWin32Sound → plSound → plSynchedObject
3206pub fn parse_win32_sound(data: &[u8]) -> Result<SoundData> {
3207    use crate::core::uoid::read_key_uoid;
3208    let mut cursor = Cursor::new(data);
3209
3210    // Creatable class index
3211    let _class_idx = cursor.read_i16()?;
3212
3213    // hsKeyedObject::Read — self-key
3214    let self_key = read_key_uoid(&mut cursor)?;
3215    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
3216
3217    // plSynchedObject::Read
3218    skip_synched_object(&mut cursor)?;
3219
3220    // plSound::IRead — C++ ref: plSound.cpp:1223-1273
3221    let _playing = cursor.read_u8()?;           // bool fPlaying
3222    let _time = cursor.read_f32()?;             // f64 fTime (only 4 bytes in some versions)
3223    // Actually it's 8 bytes (double)
3224    cursor.skip(4)?;                            // remaining 4 bytes of f64
3225    let max_falloff = cursor.read_i32()? as f32;
3226    let min_falloff = cursor.read_i32()? as f32;
3227    let _curr_volume = cursor.read_f32()?;
3228    let desired_vol = cursor.read_f32()?;
3229    let _outer_vol = cursor.read_i32()?;
3230    let _inner_cone = cursor.read_i32()?;
3231    let _outer_cone = cursor.read_i32()?;
3232    let _faded_volume = cursor.read_f32()?;
3233    let properties = cursor.read_u32()?;
3234    let sound_type = cursor.read_u8()?;
3235    let _priority = cursor.read_u8()?;
3236
3237    // Fade in params: f32 length, f32 volStart, f32 volEnd, u8 type, f32 currTime, u32 stopWhenDone, u32 fadeSoftVol
3238    cursor.skip(4 + 4 + 4 + 1 + 4 + 4 + 4)?;
3239    // Fade out params: same
3240    cursor.skip(4 + 4 + 4 + 1 + 4 + 4 + 4)?;
3241
3242    // Keys: softRegion, dataBufferKey
3243    let soft_region_key = read_key_uoid(&mut cursor)?;
3244    let soft_region = soft_region_key.map(|k| k.object_name);
3245    let buffer_key = read_key_uoid(&mut cursor)?;
3246    let buffer_name = buffer_key.map(|k| k.object_name);
3247
3248    let looping = properties & 0x00000004 != 0;  // kPropLooping
3249    let is_3d = properties & 0x00000001 != 0;     // kPropIs3DSound
3250    let auto_start = properties & 0x00000008 != 0; // kPropAutoStart
3251
3252    Ok(SoundData {
3253        name,
3254        volume: desired_vol,
3255        looping,
3256        is_3d,
3257        auto_start,
3258        sound_type,
3259        buffer_name,
3260        position: [0.0, 0.0, 0.0], // Position set from scene object transform
3261        min_falloff,
3262        max_falloff,
3263        soft_region,
3264    })
3265}
3266
3267#[cfg(test)]
3268mod tests {
3269    use super::*;
3270    use std::path::Path;
3271
3272    #[test]
3273    fn test_parse_one_shot_mods() {
3274        let prp_path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
3275        if !prp_path.exists() { return; }
3276
3277        let page = PrpPage::from_file(prp_path).unwrap();
3278        let keys: Vec<_> = page.keys_of_type(crate::core::class_index::ClassIndex::PL_ONE_SHOT_MOD)
3279            .iter().cloned().cloned().collect();
3280
3281        assert!(keys.len() >= 10, "Cleft should have 14+ OneShotMod objects, got {}", keys.len());
3282
3283        let mut ok = 0;
3284        for key in &keys {
3285            if let Some(data) = page.object_data(key) {
3286                let osm = parse_one_shot_mod(data)
3287                    .unwrap_or_else(|e| panic!("Failed to parse OneShotMod '{}': {}", key.object_name, e));
3288                assert!(!osm.anim_name.is_empty(), "OneShotMod '{}' has empty anim_name", key.object_name);
3289                assert!(osm.seek_duration >= 0.0, "OneShotMod '{}' has negative seek_duration", key.object_name);
3290                ok += 1;
3291            }
3292        }
3293        assert_eq!(ok, keys.len(), "All OneShotMod objects should parse successfully");
3294    }
3295
3296    #[test]
3297    fn test_responder_oneshot_callbacks_parse() {
3298        // Verify responders with plOneShotMsg parse correctly after the callback fix
3299        let prp_path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
3300        if !prp_path.exists() { return; }
3301
3302        let page = PrpPage::from_file(prp_path).unwrap();
3303        let mut ok = 0;
3304        let mut err = 0;
3305        for key in page.keys_of_type(crate::core::class_index::ClassIndex::PL_RESPONDER_MODIFIER) {
3306            if let Some(data) = page.object_data(key) {
3307                match parse_responder_modifier(data) {
3308                    Ok(resp) => {
3309                        // Count it as "ok" if at least one state has commands
3310                        let n_cmds: usize = resp.states.iter().map(|s| s.commands.len()).sum();
3311                        if n_cmds > 0 || resp.enabled {
3312                            ok += 1;
3313                        }
3314                    }
3315                    Err(_) => err += 1,
3316                }
3317            }
3318        }
3319        // The runtime counts Ok results, not command completeness
3320        // 261 keys with 88+ Result::Ok is expected given unknown command types
3321        assert!(ok >= 80, "At least 80 responders should parse Ok, got {} (err={})", ok, err);
3322    }
3323
3324    #[test]
3325    fn test_px_physical_parse() {
3326        let prp_path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
3327        if !prp_path.exists() { return; }
3328
3329        let page = PrpPage::from_file(prp_path).unwrap();
3330        let keys: Vec<_> = page.keys_of_type(crate::core::class_index::ClassIndex::PL_PXPHYSICAL)
3331            .iter().cloned().cloned().collect();
3332
3333        assert!(keys.len() >= 50, "Cleft should have 100+ PXPhysical objects, got {}", keys.len());
3334
3335        let mut ok = 0;
3336        let mut fail = 0;
3337        let mut trimesh_count = 0;
3338        let mut hull_count = 0;
3339        let mut total_verts = 0;
3340        for key in &keys {
3341            if let Some(data) = page.object_data(key) {
3342                match parse_px_physical(data) {
3343                    Ok(phys) => {
3344                        match &phys.shape {
3345                            PhysShapeData::TriMesh { vertices, indices } => {
3346                                trimesh_count += 1;
3347                                total_verts += vertices.len();
3348                                assert!(indices.len() % 3 == 0, "trimesh {} indices not multiple of 3", phys.name);
3349                            }
3350                            PhysShapeData::Hull { vertices } => {
3351                                hull_count += 1;
3352                                total_verts += vertices.len();
3353                            }
3354                            _ => {}
3355                        }
3356                        ok += 1;
3357                    }
3358                    Err(e) => {
3359                        eprintln!("FAIL: {} — {}", key.object_name, e);
3360                        fail += 1;
3361                    }
3362                }
3363            }
3364        }
3365        eprintln!("PXPhysical: {} ok, {} fail, {} trimesh, {} hull, {} total verts",
3366            ok, fail, trimesh_count, hull_count, total_verts);
3367        // Most physicals should parse — allow a few failures for edge cases
3368        assert!(ok >= 100, "At least 100 PXPhysical should parse, got {} (fail={})", ok, fail);
3369    }
3370
3371}
3372
3373// ============================================================================
3374// plPXPhysical — physics collision object (class 0x003F)
3375// C++ ref: plGenericPhysical.cpp:340-387, plPXCooking.cpp
3376// ============================================================================
3377
3378/// Bounds/shape type for a physical object.
3379/// C++ ref: plSimDefs.h — Bounds enum
3380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3381pub enum PhysBoundsType {
3382    Box = 1,
3383    Sphere = 2,
3384    Hull = 3,
3385    Proxy = 4,
3386    Explicit = 5,
3387}
3388
3389/// Collision group for a physical object.
3390/// C++ ref: plSimDefs.h — Group enum
3391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3392pub enum PhysGroup {
3393    Static = 0,
3394    AvatarBlocker = 1,
3395    DynamicBlocker = 2,
3396    Avatar = 3,
3397    Dynamic = 4,
3398    Detector = 5,
3399    LOSOnly = 6,
3400    ExcludeRegion = 7,
3401    Max = 255,
3402}
3403
3404/// Shape data parsed from a plPXPhysical.
3405#[derive(Debug, Clone)]
3406pub enum PhysShapeData {
3407    Sphere { radius: f32, offset: [f32; 3] },
3408    Box { dimensions: [f32; 3], offset: [f32; 3] },
3409    Hull { vertices: Vec<[f32; 3]> },
3410    TriMesh { vertices: Vec<[f32; 3]>, indices: Vec<u32> },
3411}
3412
3413/// Parsed plPXPhysical data — collision geometry for one physical object.
3414#[derive(Debug, Clone)]
3415pub struct PxPhysicalData {
3416    pub name: String,
3417    pub mass: f32,
3418    pub friction: f32,
3419    pub restitution: f32,
3420    pub bounds: PhysBoundsType,
3421    pub group: PhysGroup,
3422    pub reports_on: u32,
3423    pub los_dbs: u16,
3424    pub position: [f32; 3],
3425    pub rotation: [f32; 4], // quaternion (x, y, z, w)
3426    pub shape: PhysShapeData,
3427}
3428
3429fn read_phys_group(val: u8) -> PhysGroup {
3430    match val {
3431        0 => PhysGroup::Static,
3432        1 => PhysGroup::AvatarBlocker,
3433        2 => PhysGroup::DynamicBlocker,
3434        3 => PhysGroup::Avatar,
3435        4 => PhysGroup::Dynamic,
3436        5 => PhysGroup::Detector,
3437        6 => PhysGroup::LOSOnly,
3438        7 => PhysGroup::ExcludeRegion,
3439        _ => PhysGroup::Max,
3440    }
3441}
3442
3443fn read_phys_bounds(val: u8) -> Result<PhysBoundsType> {
3444    match val {
3445        1 => Ok(PhysBoundsType::Box),
3446        2 => Ok(PhysBoundsType::Sphere),
3447        3 => Ok(PhysBoundsType::Hull),
3448        4 => Ok(PhysBoundsType::Proxy),
3449        5 => Ok(PhysBoundsType::Explicit),
3450        _ => bail!("Unknown bounds type: {}", val),
3451    }
3452}
3453
3454/// Read hsPoint3 (3 × f32 LE) into [f32; 3].
3455fn read_point3(reader: &mut impl std::io::Read) -> Result<[f32; 3]> {
3456    let mut buf = [0u8; 12];
3457    reader.read_exact(&mut buf)?;
3458    Ok([
3459        f32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
3460        f32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
3461        f32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
3462    ])
3463}
3464
3465/// Read hsQuat (4 × f32 LE: x, y, z, w) into [f32; 4].
3466/// C++ ref: hsQuat.cpp:246-252 — reads fX, fY, fZ, fW
3467fn read_quat(reader: &mut impl std::io::Read) -> Result<[f32; 4]> {
3468    let mut buf = [0u8; 16];
3469    reader.read_exact(&mut buf)?;
3470    let mut q = [
3471        f32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
3472        f32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
3473        f32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
3474        f32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
3475    ];
3476    // C++ ref: plPXPhysical.cpp:220-221 — fix zero quat from bad exports
3477    if q[0] == 0.0 && q[1] == 0.0 && q[2] == 0.0 && q[3] == 0.0 {
3478        q[3] = 1.0;
3479    }
3480    Ok(q)
3481}
3482
3483/// Skip hsBitVector: u32 count + count × u32 words.
3484/// C++ ref: hsBitVector.cpp:90-101
3485fn skip_bit_vector(reader: &mut (impl std::io::Read + Seek)) -> Result<()> {
3486    let mut buf = [0u8; 4];
3487    reader.read_exact(&mut buf)?;
3488    let count = u32::from_le_bytes(buf) as usize;
3489    if count > 0 {
3490        let mut skip_buf = vec![0u8; count * 4];
3491        reader.read_exact(&mut skip_buf)?;
3492    }
3493    Ok(())
3494}
3495
3496/// Read hsBitVector: u32 count + count × u32 words. Returns the raw u32 words.
3497/// C++ ref: hsBitVector.cpp:90-101
3498fn read_bit_vector_words(reader: &mut impl std::io::Read) -> Result<Vec<u32>> {
3499    let mut buf = [0u8; 4];
3500    reader.read_exact(&mut buf)?;
3501    let count = u32::from_le_bytes(buf) as usize;
3502    let mut words = Vec::with_capacity(count);
3503    for _ in 0..count {
3504        reader.read_exact(&mut buf)?;
3505        words.push(u32::from_le_bytes(buf));
3506    }
3507    Ok(words)
3508}
3509
3510/// Read convex hull or trimesh data from an uncooked stream (HSP\x01 magic).
3511/// C++ ref: plPXCooking.cpp:90-97 (convex), 222-231 (trimesh)
3512fn read_uncooked_hull(reader: &mut (impl std::io::Read + Seek)) -> Result<PhysShapeData> {
3513    let mut buf4 = [0u8; 4];
3514    reader.read_exact(&mut buf4)?;
3515    let nverts = u32::from_le_bytes(buf4) as usize;
3516    let mut vertices = Vec::with_capacity(nverts);
3517    for _ in 0..nverts {
3518        vertices.push(read_point3(reader)?);
3519    }
3520    Ok(PhysShapeData::Hull { vertices })
3521}
3522
3523fn read_uncooked_trimesh(reader: &mut (impl std::io::Read + Seek)) -> Result<PhysShapeData> {
3524    let mut buf4 = [0u8; 4];
3525    reader.read_exact(&mut buf4)?;
3526    let nverts = u32::from_le_bytes(buf4) as usize;
3527    let mut vertices = Vec::with_capacity(nverts);
3528    for _ in 0..nverts {
3529        vertices.push(read_point3(reader)?);
3530    }
3531    reader.read_exact(&mut buf4)?;
3532    let nfaces = u32::from_le_bytes(buf4) as usize;
3533    let mut indices = Vec::with_capacity(nfaces * 3);
3534    for _ in 0..(nfaces * 3) {
3535        reader.read_exact(&mut buf4)?;
3536        indices.push(u32::from_le_bytes(buf4));
3537    }
3538    Ok(PhysShapeData::TriMesh { vertices, indices })
3539}
3540
3541/// Skip a max-dependent list: read u32 max, then `size` elements whose width depends on max.
3542/// C++ ref: plPXCooking.cpp:67-76
3543fn skip_max_dependent_list(reader: &mut (impl std::io::Read + Seek), size: usize) -> Result<()> {
3544    let mut buf4 = [0u8; 4];
3545    reader.read_exact(&mut buf4)?;
3546    let max_val = u32::from_le_bytes(buf4);
3547    let bytes_per_elem = if max_val > 0xFFFF { 4 } else if max_val > 0xFF { 2 } else { 1 };
3548    reader.seek(SeekFrom::Current((size * bytes_per_elem) as i64))?;
3549    Ok(())
3550}
3551
3552/// Read the suffix section common to both cooked convex and trimesh.
3553/// C++ ref: plPXCooking.cpp:78-86
3554fn read_cooked_suffix(reader: &mut (impl std::io::Read + Seek)) -> Result<()> {
3555    let mut buf4 = [0u8; 4];
3556    // HBM block — skip variable-length
3557    reader.read_exact(&mut buf4)?;
3558    let hbm_size = u32::from_le_bytes(buf4) as i64;
3559    reader.seek(SeekFrom::Current(hbm_size))?;
3560    // 11 floats
3561    reader.seek(SeekFrom::Current(11 * 4))?;
3562    // One more float; if > -1, skip 12 more floats
3563    let mut fbuf = [0u8; 4];
3564    reader.read_exact(&mut fbuf)?;
3565    let val = f32::from_le_bytes(fbuf);
3566    if val > -1.0 {
3567        reader.seek(SeekFrom::Current(12 * 4))?;
3568    }
3569    Ok(())
3570}
3571
3572/// Read cooked convex hull (NXS\x01 / CVXM format).
3573/// C++ ref: plPXCooking.cpp:90-218
3574fn read_cooked_hull(reader: &mut (impl std::io::Read + Seek)) -> Result<PhysShapeData> {
3575    let mut tag = [0u8; 4];
3576    let mut buf4 = [0u8; 4];
3577
3578    // CVXM header
3579    reader.read_exact(&mut tag)?;
3580    if &tag != b"CVXM" { bail!("Expected CVXM, got {:?}", tag); }
3581    reader.read_exact(&mut buf4)?; // version — must be 2
3582    reader.read_exact(&mut buf4)?; // unknown
3583
3584    // ICE + CLHL
3585    reader.read_exact(&mut tag)?; // ICE\x01
3586    reader.read_exact(&mut tag)?; // CLHL
3587    reader.read_exact(&mut buf4)?; // version 0
3588
3589    // ICE + CVHL
3590    reader.read_exact(&mut tag)?; // ICE\x01
3591    reader.read_exact(&mut tag)?; // CVHL
3592    reader.read_exact(&mut buf4)?; // version 5
3593
3594    reader.read_exact(&mut buf4)?;
3595    let num_verts = u32::from_le_bytes(buf4) as usize;
3596    reader.read_exact(&mut buf4)?;
3597    let num_tris = u32::from_le_bytes(buf4) as usize;
3598    reader.read_exact(&mut buf4)?;
3599    let unk2 = u32::from_le_bytes(buf4) as usize;
3600    reader.read_exact(&mut buf4)?;
3601    let _unk3 = u32::from_le_bytes(buf4) as usize;
3602    reader.read_exact(&mut buf4)?;
3603    let unk4 = u32::from_le_bytes(buf4) as usize;
3604    reader.read_exact(&mut buf4)?;
3605    let _unk5 = u32::from_le_bytes(buf4) as usize;
3606
3607    // Read vertices
3608    let mut vertices = Vec::with_capacity(num_verts);
3609    for _ in 0..num_verts {
3610        vertices.push(read_point3(reader)?);
3611    }
3612
3613    // Skip triangle indices (variable-size based on maxVertIndex)
3614    reader.read_exact(&mut buf4)?;
3615    let max_vert_index = u32::from_le_bytes(buf4);
3616    let idx_size = if max_vert_index > 0xFFFF { 4 } else if max_vert_index > 0xFF { 2 } else { 1 };
3617    reader.seek(SeekFrom::Current((num_tris * 3 * idx_size) as i64))?;
3618
3619    // Skip remaining data
3620    reader.seek(SeekFrom::Current(2))?; // u16
3621    reader.seek(SeekFrom::Current((num_verts * 2) as i64))?;
3622    reader.seek(SeekFrom::Current(12))?; // 3 floats
3623    reader.seek(SeekFrom::Current((_unk3 * 36) as i64))?;
3624    reader.seek(SeekFrom::Current(unk4 as i64))?;
3625
3626    skip_max_dependent_list(reader, unk4)?;
3627
3628    reader.seek(SeekFrom::Current(8))?; // 2 × u32
3629    reader.seek(SeekFrom::Current((unk2 * 2) as i64))?;
3630    reader.seek(SeekFrom::Current((unk2 * 2) as i64))?;
3631
3632    skip_max_dependent_list(reader, unk2)?;
3633    skip_max_dependent_list(reader, unk2)?;
3634    skip_max_dependent_list(reader, unk2)?;
3635    reader.seek(SeekFrom::Current((unk2 * 2) as i64))?;
3636
3637    // ICE + VALE
3638    reader.read_exact(&mut tag)?; // ICE\x01
3639    reader.read_exact(&mut tag)?; // VALE
3640    reader.read_exact(&mut buf4)?; // version 2
3641
3642    reader.read_exact(&mut buf4)?;
3643    let vale_unk1 = u32::from_le_bytes(buf4) as usize;
3644    reader.read_exact(&mut buf4)?;
3645    let vale_unk2 = u32::from_le_bytes(buf4) as usize;
3646
3647    skip_max_dependent_list(reader, vale_unk1)?;
3648    reader.seek(SeekFrom::Current(vale_unk2 as i64))?;
3649
3650    read_cooked_suffix(reader)?;
3651
3652    // Optional SUPM section for large hulls (> 0x20 verts)
3653    if num_verts > 0x20 {
3654        // ICE + SUPM
3655        reader.read_exact(&mut tag)?;
3656        reader.read_exact(&mut tag)?;
3657        reader.read_exact(&mut buf4)?;
3658        // ICE + GAUS
3659        reader.read_exact(&mut tag)?;
3660        reader.read_exact(&mut tag)?;
3661        reader.read_exact(&mut buf4)?;
3662
3663        reader.read_exact(&mut buf4)?; // u32
3664        reader.read_exact(&mut buf4)?;
3665        let gaus_unk2 = u32::from_le_bytes(buf4) as usize;
3666        reader.seek(SeekFrom::Current((gaus_unk2 * 2) as i64))?;
3667    }
3668
3669    Ok(PhysShapeData::Hull { vertices })
3670}
3671
3672/// Read cooked triangle mesh (NXS\x01 / MESH format).
3673/// C++ ref: plPXCooking.cpp:222-289
3674fn read_cooked_trimesh(reader: &mut (impl std::io::Read + Seek)) -> Result<PhysShapeData> {
3675    let mut tag = [0u8; 4];
3676    let mut buf4 = [0u8; 4];
3677
3678    // MESH header
3679    reader.read_exact(&mut tag)?;
3680    if &tag != b"MESH" { bail!("Expected MESH, got {:?}", tag); }
3681    reader.read_exact(&mut buf4)?; // version 0
3682
3683    reader.read_exact(&mut buf4)?;
3684    let flags = u32::from_le_bytes(buf4);
3685    reader.seek(SeekFrom::Current(4))?; // float
3686    reader.seek(SeekFrom::Current(4))?; // u32
3687    reader.seek(SeekFrom::Current(4))?; // float
3688
3689    reader.read_exact(&mut buf4)?;
3690    let num_verts = u32::from_le_bytes(buf4) as usize;
3691    reader.read_exact(&mut buf4)?;
3692    let num_tris = u32::from_le_bytes(buf4) as usize;
3693
3694    // Read vertices
3695    let mut vertices = Vec::with_capacity(num_verts);
3696    for _ in 0..num_verts {
3697        vertices.push(read_point3(reader)?);
3698    }
3699
3700    // Read indices — size depends on flags
3701    let mut indices = Vec::with_capacity(num_tris * 3);
3702    for _ in 0..(num_tris * 3) {
3703        let idx = if flags & 0x08 != 0 {
3704            let mut b = [0u8; 1];
3705            reader.read_exact(&mut b)?;
3706            b[0] as u32
3707        } else if flags & 0x10 != 0 {
3708            let mut b = [0u8; 2];
3709            reader.read_exact(&mut b)?;
3710            u16::from_le_bytes(b) as u32
3711        } else {
3712            reader.read_exact(&mut buf4)?;
3713            u32::from_le_bytes(buf4)
3714        };
3715        indices.push(idx);
3716    }
3717
3718    // Skip optional material indices
3719    if flags & 0x01 != 0 {
3720        reader.seek(SeekFrom::Current((num_tris * 2) as i64))?;
3721    }
3722    if flags & 0x02 != 0 {
3723        reader.read_exact(&mut buf4)?;
3724        let max_val = u32::from_le_bytes(buf4);
3725        let elem_size = if max_val > 0xFFFF { 4 } else if max_val > 0xFF { 2 } else { 1 };
3726        reader.seek(SeekFrom::Current((num_tris * elem_size) as i64))?;
3727    }
3728
3729    // Skip convex/flat parts
3730    reader.read_exact(&mut buf4)?;
3731    let num_convex_parts = u32::from_le_bytes(buf4) as usize;
3732    reader.read_exact(&mut buf4)?;
3733    let num_flat_parts = u32::from_le_bytes(buf4) as usize;
3734
3735    if num_convex_parts > 0 {
3736        reader.seek(SeekFrom::Current((num_tris * 2) as i64))?;
3737    }
3738    if num_flat_parts > 0 {
3739        let elem_size = if num_flat_parts > 0xFF { 2 } else { 1 };
3740        reader.seek(SeekFrom::Current((num_tris * elem_size) as i64))?;
3741    }
3742
3743    read_cooked_suffix(reader)?;
3744
3745    // Optional extra block
3746    reader.read_exact(&mut buf4)?;
3747    let extra = u32::from_le_bytes(buf4);
3748    if extra != 0 {
3749        reader.seek(SeekFrom::Current(num_tris as i64))?;
3750    }
3751
3752    Ok(PhysShapeData::TriMesh { vertices, indices })
3753}
3754
3755/// Read shape data (convex hull or trimesh) by detecting HSP/NXS magic.
3756fn read_mesh_shape(reader: &mut (impl std::io::Read + Seek), is_hull: bool) -> Result<PhysShapeData> {
3757    let mut magic = [0u8; 4];
3758    reader.read_exact(&mut magic)?;
3759
3760    if &magic == b"HSP\x01" {
3761        // Uncooked format
3762        if is_hull {
3763            read_uncooked_hull(reader)
3764        } else {
3765            read_uncooked_trimesh(reader)
3766        }
3767    } else if &magic == b"NXS\x01" {
3768        // Cooked PhysX format
3769        if is_hull {
3770            read_cooked_hull(reader)
3771        } else {
3772            read_cooked_trimesh(reader)
3773        }
3774    } else {
3775        bail!("Unknown mesh magic: {:02x}{:02x}{:02x}{:02x}", magic[0], magic[1], magic[2], magic[3]);
3776    }
3777}
3778
3779/// Parse plPXPhysical from PRP object data.
3780///
3781/// C++ ref: plGenericPhysical.cpp:340-387
3782/// Inheritance: plPXPhysical → plPhysical → plSynchedObject → hsKeyedObject
3783pub fn parse_px_physical(data: &[u8]) -> Result<PxPhysicalData> {
3784    use crate::core::uoid::read_key_uoid;
3785    let mut cursor = Cursor::new(data);
3786
3787    // Creatable class index (i16)
3788    let _class_idx = cursor.read_i16()?;
3789
3790    // hsKeyedObject::Read — self-key
3791    let self_key = read_key_uoid(&mut cursor)?;
3792    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
3793
3794    // plSynchedObject::Read
3795    skip_synched_object(&mut cursor)?;
3796
3797    // plPXPhysical-specific data (plGenericPhysical.cpp:345-380)
3798    let mass = cursor.read_f32()?;
3799    let friction = cursor.read_f32()?;
3800    let restitution = cursor.read_f32()?;
3801    let bounds_raw = cursor.read_u8()?;
3802    let group_raw = cursor.read_u8()?;
3803    let reports_on = cursor.read_u32()?;
3804    let los_dbs = cursor.read_u16()?;
3805
3806    // C++ ref: plGenericPhysical.cpp:353-355 — swim region hack
3807    let mut group = read_phys_group(group_raw);
3808    if los_dbs == 0x0080 { // kLOSDBSwimRegion
3809        group = PhysGroup::Max;
3810    }
3811
3812    let bounds = read_phys_bounds(bounds_raw)?;
3813
3814    // 4 keys: objectKey, sceneNode, worldKey, soundGroup
3815    let _object_key = read_key_uoid(&mut cursor)?;
3816    let _scene_node = read_key_uoid(&mut cursor)?;
3817    let _world_key = read_key_uoid(&mut cursor)?;
3818    let _sound_group = read_key_uoid(&mut cursor)?;
3819
3820    // Transform: hsPoint3 position + hsQuat rotation
3821    let position = read_point3(&mut cursor)?;
3822    let rotation = read_quat(&mut cursor)?;
3823
3824    // hsBitVector fProps — skip
3825    skip_bit_vector(&mut cursor)?;
3826
3827    // Shape data based on bounds type
3828    // C++ ref: plGenericPhysical.cpp:370-380
3829    let shape = match bounds {
3830        PhysBoundsType::Sphere => {
3831            let radius = cursor.read_f32()?;
3832            let offset = read_point3(&mut cursor)?;
3833            PhysShapeData::Sphere { radius, offset }
3834        }
3835        PhysBoundsType::Box => {
3836            let dimensions = read_point3(&mut cursor)?;
3837            let offset = read_point3(&mut cursor)?;
3838            PhysShapeData::Box { dimensions, offset }
3839        }
3840        PhysBoundsType::Hull => {
3841            read_mesh_shape(&mut cursor, true)?
3842        }
3843        PhysBoundsType::Proxy | PhysBoundsType::Explicit => {
3844            read_mesh_shape(&mut cursor, false)?
3845        }
3846    };
3847
3848    Ok(PxPhysicalData {
3849        name,
3850        mass,
3851        friction,
3852        restitution,
3853        bounds,
3854        group,
3855        reports_on,
3856        los_dbs,
3857        position,
3858        rotation,
3859        shape,
3860    })
3861}
3862
3863// ============================================================================
3864// plVisRegion (0x0116) — visibility region
3865// ============================================================================
3866
3867/// A visibility region — groups objects by visibility.
3868/// C++ ref: plVisRegion.h — properties kDisable=0, kIsNot=1, kReplaceNormal=2, kDisableNormal=3
3869#[derive(Debug, Clone)]
3870pub struct VisRegionData {
3871    pub self_key: Option<crate::core::uoid::Uoid>,
3872    /// Key to the soft volume region (plRegionBase / plSoftVolume).
3873    pub region_key: Option<crate::core::uoid::Uoid>,
3874    /// If true, this region disables normal rendering when active.
3875    pub disable_normal: bool,
3876    /// If true, this is a "not" region — excludes objects from rendering.
3877    pub is_not: bool,
3878    /// If true, replaces normal visibility (default: true).
3879    pub replace_normal: bool,
3880    /// If true, region is disabled and always returns false.
3881    pub disabled: bool,
3882}
3883
3884impl Default for VisRegionData {
3885    fn default() -> Self {
3886        Self {
3887            self_key: None,
3888            region_key: None,
3889            disable_normal: false,
3890            is_not: false,
3891            replace_normal: true,
3892            disabled: false,
3893        }
3894    }
3895}
3896
3897/// Parse plVisRegion from PRP object data.
3898/// C++ ref: plVisRegion.cpp:128-137
3899///
3900/// Wire format:
3901///   plObjInterface::Read:
3902///     plSynchedObject::Read:
3903///       hsKeyedObject::Read (self-key)
3904///       synch_flags(u32) + optional exclude/volatile strings
3905///     owner key
3906///     fProps (hsBitVector)
3907///   region key (plSoftVolume / plRegionBase)
3908///   visMgr key (plVisMgr)
3909pub fn parse_vis_region(data: &[u8]) -> Result<VisRegionData> {
3910    use crate::core::uoid::read_key_uoid;
3911    let mut cursor = Cursor::new(data);
3912
3913    // Creatable class index
3914    let class_idx = cursor.read_i16()?;
3915    if class_idx < 0 { bail!("Null creatable in plVisRegion"); }
3916
3917    // hsKeyedObject::Read — self-key
3918    let self_key = read_key_uoid(&mut cursor)?;
3919
3920    // plSynchedObject::Read
3921    skip_synched_object(&mut cursor)?;
3922
3923    // plObjInterface::Read — owner key
3924    let _owner_key = read_key_uoid(&mut cursor)?;
3925
3926    // plObjInterface::Read — fProps (hsBitVector)
3927    let prop_words = read_bit_vector_words(&mut cursor)?;
3928
3929    // Extract property bits
3930    // C++ plVisRegion.h: kDisable=0, kIsNot=1, kReplaceNormal=2, kDisableNormal=3
3931    let get_bit = |bit: usize| -> bool {
3932        let word_idx = bit / 32;
3933        let bit_idx = bit % 32;
3934        word_idx < prop_words.len() && (prop_words[word_idx] & (1 << bit_idx)) != 0
3935    };
3936
3937    let disabled = get_bit(0);       // kDisable
3938    let is_not = get_bit(1);         // kIsNot
3939    let replace_normal = get_bit(2); // kReplaceNormal
3940    let disable_normal = get_bit(3); // kDisableNormal
3941
3942    // region key (kRefRegion → plSoftVolume)
3943    let region_key = read_key_uoid(&mut cursor)?;
3944
3945    // visMgr key (kRefVisMgr → plVisMgr) — we read but don't need it (global singleton)
3946    let _vis_mgr_key = read_key_uoid(&mut cursor)?;
3947
3948    Ok(VisRegionData {
3949        self_key,
3950        region_key,
3951        disable_normal,
3952        is_not,
3953        replace_normal,
3954        disabled,
3955    })
3956}
3957
3958// ============================================================================
3959// plVolumeIsect types — inline creatables inside plSoftVolumeSimple
3960// ============================================================================
3961
3962/// Parsed volume intersection geometry.
3963/// Used by plSoftVolumeSimple for containment testing.
3964#[derive(Debug, Clone)]
3965pub enum VolumeIsectData {
3966    /// plConvexIsect (0x02FA): convex hull of half-planes.
3967    /// C++ ref: plVolumeIsect.cpp:737-751
3968    Convex {
3969        planes: Vec<ConvexPlane>,
3970    },
3971    /// plSphereIsect (0x02F6): sphere containment.
3972    /// C++ ref: plVolumeIsect.cpp:147-154
3973    Sphere {
3974        center: [f32; 3],
3975        world_center: [f32; 3],
3976        radius: f32,
3977        mins: [f32; 3],
3978        maxs: [f32; 3],
3979    },
3980    /// plCylinderIsect (0x02F8): cylinder containment.
3981    /// C++ ref: plVolumeIsect.cpp:497-508
3982    Cylinder {
3983        top: [f32; 3],
3984        bot: [f32; 3],
3985        radius: f32,
3986        world_bot: [f32; 3],
3987        world_norm: [f32; 3],
3988        length: f32,
3989        min: f32,
3990        max: f32,
3991    },
3992    /// plParallelIsect (0x02F9): pairs of parallel planes.
3993    /// C++ ref: plVolumeIsect.cpp:607-621
3994    Parallel {
3995        planes: Vec<ParallelPlane>,
3996    },
3997    /// plConeIsect (0x02F7): cone containment.
3998    /// C++ ref: plVolumeIsect.cpp:325-345
3999    Cone {
4000        capped: bool,
4001        rad_angle: f32,
4002        length: f32,
4003        world_tip: [f32; 3],
4004        world_norm: [f32; 3],
4005        norms: Vec<[f32; 3]>,
4006        dists: Vec<f32>,
4007    },
4008}
4009
4010#[derive(Debug, Clone)]
4011pub struct ConvexPlane {
4012    pub norm: [f32; 3],
4013    pub pos: [f32; 3],
4014    pub dist: f32,
4015    pub world_norm: [f32; 3],
4016    pub world_dist: f32,
4017}
4018
4019#[derive(Debug, Clone)]
4020pub struct ParallelPlane {
4021    pub norm: [f32; 3],
4022    pub min: f32,
4023    pub max: f32,
4024    pub pos_one: [f32; 3],
4025    pub pos_two: [f32; 3],
4026}
4027
4028/// Read an inline plVolumeIsect creatable (class_index + data).
4029/// C++ ref: plResManager::ReadCreatable — u16 class, then object::Read()
4030fn read_volume_isect(reader: &mut (impl std::io::Read + Seek)) -> Result<Option<VolumeIsectData>> {
4031    use crate::core::class_index::ClassIndex;
4032
4033    let class_idx = reader.read_u16()?;
4034    if class_idx == 0x8000 {
4035        return Ok(None); // nil creatable
4036    }
4037
4038    match class_idx {
4039        ClassIndex::PL_CONVEX_ISECT => {
4040            // C++ plConvexIsect::Read — u16 n, then n planes
4041            let n = reader.read_u16()? as usize;
4042            let mut planes = Vec::with_capacity(n);
4043            for _ in 0..n {
4044                let norm = read_point3(reader)?;
4045                let pos = read_point3(reader)?;
4046                let dist = reader.read_f32()?;
4047                let world_norm = read_point3(reader)?;
4048                let world_dist = reader.read_f32()?;
4049                planes.push(ConvexPlane { norm, pos, dist, world_norm, world_dist });
4050            }
4051            Ok(Some(VolumeIsectData::Convex { planes }))
4052        }
4053        ClassIndex::PL_SPHERE_ISECT => {
4054            // C++ plSphereIsect::Read
4055            let center = read_point3(reader)?;
4056            let world_center = read_point3(reader)?;
4057            let radius = reader.read_f32()?;
4058            let mins = read_point3(reader)?;
4059            let maxs = read_point3(reader)?;
4060            Ok(Some(VolumeIsectData::Sphere { center, world_center, radius, mins, maxs }))
4061        }
4062        ClassIndex::PL_CYLINDER_ISECT => {
4063            // C++ plCylinderIsect::Read
4064            let top = read_point3(reader)?;
4065            let bot = read_point3(reader)?;
4066            let radius = reader.read_f32()?;
4067            let world_bot = read_point3(reader)?;
4068            let world_norm = read_point3(reader)?;
4069            let length = reader.read_f32()?;
4070            let min = reader.read_f32()?;
4071            let max = reader.read_f32()?;
4072            Ok(Some(VolumeIsectData::Cylinder { top, bot, radius, world_bot, world_norm, length, min, max }))
4073        }
4074        ClassIndex::PL_PARALLEL_ISECT => {
4075            // C++ plParallelIsect::Read — u16 n, then n plane pairs
4076            let n = reader.read_u16()? as usize;
4077            let mut planes = Vec::with_capacity(n);
4078            for _ in 0..n {
4079                let norm = read_point3(reader)?;
4080                let min = reader.read_f32()?;
4081                let max = reader.read_f32()?;
4082                let pos_one = read_point3(reader)?;
4083                let pos_two = read_point3(reader)?;
4084                planes.push(ParallelPlane { norm, min, max, pos_one, pos_two });
4085            }
4086            Ok(Some(VolumeIsectData::Parallel { planes }))
4087        }
4088        ClassIndex::PL_CONE_ISECT => {
4089            // C++ plConeIsect::Read
4090            let capped = reader.read_u32()? != 0; // ReadBOOL = ReadLE32
4091            let rad_angle = reader.read_f32()?;
4092            let length = reader.read_f32()?;
4093            let world_tip = read_point3(reader)?;
4094            let world_norm = read_point3(reader)?;
4095            // Skip world-to-NDC and light-to-NDC matrices (hsMatrix44 with bool prefix each)
4096            let has_w2ndc = reader.read_u8()?;
4097            if has_w2ndc != 0 { reader.skip(64)?; }
4098            let has_l2ndc = reader.read_u8()?;
4099            if has_l2ndc != 0 { reader.skip(64)?; }
4100            let n = if capped { 5 } else { 4 };
4101            let mut norms = Vec::with_capacity(n);
4102            let mut dists = Vec::with_capacity(n);
4103            for _ in 0..n {
4104                norms.push(read_point3(reader)?);
4105                dists.push(reader.read_f32()?);
4106            }
4107            Ok(Some(VolumeIsectData::Cone { capped, rad_angle, length, world_tip, world_norm, norms, dists }))
4108        }
4109        _ => {
4110            bail!("Unknown plVolumeIsect subtype: 0x{:04X}", class_idx);
4111        }
4112    }
4113}
4114
4115// ============================================================================
4116// plSoftVolume subtypes (0x0088-0x008C) — spatial containment volumes
4117// ============================================================================
4118
4119/// Soft volume — position-based strength evaluation.
4120/// C++ ref: plSoftVolume hierarchy.
4121#[derive(Debug, Clone)]
4122pub enum SoftVolume {
4123    Simple {
4124        key: Option<crate::core::uoid::Uoid>,
4125        inside_strength: f32,
4126        outside_strength: f32,
4127        soft_dist: f32,
4128        bounds_min: [f32; 3],
4129        bounds_max: [f32; 3],
4130        disabled: bool,
4131    },
4132    Union {
4133        key: Option<crate::core::uoid::Uoid>,
4134        inside_strength: f32,
4135        outside_strength: f32,
4136        sub_keys: Vec<crate::core::uoid::Uoid>,
4137    },
4138    Intersect {
4139        key: Option<crate::core::uoid::Uoid>,
4140        inside_strength: f32,
4141        outside_strength: f32,
4142        sub_keys: Vec<crate::core::uoid::Uoid>,
4143    },
4144    Invert {
4145        key: Option<crate::core::uoid::Uoid>,
4146        inside_strength: f32,
4147        outside_strength: f32,
4148        sub_key: Option<crate::core::uoid::Uoid>,
4149    },
4150}
4151
4152impl SoftVolume {
4153    pub fn key(&self) -> &Option<crate::core::uoid::Uoid> {
4154        match self {
4155            SoftVolume::Simple { key, .. } => key,
4156            SoftVolume::Union { key, .. } => key,
4157            SoftVolume::Intersect { key, .. } => key,
4158            SoftVolume::Invert { key, .. } => key,
4159        }
4160    }
4161}
4162
4163/// Read the plObjInterface prologue (self-key + synch + owner key + props bitvector).
4164/// Returns (self_key, disabled).
4165/// C++: plObjInterface::Read → plSynchedObject::Read + owner key + fProps
4166fn read_obj_interface_header(cursor: &mut Cursor<&[u8]>) -> Result<(Option<crate::core::uoid::Uoid>, bool)> {
4167    use crate::core::uoid::read_key_uoid;
4168
4169    // Creatable class index
4170    let class_idx = cursor.read_i16()?;
4171    if class_idx < 0 { bail!("Null creatable in soft volume"); }
4172
4173    // hsKeyedObject::Read — self-key
4174    let self_key = read_key_uoid(cursor)?;
4175
4176    // plSynchedObject::Read
4177    skip_synched_object(cursor)?;
4178
4179    // plObjInterface — owner key
4180    let _owner_key = read_key_uoid(cursor)?;
4181
4182    // plObjInterface — fProps (hsBitVector)
4183    let prop_words = read_bit_vector_words(cursor)?;
4184    let disabled = !prop_words.is_empty() && (prop_words[0] & 1) != 0; // bit 0 = kDisable
4185
4186    Ok((self_key, disabled))
4187}
4188
4189/// Read plSoftVolume base fields after plObjInterface header.
4190/// Returns (listen_state, inside_strength, outside_strength).
4191/// C++ ref: plSoftVolume.cpp:51-61
4192fn read_soft_volume_base(cursor: &mut Cursor<&[u8]>) -> Result<(u32, f32, f32)> {
4193    let listen_state = cursor.read_u32()?;
4194    let inside_strength = cursor.read_f32()?;
4195    let outside_strength = cursor.read_f32()?;
4196    Ok((listen_state, inside_strength, outside_strength))
4197}
4198
4199/// Parse plSoftVolumeSimple (0x0088) from PRP object data.
4200/// C++ ref: plSoftVolumeTypes.cpp:92-98
4201pub fn parse_soft_volume_simple(data: &[u8]) -> Result<(SoftVolume, Option<VolumeIsectData>)> {
4202    let mut cursor = Cursor::new(data);
4203
4204    let (self_key, disabled) = read_obj_interface_header(&mut cursor)?;
4205    let (_listen_state, inside_strength, outside_strength) = read_soft_volume_base(&mut cursor)?;
4206
4207    // plSoftVolumeSimple::Read
4208    let soft_dist = cursor.read_f32()?;
4209
4210    // Inline creatable: plVolumeIsect subtype
4211    let volume = read_volume_isect(&mut cursor)?;
4212
4213    // Compute AABB from isect for the existing SoftVolume::Simple struct
4214    let (bounds_min, bounds_max) = match &volume {
4215        Some(VolumeIsectData::Convex { planes }) => compute_convex_bounds(planes),
4216        Some(VolumeIsectData::Sphere { world_center, radius, .. }) => {
4217            let r = *radius;
4218            (
4219                [world_center[0] - r, world_center[1] - r, world_center[2] - r],
4220                [world_center[0] + r, world_center[1] + r, world_center[2] + r],
4221            )
4222        }
4223        Some(VolumeIsectData::Cylinder { world_bot, world_norm, length, radius, .. }) => {
4224            let r = *radius;
4225            let top = [
4226                world_bot[0] + world_norm[0] * length,
4227                world_bot[1] + world_norm[1] * length,
4228                world_bot[2] + world_norm[2] * length,
4229            ];
4230            (
4231                [
4232                    world_bot[0].min(top[0]) - r,
4233                    world_bot[1].min(top[1]) - r,
4234                    world_bot[2].min(top[2]) - r,
4235                ],
4236                [
4237                    world_bot[0].max(top[0]) + r,
4238                    world_bot[1].max(top[1]) + r,
4239                    world_bot[2].max(top[2]) + r,
4240                ],
4241            )
4242        }
4243        _ => ([f32::MIN, f32::MIN, f32::MIN], [f32::MAX, f32::MAX, f32::MAX]),
4244    };
4245
4246    let sv = SoftVolume::Simple {
4247        key: self_key,
4248        inside_strength,
4249        outside_strength,
4250        soft_dist,
4251        bounds_min,
4252        bounds_max,
4253        disabled,
4254    };
4255
4256    Ok((sv, volume))
4257}
4258
4259/// Compute AABB from convex planes (intersection of half-spaces).
4260/// Uses plane positions as approximate bounds since exact convex hull is expensive.
4261fn compute_convex_bounds(planes: &[ConvexPlane]) -> ([f32; 3], [f32; 3]) {
4262    if planes.is_empty() {
4263        return ([0.0; 3], [0.0; 3]);
4264    }
4265    let mut min = [f32::MAX; 3];
4266    let mut max = [f32::MIN; 3];
4267    for p in planes {
4268        for i in 0..3 {
4269            min[i] = min[i].min(p.pos[i]);
4270            max[i] = max[i].max(p.pos[i]);
4271        }
4272    }
4273    (min, max)
4274}
4275
4276/// Parse plSoftVolumeComplex base (Union/Intersect/Invert all share this).
4277/// C++ ref: plSoftVolumeTypes.cpp:127-133
4278fn parse_soft_volume_complex_base(data: &[u8]) -> Result<(
4279    Option<crate::core::uoid::Uoid>,
4280    f32, f32,
4281    Vec<crate::core::uoid::Uoid>,
4282)> {
4283    use crate::core::uoid::read_key_uoid;
4284    let mut cursor = Cursor::new(data);
4285
4286    let (self_key, _disabled) = read_obj_interface_header(&mut cursor)?;
4287    let (_listen_state, inside_strength, outside_strength) = read_soft_volume_base(&mut cursor)?;
4288
4289    // Sub-volume keys
4290    let n = cursor.read_u32()? as usize;
4291    let mut sub_keys = Vec::with_capacity(n);
4292    for _ in 0..n {
4293        if let Some(uoid) = read_key_uoid(&mut cursor)? {
4294            sub_keys.push(uoid);
4295        }
4296    }
4297
4298    Ok((self_key, inside_strength, outside_strength, sub_keys))
4299}
4300
4301/// Parse plSoftVolumeUnion (0x008A).
4302/// C++ ref: plSoftVolumeTypes.cpp — reads plSoftVolumeComplex::Read only.
4303pub fn parse_soft_volume_union(data: &[u8]) -> Result<SoftVolume> {
4304    let (self_key, inside_strength, outside_strength, sub_keys) =
4305        parse_soft_volume_complex_base(data)?;
4306    Ok(SoftVolume::Union { key: self_key, inside_strength, outside_strength, sub_keys })
4307}
4308
4309/// Parse plSoftVolumeIntersect (0x008B).
4310/// C++ ref: plSoftVolumeTypes.cpp — reads plSoftVolumeComplex::Read only.
4311pub fn parse_soft_volume_intersect(data: &[u8]) -> Result<SoftVolume> {
4312    let (self_key, inside_strength, outside_strength, sub_keys) =
4313        parse_soft_volume_complex_base(data)?;
4314    Ok(SoftVolume::Intersect { key: self_key, inside_strength, outside_strength, sub_keys })
4315}
4316
4317/// Parse plSoftVolumeInvert (0x008C).
4318/// C++ ref: plSoftVolumeTypes.cpp — reads plSoftVolumeComplex::Read only.
4319/// Constraint: sub_volumes.len() <= 1
4320pub fn parse_soft_volume_invert(data: &[u8]) -> Result<SoftVolume> {
4321    let (self_key, inside_strength, outside_strength, sub_keys) =
4322        parse_soft_volume_complex_base(data)?;
4323    let sub_key = sub_keys.into_iter().next();
4324    Ok(SoftVolume::Invert { key: self_key, inside_strength, outside_strength, sub_key })
4325}
4326
4327/// Read hsBitVector and return as Vec<u32> words. Public for span vis parsing.
4328pub fn read_bit_vector(data: &[u8], offset: &mut usize) -> Vec<u32> {
4329    if *offset + 4 > data.len() { return Vec::new(); }
4330    let count = u32::from_le_bytes([data[*offset], data[*offset+1], data[*offset+2], data[*offset+3]]) as usize;
4331    *offset += 4;
4332    let mut words = Vec::with_capacity(count);
4333    for _ in 0..count {
4334        if *offset + 4 > data.len() { break; }
4335        words.push(u32::from_le_bytes([data[*offset], data[*offset+1], data[*offset+2], data[*offset+3]]));
4336        *offset += 4;
4337    }
4338    words
4339}
4340
4341// ============================================================================
4342// plDynaDecalMgr (0x00E6) and subclasses — dynamic decals
4343// ============================================================================
4344
4345/// Type of dynamic decal manager parsed from PRP.
4346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4347pub enum DecalManagerType {
4348    Foot,
4349    Ripple,
4350    Puddle,
4351    Bullet,
4352    Wake,
4353    Torpedo,
4354    RippleVS,
4355    TorpedoVS,
4356}
4357
4358/// Parsed plDynaDecalMgr data from PRP.
4359#[derive(Debug, Clone)]
4360pub struct DecalManagerData {
4361    pub name: String,
4362    pub manager_type: DecalManagerType,
4363    pub mat_pre_shade: Option<String>,
4364    pub mat_rt_shade: Option<String>,
4365    pub target_names: Vec<String>,
4366    pub max_num_verts: u32,
4367    pub max_num_idx: u32,
4368    pub wait_on_enable: u32,
4369    pub intensity: f32,
4370    pub wet_length: f32,
4371    pub ramp_end: f32,
4372    pub decay_start: f32,
4373    pub life_span: f32,
4374    pub grid_size_u: f32,
4375    pub grid_size_v: f32,
4376    pub scale: [f32; 3],
4377    pub party_time: f32,
4378    pub notify_names: Vec<String>,
4379    pub init_uvw: Option<[f32; 3]>,
4380    pub final_uvw: Option<[f32; 3]>,
4381    pub wake_default_dir: Option<[f32; 3]>,
4382    pub wake_anim_wgt: Option<f32>,
4383    pub wake_vel_wgt: Option<f32>,
4384}
4385
4386/// Parse plDynaDecalMgr base. C++ ref: plDynaDecalMgr.cpp:193-249
4387fn parse_dyna_decal_mgr_base(cursor: &mut Cursor<&[u8]>) -> Result<DecalManagerData> {
4388    use crate::core::uoid::read_key_uoid;
4389    let self_key = read_key_uoid(cursor)?;
4390    let name = self_key.as_ref().map(|k| k.object_name.clone()).unwrap_or_default();
4391    skip_synched_object(cursor)?;
4392    let mat_pre = read_key_name(cursor)?;
4393    let mat_rt = read_key_name(cursor)?;
4394    let num_targets = cursor.read_u32()?;
4395    let mut target_names = Vec::new();
4396    for _ in 0..num_targets { if let Some(n) = read_key_name(cursor)? { target_names.push(n); } }
4397    let num_party = cursor.read_u32()?;
4398    for _ in 0..num_party { let _ = read_key_name(cursor)?; }
4399    let max_num_verts = cursor.read_u32()?;
4400    let max_num_idx = cursor.read_u32()?;
4401    let wait_on_enable = cursor.read_u32()?;
4402    let intensity = cursor.read_f32()?;
4403    let wet_length = cursor.read_f32()?;
4404    let ramp_end = cursor.read_f32()?;
4405    let decay_start = cursor.read_f32()?;
4406    let life_span = cursor.read_f32()?;
4407    let grid_size_u = cursor.read_f32()?;
4408    let grid_size_v = cursor.read_f32()?;
4409    let sx = cursor.read_f32()?; let sy = cursor.read_f32()?; let sz = cursor.read_f32()?;
4410    let party_time = cursor.read_f32()?;
4411    let num_notifies = cursor.read_u32()?;
4412    let mut notify_names = Vec::new();
4413    for _ in 0..num_notifies { if let Some(n) = read_key_name(cursor)? { notify_names.push(n); } }
4414    Ok(DecalManagerData {
4415        name, manager_type: DecalManagerType::Foot,
4416        mat_pre_shade: mat_pre, mat_rt_shade: mat_rt, target_names,
4417        max_num_verts, max_num_idx, wait_on_enable,
4418        intensity, wet_length, ramp_end, decay_start, life_span,
4419        grid_size_u, grid_size_v, scale: [sx, sy, sz], party_time, notify_names,
4420        init_uvw: None, final_uvw: None,
4421        wake_default_dir: None, wake_anim_wgt: None, wake_vel_wgt: None,
4422    })
4423}
4424
4425fn read_ripple_uvw(c: &mut Cursor<&[u8]>, m: &mut DecalManagerData) -> Result<()> {
4426    let ix = c.read_f32()?; let iy = c.read_f32()?; let iz = c.read_f32()?;
4427    m.init_uvw = Some([ix, iy, iz]);
4428    let fx = c.read_f32()?; let fy = c.read_f32()?; let fz = c.read_f32()?;
4429    m.final_uvw = Some([fx, fy, fz]);
4430    Ok(())
4431}
4432
4433/// Parse plDynaFootMgr (0x00E8).
4434pub fn parse_dyna_foot_mgr(data: &[u8]) -> Result<DecalManagerData> {
4435    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4436    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::Foot; Ok(m)
4437}
4438/// Parse plDynaRippleMgr (0x00E9).
4439pub fn parse_dyna_ripple_mgr(data: &[u8]) -> Result<DecalManagerData> {
4440    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4441    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::Ripple;
4442    read_ripple_uvw(&mut c, &mut m)?; Ok(m)
4443}
4444/// Parse plDynaBulletMgr (0x00EA).
4445pub fn parse_dyna_bullet_mgr(data: &[u8]) -> Result<DecalManagerData> {
4446    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4447    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::Bullet; Ok(m)
4448}
4449/// Parse plDynaPuddleMgr (0x00ED).
4450pub fn parse_dyna_puddle_mgr(data: &[u8]) -> Result<DecalManagerData> {
4451    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4452    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::Puddle;
4453    read_ripple_uvw(&mut c, &mut m)?; Ok(m)
4454}
4455/// Parse plDynaWakeMgr (0x00F9).
4456pub fn parse_dyna_wake_mgr(data: &[u8]) -> Result<DecalManagerData> {
4457    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4458    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::Wake;
4459    read_ripple_uvw(&mut c, &mut m)?;
4460    let dx = c.read_f32()?; let dy = c.read_f32()?; let dz = c.read_f32()?;
4461    m.wake_default_dir = Some([dx, dy, dz]);
4462    let ac = c.read_u16()?; if ac != 0x8000 { return Ok(m); }
4463    m.wake_anim_wgt = Some(c.read_f32()?); m.wake_vel_wgt = Some(c.read_f32()?); Ok(m)
4464}
4465/// Parse plDynaTorpedoMgr (0x0129).
4466pub fn parse_dyna_torpedo_mgr(data: &[u8]) -> Result<DecalManagerData> {
4467    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4468    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::Torpedo;
4469    read_ripple_uvw(&mut c, &mut m)?; Ok(m)
4470}
4471/// Parse plDynaRippleVSMgr (0x010A).
4472pub fn parse_dyna_ripple_vs_mgr(data: &[u8]) -> Result<DecalManagerData> {
4473    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4474    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::RippleVS;
4475    read_ripple_uvw(&mut c, &mut m)?; let _ = read_key_name(&mut c)?; Ok(m)
4476}
4477/// Parse plDynaTorpedoVSMgr (0x012A).
4478pub fn parse_dyna_torpedo_vs_mgr(data: &[u8]) -> Result<DecalManagerData> {
4479    let mut c = Cursor::new(data); let _ = c.read_i16()?;
4480    let mut m = parse_dyna_decal_mgr_base(&mut c)?; m.manager_type = DecalManagerType::TorpedoVS;
4481    read_ripple_uvw(&mut c, &mut m)?; let _ = read_key_name(&mut c)?; Ok(m)
4482}
4483
4484// ============================================================================
4485// plEAXListenerMod (0x00E5) — EAX reverb zone modifier
4486// ============================================================================
4487
4488/// EAX reverb properties parsed from PRP.
4489/// C++ ref: EAXREVERBPROPERTIES (plEAXStructures.h:52-78)
4490/// C++ ref: plEAXListenerMod::Read (plEAXListenerMod.cpp:167-202)
4491#[derive(Debug, Clone)]
4492pub struct EaxListenerModData {
4493    pub self_key: Option<crate::core::uoid::Uoid>,
4494    /// Soft volume key — spatial zone that controls this reverb.
4495    /// C++ ref: plEAXListenerMod::fSoftRegion (kRefSoftRegion = 0)
4496    pub soft_region_key: Option<String>,
4497    /// EAX environment preset index.
4498    pub environment: u32,
4499    /// Environment size in meters.
4500    pub environment_size: f32,
4501    /// Environment diffusion (0.0-1.0).
4502    pub environment_diffusion: f32,
4503    /// Room effect level in millibels (-10000 to 0).
4504    pub room: i32,
4505    /// Room HF effect level in millibels (-10000 to 0).
4506    pub room_hf: i32,
4507    /// Room LF effect level in millibels (-10000 to 0).
4508    pub room_lf: i32,
4509    /// Reverberation decay time in seconds (0.1 to 20.0).
4510    pub decay_time: f32,
4511    /// High-frequency to mid-frequency decay time ratio (0.1 to 2.0).
4512    pub decay_hf_ratio: f32,
4513    /// Low-frequency to mid-frequency decay time ratio.
4514    pub decay_lf_ratio: f32,
4515    /// Early reflections level in millibels (-10000 to 1000).
4516    pub reflections: i32,
4517    /// Initial reflection delay in seconds (0.0 to 0.3).
4518    pub reflections_delay: f32,
4519    /// Late reverberation level in millibels (-10000 to 2000).
4520    pub reverb: i32,
4521    /// Late reverberation delay in seconds (0.0 to 0.1).
4522    pub reverb_delay: f32,
4523    /// Echo time in seconds.
4524    pub echo_time: f32,
4525    /// Echo depth (0.0-1.0).
4526    pub echo_depth: f32,
4527    /// Modulation time in seconds.
4528    pub modulation_time: f32,
4529    /// Modulation depth (0.0-1.0).
4530    pub modulation_depth: f32,
4531    /// Air absorption HF in millibels/meter.
4532    pub air_absorption_hf: f32,
4533    /// HF reference frequency in Hz.
4534    pub hf_reference: f32,
4535    /// LF reference frequency in Hz.
4536    pub lf_reference: f32,
4537    /// Room rolloff factor.
4538    pub room_rolloff_factor: f32,
4539    /// EAX environment flags.
4540    pub flags: u32,
4541}
4542
4543/// Parse plEAXListenerMod (0x00E5) from PRP object data.
4544/// Format: plSingleModifier::Read (self-key + synched + hsBitVector)
4545///         -> key ref (soft volume) -> 21 EAX reverb parameters
4546/// C++ ref: plEAXListenerMod.cpp:167-202
4547pub fn parse_eax_listener_mod(data: &[u8]) -> Result<EaxListenerModData> {
4548    use crate::core::uoid::read_key_uoid;
4549    let mut cursor = Cursor::new(data);
4550
4551    // Creatable class index (i16)
4552    let _class_idx = cursor.read_i16()?;
4553
4554    // hsKeyedObject::Read — self-key
4555    let self_key = read_key_uoid(&mut cursor)?;
4556
4557    // plSynchedObject::Read
4558    skip_synched_object(&mut cursor)?;
4559
4560    // plSingleModifier::Read — hsBitVector fFlags
4561    let num_bit_vectors = cursor.read_u32()?;
4562    for _ in 0..num_bit_vectors {
4563        let _word = cursor.read_u32()?;
4564    }
4565
4566    // Soft volume key reference
4567    // C++ ref: mgr->ReadKeyNotifyMe(s, ..., kRefSoftRegion)
4568    let soft_region_uoid = read_key_uoid(&mut cursor)?;
4569    let soft_region_key = soft_region_uoid.map(|u| u.object_name.clone());
4570
4571    // EAX reverb properties — exact field order from plEAXListenerMod::Read
4572    // C++ ref: plEAXListenerMod.cpp:175-198
4573    let environment = cursor.read_u32()?;
4574    let environment_size = cursor.read_f32()?;
4575    let environment_diffusion = cursor.read_f32()?;
4576    let room = cursor.read_i32()?;
4577    let room_hf = cursor.read_i32()?;
4578    let room_lf = cursor.read_i32()?;
4579    let decay_time = cursor.read_f32()?;
4580    let decay_hf_ratio = cursor.read_f32()?;
4581    let decay_lf_ratio = cursor.read_f32()?;
4582    let reflections = cursor.read_i32()?;
4583    let reflections_delay = cursor.read_f32()?;
4584    // Note: vReflectionsPan (EAXVECTOR) is NOT serialized — C++ skips it
4585    let reverb = cursor.read_i32()?;
4586    let reverb_delay = cursor.read_f32()?;
4587    // Note: vReverbPan (EAXVECTOR) is NOT serialized — C++ skips it
4588    let echo_time = cursor.read_f32()?;
4589    let echo_depth = cursor.read_f32()?;
4590    let modulation_time = cursor.read_f32()?;
4591    let modulation_depth = cursor.read_f32()?;
4592    let air_absorption_hf = cursor.read_f32()?;
4593    let hf_reference = cursor.read_f32()?;
4594    let lf_reference = cursor.read_f32()?;
4595    let room_rolloff_factor = cursor.read_f32()?;
4596    let flags = cursor.read_u32()?;
4597
4598    Ok(EaxListenerModData {
4599        self_key,
4600        soft_region_key,
4601        environment,
4602        environment_size,
4603        environment_diffusion,
4604        room,
4605        room_hf,
4606        room_lf,
4607        decay_time,
4608        decay_hf_ratio,
4609        decay_lf_ratio,
4610        reflections,
4611        reflections_delay,
4612        reverb,
4613        reverb_delay,
4614        echo_time,
4615        echo_depth,
4616        modulation_time,
4617        modulation_depth,
4618        air_absorption_hf,
4619        hf_reference,
4620        lf_reference,
4621        room_rolloff_factor,
4622        flags,
4623    })
4624}
4625
4626// ============================================================================
4627// Round-trip tests — read PRP, write back, compare bytes
4628// ============================================================================
4629
4630#[cfg(test)]
4631mod round_trip_tests {
4632    use super::*;
4633    use std::path::Path;
4634
4635    /// Round-trip a single PRP file: read → write → compare bytes.
4636    fn round_trip_file(path: &Path) -> Result<()> {
4637        let original = std::fs::read(path)?;
4638        let page = PrpPage::from_file(path)?;
4639        let written = page.to_bytes()?;
4640
4641        if original != written {
4642            // Find first difference
4643            let min_len = original.len().min(written.len());
4644            for i in 0..min_len {
4645                if original[i] != written[i] {
4646                    bail!(
4647                        "{}: first byte diff at offset 0x{:X} (orig={:#04X}, written={:#04X}), \
4648                         original={} bytes, written={} bytes",
4649                        path.display(), i, original[i], written[i],
4650                        original.len(), written.len()
4651                    );
4652                }
4653            }
4654            if original.len() != written.len() {
4655                bail!(
4656                    "{}: length mismatch: original={} bytes, written={} bytes",
4657                    path.display(), original.len(), written.len()
4658                );
4659            }
4660        }
4661        Ok(())
4662    }
4663
4664    #[test]
4665    fn test_round_trip_cleft() {
4666        let path = Path::new("../../Plasma/staging/client/dat/Cleft_District_Cleft.prp");
4667        if !path.exists() {
4668            eprintln!("Skipping: {:?} not found", path);
4669            return;
4670        }
4671        round_trip_file(path).unwrap();
4672        eprintln!("Round-trip OK: Cleft_District_Cleft.prp");
4673    }
4674
4675    #[test]
4676    fn test_round_trip_all_ages() {
4677        let dat_dir = Path::new("../../Plasma/staging/client/dat");
4678        if !dat_dir.exists() {
4679            eprintln!("Skipping: {:?} not found", dat_dir);
4680            return;
4681        }
4682
4683        let mut total = 0;
4684        let mut passed = 0;
4685        let mut failed = 0;
4686        let mut failures: Vec<String> = Vec::new();
4687
4688        let mut entries: Vec<_> = std::fs::read_dir(dat_dir).unwrap()
4689            .filter_map(|e| e.ok())
4690            .filter(|e| e.path().extension().is_some_and(|ext| ext == "prp"))
4691            .collect();
4692        entries.sort_by_key(|e| e.file_name());
4693
4694        for entry in &entries {
4695            total += 1;
4696            match round_trip_file(&entry.path()) {
4697                Ok(()) => passed += 1,
4698                Err(e) => {
4699                    failed += 1;
4700                    let msg = format!("{}", e);
4701                    if failures.len() < 10 {
4702                        failures.push(msg.clone());
4703                    }
4704                    eprintln!("FAIL: {}", msg);
4705                }
4706            }
4707        }
4708
4709        eprintln!("\nRound-trip results: {}/{} passed, {} failed", passed, total, failed);
4710
4711        if failed > 0 {
4712            panic!("{} PRP files failed round-trip. First failures:\n{}",
4713                failed, failures.join("\n"));
4714        }
4715    }
4716}