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::{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 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 pub fn new() -> Self {
130 Self::default()
131 }
132
133 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 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 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#[derive(Debug, Clone, PartialEq)]
308pub struct UtsSound {
309 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#[derive(Debug, Error)]
330pub enum UtsError {
331 #[error("unsupported UTS file type: {0:?}")]
333 UnsupportedFileType([u8; 4]),
334 #[error("UTS field `{field}` has incompatible type (expected {expected})")]
336 TypeMismatch {
337 field: &'static str,
339 expected: &'static str,
341 },
342 #[error(transparent)]
344 Gff(#[from] GffBinaryError),
345}
346
347#[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#[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#[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#[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
386static 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 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 FieldSchema {
562 label: "Sounds",
563 expected_type: GffType::List,
564 required: false,
565 children: Some(SOUNDS_CHILDREN),
566 constraint: None,
567 },
568 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); }
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}