Skip to main content

rakata_generics/
uts.rs

1//! UTS (`.uts`) typed generic wrapper.
2//!
3//! UTS resources are GFF-backed ambient sound templates.
4//!
5//! ## Scope of this slice
6//! - Typed access for all sound identity/configuration fields.
7//! - Typed handling for the `Sounds` list entries.
8//!
9//! ## Field Layout
10//! ```text
11//! UTS root struct
12//! +-- TemplateResRef / Tag / LocName / Comment
13//! +-- Active / Continuous / Looping / Positional / RandomPosition / Random
14//! +-- Elevation / MinDistance / MaxDistance
15//! +-- RandomRangeX / RandomRangeY
16//! +-- Interval / IntervalVrtn / PitchVariation / FixedVariance
17//! +-- Priority / Volume / VolumeVrtn
18//! +-- Hours / Times / PaletteID
19//! +-- Sounds                          (List<Struct>)
20//!     `-- Sound
21//! ```
22
23use std::io::{Cursor, Read, Write};
24
25use crate::gff_helpers::{
26    get_bool, get_f32, get_locstring, get_resref, get_string, get_u32_extended_signed as get_u32,
27    get_u8, upsert_field,
28};
29use rakata_core::{ResRef, StrRef};
30use rakata_formats::{
31    gff_schema::{AbsentDefault, DefaultValue, FieldLife, FieldSchema, GffSchema, GffType},
32    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
33    GffValue,
34};
35use thiserror::Error;
36
37/// Typed UTS model built from/to [`Gff`] data.
38#[derive(Debug, Clone, PartialEq)]
39pub struct Uts {
40    /// Sound template resref (`TemplateResRef`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
41    pub template_resref: ResRef,
42    /// Sound tag (`Tag`).
43    pub tag: String,
44    /// Localized sound name (`LocName`).
45    pub name: GffLocalizedString,
46    /// Toolset comment (`Comment`).
47    pub comment: String,
48    /// Active flag (`Active`).
49    pub active: bool,
50    /// Continuous flag (`Continuous`).
51    pub continuous: bool,
52    /// Looping flag (`Looping`).
53    pub looping: bool,
54    /// Positional flag (`Positional`).
55    pub positional: bool,
56    /// Random-position flag (`RandomPosition`).
57    pub random_position: bool,
58    /// Random-pick flag (`Random`).
59    pub random_pick: bool,
60    /// Elevation (`Elevation`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
61    pub elevation: f32,
62    /// Maximum distance (`MaxDistance`).
63    pub max_distance: f32,
64    /// Minimum distance (`MinDistance`).
65    pub min_distance: f32,
66    /// Random range on X axis (`RandomRangeX`).
67    pub random_range_x: f32,
68    /// Random range on Y axis (`RandomRangeY`).
69    pub random_range_y: f32,
70    /// Interval (`Interval`).
71    pub interval: u32,
72    /// Interval variation (`IntervalVrtn`).
73    pub interval_variation: u32,
74    /// Pitch variation (`PitchVariation`).
75    pub pitch_variation: f32,
76    /// Priority (`Priority`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
77    pub priority: u8,
78    /// Volume (`Volume`). Values above 127 exceed the engine boundary and may cause severe distortion or clipping.
79    pub volume: u8,
80    /// Volume variation (`VolumeVrtn`).
81    pub volume_variation: u8,
82    /// Hour restriction (`Hours`).
83    pub hours: u32,
84    /// Time restriction (`Times`, canonical `UInt8`).
85    pub times: u8,
86    /// Palette ID (`PaletteID`). Legacy Odyssey Engine artifact never natively evaluated by the KOTOR engine.
87    pub palette_id: u8,
88    /// Fixed variance (`FixedVariance`).
89    pub fixed_variance: f32,
90    /// Generated type (`GeneratedType`). Engine stores this as a single byte; values above 255 are aggressively truncated and corrupt behavior.
91    pub generated_type: u32,
92    /// 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.
93    pub sounds: Vec<UtsSound>,
94}
95
96impl Default for Uts {
97    fn default() -> Self {
98        Self {
99            template_resref: ResRef::blank(),
100            tag: String::new(),
101            name: GffLocalizedString::new(StrRef::invalid()),
102            comment: String::new(),
103            active: true,
104            continuous: false,
105            looping: false,
106            positional: true,
107            random_position: false,
108            random_pick: false,
109            elevation: 0.0,
110            max_distance: 20.0,
111            min_distance: 10.0,
112            random_range_x: 0.0,
113            random_range_y: 0.0,
114            interval: 0,
115            interval_variation: 0,
116            pitch_variation: 0.0,
117            priority: 0,
118            volume: 127,
119            volume_variation: 0,
120            hours: 0,
121            times: 3,
122            palette_id: 0,
123            fixed_variance: 1.0,
124            generated_type: 0,
125            sounds: Vec::new(),
126        }
127    }
128}
129
130impl Uts {
131    /// Creates an empty UTS value.
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Builds typed UTS data from a parsed GFF container.
137    pub fn from_gff(gff: &Gff) -> Result<Self, UtsError> {
138        if gff.file_type != *b"UTS " && gff.file_type != *b"GFF " {
139            return Err(UtsError::UnsupportedFileType(gff.file_type));
140        }
141
142        let root = &gff.root;
143
144        let sounds = match root.field("Sounds") {
145            Some(GffValue::List(sound_structs)) => sound_structs
146                .iter()
147                .map(UtsSound::from_struct)
148                .collect::<Vec<_>>(),
149            Some(_) => {
150                return Err(UtsError::TypeMismatch {
151                    field: "Sounds",
152                    expected: "List",
153                });
154            }
155            None => Vec::new(),
156        };
157
158        // TODO(rakata-generics/uts): add explicit runtime-evidence coverage for
159        // additional CSWSSoundObject loader defaults if parity targets require
160        // more than current field-level template mapping.
161        Ok(Self {
162            template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
163            tag: get_string(root, "Tag").unwrap_or_default(),
164            name: get_locstring(root, "LocName")
165                .cloned()
166                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
167            comment: get_string(root, "Comment").unwrap_or_default(),
168            active: get_bool(root, "Active").unwrap_or(true),
169            continuous: get_bool(root, "Continuous").unwrap_or(false),
170            looping: get_bool(root, "Looping").unwrap_or(false),
171            positional: get_bool(root, "Positional").unwrap_or(true),
172            random_position: get_bool(root, "RandomPosition").unwrap_or(false),
173            random_pick: get_bool(root, "Random").unwrap_or(false),
174            elevation: get_f32(root, "Elevation").unwrap_or(0.0),
175            max_distance: get_f32(root, "MaxDistance").unwrap_or(20.0),
176            min_distance: get_f32(root, "MinDistance").unwrap_or(10.0),
177            random_range_x: get_f32(root, "RandomRangeX").unwrap_or(0.0),
178            random_range_y: get_f32(root, "RandomRangeY").unwrap_or(0.0),
179            interval: get_u32(root, "Interval").unwrap_or(0),
180            interval_variation: get_u32(root, "IntervalVrtn").unwrap_or(0),
181            pitch_variation: get_f32(root, "PitchVariation").unwrap_or(0.0),
182            priority: get_u8(root, "Priority").unwrap_or(0),
183            volume: get_u8(root, "Volume").unwrap_or(127),
184            volume_variation: get_u8(root, "VolumeVrtn").unwrap_or(0),
185            hours: get_u32(root, "Hours").unwrap_or(0),
186            times: match root.field("Times") {
187                Some(GffValue::UInt8(value)) => *value,
188                Some(_) => {
189                    return Err(UtsError::TypeMismatch {
190                        field: "Times",
191                        expected: "UInt8",
192                    });
193                }
194                None => 3,
195            },
196            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
197            fixed_variance: get_f32(root, "FixedVariance").unwrap_or(1.0),
198            generated_type: get_u32(root, "GeneratedType").unwrap_or(0),
199            sounds,
200        })
201    }
202
203    /// Converts this typed UTS value into a GFF container.
204    pub fn to_gff(&self) -> Gff {
205        let mut root = GffStruct::new(-1);
206
207        upsert_field(
208            &mut root,
209            "TemplateResRef",
210            GffValue::ResRef(self.template_resref),
211        );
212        upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
213        upsert_field(
214            &mut root,
215            "LocName",
216            GffValue::LocalizedString(self.name.clone()),
217        );
218        upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
219
220        upsert_field(&mut root, "Active", GffValue::UInt8(u8::from(self.active)));
221        upsert_field(
222            &mut root,
223            "Continuous",
224            GffValue::UInt8(u8::from(self.continuous)),
225        );
226        upsert_field(
227            &mut root,
228            "Looping",
229            GffValue::UInt8(u8::from(self.looping)),
230        );
231        upsert_field(
232            &mut root,
233            "Positional",
234            GffValue::UInt8(u8::from(self.positional)),
235        );
236        upsert_field(
237            &mut root,
238            "RandomPosition",
239            GffValue::UInt8(u8::from(self.random_position)),
240        );
241        upsert_field(
242            &mut root,
243            "Random",
244            GffValue::UInt8(u8::from(self.random_pick)),
245        );
246
247        upsert_field(&mut root, "Elevation", GffValue::Single(self.elevation));
248        upsert_field(
249            &mut root,
250            "MaxDistance",
251            GffValue::Single(self.max_distance),
252        );
253        upsert_field(
254            &mut root,
255            "MinDistance",
256            GffValue::Single(self.min_distance),
257        );
258        upsert_field(
259            &mut root,
260            "RandomRangeX",
261            GffValue::Single(self.random_range_x),
262        );
263        upsert_field(
264            &mut root,
265            "RandomRangeY",
266            GffValue::Single(self.random_range_y),
267        );
268
269        upsert_field(&mut root, "Interval", GffValue::UInt32(self.interval));
270        upsert_field(
271            &mut root,
272            "IntervalVrtn",
273            GffValue::UInt32(self.interval_variation),
274        );
275        upsert_field(
276            &mut root,
277            "PitchVariation",
278            GffValue::Single(self.pitch_variation),
279        );
280        upsert_field(
281            &mut root,
282            "FixedVariance",
283            GffValue::Single(self.fixed_variance),
284        );
285        upsert_field(
286            &mut root,
287            "GeneratedType",
288            GffValue::UInt32(self.generated_type),
289        );
290
291        upsert_field(&mut root, "Priority", GffValue::UInt8(self.priority));
292        upsert_field(&mut root, "Volume", GffValue::UInt8(self.volume));
293        upsert_field(
294            &mut root,
295            "VolumeVrtn",
296            GffValue::UInt8(self.volume_variation),
297        );
298
299        upsert_field(&mut root, "Hours", GffValue::UInt32(self.hours));
300        upsert_field(&mut root, "Times", GffValue::UInt8(self.times));
301        upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
302
303        let sound_structs = self
304            .sounds
305            .iter()
306            .enumerate()
307            .map(|(index, sound)| sound.to_struct(index))
308            .collect::<Vec<GffStruct>>();
309        upsert_field(&mut root, "Sounds", GffValue::List(sound_structs));
310
311        Gff::new(*b"UTS ", root)
312    }
313}
314
315/// One UTS sound entry from the `Sounds` list.
316#[derive(Debug, Clone, PartialEq)]
317pub struct UtsSound {
318    /// Sound resref (`Sound`).
319    pub sound: ResRef,
320}
321
322impl UtsSound {
323    pub(crate) fn from_struct(structure: &GffStruct) -> Self {
324        Self {
325            sound: get_resref(structure, "Sound").unwrap_or_default(),
326        }
327    }
328
329    pub(crate) fn to_struct(&self, index: usize) -> GffStruct {
330        let mut structure =
331            GffStruct::new(i32::try_from(index).expect("sound entry index fits i32"));
332        upsert_field(&mut structure, "Sound", GffValue::ResRef(self.sound));
333        structure
334    }
335}
336
337/// Errors produced while reading or writing typed UTS data.
338#[derive(Debug, Error)]
339pub enum UtsError {
340    /// Source file type is not supported by this parser.
341    #[error("unsupported UTS file type: {0:?}")]
342    UnsupportedFileType([u8; 4]),
343    /// A required container field had an unexpected runtime type.
344    #[error("UTS field `{field}` has incompatible type (expected {expected})")]
345    TypeMismatch {
346        /// Field label where mismatch occurred.
347        field: &'static str,
348        /// Expected runtime value kind.
349        expected: &'static str,
350    },
351    /// Underlying GFF parser/writer error.
352    #[error(transparent)]
353    Gff(#[from] GffBinaryError),
354}
355
356/// Reads typed UTS data from a reader at the current stream position.
357#[cfg_attr(
358    feature = "tracing",
359    tracing::instrument(level = "debug", skip(reader))
360)]
361pub fn read_uts<R: Read>(reader: &mut R) -> Result<Uts, UtsError> {
362    let gff = read_gff(reader)?;
363    Uts::from_gff(&gff)
364}
365
366/// Reads typed UTS data directly from bytes.
367#[cfg_attr(
368    feature = "tracing",
369    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
370)]
371pub fn read_uts_from_bytes(bytes: &[u8]) -> Result<Uts, UtsError> {
372    let gff = read_gff_from_bytes(bytes)?;
373    Uts::from_gff(&gff)
374}
375
376/// Writes typed UTS data to an output writer.
377#[cfg_attr(
378    feature = "tracing",
379    tracing::instrument(level = "debug", skip(writer, uts))
380)]
381pub fn write_uts<W: Write>(writer: &mut W, uts: &Uts) -> Result<(), UtsError> {
382    let gff = uts.to_gff();
383    write_gff(writer, &gff)?;
384    Ok(())
385}
386
387/// Serializes typed UTS data into a byte vector.
388#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(uts)))]
389pub fn write_uts_to_vec(uts: &Uts) -> Result<Vec<u8>, UtsError> {
390    let mut cursor = Cursor::new(Vec::new());
391    write_uts(&mut cursor, uts)?;
392    Ok(cursor.into_inner())
393}
394
395/// UTS `Sounds` list entry child schema.
396static SOUNDS_CHILDREN: &[FieldSchema] = &[FieldSchema {
397    label: "Sound",
398    expected_type: GffType::ResRef,
399    life: FieldLife::Live,
400    required: false,
401    absent: AbsentDefault::Unverified,
402    children: None,
403    constraint: None,
404}];
405
406impl GffSchema for Uts {
407    fn schema() -> &'static [FieldSchema] {
408        static SCHEMA: &[FieldSchema] = &[
409            // --- Engine-read scalars (23) ---
410            FieldSchema {
411                label: "Tag",
412                expected_type: GffType::String,
413                life: FieldLife::Live,
414                required: false,
415                absent: AbsentDefault::Unverified,
416                children: None,
417                constraint: None,
418            },
419            FieldSchema {
420                label: "Active",
421                expected_type: GffType::UInt8,
422                life: FieldLife::Live,
423                required: false,
424                absent: AbsentDefault::Constructed(DefaultValue::UInt8(1), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
425                children: None,
426                constraint: None,
427            },
428            FieldSchema {
429                label: "Positional",
430                expected_type: GffType::UInt8,
431                life: FieldLife::Live,
432                required: false,
433                absent: AbsentDefault::Constructed(DefaultValue::UInt8(1), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
434                children: None,
435                constraint: None,
436            },
437            FieldSchema {
438                label: "Looping",
439                expected_type: GffType::UInt8,
440                life: FieldLife::Live,
441                required: false,
442                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
443                children: None,
444                constraint: None,
445            },
446            FieldSchema {
447                label: "Volume",
448                expected_type: GffType::UInt8,
449                life: FieldLife::Live,
450                required: false,
451                absent: AbsentDefault::Constructed(DefaultValue::UInt8(127), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
452                children: None,
453                constraint: None,
454            },
455            FieldSchema {
456                label: "VolumeVrtn",
457                expected_type: GffType::UInt8,
458                life: FieldLife::Live,
459                required: false,
460                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
461                children: None,
462                constraint: None,
463            },
464            FieldSchema {
465                label: "Times",
466                expected_type: GffType::UInt8,
467                life: FieldLife::Live,
468                required: false,
469                absent: AbsentDefault::Constructed(DefaultValue::UInt8(3), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
470                children: None,
471                constraint: None,
472            },
473            FieldSchema {
474                label: "PitchVariation",
475                expected_type: GffType::Single,
476                life: FieldLife::Live,
477                required: false,
478                absent: AbsentDefault::Constructed(DefaultValue::Single(0.0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
479                children: None,
480                constraint: None,
481            },
482            FieldSchema {
483                label: "Hours",
484                expected_type: GffType::UInt32,
485                life: FieldLife::Live,
486                required: false,
487                absent: AbsentDefault::Constructed(DefaultValue::UInt32(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
488                children: None,
489                constraint: None,
490            },
491            FieldSchema {
492                label: "GeneratedType",
493                expected_type: GffType::UInt32,
494                life: FieldLife::Live,
495                required: false,
496                absent: AbsentDefault::Constructed(DefaultValue::UInt32(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
497                children: None,
498                constraint: None,
499            },
500            FieldSchema {
501                label: "Interval",
502                expected_type: GffType::UInt32,
503                life: FieldLife::Live,
504                required: false,
505                absent: AbsentDefault::Constructed(DefaultValue::UInt32(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
506                children: None,
507                constraint: None,
508            },
509            FieldSchema {
510                label: "IntervalVrtn",
511                expected_type: GffType::UInt32,
512                life: FieldLife::Live,
513                required: false,
514                absent: AbsentDefault::Constructed(DefaultValue::UInt32(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
515                children: None,
516                constraint: None,
517            },
518            FieldSchema {
519                label: "MinDistance",
520                expected_type: GffType::Single,
521                life: FieldLife::Live,
522                required: false,
523                absent: AbsentDefault::Constructed(DefaultValue::Single(10.0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
524                children: None,
525                constraint: None,
526            },
527            FieldSchema {
528                label: "MaxDistance",
529                expected_type: GffType::Single,
530                life: FieldLife::Live,
531                required: false,
532                absent: AbsentDefault::Constructed(DefaultValue::Single(20.0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
533                children: None,
534                constraint: None,
535            },
536            FieldSchema {
537                label: "Continuous",
538                expected_type: GffType::UInt8,
539                life: FieldLife::Live,
540                required: false,
541                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
542                children: None,
543                constraint: None,
544            },
545            FieldSchema {
546                label: "Random",
547                expected_type: GffType::UInt8,
548                life: FieldLife::Live,
549                required: false,
550                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
551                children: None,
552                constraint: None,
553            },
554            FieldSchema {
555                label: "FixedVariance",
556                expected_type: GffType::Single,
557                life: FieldLife::Live,
558                required: false,
559                absent: AbsentDefault::Constructed(DefaultValue::Single(1.0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
560                children: None,
561                constraint: None,
562            },
563            FieldSchema {
564                label: "RandomPosition",
565                expected_type: GffType::UInt8,
566                life: FieldLife::Live,
567                required: false,
568                absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
569                children: None,
570                constraint: None,
571            },
572            FieldSchema {
573                label: "RandomRangeX",
574                expected_type: GffType::Single,
575                life: FieldLife::Live,
576                required: false,
577                absent: AbsentDefault::Constructed(DefaultValue::Single(0.0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
578                children: None,
579                constraint: None,
580            },
581            FieldSchema {
582                label: "RandomRangeY",
583                expected_type: GffType::Single,
584                life: FieldLife::Live,
585                required: false,
586                absent: AbsentDefault::Constructed(DefaultValue::Single(0.0), "uts.md: the sound loader has no literal fallbacks; every playback scalar keeps what a freshly constructed sound holds"),
587                children: None,
588                constraint: None,
589            },
590            FieldSchema {
591                label: "XPosition",
592                expected_type: GffType::Single,
593                life: FieldLife::Live,
594                required: false,
595                absent: AbsentDefault::Unverified,
596                children: None,
597                constraint: None,
598            },
599            FieldSchema {
600                label: "YPosition",
601                expected_type: GffType::Single,
602                life: FieldLife::Live,
603                required: false,
604                absent: AbsentDefault::Unverified,
605                children: None,
606                constraint: None,
607            },
608            FieldSchema {
609                label: "ZPosition",
610                expected_type: GffType::Single,
611                life: FieldLife::Live,
612                required: false,
613                absent: AbsentDefault::Unverified,
614                children: None,
615                constraint: None,
616            },
617            // --- Engine-read list ---
618            FieldSchema {
619                label: "Sounds",
620                expected_type: GffType::List,
621                life: FieldLife::Live,
622                required: false,
623                absent: AbsentDefault::Unverified,
624                children: Some(SOUNDS_CHILDREN),
625                constraint: None,
626            },
627            // --- Toolset-only fields (6) ---
628            FieldSchema {
629                label: "TemplateResRef",
630                expected_type: GffType::ResRef,
631                life: FieldLife::Live,
632                required: false,
633                absent: AbsentDefault::Unverified,
634                children: None,
635                constraint: None,
636            },
637            FieldSchema {
638                label: "LocName",
639                expected_type: GffType::LocalizedString,
640                life: FieldLife::Live,
641                required: false,
642                absent: AbsentDefault::Unverified,
643                children: None,
644                constraint: None,
645            },
646            FieldSchema {
647                label: "Comment",
648                expected_type: GffType::String,
649                life: FieldLife::Live,
650                required: false,
651                absent: AbsentDefault::Unverified,
652                children: None,
653                constraint: None,
654            },
655            FieldSchema {
656                label: "Elevation",
657                expected_type: GffType::Single,
658                life: FieldLife::Live,
659                required: false,
660                absent: AbsentDefault::Unverified,
661                children: None,
662                constraint: None,
663            },
664            FieldSchema {
665                label: "Priority",
666                expected_type: GffType::UInt8,
667                life: FieldLife::Live,
668                required: false,
669                absent: AbsentDefault::Unverified,
670                children: None,
671                constraint: None,
672            },
673            FieldSchema {
674                label: "PaletteID",
675                expected_type: GffType::UInt8,
676                life: FieldLife::Live,
677                required: false,
678                absent: AbsentDefault::Unverified,
679                children: None,
680                constraint: None,
681            },
682        ];
683        SCHEMA
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    const TEST_UTS: &[u8] = include_bytes!(concat!(
692        env!("CARGO_MANIFEST_DIR"),
693        "/../../fixtures/test.uts"
694    ));
695    const K1_UTS: &[u8] = include_bytes!(concat!(
696        env!("CARGO_MANIFEST_DIR"),
697        "/../../fixtures/test_k1.uts"
698    ));
699
700    #[test]
701    fn reads_core_uts_fields_from_fixture() {
702        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
703
704        assert_eq!(uts.tag, "3Csounds");
705        assert_eq!(uts.template_resref, "3csounds");
706        assert_eq!(uts.name.string_ref.raw(), 128_551);
707        assert_eq!(uts.comment, "comment");
708
709        assert!(uts.active);
710        assert!(uts.continuous);
711        assert!(uts.looping);
712        assert!(uts.positional);
713        assert!(uts.random_position);
714        assert!(uts.random_pick);
715
716        assert_eq!(uts.elevation, 1.5);
717        assert_eq!(uts.min_distance, 5.0);
718        assert_eq!(uts.max_distance, 8.0);
719        assert_eq!(uts.random_range_x, 0.1);
720        assert_eq!(uts.random_range_y, 0.2);
721        assert_eq!(uts.interval, 4_000);
722        assert_eq!(uts.interval_variation, 100);
723        assert_eq!(uts.pitch_variation, 0.1);
724        assert_eq!(uts.priority, 22);
725        assert_eq!(uts.hours, 0);
726        assert_eq!(uts.times, 3);
727        assert_eq!(uts.volume, 120);
728        assert_eq!(uts.volume_variation, 7);
729        assert_eq!(uts.palette_id, 6);
730        assert_eq!(uts.generated_type, 0);
731
732        assert_eq!(uts.sounds.len(), 4);
733        assert_eq!(uts.sounds[0].sound, "c_drdastro_dead");
734        assert_eq!(uts.sounds[1].sound, "c_drdastro_atk1");
735        assert_eq!(uts.sounds[2].sound, "p_t3-m4_dead");
736        assert_eq!(uts.sounds[3].sound, "c_drdastro_atk2");
737    }
738
739    #[test]
740    fn reads_k1_fixture_variant() {
741        let uts = read_uts_from_bytes(K1_UTS).expect("fixture must parse");
742
743        assert_eq!(uts.tag, "computersoundsrnd");
744        assert_eq!(uts.template_resref, "computersoundsrn");
745        assert_eq!(uts.name.string_ref.raw(), 45_774);
746        assert_eq!(uts.comment, "");
747
748        assert!(uts.active);
749        assert!(uts.continuous);
750        assert!(!uts.looping);
751        assert!(uts.positional);
752        assert!(!uts.random_position);
753        assert!(uts.random_pick);
754
755        assert_eq!(uts.min_distance, 3.0);
756        assert_eq!(uts.max_distance, 10.0);
757        assert_eq!(uts.interval, 7_000);
758        assert_eq!(uts.interval_variation, 4_000);
759        assert_eq!(uts.volume, 70);
760        assert_eq!(uts.volume_variation, 0);
761        assert_eq!(uts.generated_type, 0);
762
763        assert_eq!(uts.sounds.len(), 3);
764        assert_eq!(uts.sounds[2].sound, "as_el_compsnd_04");
765    }
766
767    #[test]
768    fn all_fields_survive_typed_roundtrip() {
769        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
770        let bytes = write_uts_to_vec(&uts).expect("write succeeds");
771        let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
772        assert_eq!(reparsed, uts);
773    }
774
775    #[test]
776    fn writes_times_as_canonical_u8() {
777        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
778        gff.root.fields.retain(|field| field.label != "Times");
779        gff.root.push_field("Times", GffValue::UInt8(3));
780
781        let mut uts = Uts::from_gff(&gff).expect("typed parse");
782        uts.times = 42;
783
784        let rebuilt = uts.to_gff();
785        assert_eq!(rebuilt.root.field("Times"), Some(&GffValue::UInt8(42)));
786    }
787
788    #[test]
789    fn rejects_non_canonical_times_width() {
790        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
791        gff.root.fields.retain(|field| field.label != "Times");
792        gff.root.push_field("Times", GffValue::UInt32(3));
793
794        let err = Uts::from_gff(&gff).expect_err("non-canonical Times width must be rejected");
795        assert!(matches!(
796            err,
797            UtsError::TypeMismatch {
798                field: "Times",
799                expected: "UInt8",
800            }
801        ));
802    }
803
804    #[test]
805    fn typed_edits_roundtrip_through_gff_writer() {
806        let mut uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
807        uts.tag = "3Csounds_rust".into();
808        uts.sounds[0].sound = ResRef::new("rust_sound").expect("valid test resref");
809        uts.volume = 90;
810
811        let bytes = write_uts_to_vec(&uts).expect("write succeeds");
812        let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
813
814        assert_eq!(reparsed.tag, "3Csounds_rust");
815        assert_eq!(reparsed.sounds[0].sound, "rust_sound");
816        assert_eq!(reparsed.volume, 90);
817    }
818
819    #[test]
820    fn read_uts_from_reader_matches_bytes_path() {
821        let mut cursor = Cursor::new(TEST_UTS);
822        let via_reader = read_uts(&mut cursor).expect("reader parse succeeds");
823        let via_bytes = read_uts_from_bytes(TEST_UTS).expect("bytes parse succeeds");
824
825        assert_eq!(via_reader, via_bytes);
826    }
827
828    #[test]
829    fn rejects_non_uts_file_type() {
830        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
831        gff.file_type = *b"UTP ";
832
833        let err = Uts::from_gff(&gff).expect_err("UTP must be rejected as UTS input");
834        assert!(matches!(
835            err,
836            UtsError::UnsupportedFileType(file_type) if file_type == *b"UTP "
837        ));
838    }
839
840    #[test]
841    fn type_mismatch_on_sounds_list_is_error() {
842        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
843        gff.root.fields.retain(|field| field.label != "Sounds");
844        gff.root.push_field("Sounds", GffValue::UInt32(123));
845
846        let err = Uts::from_gff(&gff).expect_err("type mismatch must be rejected");
847        assert!(matches!(
848            err,
849            UtsError::TypeMismatch {
850                field: "Sounds",
851                expected: "List",
852            }
853        ));
854    }
855
856    #[test]
857    fn write_uts_matches_direct_gff_writer() {
858        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
859
860        let via_typed = write_uts_to_vec(&uts).expect("typed write succeeds");
861
862        let mut direct = Cursor::new(Vec::new());
863        write_gff(&mut direct, &uts.to_gff()).expect("direct write succeeds");
864
865        assert_eq!(via_typed, direct.into_inner());
866    }
867
868    #[test]
869    fn schema_field_count() {
870        assert_eq!(Uts::schema().len(), 30); // 23 engine + 1 list + 6 toolset
871    }
872
873    #[test]
874    fn schema_no_duplicate_labels() {
875        let schema = Uts::schema();
876        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
877        labels.sort();
878        let before = labels.len();
879        labels.dedup();
880        assert_eq!(before, labels.len(), "duplicate labels in UTS schema");
881    }
882
883    #[test]
884    fn schema_sounds_has_children() {
885        let sounds = Uts::schema()
886            .iter()
887            .find(|f| f.label == "Sounds")
888            .expect("test fixture must be valid");
889        assert!(sounds.children.is_some());
890        assert_eq!(
891            sounds.children.expect("test fixture must be valid").len(),
892            1
893        );
894    }
895}