rakata_generics/
utw.rs

1//! UTW (`.utw`) typed generic wrapper.
2//!
3//! UTW resources are GFF-backed waypoint templates.
4//!
5//! ## Field Layout
6//! ```text
7//! UTW root struct
8//! +-- TemplateResRef / Tag / LocalizedName
9//! +-- Appearance / PaletteID / Comment
10//! +-- HasMapNote / MapNoteEnabled / MapNote
11//! +-- Description / LinkedTo
12//! +-- XPosition / YPosition / ZPosition
13//! +-- XOrientation / YOrientation / ZOrientation
14//! ```
15
16use 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/// Typed UTW model built from/to [`Gff`] data.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Utw {
32    /// Waypoint template resref (`TemplateResRef`).
33    pub template_resref: ResRef,
34    /// Waypoint tag (`Tag`).
35    pub tag: String,
36    /// Localized waypoint name (`LocalizedName`).
37    pub name: GffLocalizedString,
38    /// Waypoint appearance id (`Appearance`).
39    pub appearance_id: u8,
40    /// Has-map-note flag (`HasMapNote`).
41    pub has_map_note: bool,
42    /// Map-note-enabled flag (`MapNoteEnabled`).
43    pub map_note_enabled: bool,
44    /// Localized map note (`MapNote`).
45    pub map_note: GffLocalizedString,
46    /// Palette id (`PaletteID`).
47    pub palette_id: u8,
48    /// Toolset comment (`Comment`).
49    pub comment: String,
50    /// Deprecated linked target (`LinkedTo`).
51    pub linked_to: String,
52    /// Deprecated localized description (`Description`).
53    pub description: GffLocalizedString,
54    /// World X position (`XPosition`).
55    pub x_position: f32,
56    /// World Y position (`YPosition`).
57    pub y_position: f32,
58    /// World Z position (`ZPosition`).
59    pub z_position: f32,
60    /// Facing X component (`XOrientation`).
61    pub x_orientation: f32,
62    /// Facing Y component (`YOrientation`).
63    pub y_orientation: f32,
64    /// Facing Z component (`ZOrientation`).
65    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    /// Creates an empty UTW value.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Builds typed UTW data from a parsed GFF container.
99    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    /// Converts this typed UTW value into a GFF container.
156    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/// Errors produced while reading or writing typed UTW data.
222#[derive(Debug, Error)]
223pub enum UtwError {
224    /// Source file type is not supported by this parser.
225    #[error("unsupported UTW file type: {0:?}")]
226    UnsupportedFileType([u8; 4]),
227    /// A required container field had an unexpected runtime type.
228    #[error("UTW field `{field}` has incompatible type (expected {expected})")]
229    TypeMismatch {
230        /// Field label where mismatch occurred.
231        field: &'static str,
232        /// Expected runtime value kind.
233        expected: &'static str,
234    },
235    /// Underlying GFF parser/writer error.
236    #[error(transparent)]
237    Gff(#[from] GffBinaryError),
238}
239
240/// Reads typed UTW data from a reader at the current stream position.
241#[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/// Reads typed UTW data directly from bytes.
251#[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/// Writes typed UTW data to an output writer.
261#[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/// Serializes typed UTW data into a byte vector.
272#[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            // --- Engine-read fields (11) ---
283            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            // --- Toolset-only fields (6) ---
361            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); // 11 engine + 6 toolset
531    }
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}