1use 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#[derive(Debug, Clone, PartialEq)]
39pub struct Uts {
40 pub template_resref: ResRef,
42 pub tag: String,
44 pub name: GffLocalizedString,
46 pub comment: String,
48 pub active: bool,
50 pub continuous: bool,
52 pub looping: bool,
54 pub positional: bool,
56 pub random_position: bool,
58 pub random_pick: bool,
60 pub elevation: f32,
62 pub max_distance: f32,
64 pub min_distance: f32,
66 pub random_range_x: f32,
68 pub random_range_y: f32,
70 pub interval: u32,
72 pub interval_variation: u32,
74 pub pitch_variation: f32,
76 pub priority: u8,
78 pub volume: u8,
80 pub volume_variation: u8,
82 pub hours: u32,
84 pub times: u8,
86 pub palette_id: u8,
88 pub fixed_variance: f32,
90 pub generated_type: u32,
92 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 pub fn new() -> Self {
133 Self::default()
134 }
135
136 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 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 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#[derive(Debug, Clone, PartialEq)]
317pub struct UtsSound {
318 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#[derive(Debug, Error)]
339pub enum UtsError {
340 #[error("unsupported UTS file type: {0:?}")]
342 UnsupportedFileType([u8; 4]),
343 #[error("UTS field `{field}` has incompatible type (expected {expected})")]
345 TypeMismatch {
346 field: &'static str,
348 expected: &'static str,
350 },
351 #[error(transparent)]
353 Gff(#[from] GffBinaryError),
354}
355
356#[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#[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#[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#[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
395static 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 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 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 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); }
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}