1use 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#[derive(Debug, Clone, PartialEq, GffModel)]
33pub struct Utw {
34 #[gff(TemplateResRef)]
36 pub template_resref: ResRef,
37 #[gff(Tag, stamped)]
39 pub tag: String,
40 #[gff(LocalizedName, stamped)]
42 pub name: GffLocalizedString,
43 #[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 #[gff(HasMapNote, constructed)]
52 pub has_map_note: bool,
53 #[gff(MapNoteEnabled, stamped)]
55 pub map_note_enabled: bool,
56 #[gff(MapNote, constructed)]
58 pub map_note: GffLocalizedString,
59 #[gff(PaletteID)]
61 pub palette_id: u8,
62 #[gff(Comment)]
64 pub comment: String,
65 #[gff(LinkedTo)]
67 pub linked_to: String,
68 #[gff(Description)]
70 pub description: GffLocalizedString,
71 #[gff(XPosition, stamped, omit = audited_constant(2058))]
73 pub x_position: f32,
74 #[gff(YPosition, stamped, omit = audited_constant(2058))]
76 pub y_position: f32,
77 #[gff(ZPosition, stamped, omit = audited_constant(2058))]
79 pub z_position: f32,
80 #[gff(XOrientation, not_a_constant, manual_write)]
86 pub x_orientation: f32,
87 #[gff(YOrientation, not_a_constant, manual_write)]
91 pub y_orientation: f32,
92 #[gff(ZOrientation, not_a_constant, manual_write)]
96 pub z_orientation: f32,
97}
98
99impl Utw {
100 pub fn new() -> Self {
102 Self::default()
103 }
104
105 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 pub fn to_gff(&self) -> Gff {
121 let mut root = GffStruct::new(-1);
122 self.write_declared(&mut root);
123
124 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#[derive(Debug, Error)]
164pub enum UtwError {
165 #[error("unsupported UTW file type: {0:?}")]
167 UnsupportedFileType([u8; 4]),
168 #[error(transparent)]
170 Gff(#[from] GffBinaryError),
171}
172
173#[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#[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#[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#[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 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 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 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); }
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}