1use std::io::{Cursor, Read, Write};
19
20use crate::gff_helpers::{
21 get_bool, get_f32, get_i32, get_locstring, get_resref, get_string, get_u16, get_u32, get_u8,
22 upsert_field,
23};
24use crate::shared::{GitTriggerPoint, TrapSettings};
25use rakata_core::{ResRef, StrRef};
26use rakata_formats::{
27 gff_schema::{FieldSchema, GffSchema, GffType},
28 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
29 GffValue,
30};
31use thiserror::Error;
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct Utt {
36 pub template_resref: ResRef,
38 pub tag: String,
40 pub name: GffLocalizedString,
42 pub comment: String,
44 pub auto_remove_key: bool,
46 pub faction_id: u32,
48 pub cursor_id: u8,
50 pub highlight_height: f32,
52 pub key_name: String,
54 pub type_id: i32,
56 pub trap_detectable: bool,
58 pub trap_detect_dc: u8,
60 pub trap_disarmable: bool,
62 pub trap_disarm_dc: u8,
64 pub is_trap: bool,
66 pub trap_one_shot: bool,
68 pub trap_type: u8,
70 pub on_disarm: ResRef,
72 pub on_trap_triggered: ResRef,
74 pub on_click: ResRef,
76 pub on_heartbeat: ResRef,
78 pub on_enter: ResRef,
80 pub on_exit: ResRef,
82 pub on_user_defined: ResRef,
84 pub linked_to: String,
86 pub linked_to_flags: u8,
88 pub linked_to_module: ResRef,
90 pub transition_destination: GffLocalizedString,
92 pub party_required: bool,
94 pub set_by_player_party: bool,
96 pub portrait_id: u16,
98 pub portrait: ResRef,
100 pub loadscreen_id: u16,
102 pub palette_id: u8,
104 pub geometry: Vec<GitTriggerPoint>,
106}
107
108impl Default for Utt {
109 fn default() -> Self {
110 Self {
111 template_resref: ResRef::blank(),
112 tag: String::new(),
113 name: GffLocalizedString::new(StrRef::invalid()),
114 comment: String::new(),
115 auto_remove_key: false,
116 faction_id: 0,
117 cursor_id: 0,
118 highlight_height: 0.0,
119 key_name: String::new(),
120 type_id: 0,
121 trap_detectable: false,
122 trap_detect_dc: 0,
123 trap_disarmable: false,
124 trap_disarm_dc: 0,
125 is_trap: false,
126 trap_one_shot: false,
127 trap_type: 0,
128 on_disarm: ResRef::blank(),
129 on_trap_triggered: ResRef::blank(),
130 on_click: ResRef::blank(),
131 on_heartbeat: ResRef::blank(),
132 on_enter: ResRef::blank(),
133 on_exit: ResRef::blank(),
134 on_user_defined: ResRef::blank(),
135 linked_to: String::new(),
136 linked_to_flags: 0,
137 linked_to_module: ResRef::blank(),
138 transition_destination: GffLocalizedString::new(StrRef::invalid()),
139 party_required: false,
140 set_by_player_party: false,
141 portrait_id: 0,
142 portrait: ResRef::blank(),
143 loadscreen_id: 0,
144 palette_id: 0,
145 geometry: Vec::new(),
146 }
147 }
148}
149
150impl Utt {
151 pub fn new() -> Self {
153 Self::default()
154 }
155
156 pub fn trap_settings(&self) -> TrapSettings {
158 TrapSettings {
159 detectable: self.trap_detectable,
160 detect_dc: self.trap_detect_dc,
161 disarmable: self.trap_disarmable,
162 disarm_dc: self.trap_disarm_dc,
163 flag: u8::from(self.is_trap),
164 one_shot: self.trap_one_shot,
165 trap_type: self.trap_type,
166 }
167 }
168
169 pub fn set_trap_settings(&mut self, trap: TrapSettings) {
171 self.trap_detectable = trap.detectable;
172 self.trap_detect_dc = trap.detect_dc;
173 self.trap_disarmable = trap.disarmable;
174 self.trap_disarm_dc = trap.disarm_dc;
175 self.is_trap = trap.flag != 0;
176 self.trap_one_shot = trap.one_shot;
177 self.trap_type = trap.trap_type;
178 }
179
180 pub fn from_gff(gff: &Gff) -> Result<Self, UttError> {
182 if gff.file_type != *b"UTT " && gff.file_type != *b"GFF " {
183 return Err(UttError::UnsupportedFileType(gff.file_type));
184 }
185
186 let root = &gff.root;
187 let trap = TrapSettings::read(|label| get_bool(root, label), |label| get_u8(root, label));
188
189 let geometry = match root.field("Geometry") {
190 Some(GffValue::List(elements)) => elements
191 .iter()
192 .map(GitTriggerPoint::from_gff_struct)
193 .collect(),
194 _ => Vec::new(),
195 };
196
197 if matches!(root.field("LocalizedName"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
198 {
199 return Err(UttError::TypeMismatch {
200 field: "LocalizedName",
201 expected: "LocalizedString",
202 });
203 }
204
205 Ok(Self {
206 template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
207 tag: get_string(root, "Tag").unwrap_or_default(),
208 name: get_locstring(root, "LocalizedName")
209 .cloned()
210 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
211 comment: get_string(root, "Comment").unwrap_or_default(),
212 auto_remove_key: get_bool(root, "AutoRemoveKey").unwrap_or(false),
213 faction_id: get_u32(root, "Faction").unwrap_or(0),
214 cursor_id: get_u8(root, "Cursor").unwrap_or(0),
215 highlight_height: get_f32(root, "HighlightHeight").unwrap_or(0.0),
216 key_name: get_string(root, "KeyName").unwrap_or_default(),
217 type_id: get_i32(root, "Type").unwrap_or(0),
218 trap_detectable: trap.detectable,
219 trap_detect_dc: trap.detect_dc,
220 trap_disarmable: trap.disarmable,
221 trap_disarm_dc: trap.disarm_dc,
222 is_trap: trap.flag != 0,
223 trap_one_shot: trap.one_shot,
224 trap_type: trap.trap_type,
225 on_disarm: get_resref(root, "OnDisarm").unwrap_or_default(),
226 on_trap_triggered: get_resref(root, "OnTrapTriggered").unwrap_or_default(),
227 on_click: get_resref(root, "OnClick").unwrap_or_default(),
228 on_heartbeat: get_resref(root, "ScriptHeartbeat").unwrap_or_default(),
229 on_enter: get_resref(root, "ScriptOnEnter").unwrap_or_default(),
230 on_exit: get_resref(root, "ScriptOnExit").unwrap_or_default(),
231 on_user_defined: get_resref(root, "ScriptUserDefine").unwrap_or_default(),
232 linked_to: get_string(root, "LinkedTo").unwrap_or_default(),
233 linked_to_flags: get_u8(root, "LinkedToFlags").unwrap_or(0),
234 linked_to_module: get_resref(root, "LinkedToModule").unwrap_or_default(),
235 transition_destination: get_locstring(root, "TransitionDestin")
236 .cloned()
237 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
238 party_required: get_bool(root, "PartyRequired").unwrap_or(false),
239 set_by_player_party: get_bool(root, "SetByPlayerParty").unwrap_or(false),
240 portrait_id: get_u16(root, "PortraitId").unwrap_or(0),
241 portrait: get_resref(root, "Portrait").unwrap_or_default(),
242 loadscreen_id: get_u16(root, "LoadScreenID").unwrap_or(0),
243 palette_id: get_u8(root, "PaletteID").unwrap_or(0),
244 geometry,
245 })
246 }
247
248 pub fn to_gff(&self) -> Gff {
250 let mut root = GffStruct::new(-1);
251
252 upsert_field(
253 &mut root,
254 "TemplateResRef",
255 GffValue::ResRef(self.template_resref),
256 );
257 upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
258 upsert_field(
259 &mut root,
260 "LocalizedName",
261 GffValue::LocalizedString(self.name.clone()),
262 );
263 upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
264 upsert_field(
265 &mut root,
266 "AutoRemoveKey",
267 GffValue::UInt8(u8::from(self.auto_remove_key)),
268 );
269 upsert_field(&mut root, "Faction", GffValue::UInt32(self.faction_id));
270 upsert_field(&mut root, "Cursor", GffValue::UInt8(self.cursor_id));
271 upsert_field(
272 &mut root,
273 "HighlightHeight",
274 GffValue::Single(self.highlight_height),
275 );
276 upsert_field(
277 &mut root,
278 "KeyName",
279 GffValue::String(self.key_name.clone()),
280 );
281 upsert_field(&mut root, "Type", GffValue::Int32(self.type_id));
282
283 self.trap_settings()
284 .write(|label, value| upsert_field(&mut root, label, value));
285 upsert_field(&mut root, "OnDisarm", GffValue::ResRef(self.on_disarm));
286 upsert_field(
287 &mut root,
288 "OnTrapTriggered",
289 GffValue::ResRef(self.on_trap_triggered),
290 );
291 upsert_field(&mut root, "OnClick", GffValue::ResRef(self.on_click));
292 upsert_field(
293 &mut root,
294 "ScriptHeartbeat",
295 GffValue::ResRef(self.on_heartbeat),
296 );
297 upsert_field(&mut root, "ScriptOnEnter", GffValue::ResRef(self.on_enter));
298 upsert_field(&mut root, "ScriptOnExit", GffValue::ResRef(self.on_exit));
299 upsert_field(
300 &mut root,
301 "ScriptUserDefine",
302 GffValue::ResRef(self.on_user_defined),
303 );
304
305 upsert_field(
306 &mut root,
307 "LinkedTo",
308 GffValue::String(self.linked_to.clone()),
309 );
310 upsert_field(
311 &mut root,
312 "LinkedToFlags",
313 GffValue::UInt8(self.linked_to_flags),
314 );
315 upsert_field(
316 &mut root,
317 "LinkedToModule",
318 GffValue::ResRef(self.linked_to_module),
319 );
320 upsert_field(
321 &mut root,
322 "TransitionDestin",
323 GffValue::LocalizedString(self.transition_destination.clone()),
324 );
325 upsert_field(
326 &mut root,
327 "PartyRequired",
328 GffValue::UInt8(u8::from(self.party_required)),
329 );
330 upsert_field(
331 &mut root,
332 "SetByPlayerParty",
333 GffValue::UInt8(u8::from(self.set_by_player_party)),
334 );
335 upsert_field(&mut root, "PortraitId", GffValue::UInt16(self.portrait_id));
336 upsert_field(&mut root, "Portrait", GffValue::ResRef(self.portrait));
337 upsert_field(
338 &mut root,
339 "LoadScreenID",
340 GffValue::UInt16(self.loadscreen_id),
341 );
342 upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
343
344 let geometry_list: Vec<rakata_formats::GffStruct> =
345 self.geometry.iter().map(|p| p.to_gff_struct()).collect();
346 upsert_field(&mut root, "Geometry", GffValue::List(geometry_list));
347
348 Gff::new(*b"UTT ", root)
349 }
350}
351
352#[derive(Debug, Error)]
354pub enum UttError {
355 #[error("unsupported UTT file type: {0:?}")]
357 UnsupportedFileType([u8; 4]),
358 #[error("UTT field `{field}` has incompatible type (expected {expected})")]
360 TypeMismatch {
361 field: &'static str,
363 expected: &'static str,
365 },
366 #[error(transparent)]
368 Gff(#[from] GffBinaryError),
369}
370
371#[cfg_attr(
373 feature = "tracing",
374 tracing::instrument(level = "debug", skip(reader))
375)]
376pub fn read_utt<R: Read>(reader: &mut R) -> Result<Utt, UttError> {
377 let gff = read_gff(reader)?;
378 Utt::from_gff(&gff)
379}
380
381#[cfg_attr(
383 feature = "tracing",
384 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
385)]
386pub fn read_utt_from_bytes(bytes: &[u8]) -> Result<Utt, UttError> {
387 let gff = read_gff_from_bytes(bytes)?;
388 Utt::from_gff(&gff)
389}
390
391#[cfg_attr(
393 feature = "tracing",
394 tracing::instrument(level = "debug", skip(writer, utt))
395)]
396pub fn write_utt<W: Write>(writer: &mut W, utt: &Utt) -> Result<(), UttError> {
397 let gff = utt.to_gff();
398 write_gff(writer, &gff)?;
399 Ok(())
400}
401
402#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utt)))]
404pub fn write_utt_to_vec(utt: &Utt) -> Result<Vec<u8>, UttError> {
405 let mut cursor = Cursor::new(Vec::new());
406 write_utt(&mut cursor, utt)?;
407 Ok(cursor.into_inner())
408}
409
410static GEOMETRY_CHILDREN: &[FieldSchema] = &[
412 FieldSchema {
413 label: "PointX",
414 expected_type: GffType::Single,
415 required: false,
416 children: None,
417 constraint: None,
418 },
419 FieldSchema {
420 label: "PointY",
421 expected_type: GffType::Single,
422 required: false,
423 children: None,
424 constraint: None,
425 },
426 FieldSchema {
427 label: "PointZ",
428 expected_type: GffType::Single,
429 required: false,
430 children: None,
431 constraint: None,
432 },
433];
434
435impl GffSchema for Utt {
436 fn schema() -> &'static [FieldSchema] {
437 static SCHEMA: &[FieldSchema] = &[
438 FieldSchema {
440 label: "Tag",
441 expected_type: GffType::String,
442 required: false,
443 children: None,
444 constraint: None,
445 },
446 FieldSchema {
447 label: "LocalizedName",
448 expected_type: GffType::LocalizedString,
449 required: false,
450 children: None,
451 constraint: None,
452 },
453 FieldSchema {
454 label: "Faction",
455 expected_type: GffType::UInt32,
456 required: false,
457 children: None,
458 constraint: None,
459 },
460 FieldSchema {
461 label: "Cursor",
462 expected_type: GffType::UInt8,
463 required: false,
464 children: None,
465 constraint: None,
466 },
467 FieldSchema {
468 label: "KeyName",
469 expected_type: GffType::String,
470 required: false,
471 children: None,
472 constraint: None,
473 },
474 FieldSchema {
475 label: "PortraitId",
476 expected_type: GffType::UInt16,
477 required: false,
478 children: None,
479 constraint: None,
480 },
481 FieldSchema {
482 label: "Portrait",
483 expected_type: GffType::ResRef,
484 required: false,
485 children: None,
486 constraint: None,
487 },
488 FieldSchema {
490 label: "ScriptHeartbeat",
491 expected_type: GffType::ResRef,
492 required: false,
493 children: None,
494 constraint: None,
495 },
496 FieldSchema {
497 label: "ScriptOnEnter",
498 expected_type: GffType::ResRef,
499 required: false,
500 children: None,
501 constraint: None,
502 },
503 FieldSchema {
504 label: "ScriptOnExit",
505 expected_type: GffType::ResRef,
506 required: false,
507 children: None,
508 constraint: None,
509 },
510 FieldSchema {
511 label: "ScriptUserDefine",
512 expected_type: GffType::ResRef,
513 required: false,
514 children: None,
515 constraint: None,
516 },
517 FieldSchema {
518 label: "OnTrapTriggered",
519 expected_type: GffType::ResRef,
520 required: false,
521 children: None,
522 constraint: None,
523 },
524 FieldSchema {
525 label: "OnDisarm",
526 expected_type: GffType::ResRef,
527 required: false,
528 children: None,
529 constraint: None,
530 },
531 FieldSchema {
532 label: "OnClick",
533 expected_type: GffType::ResRef,
534 required: false,
535 children: None,
536 constraint: None,
537 },
538 FieldSchema {
540 label: "TrapType",
541 expected_type: GffType::UInt8,
542 required: false,
543 children: None,
544 constraint: None,
545 },
546 FieldSchema {
547 label: "TrapOneShot",
548 expected_type: GffType::UInt8,
549 required: false,
550 children: None,
551 constraint: None,
552 },
553 FieldSchema {
554 label: "TrapDisarmable",
555 expected_type: GffType::UInt8,
556 required: false,
557 children: None,
558 constraint: None,
559 },
560 FieldSchema {
561 label: "TrapDetectable",
562 expected_type: GffType::UInt8,
563 required: false,
564 children: None,
565 constraint: None,
566 },
567 FieldSchema {
569 label: "LinkedTo",
570 expected_type: GffType::String,
571 required: false,
572 children: None,
573 constraint: None,
574 },
575 FieldSchema {
576 label: "LinkedToFlags",
577 expected_type: GffType::UInt8,
578 required: false,
579 children: None,
580 constraint: None,
581 },
582 FieldSchema {
583 label: "LinkedToModule",
584 expected_type: GffType::ResRef,
585 required: false,
586 children: None,
587 constraint: None,
588 },
589 FieldSchema {
590 label: "AutoRemoveKey",
591 expected_type: GffType::UInt8,
592 required: false,
593 children: None,
594 constraint: None,
595 },
596 FieldSchema {
597 label: "TransitionDestin",
598 expected_type: GffType::LocalizedString,
599 required: false,
600 children: None,
601 constraint: None,
602 },
603 FieldSchema {
605 label: "Type",
606 expected_type: GffType::Int32,
607 required: false,
608 children: None,
609 constraint: None,
610 },
611 FieldSchema {
612 label: "HighlightHeight",
613 expected_type: GffType::Single,
614 required: false,
615 children: None,
616 constraint: None,
617 },
618 FieldSchema {
619 label: "LoadScreenID",
620 expected_type: GffType::UInt16,
621 required: false,
622 children: None,
623 constraint: None,
624 },
625 FieldSchema {
626 label: "SetByPlayerParty",
627 expected_type: GffType::UInt8,
628 required: false,
629 children: None,
630 constraint: None,
631 },
632 FieldSchema {
633 label: "CreatorId",
634 expected_type: GffType::UInt32,
635 required: false,
636 children: None,
637 constraint: None,
638 },
639 FieldSchema {
641 label: "XPosition",
642 expected_type: GffType::Single,
643 required: false,
644 children: None,
645 constraint: None,
646 },
647 FieldSchema {
648 label: "YPosition",
649 expected_type: GffType::Single,
650 required: false,
651 children: None,
652 constraint: None,
653 },
654 FieldSchema {
655 label: "ZPosition",
656 expected_type: GffType::Single,
657 required: false,
658 children: None,
659 constraint: None,
660 },
661 FieldSchema {
662 label: "XOrientation",
663 expected_type: GffType::Single,
664 required: false,
665 children: None,
666 constraint: None,
667 },
668 FieldSchema {
669 label: "YOrientation",
670 expected_type: GffType::Single,
671 required: false,
672 children: None,
673 constraint: None,
674 },
675 FieldSchema {
676 label: "ZOrientation",
677 expected_type: GffType::Single,
678 required: false,
679 children: None,
680 constraint: None,
681 },
682 FieldSchema {
684 label: "Geometry",
685 expected_type: GffType::List,
686 required: false,
687 children: Some(GEOMETRY_CHILDREN),
688 constraint: None,
689 },
690 FieldSchema {
692 label: "TemplateResRef",
693 expected_type: GffType::ResRef,
694 required: false,
695 children: None,
696 constraint: None,
697 },
698 FieldSchema {
699 label: "Comment",
700 expected_type: GffType::String,
701 required: false,
702 children: None,
703 constraint: None,
704 },
705 FieldSchema {
706 label: "PaletteID",
707 expected_type: GffType::UInt8,
708 required: false,
709 children: None,
710 constraint: None,
711 },
712 FieldSchema {
714 label: "TrapDetectDC",
715 expected_type: GffType::UInt8,
716 required: false,
717 children: None,
718 constraint: None,
719 },
720 FieldSchema {
721 label: "DisarmDC",
722 expected_type: GffType::UInt8,
723 required: false,
724 children: None,
725 constraint: None,
726 },
727 FieldSchema {
728 label: "TrapFlag",
729 expected_type: GffType::UInt8,
730 required: false,
731 children: None,
732 constraint: None,
733 },
734 FieldSchema {
736 label: "PartyRequired",
737 expected_type: GffType::UInt8,
738 required: false,
739 children: None,
740 constraint: None,
741 },
742 ];
743 SCHEMA
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 const TEST_UTT: &[u8] = include_bytes!(concat!(
752 env!("CARGO_MANIFEST_DIR"),
753 "/../../fixtures/test.utt"
754 ));
755 const NEWTRANSITION_UTT: &[u8] = include_bytes!(concat!(
756 env!("CARGO_MANIFEST_DIR"),
757 "/../../fixtures/newtransition9.utt"
758 ));
759
760 #[test]
761 fn reads_core_utt_fields_from_fixture() {
762 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
763
764 assert_eq!(utt.tag, "GenericTrigger001");
765 assert_eq!(utt.template_resref, "generictrigge001");
766 assert_eq!(utt.name.string_ref.raw(), 42_968);
767 assert_eq!(utt.comment, "comment");
768 assert!(utt.auto_remove_key);
769 assert_eq!(utt.faction_id, 1);
770 assert_eq!(utt.cursor_id, 1);
771 assert_eq!(utt.highlight_height, 3.0);
772 assert_eq!(utt.key_name, "somekey");
773 assert_eq!(utt.type_id, 1);
774 assert!(utt.trap_detectable);
775 assert_eq!(utt.trap_detect_dc, 10);
776 assert!(utt.trap_disarmable);
777 assert_eq!(utt.trap_disarm_dc, 10);
778 assert!(utt.is_trap);
779 assert!(utt.trap_one_shot);
780 assert_eq!(utt.trap_type, 1);
781 assert_eq!(utt.on_disarm, "ondisarm");
782 assert_eq!(utt.on_trap_triggered, "ontraptriggered");
783 assert_eq!(utt.on_click, "onclick");
784 assert_eq!(utt.on_heartbeat, "onheartbeat");
785 assert_eq!(utt.on_enter, "onenter");
786 assert_eq!(utt.on_exit, "onexit");
787 assert_eq!(utt.on_user_defined, "onuserdefined");
788 assert_eq!(utt.palette_id, 6);
789 assert_eq!(utt.portrait_id, 0);
790 assert_eq!(utt.loadscreen_id, 0);
791 }
792
793 #[test]
794 fn reads_transition_fixture_variant() {
795 let utt = read_utt_from_bytes(NEWTRANSITION_UTT).expect("fixture must parse");
796
797 assert_eq!(utt.tag, "AreaTransition");
798 assert_eq!(utt.template_resref, "newtransition9");
799 assert_eq!(utt.name.string_ref.raw(), -1);
800 assert_eq!(utt.cursor_id, 1);
801 assert_eq!(utt.faction_id, 1);
802 assert_eq!(utt.type_id, 1);
803 assert!(!utt.auto_remove_key);
804 assert!(!utt.is_trap);
805 assert_eq!(utt.on_enter, "ebon_11");
806 assert_eq!(utt.palette_id, 5);
807 assert_eq!(utt.linked_to, "");
808 assert_eq!(utt.linked_to_flags, 0);
809 assert!(!utt.party_required);
810 }
811
812 #[test]
813 fn all_fields_survive_typed_roundtrip() {
814 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
815 let bytes = write_utt_to_vec(&utt).expect("write succeeds");
816 let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
817
818 assert_eq!(reparsed, utt);
819 }
820
821 #[test]
822 fn typed_edits_roundtrip_through_gff_writer() {
823 let mut utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
824 utt.tag = "GenericTrigger001_Rust".into();
825 utt.on_enter = ResRef::new("rust_on_enter").expect("valid test resref");
826 utt.is_trap = false;
827
828 let bytes = write_utt_to_vec(&utt).expect("write succeeds");
829 let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
830
831 assert_eq!(reparsed.tag, "GenericTrigger001_Rust");
832 assert_eq!(reparsed.on_enter, "rust_on_enter");
833 assert!(!reparsed.is_trap);
834 }
835
836 #[test]
837 fn read_utt_from_reader_matches_bytes_path() {
838 let mut cursor = Cursor::new(TEST_UTT);
839 let via_reader = read_utt(&mut cursor).expect("reader parse succeeds");
840 let via_bytes = read_utt_from_bytes(TEST_UTT).expect("bytes parse succeeds");
841
842 assert_eq!(via_reader, via_bytes);
843 }
844
845 #[test]
846 fn rejects_non_utt_file_type() {
847 let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
848 gff.file_type = *b"UTD ";
849
850 let err = Utt::from_gff(&gff).expect_err("UTD must be rejected as UTT input");
851 assert!(matches!(
852 err,
853 UttError::UnsupportedFileType(file_type) if file_type == *b"UTD "
854 ));
855 }
856
857 #[test]
858 fn type_mismatch_on_localized_name_is_error() {
859 let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
860 gff.root
861 .fields
862 .retain(|field| field.label != "LocalizedName");
863 gff.root.push_field("LocalizedName", GffValue::UInt32(5));
864
865 let err = Utt::from_gff(&gff).expect_err("type mismatch must be rejected");
866 assert!(matches!(
867 err,
868 UttError::TypeMismatch {
869 field: "LocalizedName",
870 expected: "LocalizedString",
871 }
872 ));
873 }
874
875 #[test]
876 fn write_utt_matches_direct_gff_writer() {
877 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
878
879 let via_typed = write_utt_to_vec(&utt).expect("typed write succeeds");
880
881 let mut direct = Cursor::new(Vec::new());
882 write_gff(&mut direct, &utt.to_gff()).expect("direct write succeeds");
883
884 assert_eq!(via_typed, direct.into_inner());
885 }
886
887 #[test]
888 fn schema_field_count() {
889 assert_eq!(Utt::schema().len(), 42);
890 }
891
892 #[test]
893 fn schema_no_duplicate_labels() {
894 let schema = Utt::schema();
895 let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
896 labels.sort();
897 let before = labels.len();
898 labels.dedup();
899 assert_eq!(before, labels.len(), "duplicate labels in UTT schema");
900 }
901
902 #[test]
903 fn schema_geometry_has_children() {
904 let geometry = Utt::schema()
905 .iter()
906 .find(|f| f.label == "Geometry")
907 .expect("test fixture must be valid");
908 assert!(geometry.children.is_some());
909 assert_eq!(
910 geometry.children.expect("test fixture must be valid").len(),
911 3
912 );
913 }
914}