Skip to main content

rakata_generics/
utw.rs

1//! UTW (`.utw`) typed generic wrapper.
2//!
3//! Waypoints are invisible logic markers: named static coordinates in an area
4//! that patrol routes, spawn points, camera focal points and the player's map
5//! pins refer to. Nothing renders them.
6//!
7//! ## Field Layout
8//! ```text
9//! UTW root struct
10//! +-- TemplateResRef / Tag / LocalizedName
11//! +-- Appearance / PaletteID / Comment
12//! +-- HasMapNote / MapNoteEnabled / MapNote
13//! +-- Description / LinkedTo
14//! +-- XPosition / YPosition / ZPosition
15//! +-- XOrientation / YOrientation / ZOrientation
16//! ```
17
18use std::io::{Cursor, Read, Write};
19
20use rakata_core::ResRef;
21use rakata_formats::gff::upsert_field;
22use rakata_formats::gff_label;
23use rakata_formats::schema::FromGff;
24use rakata_formats::GENERIC_FILE_TYPE;
25use rakata_formats::{
26    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
27    GffStruct, GffValue,
28};
29use thiserror::Error;
30
31/// Typed UTW model built from/to [`Gff`] data.
32#[derive(Debug, Clone, PartialEq, GffModel)]
33pub struct Utw {
34    /// Waypoint template resref (`TemplateResRef`).
35    #[gff(TemplateResRef)]
36    pub template_resref: ResRef,
37    /// Waypoint tag (`Tag`).
38    #[gff(Tag, stamped)]
39    pub tag: String,
40    /// Localized waypoint name (`LocalizedName`).
41    #[gff(LocalizedName, stamped)]
42    pub name: GffLocalizedString,
43    /// Waypoint appearance id (`Appearance`). Never read by the K1 engine.
44    #[gff(
45        Appearance,
46        read_only_dead = "a placed waypoint has no rendered model to select, and LoadWaypoint's fully-decompiled field list has no room for it",
47        not_a_constant
48    )]
49    pub appearance_id: u8,
50    /// Has-map-note flag (`HasMapNote`).
51    #[gff(HasMapNote, constructed)]
52    pub has_map_note: bool,
53    /// Map-note-enabled flag (`MapNoteEnabled`). Engine ignores this if `has_map_note` is false.
54    #[gff(MapNoteEnabled, stamped)]
55    pub map_note_enabled: bool,
56    /// Localized map note (`MapNote`). Engine ignores this if `has_map_note` is false.
57    #[gff(MapNote, constructed)]
58    pub map_note: GffLocalizedString,
59    /// Palette id (`PaletteID`).
60    #[gff(PaletteID)]
61    pub palette_id: u8,
62    /// Toolset comment (`Comment`).
63    #[gff(Comment)]
64    pub comment: String,
65    /// Deprecated linked target (`LinkedTo`).
66    #[gff(LinkedTo)]
67    pub linked_to: String,
68    /// Deprecated localized description (`Description`).
69    #[gff(Description)]
70    pub description: GffLocalizedString,
71    /// World X position (`XPosition`).
72    #[gff(XPosition, stamped, omit = audited_constant(2058))]
73    pub x_position: f32,
74    /// World Y position (`YPosition`).
75    #[gff(YPosition, stamped, omit = audited_constant(2058))]
76    pub y_position: f32,
77    /// World Z position (`ZPosition`).
78    #[gff(ZPosition, stamped, omit = audited_constant(2058))]
79    pub z_position: f32,
80    /// Facing X component (`XOrientation`).
81    ///
82    /// The engine normalizes the orientation vector at runtime if its
83    /// magnitude is not 1.0. The write is hand-written because the condition
84    /// is the whole vector rather than any one component. It is in `to_gff`.
85    #[gff(XOrientation, not_a_constant, manual_write)]
86    pub x_orientation: f32,
87    /// Facing Y component (`YOrientation`).
88    ///
89    /// Written with the rest of the vector. See [`Utw::x_orientation`].
90    #[gff(YOrientation, not_a_constant, manual_write)]
91    pub y_orientation: f32,
92    /// Facing Z component (`ZOrientation`).
93    ///
94    /// Written with the rest of the vector. See [`Utw::x_orientation`].
95    #[gff(ZOrientation, not_a_constant, manual_write)]
96    pub z_orientation: f32,
97}
98
99impl Utw {
100    /// Creates an empty UTW value.
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Builds typed UTW data from a parsed GFF container.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`UtwError::UnsupportedFileType`] for a container that is
110    /// neither `UTW ` nor the generic `GFF ` form.
111    pub fn from_gff(gff: &Gff) -> Result<Self, UtwError> {
112        if gff.file_type != <Utw as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
113            return Err(UtwError::UnsupportedFileType(gff.file_type));
114        }
115
116        Ok(Self::read_declared(&gff.root))
117    }
118
119    /// Converts this typed UTW value into a GFF container.
120    pub fn to_gff(&self) -> Gff {
121        let mut root = GffStruct::new(-1);
122        self.write_declared(&mut root);
123
124        // Omitted as a group or not at all. No vanilla waypoint carries any
125        // of the three, and `utw.md` records the fallback as a property of
126        // the vector rather than of any component: with all three absent the
127        // engine reads a zero vector, and `Vector::Normalize` refuses to
128        // divide below a magnitude of 1e-9 and snaps to a sentinel facing of
129        // (1, 0, 0). Dropping one component of a real orientation would
130        // change the magnitude and normalize somewhere else, so the condition
131        // has to be the whole vector.
132        //
133        // The condition is the zero vector rather than the (1, 0, 0) sentinel
134        // the engine ends up with. That sentinel is what normalization
135        // produces after the read, and this view projects the file: an
136        // omitted component reads back as 0.0, so omitting at zero
137        // round-trips and omitting at the sentinel would not.
138        let facing_unset =
139            self.x_orientation == 0.0 && self.y_orientation == 0.0 && self.z_orientation == 0.0;
140        if !facing_unset {
141            upsert_field(
142                &mut root,
143                gff_label!("XOrientation"),
144                GffValue::Single(self.x_orientation),
145            );
146            upsert_field(
147                &mut root,
148                gff_label!("YOrientation"),
149                GffValue::Single(self.y_orientation),
150            );
151            upsert_field(
152                &mut root,
153                gff_label!("ZOrientation"),
154                GffValue::Single(self.z_orientation),
155            );
156        }
157
158        Gff::new(*b"UTW ", root)
159    }
160}
161
162/// Errors produced while reading or writing typed UTW data.
163#[derive(Debug, Error)]
164pub enum UtwError {
165    /// Source file type is not supported by this parser.
166    #[error("unsupported UTW file type: {0:?}")]
167    UnsupportedFileType([u8; 4]),
168    /// Underlying GFF parser/writer error.
169    #[error(transparent)]
170    Gff(#[from] GffBinaryError),
171}
172
173/// Reads typed UTW data from a reader at the current stream position.
174///
175/// # Errors
176///
177/// [`UtwError::Gff`] when the stream is not a readable GFF, and
178/// [`UtwError::UnsupportedFileType`] when it is a GFF of some other format,
179/// carrying the fourcc that was found.
180#[cfg_attr(
181    feature = "tracing",
182    tracing::instrument(level = "debug", skip(reader))
183)]
184pub fn read_utw<R: Read>(reader: &mut R) -> Result<Utw, UtwError> {
185    let gff = read_gff(reader)?;
186    Utw::from_gff(&gff)
187}
188
189/// Reads typed UTW data directly from bytes.
190///
191/// # Errors
192///
193/// [`UtwError::Gff`] when `bytes` are not a readable GFF, and
194/// [`UtwError::UnsupportedFileType`] when they are a GFF of some other format,
195/// carrying the fourcc that was found.
196#[cfg_attr(
197    feature = "tracing",
198    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
199)]
200pub fn read_utw_from_bytes(bytes: &[u8]) -> Result<Utw, UtwError> {
201    let gff = read_gff_from_bytes(bytes)?;
202    Utw::from_gff(&gff)
203}
204
205/// Authors the UTW file the typed view describes, into a writer.
206///
207/// # Errors
208///
209/// [`UtwError::Gff`] when the writer fails or a value will not encode. The
210/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
211/// this side.
212#[cfg_attr(
213    feature = "tracing",
214    tracing::instrument(level = "debug", skip(writer, utw))
215)]
216pub fn author_utw<W: Write>(writer: &mut W, utw: &Utw) -> Result<(), UtwError> {
217    let gff = utw.to_gff();
218    write_gff(writer, &gff)?;
219    Ok(())
220}
221
222/// Authors the UTW file the typed view describes, as bytes.
223///
224/// # Errors
225///
226/// [`UtwError::Gff`] when a value will not encode. Writing into a `Vec` has no
227/// I/O to fail at.
228#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utw)))]
229pub fn author_utw_to_vec(utw: &Utw) -> Result<Vec<u8>, UtwError> {
230    let mut cursor = Cursor::new(Vec::new());
231    author_utw(&mut cursor, utw)?;
232    Ok(cursor.into_inner())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use rakata_formats::schema::HasSchema;
239
240    #[test]
241    fn orientation_is_omitted_as_a_group_or_written_as_a_group() {
242        // Both directions, because a joint condition that half-fires is the
243        // failure worth catching: dropping one component of a real facing
244        // changes the vector's magnitude and normalizes it somewhere else.
245        let labels = ["XOrientation", "YOrientation", "ZOrientation"];
246
247        let unset = Utw::default();
248        assert_eq!(
249            (
250                unset.x_orientation,
251                unset.y_orientation,
252                unset.z_orientation
253            ),
254            (0.0, 0.0, 0.0)
255        );
256        let written = unset.to_gff();
257        for label in labels {
258            assert!(
259                written.root.field(label).is_none(),
260                "{label} must be omitted when the whole vector is unset"
261            );
262        }
263
264        // One component set is enough to write all three. The zeroes are
265        // meaningful here: they are two thirds of a real facing.
266        let facing = Utw {
267            x_orientation: 1.0,
268            ..Utw::default()
269        };
270        let written = facing.to_gff();
271        for label in labels {
272            assert!(
273                written.root.field(label).is_some(),
274                "{label} must be written when any component is set"
275            );
276        }
277
278        // And the round trip holds in both states, which is what makes
279        // omitting at the zero vector rather than at the (1, 0, 0) sentinel
280        // the correct condition for a projection.
281        for view in [Utw::default(), facing] {
282            let back = Utw::from_gff(&view.to_gff()).expect("a UTW round-trips");
283            assert_eq!(back.x_orientation, view.x_orientation);
284            assert_eq!(back.y_orientation, view.y_orientation);
285            assert_eq!(back.z_orientation, view.z_orientation);
286        }
287    }
288
289    const TEST_UTW: &[u8] = include_bytes!(concat!(
290        env!("CARGO_MANIFEST_DIR"),
291        "/../../fixtures/test.utw"
292    ));
293    const TAR05_UTW: &[u8] = include_bytes!(concat!(
294        env!("CARGO_MANIFEST_DIR"),
295        "/../../fixtures/tar05_sw05aa10.utw"
296    ));
297
298    #[test]
299    fn reads_core_utw_fields_from_fixture() {
300        let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
301
302        assert_eq!(utw.appearance_id, 1);
303        assert_eq!(utw.linked_to, "");
304        assert_eq!(utw.template_resref, "sw_mapnote011");
305        assert_eq!(utw.tag, "MN_106PER2");
306        assert_eq!(utw.name.string_ref.raw(), 76_857);
307        assert_eq!(utw.description.string_ref.raw(), -1);
308        assert!(utw.has_map_note);
309        assert_eq!(utw.map_note.string_ref.raw(), 76_858);
310        assert!(utw.map_note_enabled);
311        assert_eq!(utw.palette_id, 5);
312        assert_eq!(utw.comment, "comment");
313    }
314
315    #[test]
316    fn reads_tar05_fixture_variant() {
317        let utw = read_utw_from_bytes(TAR05_UTW).expect("fixture must parse");
318
319        assert_eq!(utw.appearance_id, 0);
320        assert_eq!(utw.linked_to, "");
321        assert_eq!(utw.template_resref, "");
322        assert_eq!(utw.tag, "");
323        assert_eq!(utw.name.string_ref.raw(), -1);
324        assert_eq!(utw.description.string_ref.raw(), -1);
325        assert!(!utw.has_map_note);
326        assert_eq!(utw.map_note.string_ref.raw(), -1);
327        assert!(!utw.map_note_enabled);
328        assert_eq!(utw.palette_id, 0);
329        assert_eq!(utw.comment, "");
330    }
331
332    #[test]
333    fn all_fields_survive_typed_roundtrip() {
334        let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
335        let bytes = author_utw_to_vec(&utw).expect("write succeeds");
336        let reparsed = read_utw_from_bytes(&bytes).expect("reparse succeeds");
337
338        assert_eq!(reparsed, utw);
339    }
340
341    #[test]
342    fn typed_edits_roundtrip_through_gff_writer() {
343        let mut utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
344        utw.tag = "MN_106PER2_Rust".into();
345        utw.has_map_note = false;
346        utw.map_note_enabled = false;
347
348        let bytes = author_utw_to_vec(&utw).expect("write succeeds");
349        let reparsed = read_utw_from_bytes(&bytes).expect("reparse succeeds");
350
351        assert_eq!(reparsed.tag, "MN_106PER2_Rust");
352        assert!(!reparsed.has_map_note);
353        assert!(!reparsed.map_note_enabled);
354    }
355
356    #[test]
357    fn read_utw_from_reader_matches_bytes_path() {
358        let mut cursor = Cursor::new(TEST_UTW);
359        let via_reader = read_utw(&mut cursor).expect("reader parse succeeds");
360        let via_bytes = read_utw_from_bytes(TEST_UTW).expect("bytes parse succeeds");
361
362        assert_eq!(via_reader, via_bytes);
363    }
364
365    #[test]
366    fn rejects_non_utw_file_type() {
367        let mut gff = read_gff_from_bytes(TEST_UTW).expect("fixture must parse");
368        gff.file_type = *b"UTT ";
369
370        let err = Utw::from_gff(&gff).expect_err("UTT must be rejected as UTW input");
371        assert!(matches!(
372            err,
373            UtwError::UnsupportedFileType(file_type) if file_type == *b"UTT "
374        ));
375    }
376
377    #[test]
378    fn a_mistyped_map_note_reads_as_absent() {
379        let mut gff = read_gff_from_bytes(TEST_UTW).expect("fixture must parse");
380        gff.root.fields.retain(|field| field.label != "MapNote");
381        gff.root
382            .push_field(gff_label!("MapNote"), GffValue::UInt32(123));
383
384        let utw = Utw::from_gff(&gff).expect("a mistyped field is not a read failure");
385
386        assert_eq!(utw.map_note, GffLocalizedString::default());
387    }
388
389    #[test]
390    fn write_utw_matches_direct_gff_writer() {
391        let utw = read_utw_from_bytes(TEST_UTW).expect("fixture must parse");
392
393        let via_typed = author_utw_to_vec(&utw).expect("typed write succeeds");
394
395        let mut direct = Cursor::new(Vec::new());
396        write_gff(&mut direct, &utw.to_gff()).expect("direct write succeeds");
397
398        assert_eq!(via_typed, direct.into_inner());
399    }
400
401    #[test]
402    fn schema_field_count() {
403        assert_eq!(Utw::schema().len(), 17); // 11 engine + 6 toolset
404    }
405
406    #[test]
407    fn schema_no_duplicate_labels() {
408        let mut labels: Vec<&str> = Utw::schema().iter().map(|f| f.label.as_str()).collect();
409        labels.sort_unstable();
410        let before = labels.len();
411        labels.dedup();
412        assert_eq!(before, labels.len(), "duplicate labels in UTW schema");
413    }
414}