1use std::io::{Cursor, Read, Write};
21
22use crate::gff_helpers::{
23 get_bool, get_f32, get_i16, get_i8, get_locstring, get_resref, get_string, get_u16, get_u32,
24 get_u8, upsert_field,
25};
26use crate::shared::{CommonTrapScripts, TrapSettings};
27use rakata_core::{ResRef, StrRef};
28use rakata_formats::{
29 gff_schema::{FieldConstraint, FieldSchema, GffSchema, GffType},
30 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
31 GffValue,
32};
33use thiserror::Error;
34
35#[derive(Debug, Clone, PartialEq)]
37pub struct Utd {
38 pub template_resref: ResRef,
40 pub tag: String,
42 pub name: GffLocalizedString,
44 pub description: GffLocalizedString,
46 pub comment: String,
48 pub conversation: ResRef,
50 pub faction_id: u32,
52 pub appearance_id: u8,
54 pub unused_appearance_id: u32,
56 pub open_state: u8,
58 pub animation_state: u8,
60 pub auto_remove_key: bool,
62 pub bearing: f32,
64 pub key_name: String,
66 pub key_required: bool,
68 pub lockable: bool,
70 pub locked: bool,
72 pub open_lock_dc: u8,
74 pub close_lock_dc: u8,
76 pub secret_door_dc: u8,
78 pub open_lock_diff: u8,
80 pub open_lock_diff_mod: i8,
82 pub current_hp: i16,
84 pub maximum_hp: i16,
86 pub hardness: u8,
88 pub fortitude: u8,
90 pub reflex: u8,
92 pub will: u8,
94 pub plot: bool,
96 pub invulnerable: bool,
98 pub min1_hp: bool,
100 pub is_static: bool,
102 pub not_blastable: bool,
104 pub interruptable: bool,
106 pub portrait_id: u16,
108 pub palette_id: u8,
110 pub trap_detectable: bool,
112 pub trap_detect_dc: u8,
114 pub trap_disarmable: bool,
116 pub trap_disarm_dc: u8,
118 pub trap_flag: u8,
120 pub trap_one_shot: bool,
122 pub trap_type: u8,
124 pub on_closed: ResRef,
126 pub on_damaged: ResRef,
128 pub on_death: ResRef,
130 pub on_disarm: ResRef,
132 pub on_heartbeat: ResRef,
134 pub on_lock: ResRef,
136 pub on_melee_attacked: ResRef,
138 pub on_open: ResRef,
140 pub on_spell_cast_at: ResRef,
142 pub on_trap_triggered: ResRef,
144 pub on_unlock: ResRef,
146 pub on_user_defined: ResRef,
148 pub on_click: ResRef,
150 pub on_fail_to_open: ResRef,
152 pub on_dialog: ResRef,
154 pub linked_to_flags: u8,
156 pub linked_to: String,
158 pub linked_to_module: ResRef,
160 pub transition_destination: GffLocalizedString,
162 pub loadscreen_id: u16,
164}
165
166impl Default for Utd {
167 fn default() -> Self {
168 Self {
169 template_resref: ResRef::blank(),
170 tag: String::new(),
171 name: GffLocalizedString::new(StrRef::invalid()),
172 description: GffLocalizedString::new(StrRef::invalid()),
173 comment: String::new(),
174 conversation: ResRef::blank(),
175 faction_id: 0,
176 appearance_id: 0,
177 unused_appearance_id: 0,
178 open_state: 0,
179 animation_state: 0,
180 auto_remove_key: false,
181 bearing: 0.0,
182 key_name: String::new(),
183 key_required: false,
184 lockable: false,
185 locked: false,
186 open_lock_dc: 0,
187 close_lock_dc: 0,
188 secret_door_dc: 0,
189 open_lock_diff: 0,
190 open_lock_diff_mod: 0,
191 current_hp: 0,
192 maximum_hp: 0,
193 hardness: 0,
194 fortitude: 0,
195 reflex: 0,
196 will: 0,
197 plot: false,
198 invulnerable: false,
199 min1_hp: false,
200 is_static: false,
201 not_blastable: false,
202 interruptable: false,
203 portrait_id: 0,
204 palette_id: 0,
205 trap_detectable: false,
206 trap_detect_dc: 0,
207 trap_disarmable: false,
208 trap_disarm_dc: 0,
209 trap_flag: 0,
210 trap_one_shot: false,
211 trap_type: 0,
212 on_closed: ResRef::blank(),
213 on_damaged: ResRef::blank(),
214 on_death: ResRef::blank(),
215 on_disarm: ResRef::blank(),
216 on_heartbeat: ResRef::blank(),
217 on_lock: ResRef::blank(),
218 on_melee_attacked: ResRef::blank(),
219 on_open: ResRef::blank(),
220 on_spell_cast_at: ResRef::blank(),
221 on_trap_triggered: ResRef::blank(),
222 on_unlock: ResRef::blank(),
223 on_user_defined: ResRef::blank(),
224 on_click: ResRef::blank(),
225 on_fail_to_open: ResRef::blank(),
226 on_dialog: ResRef::blank(),
227 linked_to_flags: 0,
228 linked_to: String::new(),
229 linked_to_module: ResRef::blank(),
230 transition_destination: GffLocalizedString::new(StrRef::invalid()),
231 loadscreen_id: 0,
232 }
233 }
234}
235
236impl Utd {
237 pub fn new() -> Self {
239 Self::default()
240 }
241
242 pub fn trap_settings(&self) -> TrapSettings {
244 TrapSettings {
245 detectable: self.trap_detectable,
246 detect_dc: self.trap_detect_dc,
247 disarmable: self.trap_disarmable,
248 disarm_dc: self.trap_disarm_dc,
249 flag: self.trap_flag,
250 one_shot: self.trap_one_shot,
251 trap_type: self.trap_type,
252 }
253 }
254
255 pub fn set_trap_settings(&mut self, trap: TrapSettings) {
257 self.trap_detectable = trap.detectable;
258 self.trap_detect_dc = trap.detect_dc;
259 self.trap_disarmable = trap.disarmable;
260 self.trap_disarm_dc = trap.disarm_dc;
261 self.trap_flag = trap.flag;
262 self.trap_one_shot = trap.one_shot;
263 self.trap_type = trap.trap_type;
264 }
265
266 pub fn common_trap_scripts(&self) -> CommonTrapScripts {
268 CommonTrapScripts {
269 on_closed: self.on_closed,
270 on_damaged: self.on_damaged,
271 on_death: self.on_death,
272 on_disarm: self.on_disarm,
273 on_heartbeat: self.on_heartbeat,
274 on_lock: self.on_lock,
275 on_melee_attacked: self.on_melee_attacked,
276 on_open: self.on_open,
277 on_spell_cast_at: self.on_spell_cast_at,
278 on_trap_triggered: self.on_trap_triggered,
279 on_unlock: self.on_unlock,
280 on_user_defined: self.on_user_defined,
281 on_fail_to_open: self.on_fail_to_open,
282 }
283 }
284
285 pub fn set_common_trap_scripts(&mut self, scripts: CommonTrapScripts) {
287 self.on_closed = scripts.on_closed;
288 self.on_damaged = scripts.on_damaged;
289 self.on_death = scripts.on_death;
290 self.on_disarm = scripts.on_disarm;
291 self.on_heartbeat = scripts.on_heartbeat;
292 self.on_lock = scripts.on_lock;
293 self.on_melee_attacked = scripts.on_melee_attacked;
294 self.on_open = scripts.on_open;
295 self.on_spell_cast_at = scripts.on_spell_cast_at;
296 self.on_trap_triggered = scripts.on_trap_triggered;
297 self.on_unlock = scripts.on_unlock;
298 self.on_user_defined = scripts.on_user_defined;
299 self.on_fail_to_open = scripts.on_fail_to_open;
300 }
301
302 pub fn from_gff(gff: &Gff) -> Result<Self, UtdError> {
304 if gff.file_type != *b"UTD " && gff.file_type != *b"GFF " {
305 return Err(UtdError::UnsupportedFileType(gff.file_type));
306 }
307
308 let root = &gff.root;
309
310 if matches!(root.field("TransitionDestination"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
311 {
312 return Err(UtdError::TypeMismatch {
313 field: "TransitionDestination",
314 expected: "LocalizedString",
315 });
316 }
317 if matches!(root.field("TransDest"), Some(value) if !matches!(value, GffValue::LocalizedString(_)))
318 {
319 return Err(UtdError::TypeMismatch {
320 field: "TransDest",
321 expected: "LocalizedString",
322 });
323 }
324
325 let plot = get_bool(root, "Plot").unwrap_or(false);
326 let invulnerable = get_bool(root, "Invulnerable").unwrap_or(plot);
328
329 let trap = TrapSettings::read(|label| get_bool(root, label), |label| get_u8(root, label));
330 let common_scripts = CommonTrapScripts::read(|label| get_resref(root, label));
331
332 Ok(Self {
333 template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
334 tag: get_string(root, "Tag").unwrap_or_default(),
335 name: get_locstring(root, "LocName")
336 .cloned()
337 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
338 description: get_locstring(root, "Description")
339 .cloned()
340 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
341 comment: get_string(root, "Comment").unwrap_or_default(),
342 conversation: get_resref(root, "Conversation").unwrap_or_default(),
343 faction_id: get_u32(root, "Faction").unwrap_or(0),
344 appearance_id: get_u8(root, "GenericType").unwrap_or(0),
345 unused_appearance_id: get_u32(root, "Appearance").unwrap_or(0),
346 open_state: get_u8(root, "OpenState").unwrap_or(0),
347 animation_state: get_u8(root, "AnimationState").unwrap_or(0),
348 auto_remove_key: get_bool(root, "AutoRemoveKey").unwrap_or(false),
349 bearing: get_f32(root, "Bearing").unwrap_or(0.0),
350 key_name: get_string(root, "KeyName").unwrap_or_default(),
351 key_required: get_bool(root, "KeyRequired").unwrap_or(false),
352 lockable: get_bool(root, "Lockable").unwrap_or(false),
353 locked: get_bool(root, "Locked").unwrap_or(false),
354 open_lock_dc: get_u8(root, "OpenLockDC").unwrap_or(0),
355 close_lock_dc: get_u8(root, "CloseLockDC").unwrap_or(0),
356 secret_door_dc: get_u8(root, "SecretDoorDC").unwrap_or(0),
357 open_lock_diff: get_u8(root, "OpenLockDiff").unwrap_or(0),
358 open_lock_diff_mod: get_i8(root, "OpenLockDiffMod").unwrap_or(0),
359 current_hp: get_i16(root, "CurrentHP").unwrap_or(0),
360 maximum_hp: get_i16(root, "HP").unwrap_or(0),
361 hardness: get_u8(root, "Hardness").unwrap_or(0),
362 fortitude: get_u8(root, "Fort").unwrap_or(0),
363 reflex: get_u8(root, "Ref").unwrap_or(0),
364 will: get_u8(root, "Will").unwrap_or(0),
365 plot,
366 invulnerable,
367 min1_hp: get_bool(root, "Min1HP").unwrap_or(false),
368 is_static: get_bool(root, "Static").unwrap_or(false),
369 not_blastable: get_bool(root, "NotBlastable").unwrap_or(false),
370 interruptable: get_bool(root, "Interruptable").unwrap_or(false),
371 portrait_id: get_u16(root, "PortraitId").unwrap_or(0),
372 palette_id: get_u8(root, "PaletteID").unwrap_or(0),
373 trap_detectable: trap.detectable,
374 trap_detect_dc: trap.detect_dc,
375 trap_disarmable: trap.disarmable,
376 trap_disarm_dc: trap.disarm_dc,
377 trap_flag: trap.flag,
378 trap_one_shot: trap.one_shot,
379 trap_type: trap.trap_type,
380 on_closed: common_scripts.on_closed,
381 on_damaged: common_scripts.on_damaged,
382 on_death: common_scripts.on_death,
383 on_disarm: common_scripts.on_disarm,
384 on_heartbeat: common_scripts.on_heartbeat,
385 on_lock: common_scripts.on_lock,
386 on_melee_attacked: common_scripts.on_melee_attacked,
387 on_open: common_scripts.on_open,
388 on_spell_cast_at: common_scripts.on_spell_cast_at,
389 on_trap_triggered: common_scripts.on_trap_triggered,
390 on_unlock: common_scripts.on_unlock,
391 on_user_defined: common_scripts.on_user_defined,
392 on_click: get_resref(root, "OnClick").unwrap_or_default(),
393 on_fail_to_open: common_scripts.on_fail_to_open,
394 on_dialog: get_resref(root, "OnDialog").unwrap_or_default(),
395 linked_to_flags: get_u8(root, "LinkedToFlags").unwrap_or(0),
396 linked_to: get_string(root, "LinkedTo").unwrap_or_default(),
397 linked_to_module: get_resref(root, "LinkedToModule").unwrap_or_default(),
398 transition_destination: get_locstring(root, "TransitionDestination")
399 .or_else(|| get_locstring(root, "TransDest"))
400 .cloned()
401 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
402 loadscreen_id: get_u16(root, "LoadScreenID").unwrap_or(0),
403 })
404 }
405
406 pub fn to_gff(&self) -> Gff {
408 let mut root = GffStruct::new(-1);
409
410 upsert_field(
411 &mut root,
412 "TemplateResRef",
413 GffValue::ResRef(self.template_resref),
414 );
415 upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
416 upsert_field(
417 &mut root,
418 "LocName",
419 GffValue::LocalizedString(self.name.clone()),
420 );
421 upsert_field(
422 &mut root,
423 "Description",
424 GffValue::LocalizedString(self.description.clone()),
425 );
426 upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
427 upsert_field(
428 &mut root,
429 "Conversation",
430 GffValue::ResRef(self.conversation),
431 );
432
433 upsert_field(&mut root, "Faction", GffValue::UInt32(self.faction_id));
434 upsert_field(
435 &mut root,
436 "GenericType",
437 GffValue::UInt8(self.appearance_id),
438 );
439 upsert_field(
440 &mut root,
441 "Appearance",
442 GffValue::UInt32(self.unused_appearance_id),
443 );
444 upsert_field(&mut root, "OpenState", GffValue::UInt8(self.open_state));
445 upsert_field(
446 &mut root,
447 "AnimationState",
448 GffValue::UInt8(self.animation_state),
449 );
450 upsert_field(
451 &mut root,
452 "AutoRemoveKey",
453 GffValue::UInt8(u8::from(self.auto_remove_key)),
454 );
455 upsert_field(&mut root, "Bearing", GffValue::Single(self.bearing));
456
457 upsert_field(
458 &mut root,
459 "KeyName",
460 GffValue::String(self.key_name.clone()),
461 );
462 upsert_field(
463 &mut root,
464 "KeyRequired",
465 GffValue::UInt8(u8::from(self.key_required)),
466 );
467 upsert_field(
468 &mut root,
469 "Lockable",
470 GffValue::UInt8(u8::from(self.lockable)),
471 );
472 upsert_field(&mut root, "Locked", GffValue::UInt8(u8::from(self.locked)));
473 upsert_field(&mut root, "OpenLockDC", GffValue::UInt8(self.open_lock_dc));
474 upsert_field(
475 &mut root,
476 "CloseLockDC",
477 GffValue::UInt8(self.close_lock_dc),
478 );
479 upsert_field(
480 &mut root,
481 "SecretDoorDC",
482 GffValue::UInt8(self.secret_door_dc),
483 );
484 upsert_field(
485 &mut root,
486 "OpenLockDiff",
487 GffValue::UInt8(self.open_lock_diff),
488 );
489 upsert_field(
490 &mut root,
491 "OpenLockDiffMod",
492 GffValue::Int8(self.open_lock_diff_mod),
493 );
494
495 upsert_field(&mut root, "CurrentHP", GffValue::Int16(self.current_hp));
496 upsert_field(&mut root, "HP", GffValue::Int16(self.maximum_hp));
497 upsert_field(&mut root, "Hardness", GffValue::UInt8(self.hardness));
498 upsert_field(&mut root, "Fort", GffValue::UInt8(self.fortitude));
499 upsert_field(&mut root, "Ref", GffValue::UInt8(self.reflex));
500 upsert_field(&mut root, "Will", GffValue::UInt8(self.will));
501
502 upsert_field(&mut root, "Plot", GffValue::UInt8(u8::from(self.plot)));
503 upsert_field(
504 &mut root,
505 "Invulnerable",
506 GffValue::UInt8(u8::from(self.invulnerable)),
507 );
508 upsert_field(&mut root, "Min1HP", GffValue::UInt8(u8::from(self.min1_hp)));
509 upsert_field(
510 &mut root,
511 "Static",
512 GffValue::UInt8(u8::from(self.is_static)),
513 );
514 upsert_field(
515 &mut root,
516 "NotBlastable",
517 GffValue::UInt8(u8::from(self.not_blastable)),
518 );
519 upsert_field(
520 &mut root,
521 "Interruptable",
522 GffValue::UInt8(u8::from(self.interruptable)),
523 );
524 upsert_field(&mut root, "PortraitId", GffValue::UInt16(self.portrait_id));
525 upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
526
527 self.trap_settings()
528 .write(|label, value| upsert_field(&mut root, label, value));
529 self.common_trap_scripts()
530 .write(|label, value| upsert_field(&mut root, label, value));
531 upsert_field(&mut root, "OnClick", GffValue::ResRef(self.on_click));
532 upsert_field(
533 &mut root,
534 "OnFailToOpen",
535 GffValue::ResRef(self.on_fail_to_open),
536 );
537 upsert_field(&mut root, "OnDialog", GffValue::ResRef(self.on_dialog));
538
539 upsert_field(
540 &mut root,
541 "LinkedToFlags",
542 GffValue::UInt8(self.linked_to_flags),
543 );
544 upsert_field(
545 &mut root,
546 "LinkedTo",
547 GffValue::String(self.linked_to.clone()),
548 );
549 upsert_field(
550 &mut root,
551 "LinkedToModule",
552 GffValue::ResRef(self.linked_to_module),
553 );
554 if root.field("TransitionDestination").is_some() {
558 upsert_field(
559 &mut root,
560 "TransitionDestination",
561 GffValue::LocalizedString(self.transition_destination.clone()),
562 );
563 } else if root.field("TransDest").is_some() {
564 upsert_field(
565 &mut root,
566 "TransDest",
567 GffValue::LocalizedString(self.transition_destination.clone()),
568 );
569 }
570 upsert_field(
571 &mut root,
572 "LoadScreenID",
573 GffValue::UInt16(self.loadscreen_id),
574 );
575
576 Gff::new(*b"UTD ", root)
577 }
578}
579
580#[derive(Debug, Error)]
582pub enum UtdError {
583 #[error("unsupported UTD file type: {0:?}")]
585 UnsupportedFileType([u8; 4]),
586 #[error("UTD field `{field}` has incompatible type (expected {expected})")]
588 TypeMismatch {
589 field: &'static str,
591 expected: &'static str,
593 },
594 #[error(transparent)]
596 Gff(#[from] GffBinaryError),
597}
598
599#[cfg_attr(
601 feature = "tracing",
602 tracing::instrument(level = "debug", skip(reader))
603)]
604pub fn read_utd<R: Read>(reader: &mut R) -> Result<Utd, UtdError> {
605 let gff = read_gff(reader)?;
606 Utd::from_gff(&gff)
607}
608
609#[cfg_attr(
611 feature = "tracing",
612 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
613)]
614pub fn read_utd_from_bytes(bytes: &[u8]) -> Result<Utd, UtdError> {
615 let gff = read_gff_from_bytes(bytes)?;
616 Utd::from_gff(&gff)
617}
618
619#[cfg_attr(
621 feature = "tracing",
622 tracing::instrument(level = "debug", skip(writer, utd))
623)]
624pub fn write_utd<W: Write>(writer: &mut W, utd: &Utd) -> Result<(), UtdError> {
625 let gff = utd.to_gff();
626 write_gff(writer, &gff)?;
627 Ok(())
628}
629
630#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utd)))]
632pub fn write_utd_to_vec(utd: &Utd) -> Result<Vec<u8>, UtdError> {
633 let mut cursor = Cursor::new(Vec::new());
634 write_utd(&mut cursor, utd)?;
635 Ok(cursor.into_inner())
636}
637
638impl GffSchema for Utd {
639 fn schema() -> &'static [FieldSchema] {
640 static SCHEMA: &[FieldSchema] = &[
641 FieldSchema {
643 label: "Tag",
644 expected_type: GffType::String,
645 required: false,
646 children: None,
647 constraint: None,
648 },
649 FieldSchema {
650 label: "LocName",
651 expected_type: GffType::LocalizedString,
652 required: false,
653 children: None,
654 constraint: None,
655 },
656 FieldSchema {
657 label: "Description",
658 expected_type: GffType::LocalizedString,
659 required: false,
660 children: None,
661 constraint: None,
662 },
663 FieldSchema {
664 label: "Conversation",
665 expected_type: GffType::ResRef,
666 required: false,
667 children: None,
668 constraint: None,
669 },
670 FieldSchema {
671 label: "Faction",
672 expected_type: GffType::UInt32,
673 required: false,
674 children: None,
675 constraint: None,
676 },
677 FieldSchema {
679 label: "Appearance",
680 expected_type: GffType::UInt32,
681 required: false,
682 children: None,
683 constraint: Some(FieldConstraint::RangeInt(0, 255)),
684 },
685 FieldSchema {
686 label: "GenericType",
687 expected_type: GffType::UInt8,
688 required: false,
689 children: None,
690 constraint: None,
691 },
692 FieldSchema {
693 label: "OpenState",
694 expected_type: GffType::UInt8,
695 required: false,
696 children: None,
697 constraint: None,
698 },
699 FieldSchema {
701 label: "HP",
702 expected_type: GffType::Int16,
703 required: false,
704 children: None,
705 constraint: None,
706 },
707 FieldSchema {
708 label: "CurrentHP",
709 expected_type: GffType::Int16,
710 required: false,
711 children: None,
712 constraint: None,
713 },
714 FieldSchema {
715 label: "Hardness",
716 expected_type: GffType::UInt8,
717 required: false,
718 children: None,
719 constraint: None,
720 },
721 FieldSchema {
722 label: "Fort",
723 expected_type: GffType::UInt8,
724 required: false,
725 children: None,
726 constraint: None,
727 },
728 FieldSchema {
729 label: "Ref",
730 expected_type: GffType::UInt8,
731 required: false,
732 children: None,
733 constraint: None,
734 },
735 FieldSchema {
736 label: "Will",
737 expected_type: GffType::UInt8,
738 required: false,
739 children: None,
740 constraint: None,
741 },
742 FieldSchema {
744 label: "Plot",
745 expected_type: GffType::UInt8,
746 required: false,
747 children: None,
748 constraint: None,
749 },
750 FieldSchema {
751 label: "Static",
752 expected_type: GffType::UInt8,
753 required: false,
754 children: None,
755 constraint: None,
756 },
757 FieldSchema {
758 label: "Invulnerable",
759 expected_type: GffType::UInt8,
760 required: false,
761 children: None,
762 constraint: None,
763 },
764 FieldSchema {
765 label: "Min1HP",
766 expected_type: GffType::UInt8,
767 required: false,
768 children: None,
769 constraint: None,
770 },
771 FieldSchema {
773 label: "Locked",
774 expected_type: GffType::UInt8,
775 required: false,
776 children: None,
777 constraint: None,
778 },
779 FieldSchema {
780 label: "Lockable",
781 expected_type: GffType::UInt8,
782 required: false,
783 children: None,
784 constraint: None,
785 },
786 FieldSchema {
787 label: "OpenLockDC",
788 expected_type: GffType::UInt8,
789 required: false,
790 children: None,
791 constraint: None,
792 },
793 FieldSchema {
794 label: "CloseLockDC",
795 expected_type: GffType::UInt8,
796 required: false,
797 children: None,
798 constraint: None,
799 },
800 FieldSchema {
801 label: "SecretDoorDC",
802 expected_type: GffType::UInt8,
803 required: false,
804 children: None,
805 constraint: None,
806 },
807 FieldSchema {
808 label: "KeyName",
809 expected_type: GffType::String,
810 required: false,
811 children: None,
812 constraint: None,
813 },
814 FieldSchema {
815 label: "KeyRequired",
816 expected_type: GffType::UInt8,
817 required: false,
818 children: None,
819 constraint: None,
820 },
821 FieldSchema {
822 label: "AutoRemoveKey",
823 expected_type: GffType::UInt8,
824 required: false,
825 children: None,
826 constraint: None,
827 },
828 FieldSchema {
830 label: "Bearing",
831 expected_type: GffType::Single,
832 required: false,
833 children: None,
834 constraint: None,
835 },
836 FieldSchema {
838 label: "PortraitId",
839 expected_type: GffType::UInt16,
840 required: false,
841 children: None,
842 constraint: None,
843 },
844 FieldSchema {
845 label: "Portrait",
846 expected_type: GffType::ResRef,
847 required: false,
848 children: None,
849 constraint: None,
850 },
851 FieldSchema {
853 label: "LinkedToFlags",
854 expected_type: GffType::UInt8,
855 required: false,
856 children: None,
857 constraint: None,
858 },
859 FieldSchema {
860 label: "LinkedTo",
861 expected_type: GffType::String,
862 required: false,
863 children: None,
864 constraint: None,
865 },
866 FieldSchema {
867 label: "LinkedToModule",
868 expected_type: GffType::ResRef,
869 required: false,
870 children: None,
871 constraint: None,
872 },
873 FieldSchema {
874 label: "TransitionDestination",
875 expected_type: GffType::LocalizedString,
876 required: false,
877 children: None,
878 constraint: None,
879 },
880 FieldSchema {
881 label: "LoadScreenID",
882 expected_type: GffType::UInt16,
883 required: false,
884 children: None,
885 constraint: None,
886 },
887 FieldSchema {
889 label: "TrapType",
890 expected_type: GffType::UInt8,
891 required: false,
892 children: None,
893 constraint: None,
894 },
895 FieldSchema {
896 label: "TrapDisarmable",
897 expected_type: GffType::UInt8,
898 required: false,
899 children: None,
900 constraint: None,
901 },
902 FieldSchema {
903 label: "TrapDetectable",
904 expected_type: GffType::UInt8,
905 required: false,
906 children: None,
907 constraint: None,
908 },
909 FieldSchema {
910 label: "DisarmDC",
911 expected_type: GffType::UInt8,
912 required: false,
913 children: None,
914 constraint: None,
915 },
916 FieldSchema {
917 label: "TrapDetectDC",
918 expected_type: GffType::UInt8,
919 required: false,
920 children: None,
921 constraint: None,
922 },
923 FieldSchema {
924 label: "TrapFlag",
925 expected_type: GffType::UInt8,
926 required: false,
927 children: None,
928 constraint: None,
929 },
930 FieldSchema {
931 label: "TrapOneShot",
932 expected_type: GffType::UInt8,
933 required: false,
934 children: None,
935 constraint: None,
936 },
937 FieldSchema {
939 label: "OnClosed",
940 expected_type: GffType::ResRef,
941 required: false,
942 children: None,
943 constraint: None,
944 },
945 FieldSchema {
946 label: "OnDamaged",
947 expected_type: GffType::ResRef,
948 required: false,
949 children: None,
950 constraint: None,
951 },
952 FieldSchema {
953 label: "OnDeath",
954 expected_type: GffType::ResRef,
955 required: false,
956 children: None,
957 constraint: None,
958 },
959 FieldSchema {
960 label: "OnDisarm",
961 expected_type: GffType::ResRef,
962 required: false,
963 children: None,
964 constraint: None,
965 },
966 FieldSchema {
967 label: "OnHeartbeat",
968 expected_type: GffType::ResRef,
969 required: false,
970 children: None,
971 constraint: None,
972 },
973 FieldSchema {
974 label: "OnLock",
975 expected_type: GffType::ResRef,
976 required: false,
977 children: None,
978 constraint: None,
979 },
980 FieldSchema {
981 label: "OnMeleeAttacked",
982 expected_type: GffType::ResRef,
983 required: false,
984 children: None,
985 constraint: None,
986 },
987 FieldSchema {
988 label: "OnOpen",
989 expected_type: GffType::ResRef,
990 required: false,
991 children: None,
992 constraint: None,
993 },
994 FieldSchema {
995 label: "OnSpellCastAt",
996 expected_type: GffType::ResRef,
997 required: false,
998 children: None,
999 constraint: None,
1000 },
1001 FieldSchema {
1002 label: "OnTrapTriggered",
1003 expected_type: GffType::ResRef,
1004 required: false,
1005 children: None,
1006 constraint: None,
1007 },
1008 FieldSchema {
1009 label: "OnUnlock",
1010 expected_type: GffType::ResRef,
1011 required: false,
1012 children: None,
1013 constraint: None,
1014 },
1015 FieldSchema {
1016 label: "OnUserDefined",
1017 expected_type: GffType::ResRef,
1018 required: false,
1019 children: None,
1020 constraint: None,
1021 },
1022 FieldSchema {
1023 label: "OnClick",
1024 expected_type: GffType::ResRef,
1025 required: false,
1026 children: None,
1027 constraint: None,
1028 },
1029 FieldSchema {
1030 label: "OnFailToOpen",
1031 expected_type: GffType::ResRef,
1032 required: false,
1033 children: None,
1034 constraint: None,
1035 },
1036 FieldSchema {
1037 label: "OnDialog",
1038 expected_type: GffType::ResRef,
1039 required: false,
1040 children: None,
1041 constraint: None,
1042 },
1043 FieldSchema {
1045 label: "TemplateResRef",
1046 expected_type: GffType::ResRef,
1047 required: false,
1048 children: None,
1049 constraint: None,
1050 },
1051 FieldSchema {
1052 label: "Comment",
1053 expected_type: GffType::String,
1054 required: false,
1055 children: None,
1056 constraint: None,
1057 },
1058 FieldSchema {
1059 label: "PaletteID",
1060 expected_type: GffType::UInt8,
1061 required: false,
1062 children: None,
1063 constraint: None,
1064 },
1065 FieldSchema {
1066 label: "AnimationState",
1067 expected_type: GffType::UInt8,
1068 required: false,
1069 children: None,
1070 constraint: None,
1071 },
1072 FieldSchema {
1073 label: "OpenLockDiff",
1074 expected_type: GffType::UInt8,
1075 required: false,
1076 children: None,
1077 constraint: None,
1078 },
1079 FieldSchema {
1080 label: "OpenLockDiffMod",
1081 expected_type: GffType::Int8,
1082 required: false,
1083 children: None,
1084 constraint: None,
1085 },
1086 FieldSchema {
1087 label: "NotBlastable",
1088 expected_type: GffType::UInt8,
1089 required: false,
1090 children: None,
1091 constraint: None,
1092 },
1093 FieldSchema {
1094 label: "Interruptable",
1095 expected_type: GffType::UInt8,
1096 required: false,
1097 children: None,
1098 constraint: None,
1099 },
1100 ];
1101 SCHEMA
1102 }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108
1109 const TEST_UTD: &[u8] = include_bytes!(concat!(
1110 env!("CARGO_MANIFEST_DIR"),
1111 "/../../fixtures/test.utd"
1112 ));
1113
1114 #[test]
1115 fn reads_core_utd_fields_from_fixture() {
1116 let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1117
1118 assert_eq!(utd.tag, "TelosDoor13");
1119 assert_eq!(utd.template_resref, "door_tel014");
1120 assert_eq!(utd.name.string_ref.raw(), 123_731);
1121 assert_eq!(utd.description.string_ref.raw(), -1);
1122 assert!(utd.auto_remove_key);
1123 assert_eq!(utd.close_lock_dc, 0);
1124 assert_eq!(utd.conversation, "convoresref");
1125 assert!(utd.interruptable);
1126 assert_eq!(utd.faction_id, 1);
1127 assert!(utd.plot);
1128 assert!(utd.not_blastable);
1129 assert!(utd.min1_hp);
1130 assert!(utd.key_required);
1131 assert!(utd.lockable);
1132 assert!(utd.locked);
1133 assert_eq!(utd.open_lock_dc, 28);
1134 assert_eq!(utd.open_lock_diff, 1);
1135 assert_eq!(utd.open_lock_diff_mod, 1);
1136 assert_eq!(utd.portrait_id, 0);
1137 assert!(utd.trap_detectable);
1138 assert_eq!(utd.trap_detect_dc, 0);
1139 assert!(utd.trap_disarmable);
1140 assert_eq!(utd.trap_disarm_dc, 28);
1141 assert_eq!(utd.trap_flag, 0);
1142 assert!(utd.trap_one_shot);
1143 assert_eq!(utd.trap_type, 2);
1144 assert_eq!(utd.key_name, "keyname");
1145 assert_eq!(utd.animation_state, 1);
1146 assert_eq!(utd.unused_appearance_id, 1);
1147 assert_eq!(utd.maximum_hp, 20);
1148 assert_eq!(utd.current_hp, 60);
1149 assert_eq!(utd.hardness, 5);
1150 assert_eq!(utd.fortitude, 28);
1151 assert_eq!(utd.reflex, 0);
1152 assert_eq!(utd.will, 0);
1153 assert_eq!(utd.on_closed, "onclosed");
1154 assert_eq!(utd.on_damaged, "ondamaged");
1155 assert_eq!(utd.on_death, "ondeath");
1156 assert_eq!(utd.on_disarm, "ondisarm");
1157 assert_eq!(utd.on_heartbeat, "onheartbeat");
1158 assert_eq!(utd.on_lock, "onlock");
1159 assert_eq!(utd.on_melee_attacked, "onmeleeattacked");
1160 assert_eq!(utd.on_open, "onopen");
1161 assert_eq!(utd.on_spell_cast_at, "onspellcastat");
1162 assert_eq!(utd.on_trap_triggered, "ontraptriggered");
1163 assert_eq!(utd.on_unlock, "onunlock");
1164 assert_eq!(utd.on_user_defined, "onuserdefined");
1165 assert_eq!(utd.loadscreen_id, 0);
1166 assert_eq!(utd.appearance_id, 110);
1167 assert!(utd.is_static);
1168 assert_eq!(utd.open_state, 1);
1169 assert_eq!(utd.on_click, "onclick");
1170 assert_eq!(utd.on_fail_to_open, "onfailtoopen");
1171 assert_eq!(utd.comment, "abcdefg");
1172 assert_eq!(utd.palette_id, 1);
1173 }
1174
1175 #[test]
1176 fn all_fields_survive_typed_roundtrip() {
1177 let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1178 let bytes = write_utd_to_vec(&utd).expect("write succeeds");
1179 let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
1180 assert_eq!(reparsed, utd);
1181 }
1182
1183 #[test]
1184 fn typed_edits_roundtrip_through_gff_writer() {
1185 let mut utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1186 utd.tag = "TelosDoor13_Rust".into();
1187 utd.open_state = 2;
1188 utd.on_open = ResRef::new("rust_on_open").expect("valid resref literal");
1189
1190 let bytes = write_utd_to_vec(&utd).expect("write succeeds");
1191 let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
1192
1193 assert_eq!(reparsed.tag, "TelosDoor13_Rust");
1194 assert_eq!(reparsed.open_state, 2);
1195 assert_eq!(reparsed.on_open, "rust_on_open");
1196 }
1197
1198 #[test]
1199 fn read_utd_from_reader_matches_bytes_path() {
1200 let mut cursor = Cursor::new(TEST_UTD);
1201 let from_reader = read_utd(&mut cursor).expect("reader parse succeeds");
1202 let from_bytes = read_utd_from_bytes(TEST_UTD).expect("bytes parse succeeds");
1203
1204 assert_eq!(from_reader, from_bytes);
1205 }
1206
1207 #[test]
1208 fn rejects_non_utd_file_type() {
1209 let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
1210 gff.file_type = *b"UTC ";
1211
1212 let err = Utd::from_gff(&gff).expect_err("UTC must be rejected as UTD input");
1213 assert!(matches!(
1214 err,
1215 UtdError::UnsupportedFileType(file_type) if file_type == *b"UTC "
1216 ));
1217 }
1218
1219 #[test]
1220 fn type_mismatch_on_transition_destination_is_error() {
1221 let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
1222 gff.root.push_field("TransDest", GffValue::UInt32(99));
1223
1224 let err = Utd::from_gff(&gff).expect_err("type mismatch must be rejected");
1225 assert!(matches!(
1226 err,
1227 UtdError::TypeMismatch {
1228 field: "TransDest",
1229 expected: "LocalizedString",
1230 }
1231 ));
1232 }
1233
1234 #[test]
1235 fn write_utd_matches_direct_gff_writer() {
1236 let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
1237
1238 let via_typed = write_utd_to_vec(&utd).expect("typed write succeeds");
1239
1240 let mut direct = Cursor::new(Vec::new());
1241 write_gff(&mut direct, &utd.to_gff()).expect("direct write succeeds");
1242
1243 assert_eq!(via_typed, direct.into_inner());
1244 }
1245
1246 #[test]
1247 fn schema_field_count() {
1248 assert_eq!(Utd::schema().len(), 64); }
1250
1251 #[test]
1252 fn schema_no_duplicate_labels() {
1253 let schema = Utd::schema();
1254 let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1255 labels.sort();
1256 let before = labels.len();
1257 labels.dedup();
1258 assert_eq!(before, labels.len(), "duplicate labels in UTD schema");
1259 }
1260}