1use std::io::{Cursor, Read, Write};
21
22use rakata_core::ResRef;
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,
28};
29use thiserror::Error;
30
31#[derive(Debug, Clone, PartialEq, GffModel)]
33#[gff_entry(CreatorId, wire = u32, stamped = 2130706432)]
34#[gff_entry(XPosition, wire = f32, not_a_constant)]
35#[gff_entry(YPosition, wire = f32, not_a_constant)]
36#[gff_entry(ZPosition, wire = f32, not_a_constant)]
37#[gff_entry(XOrientation, wire = f32, not_a_constant)]
38#[gff_entry(YOrientation, wire = f32, not_a_constant)]
39#[gff_entry(ZOrientation, wire = f32, not_a_constant)]
40pub struct Utt {
41 #[gff(Tag, constructed)]
43 pub tag: String,
44 #[gff(LocalizedName, stamped)]
46 pub name: GffLocalizedString,
47 #[gff(Faction, constructed)]
49 pub faction_id: u32,
50 #[gff(Cursor, stamped)]
52 pub cursor_id: u8,
53 #[gff(KeyName, stamped)]
55 pub key_name: String,
56 #[gff(PortraitId, stamped = 65535)]
58 pub portrait_id: u16,
59 #[gff(Portrait, stamped)]
61 pub portrait: ResRef,
62 #[gff(ScriptHeartbeat, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
64 pub on_heartbeat: ResRef,
65 #[gff(ScriptOnEnter, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
67 pub on_enter: ResRef,
68 #[gff(ScriptOnExit, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
70 pub on_exit: ResRef,
71 #[gff(ScriptUserDefine, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
73 pub on_user_defined: ResRef,
74 #[gff(OnTrapTriggered, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
76 pub on_trap_triggered: ResRef,
77 #[gff(OnDisarm, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
79 pub on_disarm: ResRef,
80 #[gff(OnClick, constructed = ResRef::const_new("default").expect("a literal short enough for a resref"))]
82 pub on_click: ResRef,
83 #[gff(TrapType, constructed = 255)]
85 pub trap_type: u8,
86 #[gff(TrapOneShot, constructed = true)]
88 pub trap_one_shot: bool,
89 #[gff(TrapDisarmable, stamped)]
91 pub trap_disarmable: bool,
92 #[gff(TrapDetectable, stamped)]
94 pub trap_detectable: bool,
95 #[gff(
97 LinkedTo,
98 read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
99 unexamined
100 )]
101 pub linked_to: String,
102 #[gff(
104 LinkedToFlags,
105 read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
106 unexamined
107 )]
108 pub linked_to_flags: u8,
109 #[gff(
111 LinkedToModule,
112 read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
113 unexamined
114 )]
115 pub linked_to_module: ResRef,
116 #[gff(AutoRemoveKey, constructed)]
118 pub auto_remove_key: bool,
119 #[gff(
121 TransitionDestin,
122 read_only_dead = "LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch",
123 unexamined
124 )]
125 pub transition_destination: GffLocalizedString,
126 #[gff(Type, stamped)]
128 pub type_id: i32,
129 #[gff(HighlightHeight, unexamined)]
131 pub highlight_height: f32,
132 #[gff(LoadScreenID, stamped)]
134 pub loadscreen_id: u16,
135 #[gff(SetByPlayerParty, stamped, omit = audited_constant(1121))]
137 pub set_by_player_party: bool,
138 #[gff(Geometry, not_a_constant, omit = empty(1121), list = UttGeometryPoint, element_id = 0)]
140 pub geometry: Vec<UttGeometryPoint>,
141 #[gff(TemplateResRef, unexamined)]
143 pub template_resref: ResRef,
144 #[gff(Comment, unexamined)]
146 pub comment: String,
147 #[gff(PaletteID, unexamined)]
149 pub palette_id: u8,
150 #[gff(TrapDetectDC, stamped)]
152 pub trap_detect_dc: u8,
153 #[gff(DisarmDC, stamped)]
155 pub trap_disarm_dc: u8,
156 #[gff(TrapFlag, stamped)]
158 pub is_trap: bool,
159 #[gff(
161 PartyRequired,
162 read_only_dead = "read by nothing on the trigger path",
163 not_a_constant
164 )]
165 pub party_required: bool,
166}
167
168#[derive(Debug, Clone, PartialEq, GffModel)]
176pub struct UttGeometryPoint {
177 #[gff(PointX, unexamined)]
179 pub point_x: f32,
180 #[gff(PointY, unexamined)]
182 pub point_y: f32,
183 #[gff(PointZ, unexamined)]
185 pub point_z: f32,
186}
187
188impl Utt {
189 pub fn new() -> Self {
191 Self::default()
192 }
193
194 pub fn from_gff(gff: &Gff) -> Result<Self, UttError> {
201 if gff.file_type != <Utt as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
202 return Err(UttError::UnsupportedFileType(gff.file_type));
203 }
204
205 Ok(Self::read_declared(&gff.root))
206 }
207
208 pub fn to_gff(&self) -> Gff {
210 let mut root = GffStruct::new(-1);
211 self.write_declared(&mut root);
212 Gff::new(*b"UTT ", root)
213 }
214}
215
216#[derive(Debug, Error)]
218pub enum UttError {
219 #[error("unsupported UTT file type: {0:?}")]
221 UnsupportedFileType([u8; 4]),
222 #[error(transparent)]
224 Gff(#[from] GffBinaryError),
225}
226
227#[cfg_attr(
235 feature = "tracing",
236 tracing::instrument(level = "debug", skip(reader))
237)]
238pub fn read_utt<R: Read>(reader: &mut R) -> Result<Utt, UttError> {
239 let gff = read_gff(reader)?;
240 Utt::from_gff(&gff)
241}
242
243#[cfg_attr(
251 feature = "tracing",
252 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
253)]
254pub fn read_utt_from_bytes(bytes: &[u8]) -> Result<Utt, UttError> {
255 let gff = read_gff_from_bytes(bytes)?;
256 Utt::from_gff(&gff)
257}
258
259#[cfg_attr(
267 feature = "tracing",
268 tracing::instrument(level = "debug", skip(writer, utt))
269)]
270pub fn author_utt<W: Write>(writer: &mut W, utt: &Utt) -> Result<(), UttError> {
271 let gff = utt.to_gff();
272 write_gff(writer, &gff)?;
273 Ok(())
274}
275
276#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utt)))]
283pub fn author_utt_to_vec(utt: &Utt) -> Result<Vec<u8>, UttError> {
284 let mut cursor = Cursor::new(Vec::new());
285 author_utt(&mut cursor, utt)?;
286 Ok(cursor.into_inner())
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use rakata_formats::schema::{HasSchema, Shape};
293 use rakata_formats::{gff_label, GffValue};
294
295 const TEST_UTT: &[u8] = include_bytes!(concat!(
296 env!("CARGO_MANIFEST_DIR"),
297 "/../../fixtures/test.utt"
298 ));
299 const NEWTRANSITION_UTT: &[u8] = include_bytes!(concat!(
300 env!("CARGO_MANIFEST_DIR"),
301 "/../../fixtures/newtransition9.utt"
302 ));
303
304 #[test]
305 fn reads_core_utt_fields_from_fixture() {
306 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
307
308 assert_eq!(utt.tag, "GenericTrigger001");
309 assert_eq!(utt.template_resref, "generictrigge001");
310 assert_eq!(utt.name.string_ref.raw(), 42_968);
311 assert_eq!(utt.comment, "comment");
312 assert!(utt.auto_remove_key);
313 assert_eq!(utt.faction_id, 1);
314 assert_eq!(utt.cursor_id, 1);
315 assert_eq!(utt.highlight_height, 3.0);
316 assert_eq!(utt.key_name, "somekey");
317 assert_eq!(utt.type_id, 1);
318 assert!(utt.trap_detectable);
319 assert_eq!(utt.trap_detect_dc, 10);
320 assert!(utt.trap_disarmable);
321 assert_eq!(utt.trap_disarm_dc, 10);
322 assert!(utt.is_trap);
323 assert!(utt.trap_one_shot);
324 assert_eq!(utt.trap_type, 1);
325 assert_eq!(utt.on_disarm, "ondisarm");
326 assert_eq!(utt.on_trap_triggered, "ontraptriggered");
327 assert_eq!(utt.on_click, "onclick");
328 assert_eq!(utt.on_heartbeat, "onheartbeat");
329 assert_eq!(utt.on_enter, "onenter");
330 assert_eq!(utt.on_exit, "onexit");
331 assert_eq!(utt.on_user_defined, "onuserdefined");
332 assert_eq!(utt.palette_id, 6);
333 assert_eq!(utt.portrait_id, 0);
334 assert_eq!(utt.loadscreen_id, 0);
335 }
336
337 #[test]
338 fn reads_transition_fixture_variant() {
339 let utt = read_utt_from_bytes(NEWTRANSITION_UTT).expect("fixture must parse");
340
341 assert_eq!(utt.tag, "AreaTransition");
342 assert_eq!(utt.template_resref, "newtransition9");
343 assert_eq!(utt.name.string_ref.raw(), -1);
344 assert_eq!(utt.cursor_id, 1);
345 assert_eq!(utt.faction_id, 1);
346 assert_eq!(utt.type_id, 1);
347 assert!(!utt.auto_remove_key);
348 assert!(!utt.is_trap);
349 assert_eq!(utt.on_enter, "ebon_11");
350 assert_eq!(utt.palette_id, 5);
351 assert_eq!(utt.linked_to, "");
352 assert_eq!(utt.linked_to_flags, 0);
353 assert!(!utt.party_required);
354 }
355
356 #[test]
357 fn all_fields_survive_typed_roundtrip() {
358 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
359 let bytes = author_utt_to_vec(&utt).expect("write succeeds");
360 let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
361
362 assert_eq!(reparsed, utt);
363 }
364
365 #[test]
366 fn typed_edits_roundtrip_through_gff_writer() {
367 let mut utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
368 utt.tag = "GenericTrigger001_Rust".into();
369 utt.on_enter = ResRef::new("rust_on_enter").expect("valid test resref");
370 utt.is_trap = false;
371
372 let bytes = author_utt_to_vec(&utt).expect("write succeeds");
373 let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
374
375 assert_eq!(reparsed.tag, "GenericTrigger001_Rust");
376 assert_eq!(reparsed.on_enter, "rust_on_enter");
377 assert!(!reparsed.is_trap);
378 }
379
380 #[test]
381 fn read_utt_from_reader_matches_bytes_path() {
382 let mut cursor = Cursor::new(TEST_UTT);
383 let via_reader = read_utt(&mut cursor).expect("reader parse succeeds");
384 let via_bytes = read_utt_from_bytes(TEST_UTT).expect("bytes parse succeeds");
385
386 assert_eq!(via_reader, via_bytes);
387 }
388
389 #[test]
390 fn rejects_non_utt_file_type() {
391 let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
392 gff.file_type = *b"UTD ";
393
394 let err = Utt::from_gff(&gff).expect_err("UTD must be rejected as UTT input");
395 assert!(matches!(
396 err,
397 UttError::UnsupportedFileType(file_type) if file_type == *b"UTD "
398 ));
399 }
400
401 #[test]
402 fn a_mistyped_localized_name_reads_as_absent() {
403 let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
404 gff.root
405 .fields
406 .retain(|field| field.label != "LocalizedName");
407 gff.root
408 .push_field(gff_label!("LocalizedName"), GffValue::UInt32(5));
409
410 let utt = Utt::from_gff(&gff).expect("a mistyped field is not a read failure");
411
412 assert_eq!(utt.name, GffLocalizedString::default());
413 }
414
415 #[test]
416 fn write_utt_matches_direct_gff_writer() {
417 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
418
419 let via_typed = author_utt_to_vec(&utt).expect("typed write succeeds");
420
421 let mut direct = Cursor::new(Vec::new());
422 write_gff(&mut direct, &utt.to_gff()).expect("direct write succeeds");
423
424 assert_eq!(via_typed, direct.into_inner());
425 }
426
427 #[test]
428 fn schema_field_count() {
429 assert_eq!(Utt::schema().len(), 42);
430 }
431
432 #[test]
433 fn schema_no_duplicate_labels() {
434 let mut labels: Vec<&str> = Utt::schema().iter().map(|f| f.label.as_str()).collect();
435 labels.sort_unstable();
436 let before = labels.len();
437 labels.dedup();
438 assert_eq!(before, labels.len(), "duplicate labels in UTT schema");
439 }
440
441 #[test]
442 fn schema_geometry_carries_its_element() {
443 let geometry = Utt::schema()
444 .iter()
445 .find(|f| f.label.as_str() == "Geometry")
446 .expect("Geometry is declared");
447 let Shape::List { element, .. } = geometry.shape else {
448 panic!("Geometry is a list");
449 };
450 assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 3);
451 }
452}