1use std::io::{Cursor, Read, Write};
26
27use rakata_core::ResRef;
28use rakata_formats::gff::get_u32_extended_signed;
29use rakata_formats::schema::FromGff;
30use rakata_formats::GENERIC_FILE_TYPE;
31use rakata_formats::{
32 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
33 GffStruct, GffValue,
34};
35use thiserror::Error;
36
37#[derive(Debug, Clone, PartialEq, GffModel)]
43#[gff_entry(XPosition, wire = f32, stamped)]
44#[gff_entry(YPosition, wire = f32, stamped)]
45#[gff_entry(ZPosition, wire = f32, stamped)]
46pub struct Uts {
47 #[gff(TemplateResRef, unexamined)]
49 pub template_resref: ResRef,
50 #[gff(Tag, unexamined)]
52 pub tag: String,
53 #[gff(LocName, unexamined)]
55 pub name: GffLocalizedString,
56 #[gff(Comment, unexamined)]
58 pub comment: String,
59 #[gff(Active, constructed = true)]
61 pub active: bool,
62 #[gff(Continuous, constructed)]
64 pub continuous: bool,
65 #[gff(Looping, constructed)]
67 pub looping: bool,
68 #[gff(Positional, constructed = true)]
70 pub positional: bool,
71 #[gff(RandomPosition, constructed)]
73 pub random_position: bool,
74 #[gff(Random, constructed)]
76 pub random_pick: bool,
77 #[gff(Elevation, unexamined)]
79 pub elevation: f32,
80 #[gff(MaxDistance, constructed = 20.0)]
82 pub max_distance: f32,
83 #[gff(MinDistance, constructed = 10.0)]
85 pub min_distance: f32,
86 #[gff(RandomRangeX, constructed)]
88 pub random_range_x: f32,
89 #[gff(RandomRangeY, constructed)]
91 pub random_range_y: f32,
92 #[gff(Interval, manual_read, constructed)]
94 pub interval: u32,
95 #[gff(IntervalVrtn, manual_read, constructed)]
97 pub interval_variation: u32,
98 #[gff(PitchVariation, constructed)]
100 pub pitch_variation: f32,
101 #[gff(Priority, unexamined)]
103 pub priority: u8,
104 #[gff(Volume, constructed = 127)]
106 pub volume: u8,
107 #[gff(VolumeVrtn, constructed)]
109 pub volume_variation: u8,
110 #[gff(Hours, manual_read, constructed)]
112 pub hours: u32,
113 #[gff(Times, manual_read, constructed = 3)]
115 pub times: u8,
116 #[gff(PaletteID, unexamined)]
118 pub palette_id: u8,
119 #[gff(FixedVariance, constructed = 1.0, omit = audited_constant(635))]
121 pub fixed_variance: f32,
122 #[gff(GeneratedType, manual_read, constructed, omit = audited_constant(635))]
124 pub generated_type: u32,
125 #[gff(Sounds, unexamined, list = UtsSound, element_id = 0)]
128 pub sounds: Vec<UtsSound>,
129}
130
131impl Uts {
132 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn from_gff(gff: &Gff) -> Result<Self, UtsError> {
145 if gff.file_type != <Uts as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
146 return Err(UtsError::UnsupportedFileType(gff.file_type));
147 }
148
149 let root = &gff.root;
150 let mut sound = Self::read_declared(root);
151
152 sound.hours = get_u32_extended_signed(root, "Hours").unwrap_or(0);
158 sound.interval = get_u32_extended_signed(root, "Interval").unwrap_or(0);
159 sound.interval_variation = get_u32_extended_signed(root, "IntervalVrtn").unwrap_or(0);
160 sound.generated_type = get_u32_extended_signed(root, "GeneratedType").unwrap_or(0);
161
162 sound.times = match root.field("Times") {
166 Some(GffValue::UInt8(value)) => *value,
167 Some(_) => {
168 return Err(UtsError::TypeMismatch {
169 field: "Times",
170 expected: "UInt8",
171 });
172 }
173 None => 3,
174 };
175
176 Ok(sound)
177 }
178
179 pub fn to_gff(&self) -> Gff {
181 let mut root = GffStruct::new(-1);
182 self.write_declared(&mut root);
183 Gff::new(*b"UTS ", root)
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
189pub struct UtsSound {
190 #[gff(Sound, unexamined)]
192 pub sound: ResRef,
193}
194
195#[derive(Debug, Error)]
197pub enum UtsError {
198 #[error("unsupported UTS file type: {0:?}")]
200 UnsupportedFileType([u8; 4]),
201 #[error("{field} must be {expected}")]
203 TypeMismatch {
204 field: &'static str,
206 expected: &'static str,
208 },
209 #[error(transparent)]
211 Gff(#[from] GffBinaryError),
212}
213
214#[cfg_attr(
222 feature = "tracing",
223 tracing::instrument(level = "debug", skip(reader))
224)]
225pub fn read_uts<R: Read>(reader: &mut R) -> Result<Uts, UtsError> {
226 let gff = read_gff(reader)?;
227 Uts::from_gff(&gff)
228}
229
230#[cfg_attr(
238 feature = "tracing",
239 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
240)]
241pub fn read_uts_from_bytes(bytes: &[u8]) -> Result<Uts, UtsError> {
242 let gff = read_gff_from_bytes(bytes)?;
243 Uts::from_gff(&gff)
244}
245
246#[cfg_attr(
254 feature = "tracing",
255 tracing::instrument(level = "debug", skip(writer, uts))
256)]
257pub fn author_uts<W: Write>(writer: &mut W, uts: &Uts) -> Result<(), UtsError> {
258 let gff = uts.to_gff();
259 write_gff(writer, &gff)?;
260 Ok(())
261}
262
263#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(uts)))]
270pub fn author_uts_to_vec(uts: &Uts) -> Result<Vec<u8>, UtsError> {
271 let mut cursor = Cursor::new(Vec::new());
272 author_uts(&mut cursor, uts)?;
273 Ok(cursor.into_inner())
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use rakata_formats::gff_label;
280 use rakata_formats::schema::{HasSchema, Shape};
281
282 const TEST_UTS: &[u8] = include_bytes!(concat!(
283 env!("CARGO_MANIFEST_DIR"),
284 "/../../fixtures/test.uts"
285 ));
286 const K1_UTS: &[u8] = include_bytes!(concat!(
287 env!("CARGO_MANIFEST_DIR"),
288 "/../../fixtures/test_k1.uts"
289 ));
290
291 #[test]
292 fn reads_core_uts_fields_from_fixture() {
293 let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
294
295 assert_eq!(uts.tag, "3Csounds");
296 assert_eq!(uts.template_resref, "3csounds");
297 assert_eq!(uts.name.string_ref.raw(), 128_551);
298 assert_eq!(uts.comment, "comment");
299
300 assert!(uts.active);
301 assert!(uts.continuous);
302 assert!(uts.looping);
303 assert!(uts.positional);
304 assert!(uts.random_position);
305 assert!(uts.random_pick);
306
307 assert_eq!(uts.elevation, 1.5);
308 assert_eq!(uts.min_distance, 5.0);
309 assert_eq!(uts.max_distance, 8.0);
310 assert_eq!(uts.random_range_x, 0.1);
311 assert_eq!(uts.random_range_y, 0.2);
312 assert_eq!(uts.interval, 4_000);
313 assert_eq!(uts.interval_variation, 100);
314 assert_eq!(uts.pitch_variation, 0.1);
315 assert_eq!(uts.priority, 22);
316 assert_eq!(uts.hours, 0);
317 assert_eq!(uts.times, 3);
318 assert_eq!(uts.volume, 120);
319 assert_eq!(uts.volume_variation, 7);
320 assert_eq!(uts.palette_id, 6);
321 assert_eq!(uts.generated_type, 0);
322
323 assert_eq!(uts.sounds.len(), 4);
324 assert_eq!(uts.sounds[0].sound, "c_drdastro_dead");
325 assert_eq!(uts.sounds[1].sound, "c_drdastro_atk1");
326 assert_eq!(uts.sounds[2].sound, "p_t3-m4_dead");
327 assert_eq!(uts.sounds[3].sound, "c_drdastro_atk2");
328 }
329
330 #[test]
331 fn reads_k1_fixture_variant() {
332 let uts = read_uts_from_bytes(K1_UTS).expect("fixture must parse");
333
334 assert_eq!(uts.tag, "computersoundsrnd");
335 assert_eq!(uts.template_resref, "computersoundsrn");
336 assert_eq!(uts.name.string_ref.raw(), 45_774);
337 assert_eq!(uts.comment, "");
338
339 assert!(uts.active);
340 assert!(uts.continuous);
341 assert!(!uts.looping);
342 assert!(uts.positional);
343 assert!(!uts.random_position);
344 assert!(uts.random_pick);
345
346 assert_eq!(uts.min_distance, 3.0);
347 assert_eq!(uts.max_distance, 10.0);
348 assert_eq!(uts.interval, 7_000);
349 assert_eq!(uts.interval_variation, 4_000);
350 assert_eq!(uts.volume, 70);
351 assert_eq!(uts.volume_variation, 0);
352 assert_eq!(uts.generated_type, 0);
353
354 assert_eq!(uts.sounds.len(), 3);
355 assert_eq!(uts.sounds[2].sound, "as_el_compsnd_04");
356 }
357
358 #[test]
359 fn all_fields_survive_typed_roundtrip() {
360 let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
361 let bytes = author_uts_to_vec(&uts).expect("write succeeds");
362 let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
363 assert_eq!(reparsed, uts);
364 }
365
366 #[test]
367 fn writes_times_as_canonical_u8() {
368 let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
369 gff.root.fields.retain(|field| field.label != "Times");
370 gff.root.push_field(gff_label!("Times"), GffValue::UInt8(3));
371
372 let mut uts = Uts::from_gff(&gff).expect("typed parse");
373 uts.times = 42;
374
375 let rebuilt = uts.to_gff();
376 assert_eq!(rebuilt.root.field("Times"), Some(&GffValue::UInt8(42)));
377 }
378
379 #[test]
380 fn rejects_non_canonical_times_width() {
381 let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
382 gff.root.fields.retain(|field| field.label != "Times");
383 gff.root
384 .push_field(gff_label!("Times"), GffValue::UInt32(3));
385
386 let err = Uts::from_gff(&gff).expect_err("non-canonical Times width must be rejected");
387 assert!(matches!(
388 err,
389 UtsError::TypeMismatch {
390 field: "Times",
391 expected: "UInt8",
392 }
393 ));
394 }
395
396 #[test]
397 fn typed_edits_roundtrip_through_gff_writer() {
398 let mut uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
399 uts.tag = "3Csounds_rust".into();
400 uts.sounds[0].sound = ResRef::new("rust_sound").expect("valid test resref");
401 uts.volume = 90;
402
403 let bytes = author_uts_to_vec(&uts).expect("write succeeds");
404 let reparsed = read_uts_from_bytes(&bytes).expect("reparse succeeds");
405
406 assert_eq!(reparsed.tag, "3Csounds_rust");
407 assert_eq!(reparsed.sounds[0].sound, "rust_sound");
408 assert_eq!(reparsed.volume, 90);
409 }
410
411 #[test]
412 fn read_uts_from_reader_matches_bytes_path() {
413 let mut cursor = Cursor::new(TEST_UTS);
414 let via_reader = read_uts(&mut cursor).expect("reader parse succeeds");
415 let via_bytes = read_uts_from_bytes(TEST_UTS).expect("bytes parse succeeds");
416
417 assert_eq!(via_reader, via_bytes);
418 }
419
420 #[test]
421 fn rejects_non_uts_file_type() {
422 let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
423 gff.file_type = *b"UTP ";
424
425 let err = Uts::from_gff(&gff).expect_err("UTP must be rejected as UTS input");
426 assert!(matches!(
427 err,
428 UtsError::UnsupportedFileType(file_type) if file_type == *b"UTP "
429 ));
430 }
431
432 #[test]
433 fn a_mistyped_sounds_list_reads_as_empty() {
434 let mut gff = read_gff_from_bytes(TEST_UTS).expect("fixture must parse");
435 gff.root.fields.retain(|field| field.label != "Sounds");
436 gff.root
437 .push_field(gff_label!("Sounds"), GffValue::UInt32(123));
438
439 let uts = Uts::from_gff(&gff).expect("a mistyped list is not a read failure");
440
441 assert!(uts.sounds.is_empty());
442 }
443
444 #[test]
445 fn write_uts_matches_direct_gff_writer() {
446 let uts = read_uts_from_bytes(TEST_UTS).expect("fixture must parse");
447
448 let via_typed = author_uts_to_vec(&uts).expect("typed write succeeds");
449
450 let mut direct = Cursor::new(Vec::new());
451 write_gff(&mut direct, &uts.to_gff()).expect("direct write succeeds");
452
453 assert_eq!(via_typed, direct.into_inner());
454 }
455
456 #[test]
457 fn schema_field_count() {
458 assert_eq!(Uts::schema().len(), 30); }
460
461 #[test]
462 fn schema_no_duplicate_labels() {
463 let schema = Uts::schema();
464 let mut labels: Vec<&str> = schema.iter().map(|f| f.label.as_str()).collect();
465 labels.sort();
466 let before = labels.len();
467 labels.dedup();
468 assert_eq!(before, labels.len(), "duplicate labels in UTS schema");
469 }
470
471 #[test]
472 fn schema_sounds_carries_its_element() {
473 let sounds = Uts::schema()
474 .iter()
475 .find(|f| f.label.as_str() == "Sounds")
476 .expect("Sounds is declared");
477 let Shape::List { element, .. } = sounds.shape else {
478 panic!("Sounds is a list");
479 };
480 assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 1);
481 }
482}