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::{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: false,
104            continuous: false,
105            looping: false,
106            positional: false,
107            random_position: false,
108            random_pick: false,
109            elevation: 0.0,
110            max_distance: 0.0,
111            min_distance: 0.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: 0,
119            volume_variation: 0,
120            hours: 0,
121            times: 0,
122            palette_id: 0,
123            fixed_variance: 0.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(false),
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(false),
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(0.0),
176            min_distance: get_f32(root, "MinDistance").unwrap_or(0.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(0),
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 => 0,
195            },
196            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
197            fixed_variance: get_f32(root, "FixedVariance").unwrap_or(0.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    fn from_struct(structure: &GffStruct) -> Self {
324        Self {
325            sound: get_resref(structure, "Sound").unwrap_or_default(),
326        }
327    }
328
329    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    required: false,
400    children: None,
401    constraint: None,
402}];
403
404impl GffSchema for Uts {
405    fn schema() -> &'static [FieldSchema] {
406        static SCHEMA: &[FieldSchema] = &[
407            // --- Engine-read scalars (23) ---
408            FieldSchema {
409                label: "Tag",
410                expected_type: GffType::String,
411                required: false,
412                children: None,
413                constraint: None,
414            },
415            FieldSchema {
416                label: "Active",
417                expected_type: GffType::UInt8,
418                required: false,
419                children: None,
420                constraint: None,
421            },
422            FieldSchema {
423                label: "Positional",
424                expected_type: GffType::UInt8,
425                required: false,
426                children: None,
427                constraint: None,
428            },
429            FieldSchema {
430                label: "Looping",
431                expected_type: GffType::UInt8,
432                required: false,
433                children: None,
434                constraint: None,
435            },
436            FieldSchema {
437                label: "Volume",
438                expected_type: GffType::UInt8,
439                required: false,
440                children: None,
441                constraint: None,
442            },
443            FieldSchema {
444                label: "VolumeVrtn",
445                expected_type: GffType::UInt8,
446                required: false,
447                children: None,
448                constraint: None,
449            },
450            FieldSchema {
451                label: "Times",
452                expected_type: GffType::UInt8,
453                required: false,
454                children: None,
455                constraint: None,
456            },
457            FieldSchema {
458                label: "PitchVariation",
459                expected_type: GffType::Single,
460                required: false,
461                children: None,
462                constraint: None,
463            },
464            FieldSchema {
465                label: "Hours",
466                expected_type: GffType::UInt32,
467                required: false,
468                children: None,
469                constraint: None,
470            },
471            FieldSchema {
472                label: "GeneratedType",
473                expected_type: GffType::UInt32,
474                required: false,
475                children: None,
476                constraint: None,
477            },
478            FieldSchema {
479                label: "Interval",
480                expected_type: GffType::UInt32,
481                required: false,
482                children: None,
483                constraint: None,
484            },
485            FieldSchema {
486                label: "IntervalVrtn",
487                expected_type: GffType::UInt32,
488                required: false,
489                children: None,
490                constraint: None,
491            },
492            FieldSchema {
493                label: "MinDistance",
494                expected_type: GffType::Single,
495                required: false,
496                children: None,
497                constraint: None,
498            },
499            FieldSchema {
500                label: "MaxDistance",
501                expected_type: GffType::Single,
502                required: false,
503                children: None,
504                constraint: None,
505            },
506            FieldSchema {
507                label: "Continuous",
508                expected_type: GffType::UInt8,
509                required: false,
510                children: None,
511                constraint: None,
512            },
513            FieldSchema {
514                label: "Random",
515                expected_type: GffType::UInt8,
516                required: false,
517                children: None,
518                constraint: None,
519            },
520            FieldSchema {
521                label: "FixedVariance",
522                expected_type: GffType::Single,
523                required: false,
524                children: None,
525                constraint: None,
526            },
527            FieldSchema {
528                label: "RandomPosition",
529                expected_type: GffType::UInt8,
530                required: false,
531                children: None,
532                constraint: None,
533            },
534            FieldSchema {
535                label: "RandomRangeX",
536                expected_type: GffType::Single,
537                required: false,
538                children: None,
539                constraint: None,
540            },
541            FieldSchema {
542                label: "RandomRangeY",
543                expected_type: GffType::Single,
544                required: false,
545                children: None,
546                constraint: None,
547            },
548            FieldSchema {
549                label: "XPosition",
550                expected_type: GffType::Single,
551                required: false,
552                children: None,
553                constraint: None,
554            },
555            FieldSchema {
556                label: "YPosition",
557                expected_type: GffType::Single,
558                required: false,
559                children: None,
560                constraint: None,
561            },
562            FieldSchema {
563                label: "ZPosition",
564                expected_type: GffType::Single,
565                required: false,
566                children: None,
567                constraint: None,
568            },
569            // --- Engine-read list ---
570            FieldSchema {
571                label: "Sounds",
572                expected_type: GffType::List,
573                required: false,
574                children: Some(SOUNDS_CHILDREN),
575                constraint: None,
576            },
577            // --- Toolset-only fields (6) ---
578            FieldSchema {
579                label: "TemplateResRef",
580                expected_type: GffType::ResRef,
581                required: false,
582                children: None,
583                constraint: None,
584            },
585            FieldSchema {
586                label: "LocName",
587                expected_type: GffType::LocalizedString,
588                required: false,
589                children: None,
590                constraint: None,
591            },
592            FieldSchema {
593                label: "Comment",
594                expected_type: GffType::String,
595                required: false,
596                children: None,
597                constraint: None,
598            },
599            FieldSchema {
600                label: "Elevation",
601                expected_type: GffType::Single,
602                required: false,
603                children: None,
604                constraint: None,
605            },
606            FieldSchema {
607                label: "Priority",
608                expected_type: GffType::UInt8,
609                required: false,
610                children: None,
611                constraint: None,
612            },
613            FieldSchema {
614                label: "PaletteID",
615                expected_type: GffType::UInt8,
616                required: false,
617                children: None,
618                constraint: None,
619            },
620        ];
621        SCHEMA
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    const TEST_UTS: &[u8] = include_bytes!(concat!(
630        env!("CARGO_MANIFEST_DIR"),
631        "/../../fixtures/test.uts"
632    ));
633    const K1_UTS: &[u8] = include_bytes!(concat!(
634        env!("CARGO_MANIFEST_DIR"),
635        "/../../fixtures/test_k1.uts"
636    ));
637
638    #[test]
639    fn reads_core_uts_fields_from_fixture() {
640        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
641
642        assert_eq!(uts.tag, "3Csounds");
643        assert_eq!(uts.template_resref, "3csounds");
644        assert_eq!(uts.name.string_ref.raw(), 128_551);
645        assert_eq!(uts.comment, "comment");
646
647        assert!(uts.active);
648        assert!(uts.continuous);
649        assert!(uts.looping);
650        assert!(uts.positional);
651        assert!(uts.random_position);
652        assert!(uts.random_pick);
653
654        assert_eq!(uts.elevation, 1.5);
655        assert_eq!(uts.min_distance, 5.0);
656        assert_eq!(uts.max_distance, 8.0);
657        assert_eq!(uts.random_range_x, 0.1);
658        assert_eq!(uts.random_range_y, 0.2);
659        assert_eq!(uts.interval, 4_000);
660        assert_eq!(uts.interval_variation, 100);
661        assert_eq!(uts.pitch_variation, 0.1);
662        assert_eq!(uts.priority, 22);
663        assert_eq!(uts.hours, 0);
664        assert_eq!(uts.times, 3);
665        assert_eq!(uts.volume, 120);
666        assert_eq!(uts.volume_variation, 7);
667        assert_eq!(uts.palette_id, 6);
668        assert_eq!(uts.generated_type, 0);
669
670        assert_eq!(uts.sounds.len(), 4);
671        assert_eq!(uts.sounds[0].sound, "c_drdastro_dead");
672        assert_eq!(uts.sounds[1].sound, "c_drdastro_atk1");
673        assert_eq!(uts.sounds[2].sound, "p_t3-m4_dead");
674        assert_eq!(uts.sounds[3].sound, "c_drdastro_atk2");
675    }
676
677    #[test]
678    fn reads_k1_fixture_variant() {
679        let uts = read_uts_from_bytes(K1_UTS).expect("fixture must parse");
680
681        assert_eq!(uts.tag, "computersoundsrnd");
682        assert_eq!(uts.template_resref, "computersoundsrn");
683        assert_eq!(uts.name.string_ref.raw(), 45_774);
684        assert_eq!(uts.comment, "");
685
686        assert!(uts.active);
687        assert!(uts.continuous);
688        assert!(!uts.looping);
689        assert!(uts.positional);
690        assert!(!uts.random_position);
691        assert!(uts.random_pick);
692
693        assert_eq!(uts.min_distance, 3.0);
694        assert_eq!(uts.max_distance, 10.0);
695        assert_eq!(uts.interval, 7_000);
696        assert_eq!(uts.interval_variation, 4_000);
697        assert_eq!(uts.volume, 70);
698        assert_eq!(uts.volume_variation, 0);
699        assert_eq!(uts.generated_type, 0);
700
701        assert_eq!(uts.sounds.len(), 3);
702        assert_eq!(uts.sounds[2].sound, "as_el_compsnd_04");
703    }
704
705    #[test]
706    fn all_fields_survive_typed_roundtrip() {
707        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
708        let bytes = write_uts_to_vec(&uts).expect("write succeeds");
709        let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
710        assert_eq!(reparsed, uts);
711    }
712
713    #[test]
714    fn writes_times_as_canonical_u8() {
715        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
716        gff.root.fields.retain(|field| field.label != "Times");
717        gff.root.push_field("Times", GffValue::UInt8(3));
718
719        let mut uts = Uts::from_gff(&gff).expect("typed parse");
720        uts.times = 42;
721
722        let rebuilt = uts.to_gff();
723        assert_eq!(rebuilt.root.field("Times"), Some(&GffValue::UInt8(42)));
724    }
725
726    #[test]
727    fn rejects_non_canonical_times_width() {
728        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
729        gff.root.fields.retain(|field| field.label != "Times");
730        gff.root.push_field("Times", GffValue::UInt32(3));
731
732        let err = Uts::from_gff(&gff).expect_err("non-canonical Times width must be rejected");
733        assert!(matches!(
734            err,
735            UtsError::TypeMismatch {
736                field: "Times",
737                expected: "UInt8",
738            }
739        ));
740    }
741
742    #[test]
743    fn typed_edits_roundtrip_through_gff_writer() {
744        let mut uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
745        uts.tag = "3Csounds_rust".into();
746        uts.sounds[0].sound = ResRef::new("rust_sound").expect("valid test resref");
747        uts.volume = 90;
748
749        let bytes = write_uts_to_vec(&uts).expect("write succeeds");
750        let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
751
752        assert_eq!(reparsed.tag, "3Csounds_rust");
753        assert_eq!(reparsed.sounds[0].sound, "rust_sound");
754        assert_eq!(reparsed.volume, 90);
755    }
756
757    #[test]
758    fn read_uts_from_reader_matches_bytes_path() {
759        let mut cursor = Cursor::new(TEST_UTS);
760        let via_reader = read_uts(&mut cursor).expect("reader parse succeeds");
761        let via_bytes = read_uts_from_bytes(TEST_UTS).expect("bytes parse succeeds");
762
763        assert_eq!(via_reader, via_bytes);
764    }
765
766    #[test]
767    fn rejects_non_uts_file_type() {
768        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
769        gff.file_type = *b"UTP ";
770
771        let err = Uts::from_gff(&gff).expect_err("UTP must be rejected as UTS input");
772        assert!(matches!(
773            err,
774            UtsError::UnsupportedFileType(file_type) if file_type == *b"UTP "
775        ));
776    }
777
778    #[test]
779    fn type_mismatch_on_sounds_list_is_error() {
780        let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
781        gff.root.fields.retain(|field| field.label != "Sounds");
782        gff.root.push_field("Sounds", GffValue::UInt32(123));
783
784        let err = Uts::from_gff(&gff).expect_err("type mismatch must be rejected");
785        assert!(matches!(
786            err,
787            UtsError::TypeMismatch {
788                field: "Sounds",
789                expected: "List",
790            }
791        ));
792    }
793
794    #[test]
795    fn write_uts_matches_direct_gff_writer() {
796        let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
797
798        let via_typed = write_uts_to_vec(&uts).expect("typed write succeeds");
799
800        let mut direct = Cursor::new(Vec::new());
801        write_gff(&mut direct, &uts.to_gff()).expect("direct write succeeds");
802
803        assert_eq!(via_typed, direct.into_inner());
804    }
805
806    #[test]
807    fn schema_field_count() {
808        assert_eq!(Uts::schema().len(), 30); // 23 engine + 1 list + 6 toolset
809    }
810
811    #[test]
812    fn schema_no_duplicate_labels() {
813        let schema = Uts::schema();
814        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
815        labels.sort();
816        let before = labels.len();
817        labels.dedup();
818        assert_eq!(before, labels.len(), "duplicate labels in UTS schema");
819    }
820
821    #[test]
822    fn schema_sounds_has_children() {
823        let sounds = Uts::schema()
824            .iter()
825            .find(|f| f.label == "Sounds")
826            .expect("test fixture must be valid");
827        assert!(sounds.children.is_some());
828        assert_eq!(
829            sounds.children.expect("test fixture must be valid").len(),
830            1
831        );
832    }
833}