1use std::io::{Cursor, Read, Write};
17
18use crate::gff_helpers::{
19 get_bool, get_f32, get_locstring, get_resref, get_string, get_u8, upsert_field,
20};
21use rakata_core::{ResRef, StrRef};
22use rakata_formats::{
23 gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffSchema, GffType},
24 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
25 GffValue,
26};
27use thiserror::Error;
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct Utw {
32 pub template_resref: ResRef,
34 pub tag: String,
36 pub name: GffLocalizedString,
38 pub appearance_id: u8,
40 pub has_map_note: bool,
42 pub map_note_enabled: bool,
44 pub map_note: GffLocalizedString,
46 pub palette_id: u8,
48 pub comment: String,
50 pub linked_to: String,
52 pub description: GffLocalizedString,
54 pub x_position: f32,
56 pub y_position: f32,
58 pub z_position: f32,
60 pub x_orientation: f32,
62 pub y_orientation: f32,
64 pub z_orientation: f32,
66}
67
68impl Default for Utw {
69 fn default() -> Self {
70 Self {
71 template_resref: ResRef::blank(),
72 tag: String::new(),
73 name: GffLocalizedString::new(StrRef::invalid()),
74 appearance_id: 0,
75 has_map_note: false,
76 map_note_enabled: false,
77 map_note: GffLocalizedString::new(StrRef::invalid()),
78 palette_id: 0,
79 comment: String::new(),
80 linked_to: String::new(),
81 description: GffLocalizedString::new(StrRef::invalid()),
82 x_position: 0.0,
83 y_position: 0.0,
84 z_position: 0.0,
85 x_orientation: 0.0,
86 y_orientation: 0.0,
87 z_orientation: 0.0,
88 }
89 }
90}
91
92impl Utw {
93 pub fn new() -> Self {
95 Self::default()
96 }
97
98 pub fn from_gff(gff: &Gff) -> Result<Self, UtwError> {
100 if gff.file_type != *b"UTW " && gff.file_type != *b"GFF " {
101 return Err(UtwError::UnsupportedFileType(gff.file_type));
102 }
103
104 let root = &gff.root;
105
106 if matches!(root.field("LocalizedName"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
107 {
108 return Err(UtwError::TypeMismatch {
109 field: "LocalizedName",
110 expected: "LocalizedString",
111 });
112 }
113 if matches!(root.field("Description"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
114 {
115 return Err(UtwError::TypeMismatch {
116 field: "Description",
117 expected: "LocalizedString",
118 });
119 }
120 if matches!(root.field("MapNote"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
121 {
122 return Err(UtwError::TypeMismatch {
123 field: "MapNote",
124 expected: "LocalizedString",
125 });
126 }
127
128 Ok(Self {
129 appearance_id: get_u8(root, "Appearance").unwrap_or(0),
130 linked_to: get_string(root, "LinkedTo").unwrap_or_default(),
131 template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
132 tag: get_string(root, "Tag").unwrap_or_default(),
133 name: get_locstring(root, "LocalizedName")
134 .cloned()
135 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
136 description: get_locstring(root, "Description")
137 .cloned()
138 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
139 has_map_note: get_bool(root, "HasMapNote").unwrap_or(false),
140 map_note: get_locstring(root, "MapNote")
141 .cloned()
142 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
143 map_note_enabled: get_bool(root, "MapNoteEnabled").unwrap_or(false),
144 palette_id: get_u8(root, "PaletteID").unwrap_or(0),
145 comment: get_string(root, "Comment").unwrap_or_default(),
146 x_position: get_f32(root, "XPosition").unwrap_or(0.0),
147 y_position: get_f32(root, "YPosition").unwrap_or(0.0),
148 z_position: get_f32(root, "ZPosition").unwrap_or(0.0),
149 x_orientation: get_f32(root, "XOrientation").unwrap_or(0.0),
150 y_orientation: get_f32(root, "YOrientation").unwrap_or(0.0),
151 z_orientation: get_f32(root, "ZOrientation").unwrap_or(0.0),
152 })
153 }
154
155 pub fn to_gff(&self) -> Gff {
157 let mut root = GffStruct::new(-1);
158
159 upsert_field(
160 &mut root,
161 "TemplateResRef",
162 GffValue::ResRef(self.template_resref),
163 );
164 upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
165 upsert_field(
166 &mut root,
167 "LocalizedName",
168 GffValue::LocalizedString(self.name.clone()),
169 );
170 upsert_field(&mut root, "Appearance", GffValue::UInt8(self.appearance_id));
171 upsert_field(
172 &mut root,
173 "HasMapNote",
174 GffValue::UInt8(u8::from(self.has_map_note)),
175 );
176 upsert_field(
177 &mut root,
178 "MapNoteEnabled",
179 GffValue::UInt8(u8::from(self.map_note_enabled)),
180 );
181 upsert_field(
182 &mut root,
183 "MapNote",
184 GffValue::LocalizedString(self.map_note.clone()),
185 );
186 upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
187 upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
188 upsert_field(
189 &mut root,
190 "LinkedTo",
191 GffValue::String(self.linked_to.clone()),
192 );
193 upsert_field(
194 &mut root,
195 "Description",
196 GffValue::LocalizedString(self.description.clone()),
197 );
198 upsert_field(&mut root, "XPosition", GffValue::Single(self.x_position));
199 upsert_field(&mut root, "YPosition", GffValue::Single(self.y_position));
200 upsert_field(&mut root, "ZPosition", GffValue::Single(self.z_position));
201 upsert_field(
202 &mut root,
203 "XOrientation",
204 GffValue::Single(self.x_orientation),
205 );
206 upsert_field(
207 &mut root,
208 "YOrientation",
209 GffValue::Single(self.y_orientation),
210 );
211 upsert_field(
212 &mut root,
213 "ZOrientation",
214 GffValue::Single(self.z_orientation),
215 );
216
217 Gff::new(*b"UTW ", root)
218 }
219}
220
221#[derive(Debug, Error)]
223pub enum UtwError {
224 #[error("unsupported UTW file type: {0:?}")]
226 UnsupportedFileType([u8; 4]),
227 #[error("UTW field `{field}` has incompatible type (expected {expected})")]
229 TypeMismatch {
230 field: &'static str,
232 expected: &'static str,
234 },
235 #[error(transparent)]
237 Gff(#[from] GffBinaryError),
238}
239
240#[cfg_attr(
242 feature = "tracing",
243 tracing::instrument(level = "debug", skip(reader))
244)]
245pub fn read_utw<R: Read>(reader: &mut R) -> Result<Utw, UtwError> {
246 let gff = read_gff(reader)?;
247 Utw::from_gff(&gff)
248}
249
250#[cfg_attr(
252 feature = "tracing",
253 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
254)]
255pub fn read_utw_from_bytes(bytes: &[u8]) -> Result<Utw, UtwError> {
256 let gff = read_gff_from_bytes(bytes)?;
257 Utw::from_gff(&gff)
258}
259
260#[cfg_attr(
262 feature = "tracing",
263 tracing::instrument(level = "debug", skip(writer, utw))
264)]
265pub fn write_utw<W: Write>(writer: &mut W, utw: &Utw) -> Result<(), UtwError> {
266 let gff = utw.to_gff();
267 write_gff(writer, &gff)?;
268 Ok(())
269}
270
271#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utw)))]
273pub fn write_utw_to_vec(utw: &Utw) -> Result<Vec<u8>, UtwError> {
274 let mut cursor = Cursor::new(Vec::new());
275 write_utw(&mut cursor, utw)?;
276 Ok(cursor.into_inner())
277}
278
279impl GffSchema for Utw {
280 fn schema() -> &'static [FieldSchema] {
281 static SCHEMA: &[FieldSchema] = &[
282 FieldSchema {
284 label: "Tag",
285 expected_type: GffType::String,
286 life: FieldLife::Live,
287 required: false,
288 absent: AbsentDefault::Unverified,
289 children: None,
290 constraint: None,
291 },
292 FieldSchema {
293 label: "LocalizedName",
294 expected_type: GffType::LocalizedString,
295 life: FieldLife::Live,
296 required: false,
297 absent: AbsentDefault::Unverified,
298 children: None,
299 constraint: None,
300 },
301 FieldSchema {
302 label: "HasMapNote",
303 expected_type: GffType::UInt8,
304 life: FieldLife::Live,
305 required: false,
306 absent: AbsentDefault::Unverified,
307 children: None,
308 constraint: None,
309 },
310 FieldSchema {
311 label: "MapNoteEnabled",
312 expected_type: GffType::UInt8,
313 life: FieldLife::Live,
314 required: false,
315 absent: AbsentDefault::Unverified,
316 children: None,
317 constraint: None,
318 },
319 FieldSchema {
320 label: "MapNote",
321 expected_type: GffType::LocalizedString,
322 life: FieldLife::Live,
323 required: false,
324 absent: AbsentDefault::Unverified,
325 children: None,
326 constraint: None,
327 },
328 FieldSchema {
329 label: "XPosition",
330 expected_type: GffType::Single,
331 life: FieldLife::Live,
332 required: false,
333 absent: AbsentDefault::Unverified,
334 children: None,
335 constraint: None,
336 },
337 FieldSchema {
338 label: "YPosition",
339 expected_type: GffType::Single,
340 life: FieldLife::Live,
341 required: false,
342 absent: AbsentDefault::Unverified,
343 children: None,
344 constraint: None,
345 },
346 FieldSchema {
347 label: "ZPosition",
348 expected_type: GffType::Single,
349 life: FieldLife::Live,
350 required: false,
351 absent: AbsentDefault::Unverified,
352 children: None,
353 constraint: None,
354 },
355 FieldSchema {
356 label: "XOrientation",
357 expected_type: GffType::Single,
358 life: FieldLife::Live,
359 required: false,
360 absent: AbsentDefault::Unverified,
361 children: None,
362 constraint: None,
363 },
364 FieldSchema {
365 label: "YOrientation",
366 expected_type: GffType::Single,
367 life: FieldLife::Live,
368 required: false,
369 absent: AbsentDefault::Unverified,
370 children: None,
371 constraint: None,
372 },
373 FieldSchema {
374 label: "ZOrientation",
375 expected_type: GffType::Single,
376 life: FieldLife::Live,
377 required: false,
378 absent: AbsentDefault::Unverified,
379 children: None,
380 constraint: None,
381 },
382 FieldSchema {
384 label: "TemplateResRef",
385 expected_type: GffType::ResRef,
386 life: FieldLife::Live,
387 required: false,
388 absent: AbsentDefault::Unverified,
389 children: None,
390 constraint: None,
391 },
392 FieldSchema {
393 label: "Appearance",
394 expected_type: GffType::UInt8,
395 life: FieldLife::Live,
396 required: false,
397 absent: AbsentDefault::Unverified,
398 children: None,
399 constraint: None,
400 },
401 FieldSchema {
402 label: "PaletteID",
403 expected_type: GffType::UInt8,
404 life: FieldLife::Live,
405 required: false,
406 absent: AbsentDefault::Unverified,
407 children: None,
408 constraint: None,
409 },
410 FieldSchema {
411 label: "Comment",
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: "LinkedTo",
421 expected_type: GffType::String,
422 life: FieldLife::Live,
423 required: false,
424 absent: AbsentDefault::Unverified,
425 children: None,
426 constraint: None,
427 },
428 FieldSchema {
429 label: "Description",
430 expected_type: GffType::LocalizedString,
431 life: FieldLife::Live,
432 required: false,
433 absent: AbsentDefault::Unverified,
434 children: None,
435 constraint: None,
436 },
437 ];
438 SCHEMA
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 const TEST_UTW: &[u8] = include_bytes!(concat!(
447 env!("CARGO_MANIFEST_DIR"),
448 "/../../fixtures/test.utw"
449 ));
450 const TAR05_UTW: &[u8] = include_bytes!(concat!(
451 env!("CARGO_MANIFEST_DIR"),
452 "/../../fixtures/tar05_sw05aa10.utw"
453 ));
454
455 #[test]
456 fn reads_core_utw_fields_from_fixture() {
457 let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
458
459 assert_eq!(utw.appearance_id, 1);
460 assert_eq!(utw.linked_to, "");
461 assert_eq!(utw.template_resref, "sw_mapnote011");
462 assert_eq!(utw.tag, "MN_106PER2");
463 assert_eq!(utw.name.string_ref.raw(), 76_857);
464 assert_eq!(utw.description.string_ref.raw(), -1);
465 assert!(utw.has_map_note);
466 assert_eq!(utw.map_note.string_ref.raw(), 76_858);
467 assert!(utw.map_note_enabled);
468 assert_eq!(utw.palette_id, 5);
469 assert_eq!(utw.comment, "comment");
470 }
471
472 #[test]
473 fn reads_tar05_fixture_variant() {
474 let utw = read_utw_from_bytes(TAR05_UTW).expect("fixture must parse");
475
476 assert_eq!(utw.appearance_id, 0);
477 assert_eq!(utw.linked_to, "");
478 assert_eq!(utw.template_resref, "");
479 assert_eq!(utw.tag, "");
480 assert_eq!(utw.name.string_ref.raw(), -1);
481 assert_eq!(utw.description.string_ref.raw(), -1);
482 assert!(!utw.has_map_note);
483 assert_eq!(utw.map_note.string_ref.raw(), -1);
484 assert!(!utw.map_note_enabled);
485 assert_eq!(utw.palette_id, 0);
486 assert_eq!(utw.comment, "");
487 }
488
489 #[test]
490 fn all_fields_survive_typed_roundtrip() {
491 let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
492 let bytes = write_utw_to_vec(&utw).expect("write succeeds");
493 let reparsed = read_utw_from_bytes(&bytes).expect("reparse succeeds");
494
495 assert_eq!(reparsed, utw);
496 }
497
498 #[test]
499 fn typed_edits_roundtrip_through_gff_writer() {
500 let mut utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
501 utw.tag = "MN_106PER2_Rust".into();
502 utw.has_map_note = false;
503 utw.map_note_enabled = false;
504
505 let bytes = write_utw_to_vec(&utw).expect("write succeeds");
506 let reparsed = read_utw_from_bytes(&bytes).expect("reparse succeeds");
507
508 assert_eq!(reparsed.tag, "MN_106PER2_Rust");
509 assert!(!reparsed.has_map_note);
510 assert!(!reparsed.map_note_enabled);
511 }
512
513 #[test]
514 fn read_utw_from_reader_matches_bytes_path() {
515 let mut cursor = Cursor::new(TEST_UTW);
516 let via_reader = read_utw(&mut cursor).expect("reader parse succeeds");
517 let via_bytes = read_utw_from_bytes(TEST_UTW).expect("bytes parse succeeds");
518
519 assert_eq!(via_reader, via_bytes);
520 }
521
522 #[test]
523 fn rejects_non_utw_file_type() {
524 let mut gff = read_gff_from_bytes(TEST_UTW).expect("fixture must parse");
525 gff.file_type = *b"UTT ";
526
527 let err = Utw::from_gff(&gff).expect_err("UTT must be rejected as UTW input");
528 assert!(matches!(
529 err,
530 UtwError::UnsupportedFileType(file_type) if file_type == *b"UTT "
531 ));
532 }
533
534 #[test]
535 fn type_mismatch_on_map_note_is_error() {
536 let mut gff = read_gff_from_bytes(TEST_UTW).expect("fixture must parse");
537 gff.root.fields.retain(|field| field.label != "MapNote");
538 gff.root.push_field("MapNote", GffValue::UInt32(123));
539
540 let err = Utw::from_gff(&gff).expect_err("type mismatch must be rejected");
541 assert!(matches!(
542 err,
543 UtwError::TypeMismatch {
544 field: "MapNote",
545 expected: "LocalizedString",
546 }
547 ));
548 }
549
550 #[test]
551 fn write_utw_matches_direct_gff_writer() {
552 let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
553
554 let via_typed = write_utw_to_vec(&utw).expect("typed write succeeds");
555
556 let mut direct = Cursor::new(Vec::new());
557 write_gff(&mut direct, &utw.to_gff()).expect("direct write succeeds");
558
559 assert_eq!(via_typed, direct.into_inner());
560 }
561
562 #[test]
563 fn schema_field_count() {
564 assert_eq!(Utw::schema().len(), 17); }
566
567 #[test]
568 fn schema_no_duplicate_labels() {
569 let schema = Utw::schema();
570 let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
571 labels.sort();
572 let before = labels.len();
573 labels.dedup();
574 assert_eq!(before, labels.len(), "duplicate labels in UTW schema");
575 }
576}