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 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: 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 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(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 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 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#[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 required: false,
400 children: None,
401 constraint: None,
402}];
403
404impl GffSchema for Uts {
405 fn schema() -> &'static [FieldSchema] {
406 static SCHEMA: &[FieldSchema] = &[
407 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 FieldSchema {
571 label: "Sounds",
572 expected_type: GffType::List,
573 required: false,
574 children: Some(SOUNDS_CHILDREN),
575 constraint: None,
576 },
577 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); }
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}