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