1use std::io::{Cursor, Read, Write};
35
36use crate::shared::{PORTRAIT_ID_USE_RESREF, SCRIPT_SLOT_SEED, TRAP_TYPE_ABSENT};
37use rakata_core::ResRef;
38use rakata_formats::gff::get_locstring;
39use rakata_formats::schema::FromGff;
40use rakata_formats::GENERIC_FILE_TYPE;
41use rakata_formats::{
42 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
43 GffStruct,
44};
45use thiserror::Error;
46
47#[derive(Debug, Clone, PartialEq, GffModel)]
49pub struct Utd {
50 #[gff(TemplateResRef, unexamined)]
52 pub template_resref: ResRef,
53 #[gff(Tag, constructed)]
55 pub tag: String,
56 #[gff(LocName, stamped)]
58 pub name: GffLocalizedString,
59 #[gff(Description, stamped)]
61 pub description: GffLocalizedString,
62 #[gff(Comment, unexamined)]
64 pub comment: String,
65 #[gff(Conversation, stamped)]
67 pub conversation: ResRef,
68 #[gff(Faction, constructed)]
70 pub faction_id: u32,
71 #[gff(GenericType, constructed)]
73 pub appearance_id: u8,
74 #[gff(Appearance, unexamined, range_int = (0, 255))]
76 pub unused_appearance_id: u32,
77 #[gff(OpenState, constructed, omit = audited_constant(575))]
79 pub open_state: u8,
80 #[gff(AnimationState, unexamined)]
82 pub animation_state: u8,
83 #[gff(Bearing, constructed, omit = audited_constant(575))]
85 pub bearing: f32,
86 #[gff(Lockable, unexamined)]
88 pub lockable: bool,
89 #[gff(Locked, constructed)]
91 pub locked: bool,
92 #[gff(KeyRequired, constructed)]
94 pub key_required: bool,
95 #[gff(KeyName, unexamined)]
97 pub key_name: String,
98 #[gff(AutoRemoveKey, constructed)]
100 pub auto_remove_key: bool,
101 #[gff(OpenLockDC, constructed)]
103 pub open_lock_dc: u8,
104 #[gff(CloseLockDC, constructed)]
106 pub close_lock_dc: u8,
107 #[gff(SecretDoorDC, constructed, omit = audited_constant(575))]
109 pub secret_door_dc: u8,
110 #[gff(
112 OpenLockDiff,
113 read_only_dead = "a full-text search of LoadDoor's decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them",
114 not_a_constant
115 )]
116 pub open_lock_diff: u8,
117 #[gff(
119 OpenLockDiffMod,
120 read_only_dead = "a full-text search of LoadDoor's decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them",
121 not_a_constant
122 )]
123 pub open_lock_diff_mod: i8,
124 #[gff(CurrentHP, unexamined)]
126 pub current_hp: i16,
127 #[gff(HP, constructed = 1)]
129 pub maximum_hp: i16,
130 #[gff(Hardness, stamped)]
132 pub hardness: u8,
133 #[gff(Fort, constructed)]
135 pub fortitude: u8,
136 #[gff(Ref, constructed)]
138 pub reflex: u8,
139 #[gff(Will, constructed)]
141 pub will: u8,
142 #[gff(Plot, constructed)]
144 pub plot: bool,
145 #[gff(Invulnerable, constructed, omit = audited_constant(575))]
147 pub invulnerable: bool,
148 #[gff(Min1HP, constructed)]
150 pub min1_hp: bool,
151 #[gff(Static, stamped)]
153 pub is_static: bool,
154 #[gff(
156 NotBlastable,
157 read_only_dead = "a full-text search of LoadDoor's decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them",
158 not_a_constant
159 )]
160 pub not_blastable: bool,
161 #[gff(Interruptable, unexamined)]
163 pub interruptable: bool,
164 #[gff(PortraitId, stamped = PORTRAIT_ID_USE_RESREF)]
166 pub portrait_id: u16,
167 #[gff(Portrait, stamped)]
169 pub portrait: ResRef,
170 #[gff(PaletteID, unexamined)]
172 pub palette_id: u8,
173 #[gff(TrapDetectable, constructed = true)]
175 pub trap_detectable: bool,
176 #[gff(TrapDetectDC, constructed)]
178 pub trap_detect_dc: u8,
179 #[gff(TrapDisarmable, constructed = true)]
181 pub trap_disarmable: bool,
182 #[gff(DisarmDC, constructed)]
184 pub trap_disarm_dc: u8,
185 #[gff(TrapFlag, constructed)]
187 pub trap_flag: u8,
188 #[gff(TrapOneShot, constructed = true)]
190 pub trap_one_shot: bool,
191 #[gff(TrapType, constructed = TRAP_TYPE_ABSENT)]
193 pub trap_type: u8,
194 #[gff(OnClosed, constructed = SCRIPT_SLOT_SEED)]
196 pub on_closed: ResRef,
197 #[gff(OnDamaged, constructed = SCRIPT_SLOT_SEED)]
199 pub on_damaged: ResRef,
200 #[gff(OnDeath, constructed = SCRIPT_SLOT_SEED)]
202 pub on_death: ResRef,
203 #[gff(OnDisarm, constructed = SCRIPT_SLOT_SEED)]
205 pub on_disarm: ResRef,
206 #[gff(OnHeartbeat, constructed = SCRIPT_SLOT_SEED)]
208 pub on_heartbeat: ResRef,
209 #[gff(OnLock, constructed = SCRIPT_SLOT_SEED)]
211 pub on_lock: ResRef,
212 #[gff(OnMeleeAttacked, constructed = SCRIPT_SLOT_SEED)]
214 pub on_melee_attacked: ResRef,
215 #[gff(OnOpen, constructed = SCRIPT_SLOT_SEED)]
217 pub on_open: ResRef,
218 #[gff(OnSpellCastAt, constructed = SCRIPT_SLOT_SEED)]
220 pub on_spell_cast_at: ResRef,
221 #[gff(OnTrapTriggered, constructed = SCRIPT_SLOT_SEED)]
223 pub on_trap_triggered: ResRef,
224 #[gff(OnUnlock, constructed = SCRIPT_SLOT_SEED)]
226 pub on_unlock: ResRef,
227 #[gff(OnUserDefined, constructed = SCRIPT_SLOT_SEED)]
229 pub on_user_defined: ResRef,
230 #[gff(OnClick, constructed = SCRIPT_SLOT_SEED)]
232 pub on_click: ResRef,
233 #[gff(OnFailToOpen, constructed = SCRIPT_SLOT_SEED)]
235 pub on_fail_to_open: ResRef,
236 #[gff(OnDialog, constructed = SCRIPT_SLOT_SEED, omit = audited_constant(575))]
238 pub on_dialog: ResRef,
239 #[gff(
241 LinkedToFlags,
242 read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
243 unexamined
244 )]
245 pub linked_to_flags: u8,
246 #[gff(
248 LinkedTo,
249 read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
250 unexamined
251 )]
252 pub linked_to: String,
253 #[gff(
255 LinkedToModule,
256 read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
257 unexamined
258 )]
259 pub linked_to_module: ResRef,
260 #[gff(
266 TransitionDestin,
267 manual_read,
268 read_only_dead = "LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch",
269 unexamined
270 )]
271 pub transition_destination: GffLocalizedString,
272 #[gff(LoadScreenID, stamped)]
274 pub loadscreen_id: u16,
275}
276
277impl Utd {
278 pub fn new() -> Self {
280 Self::default()
281 }
282
283 pub fn from_gff(gff: &Gff) -> Result<Self, UtdError> {
290 if gff.file_type != <Utd as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
291 return Err(UtdError::UnsupportedFileType(gff.file_type));
292 }
293
294 let root = &gff.root;
295 let mut utd = Self::read_declared(root);
296
297 if let Some(destination) = get_locstring(root, "TransitionDestin")
302 .or_else(|| get_locstring(root, "TransitionDestination"))
303 .or_else(|| get_locstring(root, "TransDest"))
304 {
305 utd.transition_destination = destination.clone();
306 }
307
308 Ok(utd)
309 }
310
311 pub fn to_gff(&self) -> Gff {
313 let mut root = GffStruct::new(-1);
314 self.write_declared(&mut root);
315 Gff::new(*b"UTD ", root)
316 }
317}
318
319#[derive(Debug, Error)]
321pub enum UtdError {
322 #[error("unsupported UTD file type: {0:?}")]
324 UnsupportedFileType([u8; 4]),
325 #[error(transparent)]
327 Gff(#[from] GffBinaryError),
328}
329
330#[cfg_attr(
338 feature = "tracing",
339 tracing::instrument(level = "debug", skip(reader))
340)]
341pub fn read_utd<R: Read>(reader: &mut R) -> Result<Utd, UtdError> {
342 let gff = read_gff(reader)?;
343 Utd::from_gff(&gff)
344}
345
346#[cfg_attr(
354 feature = "tracing",
355 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
356)]
357pub fn read_utd_from_bytes(bytes: &[u8]) -> Result<Utd, UtdError> {
358 let gff = read_gff_from_bytes(bytes)?;
359 Utd::from_gff(&gff)
360}
361
362#[cfg_attr(
370 feature = "tracing",
371 tracing::instrument(level = "debug", skip(writer, utd))
372)]
373pub fn author_utd<W: Write>(writer: &mut W, utd: &Utd) -> Result<(), UtdError> {
374 let gff = utd.to_gff();
375 write_gff(writer, &gff)?;
376 Ok(())
377}
378
379#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utd)))]
386pub fn author_utd_to_vec(utd: &Utd) -> Result<Vec<u8>, UtdError> {
387 let mut cursor = Cursor::new(Vec::new());
388 author_utd(&mut cursor, utd)?;
389 Ok(cursor.into_inner())
390}
391
392#[cfg(test)]
393mod tests {
394 #[test]
395 fn a_transition_destination_survives_a_round_trip() {
396 let mut root = GffStruct::new(-1);
403 root.push_field(
404 gff_label!("TransitionDestin"),
405 GffValue::LocalizedString(GffLocalizedString::new(StrRef::from_raw(1234))),
406 );
407
408 let utd = Utd::from_gff(&Gff::new(*b"UTD ", root)).expect("parses");
409 assert_eq!(
410 utd.transition_destination.string_ref,
411 StrRef::from_raw(1234)
412 );
413
414 let written = utd.to_gff();
415 assert!(written.root.field("TransitionDestin").is_some());
416
417 let reparsed = Utd::from_gff(&written).expect("reparses");
418 assert_eq!(reparsed.transition_destination, utd.transition_destination);
419 }
420
421 use super::*;
422 use rakata_core::StrRef;
423 use rakata_formats::schema::HasSchema;
424 use rakata_formats::{gff_label, GffValue};
425
426 const TEST_UTD: &[u8] = include_bytes!(concat!(
427 env!("CARGO_MANIFEST_DIR"),
428 "/../../fixtures/test.utd"
429 ));
430
431 #[test]
432 fn reads_core_utd_fields_from_fixture() {
433 let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
434
435 assert_eq!(utd.tag, "TelosDoor13");
436 assert_eq!(utd.template_resref, "door_tel014");
437 assert_eq!(utd.name.string_ref.raw(), 123_731);
438 assert_eq!(utd.description.string_ref.raw(), -1);
439 assert!(utd.auto_remove_key);
440 assert_eq!(utd.close_lock_dc, 0);
441 assert_eq!(utd.conversation, "convoresref");
442 assert!(utd.interruptable);
443 assert_eq!(utd.faction_id, 1);
444 assert!(utd.plot);
445 assert!(utd.not_blastable);
446 assert!(utd.min1_hp);
447 assert!(utd.key_required);
448 assert!(utd.lockable);
449 assert!(utd.locked);
450 assert_eq!(utd.open_lock_dc, 28);
451 assert_eq!(utd.open_lock_diff, 1);
452 assert_eq!(utd.open_lock_diff_mod, 1);
453 assert_eq!(utd.portrait_id, 0);
454 assert!(utd.trap_detectable);
455 assert_eq!(utd.trap_detect_dc, 0);
456 assert!(utd.trap_disarmable);
457 assert_eq!(utd.trap_disarm_dc, 28);
458 assert_eq!(utd.trap_flag, 0);
459 assert!(utd.trap_one_shot);
460 assert_eq!(utd.trap_type, 2);
461 assert_eq!(utd.key_name, "keyname");
462 assert_eq!(utd.animation_state, 1);
463 assert_eq!(utd.unused_appearance_id, 1);
464 assert_eq!(utd.maximum_hp, 20);
465 assert_eq!(utd.current_hp, 60);
466 assert_eq!(utd.hardness, 5);
467 assert_eq!(utd.fortitude, 28);
468 assert_eq!(utd.reflex, 0);
469 assert_eq!(utd.will, 0);
470 assert_eq!(utd.on_closed, "onclosed");
471 assert_eq!(utd.on_damaged, "ondamaged");
472 assert_eq!(utd.on_death, "ondeath");
473 assert_eq!(utd.on_disarm, "ondisarm");
474 assert_eq!(utd.on_heartbeat, "onheartbeat");
475 assert_eq!(utd.on_lock, "onlock");
476 assert_eq!(utd.on_melee_attacked, "onmeleeattacked");
477 assert_eq!(utd.on_open, "onopen");
478 assert_eq!(utd.on_spell_cast_at, "onspellcastat");
479 assert_eq!(utd.on_trap_triggered, "ontraptriggered");
480 assert_eq!(utd.on_unlock, "onunlock");
481 assert_eq!(utd.on_user_defined, "onuserdefined");
482 assert_eq!(utd.loadscreen_id, 0);
483 assert_eq!(utd.appearance_id, 110);
484 assert!(utd.is_static);
485 assert_eq!(utd.open_state, 1);
486 assert_eq!(utd.on_click, "onclick");
487 assert_eq!(utd.on_fail_to_open, "onfailtoopen");
488 assert_eq!(utd.comment, "abcdefg");
489 assert_eq!(utd.palette_id, 1);
490 }
491
492 #[test]
493 fn all_fields_survive_typed_roundtrip() {
494 let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
495 let bytes = author_utd_to_vec(&utd).expect("write succeeds");
496 let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
497 assert_eq!(reparsed, utd);
498 }
499
500 #[test]
501 fn typed_edits_roundtrip_through_gff_writer() {
502 let mut utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
503 utd.tag = "TelosDoor13_Rust".into();
504 utd.open_state = 2;
505 utd.on_open = ResRef::new("rust_on_open").expect("valid resref literal");
506
507 let bytes = author_utd_to_vec(&utd).expect("write succeeds");
508 let reparsed = read_utd_from_bytes(&bytes).expect("reparse succeeds");
509
510 assert_eq!(reparsed.tag, "TelosDoor13_Rust");
511 assert_eq!(reparsed.open_state, 2);
512 assert_eq!(reparsed.on_open, "rust_on_open");
513 }
514
515 #[test]
516 fn read_utd_from_reader_matches_bytes_path() {
517 let mut cursor = Cursor::new(TEST_UTD);
518 let from_reader = read_utd(&mut cursor).expect("reader parse succeeds");
519 let from_bytes = read_utd_from_bytes(TEST_UTD).expect("bytes parse succeeds");
520
521 assert_eq!(from_reader, from_bytes);
522 }
523
524 #[test]
525 fn rejects_non_utd_file_type() {
526 let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
527 gff.file_type = *b"UTC ";
528
529 let err = Utd::from_gff(&gff).expect_err("UTC must be rejected as UTD input");
530 assert!(matches!(
531 err,
532 UtdError::UnsupportedFileType(file_type) if file_type == *b"UTC "
533 ));
534 }
535
536 #[test]
537 fn a_mistyped_transition_destination_reads_as_absent() {
538 let mut gff = read_gff_from_bytes(TEST_UTD).expect("fixture must parse");
539 gff.root.fields.retain(|field| field.label != "TransDest");
540 gff.root
541 .push_field(gff_label!("TransDest"), GffValue::UInt32(99));
542
543 let utd = Utd::from_gff(&gff).expect("a mistyped field is not a read failure");
544
545 assert_eq!(utd.transition_destination, GffLocalizedString::default());
546 }
547
548 #[test]
549 fn write_utd_matches_direct_gff_writer() {
550 let utd = read_utd_from_bytes(TEST_UTD).expect("fixture must parse");
551
552 let via_typed = author_utd_to_vec(&utd).expect("typed write succeeds");
553
554 let mut direct = Cursor::new(Vec::new());
555 write_gff(&mut direct, &utd.to_gff()).expect("direct write succeeds");
556
557 assert_eq!(via_typed, direct.into_inner());
558 }
559
560 #[test]
561 fn schema_field_count() {
562 assert_eq!(Utd::schema().len(), 64); }
564
565 #[test]
566 fn schema_no_duplicate_labels() {
567 let mut labels: Vec<&str> = Utd::schema().iter().map(|f| f.label.as_str()).collect();
568 labels.sort_unstable();
569 let before = labels.len();
570 labels.dedup();
571 assert_eq!(before, labels.len(), "duplicate labels in UTD schema");
572 }
573}