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::{
25 GitTriggerPoint, TrapDefaults, TrapSettings, PORTRAIT_ID_USE_RESREF, SCRIPT_SLOT_SEED,
26 TRAP_TYPE_ABSENT,
27};
28use rakata_core::{ResRef, StrRef};
29use rakata_formats::{
30 gff_schema::{AbsentDefault, DefaultValue, FieldLife, FieldSchema, GffSchema, GffType},
31 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
32 GffValue,
33};
34use thiserror::Error;
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct Utt {
39 pub template_resref: ResRef,
41 pub tag: String,
43 pub name: GffLocalizedString,
45 pub comment: String,
47 pub auto_remove_key: bool,
49 pub faction_id: u32,
51 pub cursor_id: u8,
53 pub highlight_height: f32,
55 pub key_name: String,
57 pub type_id: i32,
59 pub trap_detectable: bool,
61 pub trap_detect_dc: u8,
63 pub trap_disarmable: bool,
65 pub trap_disarm_dc: u8,
67 pub is_trap: bool,
69 pub trap_one_shot: bool,
71 pub trap_type: u8,
73 pub on_disarm: ResRef,
75 pub on_trap_triggered: ResRef,
77 pub on_click: ResRef,
79 pub on_heartbeat: ResRef,
81 pub on_enter: ResRef,
83 pub on_exit: ResRef,
85 pub on_user_defined: ResRef,
87 pub linked_to: String,
89 pub linked_to_flags: u8,
91 pub linked_to_module: ResRef,
93 pub transition_destination: GffLocalizedString,
95 pub party_required: bool,
97 pub set_by_player_party: bool,
99 pub portrait_id: u16,
101 pub portrait: ResRef,
103 pub loadscreen_id: u16,
105 pub palette_id: u8,
107 pub geometry: Vec<GitTriggerPoint>,
109}
110
111impl Default for Utt {
112 fn default() -> Self {
113 Self {
114 template_resref: ResRef::blank(),
115 tag: String::new(),
116 name: GffLocalizedString::new(StrRef::invalid()),
117 comment: String::new(),
118 auto_remove_key: false,
119 faction_id: 0,
120 cursor_id: 0,
121 highlight_height: 0.0,
122 key_name: String::new(),
123 type_id: 0,
124 trap_detectable: false,
125 trap_detect_dc: 0,
126 trap_disarmable: false,
127 trap_disarm_dc: 0,
128 is_trap: false,
129 trap_one_shot: true,
130 trap_type: TRAP_TYPE_ABSENT,
131 on_disarm: SCRIPT_SLOT_SEED,
132 on_trap_triggered: SCRIPT_SLOT_SEED,
133 on_click: SCRIPT_SLOT_SEED,
134 on_heartbeat: SCRIPT_SLOT_SEED,
135 on_enter: SCRIPT_SLOT_SEED,
136 on_exit: SCRIPT_SLOT_SEED,
137 on_user_defined: SCRIPT_SLOT_SEED,
138 linked_to: String::new(),
139 linked_to_flags: 0,
140 linked_to_module: ResRef::blank(),
141 transition_destination: GffLocalizedString::new(StrRef::invalid()),
142 party_required: false,
143 set_by_player_party: false,
144 portrait_id: PORTRAIT_ID_USE_RESREF,
145 portrait: ResRef::blank(),
146 loadscreen_id: 0,
147 palette_id: 0,
148 geometry: Vec::new(),
149 }
150 }
151}
152
153impl Utt {
154 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub fn trap_settings(&self) -> TrapSettings {
161 TrapSettings {
162 detectable: self.trap_detectable,
163 detect_dc: self.trap_detect_dc,
164 disarmable: self.trap_disarmable,
165 disarm_dc: self.trap_disarm_dc,
166 flag: u8::from(self.is_trap),
167 one_shot: self.trap_one_shot,
168 trap_type: self.trap_type,
169 }
170 }
171
172 pub fn set_trap_settings(&mut self, trap: TrapSettings) {
174 self.trap_detectable = trap.detectable;
175 self.trap_detect_dc = trap.detect_dc;
176 self.trap_disarmable = trap.disarmable;
177 self.trap_disarm_dc = trap.disarm_dc;
178 self.is_trap = trap.flag != 0;
179 self.trap_one_shot = trap.one_shot;
180 self.trap_type = trap.trap_type;
181 }
182
183 pub fn from_gff(gff: &Gff) -> Result<Self, UttError> {
185 if gff.file_type != *b"UTT " && gff.file_type != *b"GFF " {
186 return Err(UttError::UnsupportedFileType(gff.file_type));
187 }
188
189 let root = &gff.root;
190 let trap = TrapSettings::read(
191 TrapDefaults::TRIGGER,
192 |label| get_bool(root, label),
193 |label| get_u8(root, label),
194 );
195
196 let geometry = match root.field("Geometry") {
197 Some(GffValue::List(elements)) => elements
198 .iter()
199 .map(GitTriggerPoint::from_gff_struct)
200 .collect(),
201 _ => Vec::new(),
202 };
203
204 if matches!(root.field("LocalizedName"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
205 {
206 return Err(UttError::TypeMismatch {
207 field: "LocalizedName",
208 expected: "LocalizedString",
209 });
210 }
211
212 Ok(Self {
213 template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
214 tag: get_string(root, "Tag").unwrap_or_default(),
215 name: get_locstring(root, "LocalizedName")
216 .cloned()
217 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
218 comment: get_string(root, "Comment").unwrap_or_default(),
219 auto_remove_key: get_bool(root, "AutoRemoveKey").unwrap_or(false),
220 faction_id: get_u32(root, "Faction").unwrap_or(0),
221 cursor_id: get_u8(root, "Cursor").unwrap_or(0),
222 highlight_height: get_f32(root, "HighlightHeight").unwrap_or(0.0),
223 key_name: get_string(root, "KeyName").unwrap_or_default(),
224 type_id: get_i32(root, "Type").unwrap_or(0),
225 trap_detectable: trap.detectable,
226 trap_detect_dc: trap.detect_dc,
227 trap_disarmable: trap.disarmable,
228 trap_disarm_dc: trap.disarm_dc,
229 is_trap: trap.flag != 0,
230 trap_one_shot: trap.one_shot,
231 trap_type: trap.trap_type,
232 on_disarm: get_resref(root, "OnDisarm").unwrap_or(SCRIPT_SLOT_SEED),
233 on_trap_triggered: get_resref(root, "OnTrapTriggered").unwrap_or(SCRIPT_SLOT_SEED),
234 on_click: get_resref(root, "OnClick").unwrap_or(SCRIPT_SLOT_SEED),
235 on_heartbeat: get_resref(root, "ScriptHeartbeat").unwrap_or(SCRIPT_SLOT_SEED),
236 on_enter: get_resref(root, "ScriptOnEnter").unwrap_or(SCRIPT_SLOT_SEED),
237 on_exit: get_resref(root, "ScriptOnExit").unwrap_or(SCRIPT_SLOT_SEED),
238 on_user_defined: get_resref(root, "ScriptUserDefine").unwrap_or(SCRIPT_SLOT_SEED),
239 linked_to: get_string(root, "LinkedTo").unwrap_or_default(),
240 linked_to_flags: get_u8(root, "LinkedToFlags").unwrap_or(0),
241 linked_to_module: get_resref(root, "LinkedToModule").unwrap_or_default(),
242 transition_destination: get_locstring(root, "TransitionDestin")
243 .cloned()
244 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
245 party_required: get_bool(root, "PartyRequired").unwrap_or(false),
246 set_by_player_party: get_bool(root, "SetByPlayerParty").unwrap_or(false),
247 portrait_id: get_u16(root, "PortraitId").unwrap_or(PORTRAIT_ID_USE_RESREF),
248 portrait: get_resref(root, "Portrait").unwrap_or_default(),
249 loadscreen_id: get_u16(root, "LoadScreenID").unwrap_or(0),
250 palette_id: get_u8(root, "PaletteID").unwrap_or(0),
251 geometry,
252 })
253 }
254
255 pub fn to_gff(&self) -> Gff {
257 let mut root = GffStruct::new(-1);
258
259 upsert_field(
260 &mut root,
261 "TemplateResRef",
262 GffValue::ResRef(self.template_resref),
263 );
264 upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
265 upsert_field(
266 &mut root,
267 "LocalizedName",
268 GffValue::LocalizedString(self.name.clone()),
269 );
270 upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
271 upsert_field(
272 &mut root,
273 "AutoRemoveKey",
274 GffValue::UInt8(u8::from(self.auto_remove_key)),
275 );
276 upsert_field(&mut root, "Faction", GffValue::UInt32(self.faction_id));
277 upsert_field(&mut root, "Cursor", GffValue::UInt8(self.cursor_id));
278 upsert_field(
279 &mut root,
280 "HighlightHeight",
281 GffValue::Single(self.highlight_height),
282 );
283 upsert_field(
284 &mut root,
285 "KeyName",
286 GffValue::String(self.key_name.clone()),
287 );
288 upsert_field(&mut root, "Type", GffValue::Int32(self.type_id));
289
290 self.trap_settings()
291 .write(|label, value| upsert_field(&mut root, label, value));
292 upsert_field(&mut root, "OnDisarm", GffValue::ResRef(self.on_disarm));
293 upsert_field(
294 &mut root,
295 "OnTrapTriggered",
296 GffValue::ResRef(self.on_trap_triggered),
297 );
298 upsert_field(&mut root, "OnClick", GffValue::ResRef(self.on_click));
299 upsert_field(
300 &mut root,
301 "ScriptHeartbeat",
302 GffValue::ResRef(self.on_heartbeat),
303 );
304 upsert_field(&mut root, "ScriptOnEnter", GffValue::ResRef(self.on_enter));
305 upsert_field(&mut root, "ScriptOnExit", GffValue::ResRef(self.on_exit));
306 upsert_field(
307 &mut root,
308 "ScriptUserDefine",
309 GffValue::ResRef(self.on_user_defined),
310 );
311
312 upsert_field(
313 &mut root,
314 "LinkedTo",
315 GffValue::String(self.linked_to.clone()),
316 );
317 upsert_field(
318 &mut root,
319 "LinkedToFlags",
320 GffValue::UInt8(self.linked_to_flags),
321 );
322 upsert_field(
323 &mut root,
324 "LinkedToModule",
325 GffValue::ResRef(self.linked_to_module),
326 );
327 upsert_field(
328 &mut root,
329 "TransitionDestin",
330 GffValue::LocalizedString(self.transition_destination.clone()),
331 );
332 upsert_field(
333 &mut root,
334 "PartyRequired",
335 GffValue::UInt8(u8::from(self.party_required)),
336 );
337 upsert_field(
338 &mut root,
339 "SetByPlayerParty",
340 GffValue::UInt8(u8::from(self.set_by_player_party)),
341 );
342 upsert_field(&mut root, "PortraitId", GffValue::UInt16(self.portrait_id));
343 upsert_field(&mut root, "Portrait", GffValue::ResRef(self.portrait));
344 upsert_field(
345 &mut root,
346 "LoadScreenID",
347 GffValue::UInt16(self.loadscreen_id),
348 );
349 upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
350
351 let geometry_list: Vec<rakata_formats::GffStruct> =
352 self.geometry.iter().map(|p| p.to_gff_struct()).collect();
353 upsert_field(&mut root, "Geometry", GffValue::List(geometry_list));
354
355 Gff::new(*b"UTT ", root)
356 }
357}
358
359#[derive(Debug, Error)]
361pub enum UttError {
362 #[error("unsupported UTT file type: {0:?}")]
364 UnsupportedFileType([u8; 4]),
365 #[error("UTT field `{field}` has incompatible type (expected {expected})")]
367 TypeMismatch {
368 field: &'static str,
370 expected: &'static str,
372 },
373 #[error(transparent)]
375 Gff(#[from] GffBinaryError),
376}
377
378#[cfg_attr(
380 feature = "tracing",
381 tracing::instrument(level = "debug", skip(reader))
382)]
383pub fn read_utt<R: Read>(reader: &mut R) -> Result<Utt, UttError> {
384 let gff = read_gff(reader)?;
385 Utt::from_gff(&gff)
386}
387
388#[cfg_attr(
390 feature = "tracing",
391 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
392)]
393pub fn read_utt_from_bytes(bytes: &[u8]) -> Result<Utt, UttError> {
394 let gff = read_gff_from_bytes(bytes)?;
395 Utt::from_gff(&gff)
396}
397
398#[cfg_attr(
400 feature = "tracing",
401 tracing::instrument(level = "debug", skip(writer, utt))
402)]
403pub fn write_utt<W: Write>(writer: &mut W, utt: &Utt) -> Result<(), UttError> {
404 let gff = utt.to_gff();
405 write_gff(writer, &gff)?;
406 Ok(())
407}
408
409#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utt)))]
411pub fn write_utt_to_vec(utt: &Utt) -> Result<Vec<u8>, UttError> {
412 let mut cursor = Cursor::new(Vec::new());
413 write_utt(&mut cursor, utt)?;
414 Ok(cursor.into_inner())
415}
416
417static GEOMETRY_CHILDREN: &[FieldSchema] = &[
419 FieldSchema {
420 label: "PointX",
421 expected_type: GffType::Single,
422 life: FieldLife::Live,
423 required: false,
424 absent: AbsentDefault::Unverified,
425 children: None,
426 constraint: None,
427 },
428 FieldSchema {
429 label: "PointY",
430 expected_type: GffType::Single,
431 life: FieldLife::Live,
432 required: false,
433 absent: AbsentDefault::Unverified,
434 children: None,
435 constraint: None,
436 },
437 FieldSchema {
438 label: "PointZ",
439 expected_type: GffType::Single,
440 life: FieldLife::Live,
441 required: false,
442 absent: AbsentDefault::Unverified,
443 children: None,
444 constraint: None,
445 },
446];
447
448impl GffSchema for Utt {
449 fn schema() -> &'static [FieldSchema] {
450 static SCHEMA: &[FieldSchema] = &[
451 FieldSchema {
453 label: "Tag",
454 expected_type: GffType::String,
455 life: FieldLife::Live,
456 required: false,
457 absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utt.md: the remaining fields are unconditional literals"),
458 children: None,
459 constraint: None,
460 },
461 FieldSchema {
462 label: "LocalizedName",
463 expected_type: GffType::LocalizedString,
464 life: FieldLife::Live,
465 required: false,
466 absent: AbsentDefault::Stamped(DefaultValue::EmptyLocalizedString, "utt.md: the remaining fields are unconditional literals"),
467 children: None,
468 constraint: None,
469 },
470 FieldSchema {
471 label: "Faction",
472 expected_type: GffType::UInt32,
473 life: FieldLife::Live,
474 required: false,
475 absent: AbsentDefault::Constructed(DefaultValue::UInt32(0), "utt.md: a found-flag leaves the prior value in place when the label is absent"),
476 children: None,
477 constraint: None,
478 },
479 FieldSchema {
480 label: "Cursor",
481 expected_type: GffType::UInt8,
482 life: FieldLife::Live,
483 required: false,
484 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: the remaining fields are unconditional literals"),
485 children: None,
486 constraint: None,
487 },
488 FieldSchema {
489 label: "KeyName",
490 expected_type: GffType::String,
491 life: FieldLife::Live,
492 required: false,
493 absent: AbsentDefault::Stamped(DefaultValue::Text(""), "utt.md: the remaining fields are unconditional literals"),
494 children: None,
495 constraint: None,
496 },
497 FieldSchema {
498 label: "PortraitId",
499 expected_type: GffType::UInt16,
500 life: FieldLife::Live,
501 required: false,
502 absent: AbsentDefault::Stamped(DefaultValue::UInt16(65535), "utt.md: an absent PortraitId stamps 0xFFFF, inside the range that defers to the Portrait resref"),
503 children: None,
504 constraint: None,
505 },
506 FieldSchema {
507 label: "Portrait",
508 expected_type: GffType::ResRef,
509 life: FieldLife::Live,
510 required: false,
511 absent: AbsentDefault::Stamped(DefaultValue::Text(""), "utt.md: an absent PortraitId stamps 0xFFFF, inside the range that defers to the Portrait resref"),
512 children: None,
513 constraint: None,
514 },
515 FieldSchema {
517 label: "ScriptHeartbeat",
518 expected_type: GffType::ResRef,
519 life: FieldLife::Live,
520 required: false,
521 absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utt.md: the trigger constructor seeds all seven script slots with the literal `default`"),
522 children: None,
523 constraint: None,
524 },
525 FieldSchema {
526 label: "ScriptOnEnter",
527 expected_type: GffType::ResRef,
528 life: FieldLife::Live,
529 required: false,
530 absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utt.md: the trigger constructor seeds all seven script slots with the literal `default`"),
531 children: None,
532 constraint: None,
533 },
534 FieldSchema {
535 label: "ScriptOnExit",
536 expected_type: GffType::ResRef,
537 life: FieldLife::Live,
538 required: false,
539 absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utt.md: the trigger constructor seeds all seven script slots with the literal `default`"),
540 children: None,
541 constraint: None,
542 },
543 FieldSchema {
544 label: "ScriptUserDefine",
545 expected_type: GffType::ResRef,
546 life: FieldLife::Live,
547 required: false,
548 absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utt.md: the trigger constructor seeds all seven script slots with the literal `default`"),
549 children: None,
550 constraint: None,
551 },
552 FieldSchema {
553 label: "OnTrapTriggered",
554 expected_type: GffType::ResRef,
555 life: FieldLife::Live,
556 required: false,
557 absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utt.md: the trigger constructor seeds all seven script slots with the literal `default`"),
558 children: None,
559 constraint: None,
560 },
561 FieldSchema {
562 label: "OnDisarm",
563 expected_type: GffType::ResRef,
564 life: FieldLife::Live,
565 required: false,
566 absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utt.md: the trigger constructor seeds all seven script slots with the literal `default`"),
567 children: None,
568 constraint: None,
569 },
570 FieldSchema {
571 label: "OnClick",
572 expected_type: GffType::ResRef,
573 life: FieldLife::Live,
574 required: false,
575 absent: AbsentDefault::Constructed(DefaultValue::Text("default"), "utt.md: the trigger constructor seeds all seven script slots with the literal `default`"),
576 children: None,
577 constraint: None,
578 },
579 FieldSchema {
581 label: "TrapType",
582 expected_type: GffType::UInt8,
583 life: FieldLife::Live,
584 required: false,
585 absent: AbsentDefault::Constructed(DefaultValue::UInt8(255), "utt.md: TrapType's absent default is the sentinel 0xFF"),
586 children: None,
587 constraint: None,
588 },
589 FieldSchema {
590 label: "TrapOneShot",
591 expected_type: GffType::UInt8,
592 life: FieldLife::Live,
593 required: false,
594 absent: AbsentDefault::Constructed(DefaultValue::UInt8(1), "utt.md: TrapOneShot is the one trap flag that genuinely carries its constructed 1"),
595 children: None,
596 constraint: None,
597 },
598 FieldSchema {
599 label: "TrapDisarmable",
600 expected_type: GffType::UInt8,
601 life: FieldLife::Live,
602 required: false,
603 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: TrapDetectable and TrapDisarmable are constructed 1 and stamped 0 by their own reads"),
604 children: None,
605 constraint: None,
606 },
607 FieldSchema {
608 label: "TrapDetectable",
609 expected_type: GffType::UInt8,
610 life: FieldLife::Live,
611 required: false,
612 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: TrapDetectable and TrapDisarmable are constructed 1 and stamped 0 by their own reads"),
613 children: None,
614 constraint: None,
615 },
616 FieldSchema {
618 label: "LinkedTo",
619 expected_type: GffType::String,
620 life: FieldLife::Live,
621 required: false,
622 absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utt.md: a found-flag leaves the prior value in place when the label is absent"),
623 children: None,
624 constraint: None,
625 },
626 FieldSchema {
627 label: "LinkedToFlags",
628 expected_type: GffType::UInt8,
629 life: FieldLife::Live,
630 required: false,
631 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: the remaining fields are unconditional literals"),
632 children: None,
633 constraint: None,
634 },
635 FieldSchema {
636 label: "LinkedToModule",
637 expected_type: GffType::ResRef,
638 life: FieldLife::Live,
639 required: false,
640 absent: AbsentDefault::Constructed(DefaultValue::Text(""), "utt.md: a found-flag leaves the prior value in place when the label is absent"),
641 children: None,
642 constraint: None,
643 },
644 FieldSchema {
645 label: "AutoRemoveKey",
646 expected_type: GffType::UInt8,
647 life: FieldLife::Live,
648 required: false,
649 absent: AbsentDefault::Constructed(DefaultValue::UInt8(0), "utt.md: a found-flag leaves the prior value in place when the label is absent"),
650 children: None,
651 constraint: None,
652 },
653 FieldSchema {
654 label: "TransitionDestin",
655 expected_type: GffType::LocalizedString,
656 life: FieldLife::Live,
657 required: false,
658 absent: AbsentDefault::Constructed(DefaultValue::EmptyLocalizedString, "utt.md: TransitionDestin carries over the object's own empty localized string"),
659 children: None,
660 constraint: None,
661 },
662 FieldSchema {
664 label: "Type",
665 expected_type: GffType::Int32,
666 life: FieldLife::Live,
667 required: false,
668 absent: AbsentDefault::Stamped(DefaultValue::Int32(0), "utt.md: the remaining fields are unconditional literals"),
669 children: None,
670 constraint: None,
671 },
672 FieldSchema {
673 label: "HighlightHeight",
674 expected_type: GffType::Single,
675 life: FieldLife::Live,
676 required: false,
677 absent: AbsentDefault::Unverified,
678 children: None,
679 constraint: None,
680 },
681 FieldSchema {
682 label: "LoadScreenID",
683 expected_type: GffType::UInt16,
684 life: FieldLife::Live,
685 required: false,
686 absent: AbsentDefault::Stamped(DefaultValue::UInt16(0), "utt.md: the remaining fields are unconditional literals"),
687 children: None,
688 constraint: None,
689 },
690 FieldSchema {
691 label: "SetByPlayerParty",
692 expected_type: GffType::UInt8,
693 life: FieldLife::Live,
694 required: false,
695 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: the remaining fields are unconditional literals"),
696 children: None,
697 constraint: None,
698 },
699 FieldSchema {
700 label: "CreatorId",
701 expected_type: GffType::UInt32,
702 life: FieldLife::Live,
703 required: false,
704 absent: AbsentDefault::Unverified,
705 children: None,
706 constraint: None,
707 },
708 FieldSchema {
710 label: "XPosition",
711 expected_type: GffType::Single,
712 life: FieldLife::Live,
713 required: false,
714 absent: AbsentDefault::Unverified,
715 children: None,
716 constraint: None,
717 },
718 FieldSchema {
719 label: "YPosition",
720 expected_type: GffType::Single,
721 life: FieldLife::Live,
722 required: false,
723 absent: AbsentDefault::Unverified,
724 children: None,
725 constraint: None,
726 },
727 FieldSchema {
728 label: "ZPosition",
729 expected_type: GffType::Single,
730 life: FieldLife::Live,
731 required: false,
732 absent: AbsentDefault::Unverified,
733 children: None,
734 constraint: None,
735 },
736 FieldSchema {
737 label: "XOrientation",
738 expected_type: GffType::Single,
739 life: FieldLife::Live,
740 required: false,
741 absent: AbsentDefault::Unverified,
742 children: None,
743 constraint: None,
744 },
745 FieldSchema {
746 label: "YOrientation",
747 expected_type: GffType::Single,
748 life: FieldLife::Live,
749 required: false,
750 absent: AbsentDefault::Unverified,
751 children: None,
752 constraint: None,
753 },
754 FieldSchema {
755 label: "ZOrientation",
756 expected_type: GffType::Single,
757 life: FieldLife::Live,
758 required: false,
759 absent: AbsentDefault::Unverified,
760 children: None,
761 constraint: None,
762 },
763 FieldSchema {
765 label: "Geometry",
766 expected_type: GffType::List,
767 life: FieldLife::Live,
768 required: false,
769 absent: AbsentDefault::Unverified,
770 children: Some(GEOMETRY_CHILDREN),
771 constraint: None,
772 },
773 FieldSchema {
775 label: "TemplateResRef",
776 expected_type: GffType::ResRef,
777 life: FieldLife::Live,
778 required: false,
779 absent: AbsentDefault::Unverified,
780 children: None,
781 constraint: None,
782 },
783 FieldSchema {
784 label: "Comment",
785 expected_type: GffType::String,
786 life: FieldLife::Live,
787 required: false,
788 absent: AbsentDefault::Unverified,
789 children: None,
790 constraint: None,
791 },
792 FieldSchema {
793 label: "PaletteID",
794 expected_type: GffType::UInt8,
795 life: FieldLife::Live,
796 required: false,
797 absent: AbsentDefault::Unverified,
798 children: None,
799 constraint: None,
800 },
801 FieldSchema {
803 label: "TrapDetectDC",
804 expected_type: GffType::UInt8,
805 life: FieldLife::Live,
806 required: false,
807 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: the remaining fields are unconditional literals"),
808 children: None,
809 constraint: None,
810 },
811 FieldSchema {
812 label: "DisarmDC",
813 expected_type: GffType::UInt8,
814 life: FieldLife::Live,
815 required: false,
816 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: the remaining fields are unconditional literals"),
817 children: None,
818 constraint: None,
819 },
820 FieldSchema {
821 label: "TrapFlag",
822 expected_type: GffType::UInt8,
823 life: FieldLife::Live,
824 required: false,
825 absent: AbsentDefault::Stamped(DefaultValue::UInt8(0), "utt.md: the remaining fields are unconditional literals"),
826 children: None,
827 constraint: None,
828 },
829 FieldSchema {
831 label: "PartyRequired",
832 expected_type: GffType::UInt8,
833 life: FieldLife::Live,
834 required: false,
835 absent: AbsentDefault::Unverified,
836 children: None,
837 constraint: None,
838 },
839 ];
840 SCHEMA
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 const TEST_UTT: &[u8] = include_bytes!(concat!(
849 env!("CARGO_MANIFEST_DIR"),
850 "/../../fixtures/test.utt"
851 ));
852 const NEWTRANSITION_UTT: &[u8] = include_bytes!(concat!(
853 env!("CARGO_MANIFEST_DIR"),
854 "/../../fixtures/newtransition9.utt"
855 ));
856
857 #[test]
858 fn reads_core_utt_fields_from_fixture() {
859 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
860
861 assert_eq!(utt.tag, "GenericTrigger001");
862 assert_eq!(utt.template_resref, "generictrigge001");
863 assert_eq!(utt.name.string_ref.raw(), 42_968);
864 assert_eq!(utt.comment, "comment");
865 assert!(utt.auto_remove_key);
866 assert_eq!(utt.faction_id, 1);
867 assert_eq!(utt.cursor_id, 1);
868 assert_eq!(utt.highlight_height, 3.0);
869 assert_eq!(utt.key_name, "somekey");
870 assert_eq!(utt.type_id, 1);
871 assert!(utt.trap_detectable);
872 assert_eq!(utt.trap_detect_dc, 10);
873 assert!(utt.trap_disarmable);
874 assert_eq!(utt.trap_disarm_dc, 10);
875 assert!(utt.is_trap);
876 assert!(utt.trap_one_shot);
877 assert_eq!(utt.trap_type, 1);
878 assert_eq!(utt.on_disarm, "ondisarm");
879 assert_eq!(utt.on_trap_triggered, "ontraptriggered");
880 assert_eq!(utt.on_click, "onclick");
881 assert_eq!(utt.on_heartbeat, "onheartbeat");
882 assert_eq!(utt.on_enter, "onenter");
883 assert_eq!(utt.on_exit, "onexit");
884 assert_eq!(utt.on_user_defined, "onuserdefined");
885 assert_eq!(utt.palette_id, 6);
886 assert_eq!(utt.portrait_id, 0);
887 assert_eq!(utt.loadscreen_id, 0);
888 }
889
890 #[test]
891 fn reads_transition_fixture_variant() {
892 let utt = read_utt_from_bytes(NEWTRANSITION_UTT).expect("fixture must parse");
893
894 assert_eq!(utt.tag, "AreaTransition");
895 assert_eq!(utt.template_resref, "newtransition9");
896 assert_eq!(utt.name.string_ref.raw(), -1);
897 assert_eq!(utt.cursor_id, 1);
898 assert_eq!(utt.faction_id, 1);
899 assert_eq!(utt.type_id, 1);
900 assert!(!utt.auto_remove_key);
901 assert!(!utt.is_trap);
902 assert_eq!(utt.on_enter, "ebon_11");
903 assert_eq!(utt.palette_id, 5);
904 assert_eq!(utt.linked_to, "");
905 assert_eq!(utt.linked_to_flags, 0);
906 assert!(!utt.party_required);
907 }
908
909 #[test]
910 fn all_fields_survive_typed_roundtrip() {
911 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
912 let bytes = write_utt_to_vec(&utt).expect("write succeeds");
913 let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
914
915 assert_eq!(reparsed, utt);
916 }
917
918 #[test]
919 fn typed_edits_roundtrip_through_gff_writer() {
920 let mut utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
921 utt.tag = "GenericTrigger001_Rust".into();
922 utt.on_enter = ResRef::new("rust_on_enter").expect("valid test resref");
923 utt.is_trap = false;
924
925 let bytes = write_utt_to_vec(&utt).expect("write succeeds");
926 let reparsed = read_utt_from_bytes(&bytes).expect("reparse succeeds");
927
928 assert_eq!(reparsed.tag, "GenericTrigger001_Rust");
929 assert_eq!(reparsed.on_enter, "rust_on_enter");
930 assert!(!reparsed.is_trap);
931 }
932
933 #[test]
934 fn read_utt_from_reader_matches_bytes_path() {
935 let mut cursor = Cursor::new(TEST_UTT);
936 let via_reader = read_utt(&mut cursor).expect("reader parse succeeds");
937 let via_bytes = read_utt_from_bytes(TEST_UTT).expect("bytes parse succeeds");
938
939 assert_eq!(via_reader, via_bytes);
940 }
941
942 #[test]
943 fn rejects_non_utt_file_type() {
944 let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
945 gff.file_type = *b"UTD ";
946
947 let err = Utt::from_gff(&gff).expect_err("UTD must be rejected as UTT input");
948 assert!(matches!(
949 err,
950 UttError::UnsupportedFileType(file_type) if file_type == *b"UTD "
951 ));
952 }
953
954 #[test]
955 fn type_mismatch_on_localized_name_is_error() {
956 let mut gff = read_gff_from_bytes(TEST_UTT).expect("fixture must parse");
957 gff.root
958 .fields
959 .retain(|field| field.label != "LocalizedName");
960 gff.root.push_field("LocalizedName", GffValue::UInt32(5));
961
962 let err = Utt::from_gff(&gff).expect_err("type mismatch must be rejected");
963 assert!(matches!(
964 err,
965 UttError::TypeMismatch {
966 field: "LocalizedName",
967 expected: "LocalizedString",
968 }
969 ));
970 }
971
972 #[test]
973 fn write_utt_matches_direct_gff_writer() {
974 let utt = read_utt_from_bytes(TEST_UTT).expect("fixture must parse");
975
976 let via_typed = write_utt_to_vec(&utt).expect("typed write succeeds");
977
978 let mut direct = Cursor::new(Vec::new());
979 write_gff(&mut direct, &utt.to_gff()).expect("direct write succeeds");
980
981 assert_eq!(via_typed, direct.into_inner());
982 }
983
984 #[test]
985 fn schema_field_count() {
986 assert_eq!(Utt::schema().len(), 42);
987 }
988
989 #[test]
990 fn schema_no_duplicate_labels() {
991 let schema = Utt::schema();
992 let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
993 labels.sort();
994 let before = labels.len();
995 labels.dedup();
996 assert_eq!(before, labels.len(), "duplicate labels in UTT schema");
997 }
998
999 #[test]
1000 fn schema_geometry_has_children() {
1001 let geometry = Utt::schema()
1002 .iter()
1003 .find(|f| f.label == "Geometry")
1004 .expect("test fixture must be valid");
1005 assert!(geometry.children.is_some());
1006 assert_eq!(
1007 geometry.children.expect("test fixture must be valid").len(),
1008 3
1009 );
1010 }
1011}