Skip to main content

rakata_generics/
uts.rs

1//! UTS (`.uts`) typed generic wrapper.
2//!
3//! Sound objects are positional ambient audio emitters: environmental hums,
4//! randomised crowd chatter, localised loops. Spatial coordinates plus
5//! randomised pitch, interval and volume.
6//!
7//! ## Scope of this slice
8//! - Typed access for all sound identity/configuration fields.
9//! - Typed handling for the `Sounds` list entries.
10//!
11//! ## Field Layout
12//! ```text
13//! UTS root struct
14//! +-- TemplateResRef / Tag / LocName / Comment
15//! +-- Active / Continuous / Looping / Positional / RandomPosition / Random
16//! +-- Elevation / MinDistance / MaxDistance
17//! +-- RandomRangeX / RandomRangeY
18//! +-- Interval / IntervalVrtn / PitchVariation / FixedVariance
19//! +-- Priority / Volume / VolumeVrtn
20//! +-- Hours / Times / PaletteID
21//! +-- Sounds                          (List<Struct>)
22//!     `-- Sound
23//! ```
24
25use std::io::{Cursor, Read, Write};
26
27use rakata_core::ResRef;
28use rakata_formats::gff::get_u32_extended_signed;
29use rakata_formats::schema::FromGff;
30use rakata_formats::GENERIC_FILE_TYPE;
31use rakata_formats::{
32    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
33    GffStruct, GffValue,
34};
35use thiserror::Error;
36
37/// Typed UTS model built from/to [`Gff`] data.
38///
39/// The three position labels are declared without members. A sound's placement
40/// is carried by the `.git` instance that spawns it, so the blueprint's copy
41/// is read and discarded.
42#[derive(Debug, Clone, PartialEq, GffModel)]
43#[gff_entry(XPosition, wire = f32, stamped)]
44#[gff_entry(YPosition, wire = f32, stamped)]
45#[gff_entry(ZPosition, wire = f32, stamped)]
46pub struct Uts {
47    /// Sound template resref (`TemplateResRef`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
48    #[gff(TemplateResRef, unexamined)]
49    pub template_resref: ResRef,
50    /// Sound tag (`Tag`).
51    #[gff(Tag, unexamined)]
52    pub tag: String,
53    /// Localized sound name (`LocName`).
54    #[gff(LocName, unexamined)]
55    pub name: GffLocalizedString,
56    /// Toolset comment (`Comment`).
57    #[gff(Comment, unexamined)]
58    pub comment: String,
59    /// Active flag (`Active`).
60    #[gff(Active, constructed = true)]
61    pub active: bool,
62    /// Continuous flag (`Continuous`).
63    #[gff(Continuous, constructed)]
64    pub continuous: bool,
65    /// Looping flag (`Looping`).
66    #[gff(Looping, constructed)]
67    pub looping: bool,
68    /// Positional flag (`Positional`).
69    #[gff(Positional, constructed = true)]
70    pub positional: bool,
71    /// Random-position flag (`RandomPosition`).
72    #[gff(RandomPosition, constructed)]
73    pub random_position: bool,
74    /// Random-pick flag (`Random`).
75    #[gff(Random, constructed)]
76    pub random_pick: bool,
77    /// Elevation (`Elevation`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
78    #[gff(Elevation, unexamined)]
79    pub elevation: f32,
80    /// Maximum distance (`MaxDistance`).
81    #[gff(MaxDistance, constructed = 20.0)]
82    pub max_distance: f32,
83    /// Minimum distance (`MinDistance`).
84    #[gff(MinDistance, constructed = 10.0)]
85    pub min_distance: f32,
86    /// Random range on X axis (`RandomRangeX`).
87    #[gff(RandomRangeX, constructed)]
88    pub random_range_x: f32,
89    /// Random range on Y axis (`RandomRangeY`).
90    #[gff(RandomRangeY, constructed)]
91    pub random_range_y: f32,
92    /// Interval (`Interval`).
93    #[gff(Interval, manual_read, constructed)]
94    pub interval: u32,
95    /// Interval variation (`IntervalVrtn`).
96    #[gff(IntervalVrtn, manual_read, constructed)]
97    pub interval_variation: u32,
98    /// Pitch variation (`PitchVariation`).
99    #[gff(PitchVariation, constructed)]
100    pub pitch_variation: f32,
101    /// Priority (`Priority`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
102    #[gff(Priority, unexamined)]
103    pub priority: u8,
104    /// Volume (`Volume`). Values above 127 exceed the engine boundary and may cause severe distortion or clipping.
105    #[gff(Volume, constructed = 127)]
106    pub volume: u8,
107    /// Volume variation (`VolumeVrtn`).
108    #[gff(VolumeVrtn, constructed)]
109    pub volume_variation: u8,
110    /// Hour restriction (`Hours`).
111    #[gff(Hours, manual_read, constructed)]
112    pub hours: u32,
113    /// Time restriction (`Times`, canonical `UInt8`).
114    #[gff(Times, manual_read, constructed = 3)]
115    pub times: u8,
116    /// Palette ID (`PaletteID`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
117    #[gff(PaletteID, unexamined)]
118    pub palette_id: u8,
119    /// Fixed variance (`FixedVariance`).
120    #[gff(FixedVariance, constructed = 1.0, omit = audited_constant(635))]
121    pub fixed_variance: f32,
122    /// Generated type (`GeneratedType`). Engine stores this as a single byte; values above 255 are aggressively truncated and corrupt behavior.
123    #[gff(GeneratedType, manual_read, constructed, omit = audited_constant(635))]
124    pub generated_type: u32,
125    /// Sound entries (`Sounds`). If empty, the object is loaded as a completely dead node. Blank resrefs within the list are ignored and not mapped to playable memory.
126    /// Every element is struct id `0` rather than its index.
127    #[gff(Sounds, unexamined, list = UtsSound, element_id = 0)]
128    pub sounds: Vec<UtsSound>,
129}
130
131impl Uts {
132    /// Creates an empty UTS value.
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Builds typed UTS data from a parsed GFF container.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`UtsError::UnsupportedFileType`] for a container that is
142    /// neither `UTS ` nor the generic `GFF ` form, and
143    /// [`UtsError::TypeMismatch`] for a `Times` of another width.
144    pub fn from_gff(gff: &Gff) -> Result<Self, UtsError> {
145        if gff.file_type != <Uts as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
146            return Err(UtsError::UnsupportedFileType(gff.file_type));
147        }
148
149        let root = &gff.root;
150        let mut sound = Self::read_declared(root);
151
152        // The four DWORD fields read through a wider ladder than the type's
153        // own, accepting `Int16` and `Int8` as well. That tolerance belongs to
154        // these fields rather than to `u32`, which is why it is here and not
155        // in the impl. No vanilla file carries either width, so nothing
156        // measured over the install would notice; the tolerance is the point.
157        sound.hours = get_u32_extended_signed(root, "Hours").unwrap_or(0);
158        sound.interval = get_u32_extended_signed(root, "Interval").unwrap_or(0);
159        sound.interval_variation = get_u32_extended_signed(root, "IntervalVrtn").unwrap_or(0);
160        sound.generated_type = get_u32_extended_signed(root, "GeneratedType").unwrap_or(0);
161
162        // Read strictly rather than through the coercion ladder, so a
163        // caller handing us a `Times` of another width learns about it.
164        // The generated reader would have taken the fallback instead.
165        sound.times = match root.field("Times") {
166            Some(GffValue::UInt8(value)) => *value,
167            Some(_) => {
168                return Err(UtsError::TypeMismatch {
169                    field: "Times",
170                    expected: "UInt8",
171                });
172            }
173            None => 3,
174        };
175
176        Ok(sound)
177    }
178
179    /// Converts this typed UTS value into a GFF container.
180    pub fn to_gff(&self) -> Gff {
181        let mut root = GffStruct::new(-1);
182        self.write_declared(&mut root);
183        Gff::new(*b"UTS ", root)
184    }
185}
186
187/// One entry of the `Sounds` list.
188#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
189pub struct UtsSound {
190    /// Sound resref (`Sound`).
191    #[gff(Sound, unexamined)]
192    pub sound: ResRef,
193}
194
195/// Errors produced while reading or writing typed UTS data.
196#[derive(Debug, Error)]
197pub enum UtsError {
198    /// Source file type is not supported by this parser.
199    #[error("unsupported UTS file type: {0:?}")]
200    UnsupportedFileType([u8; 4]),
201    /// A field is present at a width this parser does not accept.
202    #[error("{field} must be {expected}")]
203    TypeMismatch {
204        /// The label that was the wrong width.
205        field: &'static str,
206        /// The width it has to be.
207        expected: &'static str,
208    },
209    /// Underlying GFF parser/writer error.
210    #[error(transparent)]
211    Gff(#[from] GffBinaryError),
212}
213
214/// Reads typed UTS data from a reader at the current stream position.
215///
216/// # Errors
217///
218/// [`UtsError::Gff`] when the stream is not a readable GFF, and
219/// [`UtsError::UnsupportedFileType`] when it is a GFF of some other format,
220/// carrying the fourcc that was found.
221#[cfg_attr(
222    feature = "tracing",
223    tracing::instrument(level = "debug", skip(reader))
224)]
225pub fn read_uts<R: Read>(reader: &mut R) -> Result<Uts, UtsError> {
226    let gff = read_gff(reader)?;
227    Uts::from_gff(&gff)
228}
229
230/// Reads typed UTS data directly from bytes.
231///
232/// # Errors
233///
234/// [`UtsError::Gff`] when `bytes` are not a readable GFF, and
235/// [`UtsError::UnsupportedFileType`] when they are a GFF of some other format,
236/// carrying the fourcc that was found.
237#[cfg_attr(
238    feature = "tracing",
239    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
240)]
241pub fn read_uts_from_bytes(bytes: &[u8]) -> Result<Uts, UtsError> {
242    let gff = read_gff_from_bytes(bytes)?;
243    Uts::from_gff(&gff)
244}
245
246/// Authors the UTS file the typed view describes, into a writer.
247///
248/// # Errors
249///
250/// [`UtsError::Gff`] when the writer fails or a value will not encode. The
251/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
252/// this side.
253#[cfg_attr(
254    feature = "tracing",
255    tracing::instrument(level = "debug", skip(writer, uts))
256)]
257pub fn author_uts<W: Write>(writer: &mut W, uts: &Uts) -> Result<(), UtsError> {
258    let gff = uts.to_gff();
259    write_gff(writer, &gff)?;
260    Ok(())
261}
262
263/// Authors the UTS file the typed view describes, as bytes.
264///
265/// # Errors
266///
267/// [`UtsError::Gff`] when a value will not encode. Writing into a `Vec` has no
268/// I/O to fail at.
269#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(uts)))]
270pub fn author_uts_to_vec(uts: &Uts) -> Result<Vec<u8>, UtsError> {
271    let mut cursor = Cursor::new(Vec::new());
272    author_uts(&mut cursor, uts)?;
273    Ok(cursor.into_inner())
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use rakata_formats::gff_label;
280    use rakata_formats::schema::{HasSchema, Shape};
281
282    const TEST_UTS: &[u8] = include_bytes!(concat!(
283        env!("CARGO_MANIFEST_DIR"),
284        "/../../fixtures/test.uts"
285    ));
286    const K1_UTS: &[u8] = include_bytes!(concat!(
287        env!("CARGO_MANIFEST_DIR"),
288        "/../../fixtures/test_k1.uts"
289    ));
290
291    #[test]
292    fn reads_core_uts_fields_from_fixture() {
293        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
294
295        assert_eq!(uts.tag, "3Csounds");
296        assert_eq!(uts.template_resref, "3csounds");
297        assert_eq!(uts.name.string_ref.raw(), 128_551);
298        assert_eq!(uts.comment, "comment");
299
300        assert!(uts.active);
301        assert!(uts.continuous);
302        assert!(uts.looping);
303        assert!(uts.positional);
304        assert!(uts.random_position);
305        assert!(uts.random_pick);
306
307        assert_eq!(uts.elevation, 1.5);
308        assert_eq!(uts.min_distance, 5.0);
309        assert_eq!(uts.max_distance, 8.0);
310        assert_eq!(uts.random_range_x, 0.1);
311        assert_eq!(uts.random_range_y, 0.2);
312        assert_eq!(uts.interval, 4_000);
313        assert_eq!(uts.interval_variation, 100);
314        assert_eq!(uts.pitch_variation, 0.1);
315        assert_eq!(uts.priority, 22);
316        assert_eq!(uts.hours, 0);
317        assert_eq!(uts.times, 3);
318        assert_eq!(uts.volume, 120);
319        assert_eq!(uts.volume_variation, 7);
320        assert_eq!(uts.palette_id, 6);
321        assert_eq!(uts.generated_type, 0);
322
323        assert_eq!(uts.sounds.len(), 4);
324        assert_eq!(uts.sounds[0].sound, "c_drdastro_dead");
325        assert_eq!(uts.sounds[1].sound, "c_drdastro_atk1");
326        assert_eq!(uts.sounds[2].sound, "p_t3-m4_dead");
327        assert_eq!(uts.sounds[3].sound, "c_drdastro_atk2");
328    }
329
330    #[test]
331    fn reads_k1_fixture_variant() {
332        let uts = read_uts_from_bytes(K1_UTS).expect("fixture must parse");
333
334        assert_eq!(uts.tag, "computersoundsrnd");
335        assert_eq!(uts.template_resref, "computersoundsrn");
336        assert_eq!(uts.name.string_ref.raw(), 45_774);
337        assert_eq!(uts.comment, "");
338
339        assert!(uts.active);
340        assert!(uts.continuous);
341        assert!(!uts.looping);
342        assert!(uts.positional);
343        assert!(!uts.random_position);
344        assert!(uts.random_pick);
345
346        assert_eq!(uts.min_distance, 3.0);
347        assert_eq!(uts.max_distance, 10.0);
348        assert_eq!(uts.interval, 7_000);
349        assert_eq!(uts.interval_variation, 4_000);
350        assert_eq!(uts.volume, 70);
351        assert_eq!(uts.volume_variation, 0);
352        assert_eq!(uts.generated_type, 0);
353
354        assert_eq!(uts.sounds.len(), 3);
355        assert_eq!(uts.sounds[2].sound, "as_el_compsnd_04");
356    }
357
358    #[test]
359    fn all_fields_survive_typed_roundtrip() {
360        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
361        let bytes = author_uts_to_vec(&uts).expect("write succeeds");
362        let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
363        assert_eq!(reparsed, uts);
364    }
365
366    #[test]
367    fn writes_times_as_canonical_u8() {
368        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
369        gff.root.fields.retain(|field| field.label != "Times");
370        gff.root.push_field(gff_label!("Times"), GffValue::UInt8(3));
371
372        let mut uts = Uts::from_gff(&gff).expect("typed parse");
373        uts.times = 42;
374
375        let rebuilt = uts.to_gff();
376        assert_eq!(rebuilt.root.field("Times"), Some(&GffValue::UInt8(42)));
377    }
378
379    #[test]
380    fn rejects_non_canonical_times_width() {
381        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
382        gff.root.fields.retain(|field| field.label != "Times");
383        gff.root
384            .push_field(gff_label!("Times"), GffValue::UInt32(3));
385
386        let err = Uts::from_gff(&gff).expect_err("non-canonical Times width must be rejected");
387        assert!(matches!(
388            err,
389            UtsError::TypeMismatch {
390                field: "Times",
391                expected: "UInt8",
392            }
393        ));
394    }
395
396    #[test]
397    fn typed_edits_roundtrip_through_gff_writer() {
398        let mut uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
399        uts.tag = "3Csounds_rust".into();
400        uts.sounds[0].sound = ResRef::new("rust_sound").expect("valid test resref");
401        uts.volume = 90;
402
403        let bytes = author_uts_to_vec(&uts).expect("write succeeds");
404        let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
405
406        assert_eq!(reparsed.tag, "3Csounds_rust");
407        assert_eq!(reparsed.sounds[0].sound, "rust_sound");
408        assert_eq!(reparsed.volume, 90);
409    }
410
411    #[test]
412    fn read_uts_from_reader_matches_bytes_path() {
413        let mut cursor = Cursor::new(TEST_UTS);
414        let via_reader = read_uts(&mut cursor).expect("reader parse succeeds");
415        let via_bytes = read_uts_from_bytes(TEST_UTS).expect("bytes parse succeeds");
416
417        assert_eq!(via_reader, via_bytes);
418    }
419
420    #[test]
421    fn rejects_non_uts_file_type() {
422        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
423        gff.file_type = *b"UTP ";
424
425        let err = Uts::from_gff(&gff).expect_err("UTP must be rejected as UTS input");
426        assert!(matches!(
427            err,
428            UtsError::UnsupportedFileType(file_type) if file_type == *b"UTP "
429        ));
430    }
431
432    #[test]
433    fn a_mistyped_sounds_list_reads_as_empty() {
434        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
435        gff.root.fields.retain(|field| field.label != "Sounds");
436        gff.root
437            .push_field(gff_label!("Sounds"), GffValue::UInt32(123));
438
439        let uts = Uts::from_gff(&gff).expect("a mistyped list is not a read failure");
440
441        assert!(uts.sounds.is_empty());
442    }
443
444    #[test]
445    fn write_uts_matches_direct_gff_writer() {
446        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
447
448        let via_typed = author_uts_to_vec(&uts).expect("typed write succeeds");
449
450        let mut direct = Cursor::new(Vec::new());
451        write_gff(&mut direct, &uts.to_gff()).expect("direct write succeeds");
452
453        assert_eq!(via_typed, direct.into_inner());
454    }
455
456    #[test]
457    fn schema_field_count() {
458        assert_eq!(Uts::schema().len(), 30); // 23 engine + 1 list + 6 toolset
459    }
460
461    #[test]
462    fn schema_no_duplicate_labels() {
463        let schema = Uts::schema();
464        let mut labels: Vec<&str> = schema.iter().map(|f| f.label.as_str()).collect();
465        labels.sort();
466        let before = labels.len();
467        labels.dedup();
468        assert_eq!(before, labels.len(), "duplicate labels in UTS schema");
469    }
470
471    #[test]
472    fn schema_sounds_carries_its_element() {
473        let sounds = Uts::schema()
474            .iter()
475            .find(|f| f.label.as_str() == "Sounds")
476            .expect("Sounds is declared");
477        let Shape::List { element, .. } = sounds.shape else {
478            panic!("Sounds is a list");
479        };
480        assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 1);
481    }
482}