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::{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 required: false,
287 children: None,
288 constraint: None,
289 },
290 FieldSchema {
291 label: "LocalizedName",
292 expected_type: GffType::LocalizedString,
293 required: false,
294 children: None,
295 constraint: None,
296 },
297 FieldSchema {
298 label: "HasMapNote",
299 expected_type: GffType::UInt8,
300 required: false,
301 children: None,
302 constraint: None,
303 },
304 FieldSchema {
305 label: "MapNoteEnabled",
306 expected_type: GffType::UInt8,
307 required: false,
308 children: None,
309 constraint: None,
310 },
311 FieldSchema {
312 label: "MapNote",
313 expected_type: GffType::LocalizedString,
314 required: false,
315 children: None,
316 constraint: None,
317 },
318 FieldSchema {
319 label: "XPosition",
320 expected_type: GffType::Single,
321 required: false,
322 children: None,
323 constraint: None,
324 },
325 FieldSchema {
326 label: "YPosition",
327 expected_type: GffType::Single,
328 required: false,
329 children: None,
330 constraint: None,
331 },
332 FieldSchema {
333 label: "ZPosition",
334 expected_type: GffType::Single,
335 required: false,
336 children: None,
337 constraint: None,
338 },
339 FieldSchema {
340 label: "XOrientation",
341 expected_type: GffType::Single,
342 required: false,
343 children: None,
344 constraint: None,
345 },
346 FieldSchema {
347 label: "YOrientation",
348 expected_type: GffType::Single,
349 required: false,
350 children: None,
351 constraint: None,
352 },
353 FieldSchema {
354 label: "ZOrientation",
355 expected_type: GffType::Single,
356 required: false,
357 children: None,
358 constraint: None,
359 },
360 FieldSchema {
362 label: "TemplateResRef",
363 expected_type: GffType::ResRef,
364 required: false,
365 children: None,
366 constraint: None,
367 },
368 FieldSchema {
369 label: "Appearance",
370 expected_type: GffType::UInt8,
371 required: false,
372 children: None,
373 constraint: None,
374 },
375 FieldSchema {
376 label: "PaletteID",
377 expected_type: GffType::UInt8,
378 required: false,
379 children: None,
380 constraint: None,
381 },
382 FieldSchema {
383 label: "Comment",
384 expected_type: GffType::String,
385 required: false,
386 children: None,
387 constraint: None,
388 },
389 FieldSchema {
390 label: "LinkedTo",
391 expected_type: GffType::String,
392 required: false,
393 children: None,
394 constraint: None,
395 },
396 FieldSchema {
397 label: "Description",
398 expected_type: GffType::LocalizedString,
399 required: false,
400 children: None,
401 constraint: None,
402 },
403 ];
404 SCHEMA
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 const TEST_UTW: &[u8] = include_bytes!(concat!(
413 env!("CARGO_MANIFEST_DIR"),
414 "/../../fixtures/test.utw"
415 ));
416 const TAR05_UTW: &[u8] = include_bytes!(concat!(
417 env!("CARGO_MANIFEST_DIR"),
418 "/../../fixtures/tar05_sw05aa10.utw"
419 ));
420
421 #[test]
422 fn reads_core_utw_fields_from_fixture() {
423 let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
424
425 assert_eq!(utw.appearance_id, 1);
426 assert_eq!(utw.linked_to, "");
427 assert_eq!(utw.template_resref, "sw_mapnote011");
428 assert_eq!(utw.tag, "MN_106PER2");
429 assert_eq!(utw.name.string_ref.raw(), 76_857);
430 assert_eq!(utw.description.string_ref.raw(), -1);
431 assert!(utw.has_map_note);
432 assert_eq!(utw.map_note.string_ref.raw(), 76_858);
433 assert!(utw.map_note_enabled);
434 assert_eq!(utw.palette_id, 5);
435 assert_eq!(utw.comment, "comment");
436 }
437
438 #[test]
439 fn reads_tar05_fixture_variant() {
440 let utw = read_utw_from_bytes(TAR05_UTW).expect("fixture must parse");
441
442 assert_eq!(utw.appearance_id, 0);
443 assert_eq!(utw.linked_to, "");
444 assert_eq!(utw.template_resref, "");
445 assert_eq!(utw.tag, "");
446 assert_eq!(utw.name.string_ref.raw(), -1);
447 assert_eq!(utw.description.string_ref.raw(), -1);
448 assert!(!utw.has_map_note);
449 assert_eq!(utw.map_note.string_ref.raw(), -1);
450 assert!(!utw.map_note_enabled);
451 assert_eq!(utw.palette_id, 0);
452 assert_eq!(utw.comment, "");
453 }
454
455 #[test]
456 fn all_fields_survive_typed_roundtrip() {
457 let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
458 let bytes = write_utw_to_vec(&utw).expect("write succeeds");
459 let reparsed = read_utw_from_bytes(&bytes).expect("reparse succeeds");
460
461 assert_eq!(reparsed, utw);
462 }
463
464 #[test]
465 fn typed_edits_roundtrip_through_gff_writer() {
466 let mut utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
467 utw.tag = "MN_106PER2_Rust".into();
468 utw.has_map_note = false;
469 utw.map_note_enabled = false;
470
471 let bytes = write_utw_to_vec(&utw).expect("write succeeds");
472 let reparsed = read_utw_from_bytes(&bytes).expect("reparse succeeds");
473
474 assert_eq!(reparsed.tag, "MN_106PER2_Rust");
475 assert!(!reparsed.has_map_note);
476 assert!(!reparsed.map_note_enabled);
477 }
478
479 #[test]
480 fn read_utw_from_reader_matches_bytes_path() {
481 let mut cursor = Cursor::new(TEST_UTW);
482 let via_reader = read_utw(&mut cursor).expect("reader parse succeeds");
483 let via_bytes = read_utw_from_bytes(TEST_UTW).expect("bytes parse succeeds");
484
485 assert_eq!(via_reader, via_bytes);
486 }
487
488 #[test]
489 fn rejects_non_utw_file_type() {
490 let mut gff = read_gff_from_bytes(TEST_UTW).expect("fixture must parse");
491 gff.file_type = *b"UTT ";
492
493 let err = Utw::from_gff(&gff).expect_err("UTT must be rejected as UTW input");
494 assert!(matches!(
495 err,
496 UtwError::UnsupportedFileType(file_type) if file_type == *b"UTT "
497 ));
498 }
499
500 #[test]
501 fn type_mismatch_on_map_note_is_error() {
502 let mut gff = read_gff_from_bytes(TEST_UTW).expect("fixture must parse");
503 gff.root.fields.retain(|field| field.label != "MapNote");
504 gff.root.push_field("MapNote", GffValue::UInt32(123));
505
506 let err = Utw::from_gff(&gff).expect_err("type mismatch must be rejected");
507 assert!(matches!(
508 err,
509 UtwError::TypeMismatch {
510 field: "MapNote",
511 expected: "LocalizedString",
512 }
513 ));
514 }
515
516 #[test]
517 fn write_utw_matches_direct_gff_writer() {
518 let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
519
520 let via_typed = write_utw_to_vec(&utw).expect("typed write succeeds");
521
522 let mut direct = Cursor::new(Vec::new());
523 write_gff(&mut direct, &utw.to_gff()).expect("direct write succeeds");
524
525 assert_eq!(via_typed, direct.into_inner());
526 }
527
528 #[test]
529 fn schema_field_count() {
530 assert_eq!(Utw::schema().len(), 17); }
532
533 #[test]
534 fn schema_no_duplicate_labels() {
535 let schema = Utw::schema();
536 let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
537 labels.sort();
538 let before = labels.len();
539 labels.dedup();
540 assert_eq!(before, labels.len(), "duplicate labels in UTW schema");
541 }
542}